Usage¶
The sections below detail how to fully use this module, along with context for design decisions made during development of the plugin.
Access Control¶
Applications housing sensitive material are often required to restrict certain types of access to both content and actions related to that content. This means that developers of the application need the ability to either permit or deny:
- Creation of new content.
- Read access to existing content.
- Updates to existing content.
- Deletion of existing content.
- Other customized specific actions on existing content.
Moreover, there are several mechanisms for assigning permissions to users of the application:
- Role-based access control via permissions or restrictions (RBACs).
- Group-based access control via permissions or restrictions.
- Access rights for existing content via owner/group(s) and permission schemes (ACLs).
This package tries to accommodate each of these needs, providing a flexible set of tools to fit alongside all of these authorization schemes. The flexibility of this plugin allows developers to only use what their application requires.
Users, Roles, Groups¶
With any authorization mechanism, you need an entity to authorize against. In standard web applications, there are three types of entities that are typically authorized against: the User
, Group
, and Role
. To understand the nuances of each model, let’s go over the purpose of each.
- User: A user represents a singular entity that is interacting with the application. They can assume multiple roles or be part of multiple groups.
- Role: A role represents an identity that a user can take while performing certain actions in the application. Roles are typically associated with permissions or permission restrictions.
- Group: A group represents a collection of users. Groups can be associated with permissions or permission restrictions.
Let’s use a basketball analogy to make things more concrete. In this analogy, examples of each model are as follows:
- Users: MJ, Scottie Pippen, Dennis Rodman, Toni Kukoc, Steve Kerr, Robert Parish
- Roles: Shooting Guard, Small Forward, Power Forward, Point Guard
- Group: Bulls, Team Captains, Scorers, Role-Players
In this analogy, there are a multitude of actions that can be performed by any of these entities. However, only certain entities should be allowed to do certain things. For example, in this analogy:
- A user assuming the role ‘Small Forward’ (Rodman) shouldn’t be able to perform the action ‘shoot 3s’.
- A user in the group ‘Bulls’ shouldn’t perform the action ‘score for the Jazz’.
In addition, you might need to restrict access to certain types of created content in the application to specific users or groups. Using a playbook as a content example, you might want to say that everyone in the ‘Bulls’ group can read the playbook, but only members of the ‘Team Captains’ group can make edits to it. We could go down this analogy further, but let’s switch context to a more relevant use case.
A Relevant Use-Case¶
In the documentation below, we need a use-case to illustrate the various functionality this plugin provides. Let’s use the following models in the examples throughout the rest of the documentation.
User
- The current logged-in user issuing a request.Group
- A collection of users. The current user can be assigned to one or multiple groups.Role
- A vehicle for assigning permissions. The current user can be allowed to take on one or multiple roles.Article
- A piece of content that needs to potentially have both RBAC and ACL enforcement.
Here are model definitions for the above scheme in the context of a Flask application:
from flask_authorize import RestrictionsMixin, AllowancesMixin
from flask_authorize import PermissionsMixin
# mapping tables
UserGroup = db.Table(
'user_group', db.Model.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('users.id')),
db.Column('group_id', db.Integer, db.ForeignKey('groups.id'))
)
UserRole = db.Table(
'user_role', db.Model.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('users.id')),
db.Column('role_id', db.Integer, db.ForeignKey('roles.id'))
)
# models
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
# `roles` and `groups` are reserved words that *must* be defined
# on the `User` model to use group- or role-based authorization.
roles = db.relationship('Role', secondary=UserRole)
groups = db.relationship('Group', secondary=UserGroup)
class Group(db.Model, RestrictionsMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
class Role(db.Model, AllowancesMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
class Article(db.Model, PermissionsMixin):
__tablename__ = 'articles'
__permissions__ = dict(
owner=['read', 'update', 'delete', 'revoke'],
group=['read', 'update'],
other=['read']
)
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), index=True, nullable=False)
contents = db.Column(db.Text)
Note
Not all of these models are necessary for using this plugin. For example: if your application doesn’t need Role-based authentication, you don’t need to define a Role model in your database.
What’s actually necessary?¶
It really depends on how you want to structure your application. If your application requires only owner or other content restrictions, you don’t need to configure a Group
or Role
model for this plugin to work. if your application doesn’t need the additional role authorization, you don’t need to configure a Role
to use with the plugin.
The important thing to understand is that there are two reserved keywords on the User
model (the object returned by the current_user
function configured for the plugin): roles
and groups
. These need to be configured to return (respectively) a list of Role
or Group
objects to check authorization for if your application is configured to do role- or group-based authorization. Here’s an example of a correctly configured user model (UserRole
and UserGroup
are separate mapping tables).
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
# `roles` and `groups` are reserved words that *must* be defined
# on the `User` model to use group- or role-based authorization.
roles = db.relationship('Role', secondary=UserRole)
groups = db.relationship('Group', secondary=UserGroup)
This application will implicitly check the existence of roles
and groups
properties on the current user object when checking authorization. If either of these properties is not defined, this plugin will not perform associated authorization checks.
Content Permissions¶
Permissions administration for this plugin was inspired by Filesystem ACLs in Linux, where content (files) are associated with three things: an owner, a group, and a set of permissions. For each content model you want to restrict access to, you can define permissions like so:
class Article(db.Model, PermissionsMixin):
pass
This uses default content permissions taken from the AUTHORIZE_DEFAULT_PERMISSIONS
configuration variable. If you want to customize content permissions, you can set the value of the __permissions__
property:
class Article(db.Model, PermissionsMixin):
__permissions__ = dict(
owner=['read', 'update', 'delete'],
group=['read', 'update'],
other=['read']
)
This explicit syntax is designed to allow for more customized authorization schemes. For the Article example, to add a permission specific to revoke-ing an article, you can configure the permissions like so:
class Article(db.Model, PermissionsMixin):
__permissions__ = dict(
owner=['read', 'update', 'delete', 'revoke'], # owners can revoke
group=['read', 'update', 'revoke'], # group can revoke
other=['read']
)
And once you’ve done that, you can use the @authorize.action decorator with the name of the permission:
@authorize.revoke
def revoke_article(article):
# only those with access to revoke are allowed
pass
For developers who enjoy assigning permissions via numeric schemes (à la Unix systems), that is also covered:
class Article(db.Model, PermissionsMixin):
__permissions__ = 764 # owner (read, update, delete)
# group (read, update)
# other (read)
Note
Numeric permissions schemes are only supported for restricting read, update, and delete permissions on created content. Bit masks are as follows: 1 (0b001): delete, 2 (0b010): read, 4 (0b100): update. Custom permission schemes must explicitly state permission names.
Setting Custom Content Permissions¶
If you want to override default permissions for a piece of content, you can do so with the set_permissions
method on a content object:
article = Article(
name='test'
)
article.set_permissions(
group=['read', 'update'] # read and update
other=2 # read
)
Alternatively, using a numeric scheme:
article = Article(
name='test'
)
article.set_permissions(762)
Additionally, permissions can be accessed with the permissions
property on a content object:
>>> article = Article(name='test')
>>> print(article.permissions)
{
'owner': ['read', 'update', 'delete'],
'group': ['read', 'update'],
'other': ['read']
}
Restrictions¶
In addition to authorizing permissions on created content, we can also add another layer of authorization with Role
or Group
content restrictions. With content restrictions, users in associated roles or groups will be unauthorized to perform specific actions. To configure your roles or groups to enable restrictions, you can use the RestrictionsMixin
object:
class Role(db.Model, RestrictionMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
class Group(db.Model, RestrictionMixin):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
Once configured with this mixin, restrictions can be set up for specific users like so:
# create user and associated role
role = Role(
name='reader',
restrictions=dict(
articles=['create', 'update', 'delete'],
secret_articles=['create', 'read', 'update', 'delete']
)
)
user = User(name='User 1')
user.roles = [role]
db.session.add(role, user)
db.session.commit()
Once this is all configured, you can enforce these restrictions like so:
# via decoration
@authorize.create(Article)
def create_article(name):
# will raise an Unauthorized error if the user
# is not authorized to create articles
pass
@authorize.update
@authorize.in_group('admin-editors')
def update_article(name):
# will raise an Unauthorized error if the user
# is not authorized to update articles or is
# not in the group 'admin-editors'
pass
@authorize.delete
@authorize.has_role('admin')
def delete_article(name):
# will raise an Unauthorized error if the user
# is not an admin or not authorized to delete articles
pass
# directly
def get_article(name):
article = session.query(Article).filter_by(name=name).first()
if not article:
raise NotFound
# check if the current user has no read access restrictions
if not authorize.read(article):
raise Unauthorized
return article
To configure default restrictions for models inheriting the RestrictionsMixin
, explicitly set the __restrictions__
property on the model:
# restrict nothing by default (default)
class Role(db.Model, RestrictionMixin):
__restrictions__ = {}
# restrict everything by default (common fail-safe)
class Role(db.Model, RestrictionMixin):
__restrictions__ = '*'
# create specific restrictions for items in the `articles` table by default
class Role(db.Model, RestrictionMixin):
__restrictions__ = {
'articles': ['update', 'delete']
}
Note
By default, no restrictions will be applied to any models in the application. To enable a fail-safe where all actions to all models are restricted by default, use __restrictions__ = '*'
when configuring models with the RestrictionsMixin
.
Even if your content permissions are configured to be wide open, user role/group restrictions will still be checked when determining access.
Note
In cases where both Role/Group restrictions and content permissions are conflicting, the most stringent set of permissions will be used. For example, if a user is configured with update restrictions to all Article objects and has update access via Article permissions, they will be unauthorized to update that content.
Allowances¶
If you want to explicitly allow access to each type of action (i.e. the inverse of restrictions), you can do so using the RoleAllowancesMixin
and GroupAllowancesMixin
mixin objects when defining your models. See the Database Mixins section below for more details on what each of the mixins provide.
Mirroring the example above, we can explicitly set allowances for a role via:
role = Role(
name='reader',
allowances=dict(
articles='r' # read only authorization
secret_articles=None # no authorization
)
)
db.session.add(role)
db.session.commit()
Note
In cases where both Role/Group allowances and content permissions are conflicting, the most stringent set of permissions will be used. For example, if a user is configured with read access to all Article objects but doesn’t have access via Article permissions, they will be unauthorized to view that content.
To configure default allowances for models inheriting the AllowancesMixin
, explicitly set the __allowances__
property on the model:
# allow everything by default (default)
class Role(db.Model, AllowancesMixin):
__allowances__ = '*'
# restrict everything by default (common fail-safe)
class Role(db.Model, AllowancesMixin):
__allowances__ = {}
# create specific allowances for items in the `articles` table by default
class Role(db.Model, AllowancesMixin):
__allowances__ = {
'articles': ['create', 'read']
}
Note
By default, allowances are set to allow all actions against all models of the application (wide open). To enable a fail-safe where all actions to all models are not allowed by default, use __allowances__ = {}
when configuring models with the AllowancesMixin
.
Authorization Schemes¶
authorize.<action>¶
Methods under this authorization scheme:
authorize.read
authorize.update
authorize.delete
authorize.create(ContentModel)
authorize.custom_scheme
Return True
if the current_user
is authorized to access content either by content permissions or by Group- or Role- based permissions or restrictions. Since this type of permissions scheme includes both content permissions and potential Role/Group restrictions or permissions, let’s go over logical flow in two stages. First, role- or group-based access control:
- Is the user assuming a role or have a role that does not allow access (restrictions)? (if applicable)
- Is the user assuming a role or have a role that does not include access in allowances? (if applicable)
- Is the user in a group that does not allow access? (if applicable)
- Is the user in a group that does not include access in allowances? (if applicable)
If any of these criteria are met, the authorization scheme will return False
. Now for access control lists related to the specific content item:
- For the specific content item, does the
other
permissions component allow access? - For the specific content item, does the
owner
permissions component allow access? - For the specific content item, does the
group
permissions component allow access?
If any of these criteria are not met, the authorization scheme will return False
.
Below is an example of how this scheme might be used:
# decoration
@authorize.create(Article)
def create_article(name):
# raise Unauthorized if the `current_user` is not
# authorized to create the article
pass
@authorize.read
def get_article(article):
# raise Unauthorized if the `current_user` is not
# authorized to read the article
pass
@authorize.update
def update_article(article):
# raise Unauthorized if the `current_user` is not
# authorized to update the article
pass
@authorize.delete
def update_article(article):
# raise Unauthorized if the `current_user` is not
# authorized to delete the article
pass
@authorize.revoke
def revoke_article(article):
# raise Unauthorized if the `current_user` is not
# authorized to revoke the article. In this example,
# `revoke` is a custom authorization scheme.
pass
# explicit
def all_article_actions(article):
if not authorize.create(article.__class__) or \
not authorize.read(article) or \
not authorize.update(article) or \
not authorize.delete(article) or \
not authorize.revoke(article):
raise Unauthorized
pass
This authorization mechanism can be used in conjunction with content models using the PermissionsMixin
or MultiGroupPermissionsMixin
.
authorize.in_group(‘<group>’)¶
Return True
if the current_user
is not associated with the specified Group
. For example:
# decorator
@authorize.in_group('administrators')
def admin_func(article):
# raise Unauthorized if the `current_user` is not in
# the `administrators` group.
pass
# explicit
def admin_handler(article):
if not authorize.in_group('administrators'):
raise Unauthorized
pass
Note
For this method to be supported, the Group
model must have a name
property. The name
property of that model is used as the key for checking membership. All Groups
tied to Users
must have a unique name
.
authorize.has_role(‘<role>’)¶
Return True
if the current_user
is not associated with the specified Role
. For example:
# decorator
@authorize.has_role('admin')
def admin_func(article):
# raise Unauthorized if the `current_user` is not associated
# with the `admin` role.
pass
# explicit
def admin_handler(article):
if not authorize.has_role('admin'):
raise Unauthorized
pass
Note
For this method to be supported, the Role
model must have a name
property. The name
property of that model is used as the key for checking membership. All Roles
tied to Users
must have a unique name
.
Database Mixins¶
Talk about what mixins are available and what they create
Content Authorization¶
PermissionsMixin
: A mixin that enables authorization on the owner and group associated with a content item. The database columns included in this mixin are:owner
- The owner of the content. Defaults to the current_user when the object was created.group
- A single Group associated with the content.permissions
- JSON data encoding permissions for the content.
OwnerPermissionsMixin
: A mixin that enables only owner authorization with a content item. The database columns included in this mixin are:owner
- The owner of the content. Defaults to the current_user when the object was created.permissions
- JSON data encoding permissions for the content.
GroupPermissionsMixin
: A mixin that enables only group authorization with a content item. The database columns included in this mixin are:group
- A single Group associated with the content.permissions
- JSON data encoding permissions for the content.
Role/Group Authorization¶
RestrictionsMixin
: A mixin that enables restriction checking onGroup
orRole
models associated with thecurrent_user
. Database columns included in this mixin are:restrictions
: JSON data encoding content restrictions associated with the group.
AllowancesMixin
: A mixin that enables permission checking onGroup
orRole
models associated with thecurrent_user
. Database columns included in this mixin are:allowances
: JSON data encoding content permissions associated with the group.
Bulk Query Support¶
In addition to operators for checking permission schemes on individual article instances, this extension also provides a mechanism (Model.authorized
) for doing permission checks on bulk queries. This is useful for queries where developers need to find all database items matching some criteria and authorization criteria. For example, if we need to query for all articles that the current_user
has access to, we can use:
# query for all articles that the current user can read
articles = Article.query.filter(Article.authorized('read')).all()
And if we need to perform a more complex query where we’re filtering on multiple operators, we can use:
# query for all articles that the current user can
# update or that has 'open article' in the name
articles = Article.query.filter(or_(
Article.name.contains('open article'),
Article.authorized('update')
)).all()
Finally, we can do complex permission type checking with conditional operators using the same Model.authorized
query filter:
# query for all articles that the current user can
# update AND read or that has 'open article' in the name
articles = Article.query.filter(or_(
Article.name.contains('open article'),
and_(
Article.authorized('read'),
Article.authorized('update')
)
)).all()
Jinja Support¶
In addition to using the authorize
plugin for controlling rest-based data access, you can also use it in your Jinja templates. For example, if your request handler injects a set of Article
instances into the template like so:
@app.route('/app/feed')
def feed_view(self):
articles = Article.query.limit(50).offset(0).all()
return render_template('feed.html', articles=articles)
The feed.html
template can contain the following Jinja expressions for conditionally rendering authorized content:
<!-- button for creating new article -->
{% if authorize.create('articles') %}
<button>Create Article</button>
{% endif %}
<!-- display article feed -->
{% for article in articles %}
<!-- show article if user has read access -->
{% if authorize.read(article) %}
<h1>{{ article.name }}</h1>
<!-- add edit button -->
{% if authorize.update(article) %}
<button>Update Article</button>
{% endif %}
<!-- add delete button for administrators -->
{% if authorize.in_group('admins') %}
<button>Delete Article</button>
{% endif %}
{% endif %}
{% endfor %}
The authorize
decorator is automatically injected into the Jinja context, so developers can use any method available on the object.
Configuration¶
The following configuration values exist for Flask-Authorize. Flask-Authorize loads these values from your main Flask config which can be populated in various ways. Note that some of those cannot be modified after the database engine was created so make sure to configure as early as possible and to not modify them at runtime.
Configuration Keys¶
A list of configuration keys currently understood by the extension:
AUTHORIZE_DEFAULT_PERMISSIONS |
Either a number that can be used as a permissions scheme (i.e. 764), or a dictionary like the following: dict(
user=['read', 'update', 'delete'],
group=['read', 'update'],
other=['read']
)
|
AUTHORIZE_DISABLE_JINJA |
Don’t add the authorize plugin to Jinja context.
This disables jinja support. |
AUTHORIZE_DEFAULT_ALLOWANCES |
Default allowances for any model instantiated
with a AllowancesMixin . |
AUTHORIZE_DEFAULT_RESTRICTIONS |
Default restrictions for any model instantiated
with a RestrictionsMixin . |
AUTHORIZE_MODEL_PARSER |
How to determine key names for authorization or restriction data structures. By default, sqlalchemy table names will be used. The schemes for parsing keys are as follows:
|
AUTHORIZE_IGNORE_PROPERTY |
Model property that can be set to automatically skip all allowances/restrictions checks. This is useful for speeding up the authorization checks, if you don’t need allowances/restrictions checks on specific models. |
AUTHORIZE_ALLOW_ANONYMOUS_ACTIONS |
Whether or not to allow actions if the function for returning the current user returns None |
Other Customizations¶
As detailed in the Overview section of the documentation, the plugin can be customized with specific triggers. The following detail what can be customized:
current_user
- The current user to authorize actions for. By default,- this uses the
current_user
object from Flask-Login.
exception
- An exception class to raise when the authorize plugin object is- used as a decorator and the current user does not have authorization
to perform an action. By default, this uses the
Unauthorized
exception fromwerkzeug.exceptions
.
strict
- Whether or not to throw errors when reserved model properties likeRole.name
orGroup.name
can’t be found when usinghas_role
orhas_group
decorators. The default isTrue
.
The code below details how you can override all of these configuration options:
from flask import Flask, g
from flask_authorize import Authorize
from werkzeug.exceptions import HTTPException
def get_current_user():
return g.user
class MyUnauthorizedException(HTTPException):
code = 405
description = 'Unauthorized'
app = Flask(__name__)
authorize = Authorize(
current_user=get_current_user,
exception=MyUnauthorizedException,
strict=False,
)
For even more in-depth information on the module and the tools it provides, see the API section of the documentation.
Usage with Factory Boy¶
By default, common factory-pattern utilities used in testing frameworks will set unreferenced properties to None
instead of using model defaults for a property. To avoid this and set permissions explicitly during testing, use the factory.LazyFunction
decorator with the default_permissions
function from this package for any permissions
properties on content models. See the example below for additional context:
import factory
from flask_authorize import default_permissions
# defining user and article factories
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
id = factory.Sequence(lambda x: x + 1)
name = factory.Faker('name')
email = factory.Faker('email')
password = factory.Faker('password')
class Meta:
model = User
sqlalchemy_session = db.session
class ArticleFactory(factory.alchemy.SQLAlchemyModelFactory):
id = factory.Sequence(lambda x: x + 1)
name = factory.Faker('name')
body = factory.Faker('paragraph')
tags = factory.Faker('words')
owner = factory.LazyFunction(UserFactory)
permissions = factory.LazyFunction(default_permissions)
class Meta:
model = Article
sqlalchemy_session = db.session
# using factories to create models
user = UserFactory.create()
ArticleFactory.create(owner=user)
Code Structure and Clarity¶
When used in conjunction with Flask-Occam, this plugin enables a very simple and understandable approach to API development. Here is an example of using the authorize decorators in that context:
@app.route('/items')
class Items(object):
def get(self):
"""
GET /items
Query for existing item in application database.
Parameters:
limit (str): (optional) Return limit for query.
offset (str): (optional) Offset for querying results.
Response:
List of item objects. See GET /items/:id for
information on return payloads.
Status:
Success: 200 Created
Missing: 404 Not Found
"""
items = list(filter(authorize.read, Item.all()))
return [x.json() for x in items], 200
@authorize.create(Item)
@validate(name=str)
@transactional
def post(self):
"""
POST /items
Query for existing item in application database.
Arguments:
id (int): Identifier for item.
Parameters:
name (str): Name for item
Response:
id (int): Identifier for item.
name (str): Item name.
url (str): Item URL.
Status:
Success: 201 Created
Missing: 404 Not Found
Failure: 422 Invalid Request
"""
item = Item.create(
name=request.json.get('name'),
url=request.json.get('url')
)
return item.json(), 201
@app.route('/items/<id(Item):item>')
class SingleItem(object):
@authorize.read
def get(self, item):
"""
GET /items/:id
Query for existing item in application database.
Arguments:
id (int): Identifier for item.
Response:
id (int): Identifier for item.
name (str): Item name.
Status:
Success: 200 OK
Missing: 404 Not Found
"""
return jsonify(id=item.id, name=item.name), 200
@authorize.update
@validate(
name=optional(str),
url=optional(validators.URL())
)
@transactional
@log.info('Changed metadata for item {item.name}')
def put(self, item):
"""
PUT /items/:id
Update existing item in application database.
Arguments:
id (int): Identifier for item.
Parameters:
name (str): (optional) Name for item
url (str): (optional) URL for item
Response:
id (int): Identifier for item.
name (str): Item name.
url (str): Item url.
Status:
Success: 200 OK
Missing: 404 Not Found
Failure: 422 Invalid Request
"""
item.update(
name=request.json.get('name', item.name),
url=request.json.get('url', item.url)
)
return item.json(), 200
@authorize.delete
@transactional
def delete(self, item):
"""
DELETE /items/:id
Delete existing item in application database.
Arguments:
id (int): Identifier for item.
Status:
Success: 204 No Content
Missing: 404 Not Found
"""
item.delete()
return jsonify(msg='Deleted item'), 204
For more information on the Flask-Occam
module, see the documentation.