Role-based access control¶
Coaster provides a RoleMixin
class that can be used to define role-based access
control to the attributes and methods of any SQLAlchemy model. RoleMixin
is a
base class for BaseMixin
and applies to all derived
classes. Access is defined as one of ‘call’ (for methods), ‘read’ or ‘write’ (both for
attributes).
Roles are freeform string tokens. A model may freely define and grant roles to actors
(users and sometimes client apps) based on internal criteria. The following standard
tokens are recommended. Required tokens are granted by RoleMixin
itself.
all
: Any actor, authenticated or anonymous (required)anon
: Anonymous actor (required)auth
: Authenticated actor (required)creator
: The creator of an object (may or may not be the current owner)owner
: The current owner of an objectauthor
: Author of the object’s contents (all creators are authors)editor
: Someone authorised to edit the objectreader
: Someone authorised to read the object (assuming it’s not public)subject
: User who is described by an object, typically having limited rights
Example use:
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from coaster.sqlalchemy import BaseMixin, with_roles
app = Flask(__name__)
db = SQLAlchemy(app)
class ColumnMixin:
'''
Mixin class that offers some columns to the RoleModel class below,
demonstrating two ways to use `with_roles`.
'''
@with_roles(rw={'owner'})
def mixed_in1(cls):
return db.Column(db.Unicode(250))
@declared_attr
def mixed_in2(cls):
return with_roles(db.Column(db.Unicode(250)),
rw={'owner'})
class RoleModel(ColumnMixin, RoleMixin, db.Model):
__tablename__ = 'role_model'
# The low level approach is to declare roles all at once.
# 'all' is a special role that is always granted from the base class.
# Avoid this approach in a parent or mixin class as definitions will
# be lost if the subclass does not copy `__roles__`.
__roles__ = {
'all': {
'read': {'id', 'name', 'title'},
},
'owner': {
'granted_by': ['user'],
},
}
# Recommended for parent and mixin classes: annotate roles on the attributes
# using `with_roles`. These annotations are added to `__roles__` when
# SQLAlchemy configures mappers.
id = db.Column(db.Integer, primary_key=True)
name = with_roles(db.Column(db.Unicode(250)),
rw={'owner'}) # Specify read+write access
user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False)
user = with_roles(
db.relationship(User),
grants={'owner'}, # Use `grants` here or `granted_by` in `__roles__`
)
# `with_roles` can also be called later. This is required for
# properties, where roles must be assigned after the property is
# fully described:
_title = db.Column('title', db.Unicode(250))
@property
def title(self):
return self._title
@title.setter
def title(self, value):
self._title = value
# This grants 'owner' and 'editor' write but not read access
title = with_roles(title, write={'owner', 'editor'})
# `with_roles` can be used as a decorator on methods, in which case
# access is controlled with the 'call' action.
@with_roles(call={'all'})
def hello(self):
return "Hello!"
# `RoleMixin` will grant roles by examining relationships specified in the
# `granted_by` list under each role in `__roles__`. The `actor` parameter
# to `roles_for` must be present in the relationship. You can augment this
# by providing a custom `roles_for` method:
def roles_for(self, actor=None, anchors=()):
# Calling super gives us a LazyRoleSet with the standard roles
# and with lazy evaluation of of other roles from `granted_by`
roles = super().roles_for(actor, anchors)
# We can manually add a role to override lazy evaluation
if 'owner-secret' in anchors:
roles.add('owner')
return roles
-
class
coaster.sqlalchemy.roles.
RoleGrantABC
[source]¶ Base class for an object that grants roles to an actor
-
offered_roles
¶ Roles offered by this object
-
-
class
coaster.sqlalchemy.roles.
LazyRoleSet
(obj, actor, initial=())[source]¶ Set that provides lazy evaluations for whether a role is present
-
copy
()[source]¶ Return a shallow copy of the
LazyRoleSet
.
-
has_any
(roles)[source]¶ Convenience method for checking if any of the given roles is present in the set.
Equivalent of evaluating using either of these approaches:
not roles.isdisjoint(lazy_role_set)
any(role in lazy_role_set for role in roles)
This implementation optimizes for cached roles before evaluating role granting sources that may cause a database hit.
-
-
class
coaster.sqlalchemy.roles.
RoleAccessProxy
(obj, roles, actor, anchors, datasets)[source]¶ A proxy interface that wraps an object and provides pass-through read and write access to attributes that the specified roles have access to. Consults the
__roles__
dictionary on the object for determining which roles can access which attributes. Provides both attribute and dictionary interfaces.Note that if the underlying attribute is a callable and is specified with the ‘call’ action, it will be available via attribute access but not dictionary access.
RoleAccessProxy
is typically accessed directly from the target object viaaccess_for()
(fromRoleMixin
).Example:
proxy = RoleAccessProxy(obj, roles={'writer'}) proxy.attr1 proxy.attr1 = 'new value' proxy['attr2'] = 'new value' dict(proxy)
Parameters: - obj – The object that should be wrapped with the proxy
- roles – A set of roles to determine what attributes are accessible
- actor – The actor this proxy has been constructed for
- anchors – The anchors this proxy has been constructed with
- datasets – Datasets to limit attribute enumeration to
The actor and anchors parameters are not used by the proxy, but are used to construct proxies for objects accessed via relationships.
-
class
coaster.sqlalchemy.roles.
DynamicAssociationProxy
(rel, attr)[source]¶ Association proxy for dynamic relationships. Use this instead of SQLAlchemy’s association_proxy when the underlying relationship uses lazy=’dynamic’.
Usage:
# Assuming a relationship like this: Document.child_relationship = db.relationship(ChildDocument, lazy='dynamic') # Proxy to an attribute on the target of the relationship: Document.child_attributes = DynamicAssociationProxy( 'child_relationship', 'attribute')
This proxy does not provide access to the query capabilities of dynamic relationships. It merely optimises for containment queries. A query like this:
Document.child_relationship.filter_by(attribute=value).exists()
Can be reduced to this:
value in Document.child_attributes
Parameters:
-
class
coaster.sqlalchemy.roles.
RoleMixin
[source]¶ Provides methods for role-based access control.
Subclasses must define a
__roles__
dictionary with roles and the attributes they have call, read and write access to:__roles__ = { 'role_name': { 'call': {'meth1', 'meth2'}, 'read': {'attr1', 'attr2'}, 'write': {'attr1', 'attr2'}, 'grant': {'rel1', 'rel2'}, }, }
The
grant
key works in reverse: if the actor is present in any of the attributes in the set, they are granted that role viaroles_for()
. Attributes must be SQLAlchemy relationships and can be scalar, a collection or dynamic.The
with_roles()
decorator is recommended over__roles__
.-
access_for
(roles=None, actor=None, anchors=(), datasets=None)[source]¶ Return a proxy object that limits read and write access to attributes based on the actor’s roles.
Warning
If the roles parameter is provided, it overrides discovery of the actor’s roles in both the current object and related objects. It should only be used when roles are pre-determined and related objects are not required.
Parameters: - roles (set) – Roles to limit access to (not recommended)
- actor – Limit access to this actor’s roles
- anchors – Retrieve additional roles from anchors
- datasets (tuple) – Limit enumeration to the attributes in the dataset
If a datasets sequence is provided, the first dataset is applied to the current object and subsequent datasets are applied to objects accessed via relationships. Datasets limit the attributes available via enumeration when the proxy is cast into a dict or JSON. This can be used to remove unnecessary data or bi-directional relationships, which JSON can’t handle.
Attributes must be specified in a
__datasets__
dictionary on the object:__datasets__ = { 'primary': {'uuid', 'name', 'title', 'children', 'parent'}, 'related': {'uuid', 'name', 'title'} }
Objects and related objects can be safely enumerated like this:
proxy = obj.access_for(user, datasets=('primary', 'related')) proxydict = dict(proxy) proxyjson = json.dumps(proxy) # This needs a custom JSON encoder
If a dataset includes an attribute the role doesn’t have access to, it will be skipped. If it includes a relationship for which no dataset is specified, it will be rendered as an empty dict.
-
actors_with
(roles, with_role=False)[source]¶ Return actors who have the specified roles on this object, as an iterator.
Uses: 1.
__roles__[role]['granted_by']
2.__roles__[role]['granted_via']
Subclasses of
RoleMixin
that have custom role granting logic inroles_for()
must provide a matchingactors_with()
implementation.Parameters:
-
current_access
(datasets=None)[source]¶ Wraps
access_for()
withcurrent_auth
to return a proxy for the currently authenticated user.Parameters: datasets (tuple) – Datasets to limit enumeration to
-
current_roles
¶ InspectableSet
containing currently available roles on this object, usingcurrent_auth
. Use in the view layer to inspect for a role being present:- if obj.current_roles.editor:
- pass
{% if obj.current_roles.editor %}…{% endif %}
This property is also available in
RoleAccessProxy
.Warning
current_roles maintains a cache for efficient use in a template where it may be consulted multiple times. It is therefore not safe to use before and after code that modifies role assignment. Use
roles_for()
instead, or use current_roles only after roles are changed.
-
roles_for
(actor=None, anchors=())[source]¶ Return roles available to the given
actor
oranchors
on this object. The data type for both parameters are intentionally undefined here. Subclasses are free to define them in any way appropriate. Actors and anchors are assumed to be valid.The role
all
is always granted. Ifactor
is specified, the roleauth
is granted. If not,anon
is granted.Subclasses overriding
roles_for()
must always callsuper()
to ensure they are receiving the standard roles. Recommended boilerplate:def roles_for(self, actor=None, anchors=()): roles = super().roles_for(actor, anchors) # 'roles' is a set. Add more roles here # ... return roles
-
-
coaster.sqlalchemy.roles.
with_roles
(obj=None, rw=None, call=None, read=None, write=None, grants=None, grants_via=None, datasets=None)[source]¶ Convenience function and decorator to define roles on an attribute. Only works with
RoleMixin
, which reads the annotations made by this function and populates__roles__
.Examples:
id = db.Column(Integer, primary_key=True) with_roles(id, read={'all'}) title = with_roles(db.Column(db.UnicodeText), read={'all'}) @with_roles(read={'all'}) @hybrid_property def url_id(self): return str(self.id)
When used with properties, with_roles must always be applied after the property is fully described:
@property def title(self): return self._title @title.setter def title(self, value): self._title = value # Either of the following is fine, since with_roles annotates objects # instead of wrapping them. The return value can be discarded if it's # already present on the host object: title = with_roles(title, read={'all'}, write={'owner', 'editor'}) with_roles(title, read={'all'}, write={'owner', 'editor'})
Parameters: - rw (set) – Roles which get read and write access to the decorated attribute
- call (set) – Roles which get call access to the decorated method
- read (set) – Roles which get read access to the decorated attribute
- write (set) – Roles which get write access to the decorated attribute
- grants (set) – The decorated attribute contains actors with the given roles
- grants_via (dict) – The decorated attribute is a relationship to another object type which contains one or more actors who are granted roles here
- datasets (set) – Datasets to include the attribute in
grants_via
is typically used like this:class RoleModel(db.Model): user_id = db.Column(None, db.ForeignKey('user.id')) user = db.relationship(UserModel) document_id = db.Column(None, db.ForeignKey('document.id')) document = db.relationship(DocumentModel) DocumentModel.rolemodels = with_roles(db.relationship(RoleModel), grants_via={'user': {'role1', 'role2'}})
In this example, a user gets roles ‘role1’ and ‘role2’ on DocumentModel via the secondary RoleModel. Grants are recorded in
__roles__['role1']['granted_via']
and are honoured by theLazyRoleSet
used inroles_for()
.grants_via
supports an additional advanced definition for when the role granting model has variable roles and offers them via a property namedoffered_roles
:class RoleModel(db.Model): user_id = db.Column(None, db.ForeignKey('user.id')) user = db.relationship(UserModel) has_role1 = db.Column(db.Boolean) has_role2 = db.Column(db.Boolean) document_id = db.Column(None, db.ForeignKey('document.id')) document = db.relationship(DocumentModel) @property def offered_roles(self): roles = set() if self.has_role1: roles.add('role1') if self.has_role2: roles.add('role2') return roles DocumentModel.rolemodels = with_roles(db.relationship(RoleModel), grants_via={'user': { 'role1': 'renamed_role1, 'role2': {'renamed_role2', 'also_role2'} }} )
-
coaster.sqlalchemy.roles.
declared_attr_roles
(rw=None, call=None, read=None, write=None)[source]¶ Equivalent of
with_roles()
for use with@declared_attr
:@declared_attr @declared_attr_roles(read={'all'}) def my_column(cls): return Column(Integer)
While
with_roles()
is always the outermost decorator on properties and functions,declared_attr_roles()
must appear below@declared_attr
to work correctly.Deprecated since version 0.6.1: Use
with_roles()
instead. It works fordeclared_attr
since 0.6.1