"""
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
[docs]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"{}:{:d}".format(object.object_type, 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()