abilian.services.security.service — Abilian 0.1 documentation

# Source code for abilian.services.security.service

    """
    Security service, manages roles and permissions.

    Currently very simple (simplisitic?).

    Roles and permissions are just strings, and are currently hardcoded.
    """
    from functools import wraps
    from itertools import chain

    from flask import g, current_app
    from sqlalchemy.orm import subqueryload, object_session
    from sqlalchemy import sql

    from abilian.core.models.subjects import User, Group
    from abilian.core.entities import Entity
    from abilian.core.extensions import db
    from abilian.core.util import noproxy
    from abilian.services import Service, ServiceState
    from abilian.services.security.models import (
      SecurityAudit, RoleAssignment, Anonymous, Admin, Manager,
      Owner, Creator,
      InheritSecurity, Role, Permission, READ, WRITE
    )

    PERMISSION = frozenset(['read', 'write', 'manage'])

    __all__ = ['security', 'SecurityError', 'SecurityService',
               'InheritSecurity', 'SecurityAudit']

    class SecurityError(Exception):
      pass

    class SecurityServiceState(ServiceState):
      """ """
      use_cache = True
      #: True if security has changed
      needs_db_flush = False

    def require_flush(fun):
      """ Decorator for methods that need to query security. It ensures all security
      related operations are flushed to DB, but avoids unneeded flushes.
      """
      @wraps(fun)
      def ensure_flushed(service, *args, **kwargs):
        if service.app_state.needs_db_flush:
          session = current_app.db.session()
          if (not session._flushing
              and any(isinstance(m, (RoleAssignment, SecurityAudit))
                      for models in (session.new, session.dirty, session.deleted)
                      for m in models)):
            session.flush()
          service.app_state.needs_db_flush = False

        return fun(service, *args, **kwargs)

      return ensure_flushed

    # noinspection PyComparisonWithNone
    class SecurityService(Service):
      """ """
      name = 'security'
      AppStateClass = SecurityServiceState

      def init_app(self, app):
        Service.init_app(self, app)
        state = app.extensions[self.name]
        state.use_cache = True

      def _needs_flush(self):
        """ Mark next security queries needs DB flush to have up to date information
        """
        self.app_state.needs_db_flush = True

      def clear(self):
        pass

      def _current_user_manager(self):
        """Returns the current user, or SYSTEM user.
        """
        try:
          return g.user
        except:
          return User.query.get(0)

      # security log
      @require_flush
      def entries_for(self, obj, limit=20):
        assert isinstance(obj, Entity)
        return SecurityAudit.query.filter(SecurityAudit.object == obj)\
          .order_by(SecurityAudit.happened_at.desc())\
          .limit(limit)

      # inheritance
      def set_inherit_security(self, obj, inherit_security):
        """
        """
        assert isinstance(obj, InheritSecurity)
        assert isinstance(obj, Entity)

        obj.inherit_security = inherit_security
        db.session.add(obj)

        manager = self._current_user_manager()
        op = (SecurityAudit.SET_INHERIT if inherit_security
              else SecurityAudit.UNSET_INHERIT)
        audit = SecurityAudit(manager=manager, op=op, object=obj,
                              object_id=obj.id,
                              object_type=obj.entity_type,
                              object_name=obj.name)
        db.session.add(audit)
        self._needs_flush()

      #
      # Roles-related API.
      #
      @require_flush
      def get_roles(self, principal, object=None):
        """
        Gets all the roles attached to given `user`, on a given `object`.
        """
        assert principal
        if hasattr(principal, 'is_anonymous') and principal.is_anonymous():
          return [Anonymous]

        q = db.session.query(RoleAssignment.role)
        filter_col = (RoleAssignment.user
                      if not isinstance(principal, Group)
                      else RoleAssignment.group)
        q = q.filter(filter_col == principal)

        if object is not None:
          assert isinstance(object, Entity)

        q = q.filter(RoleAssignment.object == object)
        roles = {i[0] for i in q.all()}

        if object is not None:
          for attr, role in (('creator', Creator), ('owner', Owner)):
            if getattr(object, attr) == principal:
              roles.add(role)
        return list(roles)

      @require_flush
      def get_principals(self, role, anonymous=True, users=True, groups=True,
                         object=None):
        """
        Return all users which are assigned given role
        """
        if not isinstance(role, Role):
          role = Role(role)
        assert role
        assert (users or groups)
        q = RoleAssignment.query.filter_by(role=role)

        if not anonymous:
          q = q.filter(RoleAssignment.anonymous == False)
        if not users:
          q = q.filter(RoleAssignment.user == None)
        elif not groups:
          q = q.filter(RoleAssignment.group == None)

        q = q.filter(RoleAssignment.object == object)
        principals = {(ra.user or ra.group) for ra in q.all()}

        if object is not None and role in (Creator, Owner):
          p = object.creator if role == Creator else object.owner
          if p:
            principals.add(p)

        return list(principals)

      @require_flush
      def _all_roles(self, principal):
        q = db.session.query(RoleAssignment.object_id, RoleAssignment.role)\
          .outerjoin(Entity)\
          .add_columns(Entity._entity_type)

        if isinstance(principal, User):
          filter_cond = (RoleAssignment.user == principal)
          if len(principal.groups) > 0:
             filter_cond |= (RoleAssignment.group in principal.groups)
          q = q.filter(filter_cond)
        else:
          q = q.filter(RoleAssignment.group == principal)

        results = q.all()
        all_roles = {}
        for object_id, role, object_type in results:
          if object_id is None:
            object_key = None
          else:
            object_key = u'{}:{}'.format(object_type, object_id)
          all_roles.setdefault(object_key, set()).add(role)

        return all_roles

      def _role_cache(self, principal):
        if not self._has_role_cache(principal):
          # FIXME: should call _fill_role_cache?
          principal.__roles_cache__ = {}

        return principal.__roles_cache__

      def _has_role_cache(self, principal):
        return hasattr(principal, "__roles_cache__")

      def _set_role_cache(self, principal, cache):
        principal.__roles_cache__ = cache

      def _fill_role_cache(self, principal, overwrite=False):
        """ Fill role cache for `principal` (User or Group), in order to avoid too
        many queries when checking role access with 'has_role'

        Return role_cache of `principal`
        """
        if not self.app_state.use_cache:
          return None

        if not self._has_role_cache(principal) or overwrite:
          self._set_role_cache(principal, self._all_roles(principal))
        return self._role_cache(principal)

      @require_flush
      def _fill_role_cache_batch(self, principals, overwrite=False):
        """ Fill role cache for `principals` (Users and/or Groups), in order to
        avoid too many queries when checking role access with 'has_role'
        """
        if not self.app_state.use_cache:
          return

        q = db.session.query(RoleAssignment)
        users = set((u for u in principals if isinstance(u, User)))
        groups = set((g for g in principals if isinstance(g, Group)))
        groups |= set((g for u in users
                         for g in u.groups))

        if not overwrite:
          users = set((u for u in users if not self._has_role_cache(u)))
          groups = set((g for g in groups if not self._has_role_cache(g)))

        if not (users or groups):
          return

        # ensure principals processed here will have role cache. Thus users or
        # groups without any role will have an empty role cache, to avoid unneeded
        # individual DB query when calling self._fill_role_cache(p).
        map(lambda p: self._set_role_cache(p, {}), (p for p in chain(users, groups)))

        filter_cond = []
        if users:
          filter_cond.append(RoleAssignment.user_id.in_((u.id for u in users)))
        if groups:
          filter_cond.append(RoleAssignment.group_id.in_((g.id for g in groups)))

        q = q.filter(sql.or_(*filter_cond))
        ra_users = {}
        ra_groups = {}
        for ra in q.all():
          if ra.user:
            all_roles = ra_users.setdefault(ra.user, {})
          else:
            all_roles = ra_groups.setdefault(ra.group, {})

          object_key = (
            u'{}:{:d}'.format(ra.object.entity_type, ra.object_id)
            if ra.object is not None
            else None
          )
          all_roles.setdefault(object_key, set()).add(ra.role)

        for group, all_roles in ra_groups.iteritems():
          self._set_role_cache(group, all_roles)

        for user, all_roles in ra_users.iteritems():
          for gr in user.groups:
            group_roles = self._fill_role_cache(gr)
            for object_key, roles in group_roles.iteritems():
              obj_roles = all_roles.setdefault(object_key, set())
              obj_roles |= roles

          self._set_role_cache(user, all_roles)

      def has_role(self, principal, role, object=None):
        """
        True if `principal` has `role` (either globally, if `object` is None, or on
        the specific `object`).

        :param:role:  can be a list or tuple of strings or a :class:`Role` instance

        `object` can be an :class:`Entity`, a string, or `None`.

        Note: we're using a cache for efficiency here. TODO: check that we're not
        over-caching.

        Note2: caching could also be moved upfront to when the user is loaded.
        """
        if not principal:
          return False

        principal = noproxy(principal)
        if not self.running:
          return True

        if (principal is Anonymous
            or (hasattr(principal, 'is_anonymous') and principal.is_anonymous())):
          return False

        # root always have any role
        if isinstance(principal, User) and principal.id == 0:
          return True

        # admin & manager always have role
        if isinstance(role, (Role, basestring)):
          role = (role,)
        valid_roles = frozenset((Admin, Manager) + tuple(role))

        if object:
          assert isinstance(object, Entity)
          object_key = u"{}:{}".format(object.object_type, unicode(object.id))
        else:
          object_key = None

        if self.app_state.use_cache:
          cache = self._fill_role_cache(principal)

          if Admin in cache.get(None, ()):
            # user is a global admin
            return True

          if object_key in cache:
            roles = cache[object_key]
            return len(valid_roles & roles) > 0
          return False

        all_roles = self._all_roles(principal)

        if Admin in all_roles.get(None, ()):
          # user is a global admin
          return True

        roles = all_roles.get(object_key, set())
        return len(valid_roles & roles) > 0

      def grant_role(self, principal, role, obj=None):
        """
        Grants `role` to `user` (either globally, if `obj` is None, or on
        the specific `obj`).
        """
        assert principal
        principal = noproxy(principal)
        manager = self._current_user_manager()
        session = object_session(obj) if obj is not None else db.session

        args = dict(role=role, object=obj,
                    anonymous=False,
                    user=None, group=None)

        if (principal is Anonymous
            or (hasattr(principal, 'is_anonymous') and principal.is_anonymous())):
          args['anonymous'] = True
        elif isinstance(principal, User):
          args['user'] = principal
        else:
          args['group'] = principal

        if len(RoleAssignment.query.filter_by(**args).limit(1).all()) > 0:
          # role already granted, nothing to do
          return

        # same as above but in current, not yet flushed objects in session. We
        # cannot call flush() in grant_role() since this method may be called a
        # great number of times in the same transaction, and sqlalchemy limits to
        # 100 flushes before triggering a warning
        for obj in (o for models in (session.new, session.dirty)
                    for o in models if isinstance(o, RoleAssignment)):
          if all(getattr(obj, attr) == val for attr, val in args.items()):
            return

        ra = RoleAssignment(**args)
        session.add(ra)
        audit = SecurityAudit(manager=manager, op=SecurityAudit.GRANT, **args)
        if obj is not None:
          audit.object_id = obj.id
          audit.object_type = obj.entity_type
          object_name = u''
          for attr_name in ('name', 'path', '__path_before_delete'):
            if hasattr(obj, attr_name):
              object_name = getattr(obj, attr_name)
          audit.object_name = object_name

        session.add(audit)
        self._needs_flush()

        if hasattr(principal, "__roles_cache__"):
          del principal.__roles_cache__

      def ungrant_role(self, principal, role, object=None):
        """
        Ungrants `role` to `user` (either globally, if `object` is None, or on
        the specific `object`).
        """
        assert principal
        principal = noproxy(principal)
        session = object_session(object) if object is not None else db.session
        manager = self._current_user_manager()

        args = dict(role=role, object=object,
                    anonymous=False, user=None, group=None)
        q = session.query(RoleAssignment)
        q = q.filter(RoleAssignment.role == role,
                     RoleAssignment.object == object)

        if (principal is Anonymous
            or (hasattr(principal, 'is_anonymous') and principal.is_anonymous())):
          args['anonymous'] = True
          q.filter(RoleAssignment.anonymous == False,
                   RoleAssignment.user == None,
                   RoleAssignment.group == None)

        elif isinstance(principal, User):
          args['user'] = principal
          q = q.filter(RoleAssignment.user == principal)
        else:
          args['group'] = principal
          q = q.filter(RoleAssignment.group == principal)

        ra = q.one()
        session.delete(ra)
        audit = SecurityAudit(manager=manager, op=SecurityAudit.REVOKE, **args)
        session.add(audit)
        self._needs_flush()

        if hasattr(principal, "__roles_cache__"):
          del principal.__roles_cache__

      @require_flush
      def get_role_assignements(self, object):
        session = object_session(object) if object is not None else db.session
        if not session:
          session = db.session()
        q = session.query(RoleAssignment)
        q = q.filter(RoleAssignment.object == object)\
             .options(subqueryload('user.groups'))

        role_assignments = q.all()

        results = []
        for ra in role_assignments:
          principal = None
          if ra.anonymous:
            principal = Anonymous
          elif ra.user:
            principal = ra.user
          else:
            principal = ra.group
          results.append((principal, ra.role))
        return results

      #
      # Permission API, currently hardcoded
      #
      def has_permission(self, user, permission, obj=None, inherit=False,
                         roles=None):
        """
        @param `obj`: target object to check permissions.
        @param `inherit`: check with permission inheritance. By default, check only
        local roles.
        @param `roles`: additional valid role or iterable of roles having
                        `permission`.
        """
        if not isinstance(permission, Permission):
          assert permission in PERMISSION
          permission = Permission(permission)
        user = noproxy(user)

        # root always have any permission
        if isinstance(user, User) and user.id == 0:
          return True

        valid_roles = {Manager, Admin}  # have all permissions

        if roles is not None:
          if isinstance(roles, (Role, bytes, unicode)):
            roles = (roles,)

          for r in roles:
            valid_roles.add(Role(r))

        if Anonymous in valid_roles:
          return True

        # implicit role-permission mapping
        if permission in (READ, WRITE,):
          valid_roles.add(Role('writer'))

        if permission == READ:
          valid_roles.add(Role('reader'))

        checked_objs = [None, obj] # first test global roles, then object local
                                   # roles

        if inherit and obj is not None:
          while (obj.inherit_security and obj.parent is not None):
            obj = obj.parent
            checked_objs.append(obj)

        principals = [user] + list(user.groups)
        self._fill_role_cache_batch(principals)

        return any((self.has_role(principal, valid_roles, item)
                    for principal in principals
                    for item in checked_objs))

      def filter_with_permission(self, user, permission, obj_list, inherit=False):
        user = noproxy(user)
        return [ obj for obj in obj_list
                 if self.has_permission(user, permission, obj, inherit) ]

    # Instanciate the service
    security = SecurityService()