Permissions with Flask-Principal¶
Flask-Potion includes a permission system. The permissions system is
built on Flask-Principal.
and enabled by decorating a manager.RelationalManager
with contrib.principals.principals
, which returns a class
extending both the manager and contrib.principals.PrincipalMixin
.
Permissions are specified as a dict
in Meta.permissions
.
Defining Permissions¶
There are four basic actions — read, create, update, delete — for which permissions must be defined. Additional virtual actions can be declared for various purposes.
For example, the default permission declaration looks somewhat like this:
class Meta:
permissions = {
'read': 'yes',
'create': 'no',
'update': 'create',
'delete': 'update'
}
Patterns and Needs they produce:
Pattern | Matches | Description |
---|---|---|
{action} | a key in the permissions dict If |
equal to the action it is declared for
— e.g.
Otherwise re-use needs from other action. |
{role} | not a key in the permissions dict |
RoleNeed({role}) |
{action}:{field} | *:* | Copy {action} permissions from ToOne
linked resource at {field} . |
user:{field} | user:* | UserNeed(item.{field}.id) for ToOne fields. |
no, nobody | no | Do not permit. |
yes, everybody | yes | Always permit. |
Note
When protecting an ItemRoute
, read access permissions, and updates using the resource manager are checked automatically;
for other actions, permissions have to be checked manually from within the function. The manager has helper functions such as
PrincipalMixin.can_update_item()
to facilitate this.
Example API with permissions¶
Changed in version 0.11: The PrincipalManager
extending SQLAlchemyManager
has been replaced by a principals()
class-decorator.
We’re going to go ahead and create an example API using PrincipalMixin
with
Flask-Login for authentication. Since there are quite a few moving parts, this
example is split up into several sections.
Our example is a simple blog with articles and comments. First, let’s create the database models:
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from sqlalchemy.orm import relationship
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret' # XXX replace with actual secret and don't keep it in source code
db = SQLAlchemy(app)
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(), nullable=False)
is_admin = db.Column(db.Boolean(), default=False)
is_editor = db.Column(db.Boolean(), default=False)
class Article(db.Model):
id = db.Column(db.Integer, primary_key=True)
author_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False)
author = relationship(User)
content = db.Column(db.Text)
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
article_id = db.Column(db.Integer, db.ForeignKey(Article.id), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False)
article = relationship(Article)
author = relationship(User)
message = db.Column(db.Text)
db.create_all()
We’re going to use Flask-Login to authenticate requests using Basic Authentication:
from flask_login import LoginManager, current_user
login_manager = LoginManager(app)
@login_manager.request_loader
def load_user_from_request(request):
if request.authorization:
username, password = request.authorization.username, request.authorization.password
# XXX replace this with an actual password check.
if username == password:
return User.query.filter_by(username=username).first()
return None
This is where Flask-Principal comes in. With every request it adds the needs the identity should provide. Authenticated users are given a user need and maybe some role needs. If this example had some top-level object based permissions (think groups, projects, teams, etc.) they would also be added here.
from flask_principal import Principal, Identity, UserNeed, AnonymousIdentity, identity_loaded, RoleNeed
principals = Principal(app)
@principals.identity_loader
def read_identity_from_flask_login():
if current_user.is_authenticated():
return Identity(current_user.id)
return AnonymousIdentity()
@identity_loaded.connect_via(app)
def on_identity_loaded(sender, identity):
if not isinstance(identity, AnonymousIdentity):
identity.provides.add(UserNeed(identity.id))
if current_user.is_editor:
identity.provides.add(RoleNeed('editor'))
if current_user.is_admin:
identity.provides.add(RoleNeed('admin'))
Finally, we create our API with the login_required
decorator from Flask-Login.
from flask_login import login_required
from flask_potion import fields, signals, Api, ModelResource
from flask_potion.contrib.alchemy import SQLAlchemyManager
from flask_potion.contrib.principals import principals
api = Api(app, decorators=[login_required])
class PrincipalResource(ModelResource):
class Meta:
manager = principals(SQLAlchemyManager)
class UserResource(PrincipalResource):
class Meta:
model = User
class ArticleResource(PrincipalResource):
class Schema:
author = fields.ToOne('user')
class Meta:
model = Article
read_only_fields = ['author']
permissions = {
'create': 'editor',
'update': ['user:author', 'admin']
}
class CommentResource(PrincipalResource):
class Schema:
article = fields.ToOne('article')
author = fields.ToOne('user')
class Meta:
model = Comment
read_only_fields = ['author']
permissions = {
'create': 'anybody',
'update': 'user:author',
'delete': ['update:article', 'admin']
}
api.add_resource(UserResource)
api.add_resource(ArticleResource)
api.add_resource(CommentResource)
# add the author to articles & comments when they are created:
@signals.before_create.connect_via(ANY)
def before_create_article_comment(sender, item):
if issubclass(sender, (ArticleResource, CommentResource)):
item.author_id = current_user.id
We’ve implemented the following permissions:
- only editors can create articles
- articles can be updated or deleted by either their authors or by admins
- comments can be created by anyone who is authenticated
- comments can updated only by the person who wrote the comment, but deleted both by admins and the author of the article
Now we just need to start the app:
if __name__ == '__main__':
# add some example users & run the application
db.session.add(User(username='editorA', is_editor=True))
db.session.add(User(username='editorB', is_editor=True))
db.session.add(User(username='admin', is_admin=True))
db.session.add(User(username='user'))
db.session.commit()
app.run()
You can find the complete example code on GitHub under:
examples/permissions_example.py
http --auth editorA:editorA :5000/article content=foo
HTTP/1.0 200 OK
Content-Length: 71
Content-Type: application/json
Date: Sun, 08 Feb 2015 10:48:03 GMT
Server: Werkzeug/0.9.6 Python/3.3.2
Set-Cookie: session=.eJyrVorPTFGyUjK3SEw0TDOzSDRPtDRJtUxNMzZKM0pNNE4zNks1TU6zVNJRykxJzSvJLKnUSywtyYgvqSxIVbLKK83JQZIBGWVYCwBQWxtd.B7jQYw.Nhh6qE-h5WrGPfsYibXnDzCaJQM; HttpOnly; Path=/
{
"$uri": "/article/2",
"author": {
"$ref": "/user/1"
},
"content": "foo"
}
Object-based permissions¶
The example above did already sort of touch on object-based permissions, with the 'user:author'
pattern that restricts
access to the user who has authored a comment or article. We’ve also used permissions options, with more than one need potentially providing access. Finally, you have seen a hint of cascading object-based permissions with the
'update:article'
pattern that conditions access to the permissions on a relation.
There is another permission layer, building on flask_principal.ItemNeed
, for object-specific permissions. You would want to use them on something important, such as this project resource:
class ProjectResource(ModelResource):
class Meta:
manager = principals(SQLAlchemyManager)
model = Project
permissions = {
'create': 'anybody',
'update': 'manage',
'manage': 'manage'
}
To update a project, your identity needs this need:
ItemNeed('manage', PROJECT_ID, 'project')
The pair {'manage': 'manage'}
makes manage a new virtual action, which is why the flask_principals.ItemNeed
wants
a 'manage'
permission. We could also have written {'update': 'update'}
— then the required need would have been:
ItemNeed('update', PROJECT_ID, 'project')
With cascading permissions, role-based, user-based, and object-based permissions you should now have all the tools to implement all sorts of complex permissions setups.
PrincipalMixin
class¶
-
class
flask_potion.contrib.principals.
PrincipalMixin
(*args, **kwargs)¶ -
get_permissions_for_item
(item)¶ Returns a dictionary of evaluated permissions for an item. :param item: :return: Dictionary in the form
{operation: bool, ..}
-
can_create_item
(item)¶ Looks up permissions on whether an item may be created. :param item:
-
can_update_item
(item, changes=None)¶ Looks up permissions on whether an item may be updated. :param item: :param changes: dictionary of changes
-
can_delete_item
(item)¶ Looks up permissions on whether an item may be deleted. :param item:
-
Efficiency¶
Those who have worked with Flask-Principal know that it is on its own not well-suited for object-based permissions where large numbers of objects are involved, because each permission has
to be loaded into memory as ItemNeed
at the start of the session.
The permission system built into Potion introduces the HybridNeed
and HybridPermission
classes to solve this issue.
They can either be evaluated directly or be applied to SQLAlchemy queries, and are therefore efficient with any number of object-based permissions.
-
class
flask_potion.contrib.principals.needs.
HybridNeed
¶ HybridNeed
base class. Hybrid needs can both be evaluated directly or produce an expression for use with SQLAlchemy.
-
class
flask_potion.contrib.principals.permission.
HybridPermission
(*needs)¶ Hybrid
flask_principal.Permission
that evaluates both regular and hybrid needs.-
allows
(identity)¶ Determines whether a given identity meets this permission.
Parameters: identity (flask_principal.Identity) – An identity with a set of provided needs
-
can
(item=None)¶ Depending on whether or not
item
is given, this function either:- evaluates all regular needs needs
- also evaluates the hybrid needs against the item
If any of the needs are met, the function returns
True
.Parameters: item – SQLAlchemy model instance
-