Source code for invenio_access.permissions

# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Permission and action needs for Invenio."""

from collections import namedtuple
from functools import partial
from itertools import chain

from flask_principal import ActionNeed, Need
from flask_principal import Permission as _Permission

from .models import ActionRoles, ActionSystemRoles, ActionUsers, \
    get_action_cache_key
from .proxies import current_access

_Need = namedtuple("Need", ["method", "value", "argument"])

ParameterizedActionNeed = partial(_Need, "action")

ParameterizedActionNeed.__doc__ = \
    """A need having the method preset to `"action"` and a parameter.

    If it is called with `argument=None` then this need is equivalent
    to ``ActionNeed``.
    """

SystemRoleNeed = partial(Need, "system_role")
SystemRoleNeed.__doc__ = \
    """A need with the method preset to `"system_role"`."""

#
# Need instances
#
superuser_access = ActionNeed("superuser-access")
"""Superuser access aciton which allow access to everything."""

any_user = SystemRoleNeed("any_user")
"""Any user system role.

This role is used to assign all possible users (authenticated and guests)
to an action.
"""

authenticated_user = SystemRoleNeed("authenticated_user")
"""Authenticated user system role.

This role is used to assign all authenticated users to an action.
"""


class _P(namedtuple("Permission", ["needs", "excludes"])):
    """Helper for simple permission updates."""

    def update(self, permission):
        """In-place update of permissions."""
        self.needs.update(permission.needs)
        self.excludes.update(permission.excludes)


[docs]class Permission(_Permission): """Represents a set of required needs. Extends Flask-Principal's :py:class:`flask_principal.Permission` with support for loading action grants from the database including caching support. Essentially the class works as a translation layer that expands action needs into a list of user/roles needs. For instance, take the following permission: .. code-block:: python Permission(ActionNeed('my-action')) Once the permission is checked with an identity, the class will fetch a list of all users and roles that have been granted/denied access to the action, and expand the permission into something similar to (depending on the state of the database): .. code-block:: python Permission(UserNeed('1'), RoleNeed('admin')) The expansion is cached until the action is modified (e.g. a user is granted access to the action). The alternative approach to expanding the action need like this class is doing, would be to load the list of allowed actions for a user on login and cache the result. However retrieving all allowed actions for a user could results in very large lists, where as caching allowed users/roles for an action would usually yield smaller lists (especially if roles are used). """ allow_by_default = False """If enabled, all permissions are granted when they are not assigned to anybody. Disabled by default. """ def __init__(self, *needs): r"""Initialize permission. :param \*needs: The needs for this permission. """ self._permissions = None self.explicit_needs = set(needs) self.explicit_needs.add(superuser_access) self.explicit_excludes = set() @staticmethod def _cache_key(action_need): """Helper method to generate cache key.""" return get_action_cache_key( action_need.value, action_need.argument if hasattr(action_need, "argument") else None, ) @staticmethod def _split_actionsneeds(needs): """Split needs into sets of ActionNeed and any other *Need.""" action_needs, other_needs = set(), set() for need in needs: if need.method == "action": action_needs.add(need) else: other_needs.add(need) return action_needs, other_needs def _load_permissions(self): """Load permissions for all needs, expanding actions.""" result = _P(needs=set(), excludes=set()) # split ActionNeeds and any other Need in separates Sets action_needs, explicit_needs = self._split_actionsneeds( self.explicit_needs) action_excludes, explicit_excludes = self._split_actionsneeds( self.explicit_excludes) # add all explicit needs/excludes to the result permissions result.needs.update(explicit_needs) result.excludes.update(explicit_excludes) # expand all ActionNeeds to get all needs/excludes and add them to the # result permissions for need in action_needs | action_excludes: result.update(self._expand_action(need)) # "allow_by_default = False" means that when needs are empty, # then it should deny access. # By default, `flask_principal.Permission.allows` will allow access # if needs are empty! needs_empty = len(result.needs) == 0 deny_access_when_empty_needs = not self.allow_by_default if needs_empty and deny_access_when_empty_needs: # Add at least one dummy need so that it will always deny access result.needs.update(action_needs) self._permissions = result def _expand_action(self, explicit_action): """Expand action to user/roles needs and excludes.""" action = current_access.get_action_cache( self._cache_key(explicit_action) ) if action is None: action = _P(needs=set(), excludes=set()) actionsusers = ActionUsers.query_by_action(explicit_action).all() actionsroles = ( ActionRoles.query_by_action(explicit_action) .join(ActionRoles.role) .all() ) actionssystem = ActionSystemRoles.query_by_action( explicit_action ).all() for db_action in chain(actionsusers, actionsroles, actionssystem): if db_action.exclude: action.excludes.add(db_action.need) else: action.needs.add(db_action.need) current_access.set_action_cache( self._cache_key(explicit_action), action ) return action @property def needs(self): """Return allowed permissions from database. :returns: A list of need instances. """ self._load_permissions() return self._permissions.needs @property def excludes(self): """Return denied permissions from database. :returns: A list of need instances. """ self._load_permissions() return self._permissions.excludes