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.

  1. all: Any actor, authenticated or anonymous (required)
  2. anon: Anonymous actor (required)
  3. auth: Authenticated actor (required)
  4. creator: The creator of an object (may or may not be the current owner)
  5. owner: The current owner of an object
  6. author: Author of the object’s contents (all creators are authors)
  7. editor: Someone authorised to edit the object
  8. reader: Someone authorised to read the object (assuming it’s not public)
  9. 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

add(value)[source]

Add role value to the set.

copy()[source]

Return a shallow copy of the LazyRoleSet.

discard(value)[source]

Remove role value from the set if it is present.

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:

  1. not roles.isdisjoint(lazy_role_set)
  2. 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 via access_for() (from RoleMixin).

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:
  • rel (str) – Relationship name (must use lazy='dynamic')
  • attr (str) – Attribute on the target of the relationship
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 via roles_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 in roles_for() must provide a matching actors_with() implementation.

Parameters:
  • roles (set) – Iterable specifying roles to find actors with. May be an ordered type if ordering is important
  • with_role (bool) – If True, yields a tuple of the actor and the role they were found with. The actor may have more roles, but only the first match is returned
current_access(datasets=None)[source]

Wraps access_for() with current_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, using current_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 or anchors 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. If actor is specified, the role auth is granted. If not, anon is granted.

Subclasses overriding roles_for() must always call super() 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 the LazyRoleSet used in roles_for().

grants_via supports an additional advanced definition for when the role granting model has variable roles and offers them via a property named offered_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 for declared_attr since 0.6.1