Introduced role-based capability system.
It's still rather limited and hard-coded, but it works.
This commit is contained in:
@@ -16,7 +16,7 @@ blueprint_api = Blueprint('projects_api', __name__)
|
||||
|
||||
|
||||
@blueprint_api.route('/create', methods=['POST'])
|
||||
@authorization.require_login(require_roles={'admin', 'subscriber', 'demo'})
|
||||
@authorization.require_login(require_cap='subscriber')
|
||||
def create_project(overrides=None):
|
||||
"""Creates a new project."""
|
||||
|
||||
|
@@ -265,12 +265,19 @@ def merge_permissions(*args):
|
||||
|
||||
|
||||
def require_login(require_roles=set(),
|
||||
require_cap='',
|
||||
require_all=False):
|
||||
"""Decorator that enforces users to authenticate.
|
||||
|
||||
Optionally only allows access to users with a certain role.
|
||||
Optionally only allows access to users with a certain role and/or capability.
|
||||
|
||||
Either check on roles or on a capability, but never on both. There is no
|
||||
require_all check for capabilities; if you need to check for multiple
|
||||
capabilities at once, it's a sign that you need to add another capability
|
||||
and give it to everybody that needs it.
|
||||
|
||||
:param require_roles: set of roles.
|
||||
:param require_cap: a capability.
|
||||
:param require_all:
|
||||
When False (the default): if the user's roles have a
|
||||
non-empty intersection with the given roles, access is granted.
|
||||
@@ -279,7 +286,13 @@ def require_login(require_roles=set(),
|
||||
"""
|
||||
|
||||
if not isinstance(require_roles, set):
|
||||
raise TypeError('require_roles param should be a set, but is a %r' % type(require_roles))
|
||||
raise TypeError(f'require_roles param should be a set, but is {type(require_roles)!r}')
|
||||
|
||||
if not isinstance(require_cap, str):
|
||||
raise TypeError(f'require_caps param should be a str, but is {type(require_cap)!r}')
|
||||
|
||||
if require_roles and require_cap:
|
||||
raise ValueError('either use require_roles or require_cap, but not both')
|
||||
|
||||
if require_all and not require_roles:
|
||||
raise ValueError('require_login(require_all=True) cannot be used with empty require_roles.')
|
||||
@@ -287,15 +300,21 @@ def require_login(require_roles=set(),
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not user_matches_roles(require_roles, require_all):
|
||||
if g.current_user is None:
|
||||
# We don't need to log at a higher level, as this is very common.
|
||||
# Many browsers first try to see whether authentication is needed
|
||||
# at all, before sending the password.
|
||||
log.debug('Unauthenticated acces to %s attempted.', func)
|
||||
else:
|
||||
log.warning('User %s is authenticated, but does not have required roles %s to '
|
||||
'access %s', g.current_user['user_id'], require_roles, func)
|
||||
if g.current_user is None:
|
||||
# We don't need to log at a higher level, as this is very common.
|
||||
# Many browsers first try to see whether authentication is needed
|
||||
# at all, before sending the password.
|
||||
log.debug('Unauthenticated acces to %s attempted.', func)
|
||||
abort(403)
|
||||
|
||||
if require_roles and not g.current_user.matches_roles(require_roles, require_all):
|
||||
log.warning('User %s is authenticated, but does not have required roles %s to '
|
||||
'access %s', g.current_user['user_id'], require_roles, func)
|
||||
abort(403)
|
||||
|
||||
if require_cap and not g.current_user.has_cap(require_cap):
|
||||
log.warning('User %s is authenticated, but does not have required capability %s to '
|
||||
'access %s', g.current_user.user_id, require_cap, func)
|
||||
abort(403)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
@@ -352,6 +371,23 @@ def user_has_role(role, user: UserClass=None):
|
||||
return user.has_role(role)
|
||||
|
||||
|
||||
def user_has_cap(capability: str, user: UserClass=None) -> bool:
|
||||
"""Returns True iff the user is logged in and has the given capability."""
|
||||
|
||||
assert capability
|
||||
|
||||
if user is None:
|
||||
user = g.get('current_user')
|
||||
|
||||
if user is None:
|
||||
return False
|
||||
|
||||
if not isinstance(user, UserClass):
|
||||
raise TypeError(f'user should be instance of UserClass, not {type(user)}')
|
||||
|
||||
return user.has_cap(capability)
|
||||
|
||||
|
||||
def user_matches_roles(require_roles=set(),
|
||||
require_all=False):
|
||||
"""Returns True iff the user's roles matches the query.
|
||||
|
@@ -16,6 +16,14 @@ from ..api.utils import authentication
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Mapping from user role to capabilities obtained by users with that role.
|
||||
CAPABILITIES = collections.defaultdict(**{
|
||||
'subscriber': {'subscriber', 'home-project'},
|
||||
'demo': {'subscriber', 'home-project'},
|
||||
'admin': {'subscriber', 'home-project', 'video-encoding', 'admin',
|
||||
'view-pending-nodes', 'edit-project-node-types'},
|
||||
}, default_factory=frozenset)
|
||||
|
||||
|
||||
class UserClass(flask_login.UserMixin):
|
||||
def __init__(self, token: typing.Optional[str]):
|
||||
@@ -30,6 +38,7 @@ class UserClass(flask_login.UserMixin):
|
||||
self.roles: typing.List[str] = []
|
||||
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
|
||||
self.group_ids: typing.List[bson.ObjectId] = []
|
||||
self.capabilities: typing.Set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def construct(cls, token: str, db_user: dict) -> 'UserClass':
|
||||
@@ -48,6 +57,7 @@ class UserClass(flask_login.UserMixin):
|
||||
user.objectid = str(db_user['_id'])
|
||||
user.gravatar = utils.gravatar(user.email)
|
||||
user.groups = [str(g) for g in user.group_ids]
|
||||
user.collect_capabilities()
|
||||
|
||||
return user
|
||||
|
||||
@@ -71,6 +81,12 @@ class UserClass(flask_login.UserMixin):
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def collect_capabilities(self):
|
||||
"""Constructs the capabilities set given the user's current roles."""
|
||||
|
||||
self.capabilities = set().union(*(CAPABILITIES.get(role, frozenset())
|
||||
for role in self.roles))
|
||||
|
||||
def has_role(self, *roles):
|
||||
"""Returns True iff the user has one or more of the given roles."""
|
||||
|
||||
@@ -79,6 +95,14 @@ class UserClass(flask_login.UserMixin):
|
||||
|
||||
return bool(set(self.roles).intersection(set(roles)))
|
||||
|
||||
def has_cap(self, *capabilities: typing.Iterable[str]) -> bool:
|
||||
"""Returns True iff the user has one or more of the given capabilities."""
|
||||
|
||||
if not self.capabilities:
|
||||
return False
|
||||
|
||||
return bool(set(self.capabilities).intersection(set(capabilities)))
|
||||
|
||||
def matches_roles(self,
|
||||
require_roles=set(),
|
||||
require_all=False) -> bool:
|
||||
@@ -113,6 +137,8 @@ class AnonymousUser(flask_login.AnonymousUserMixin, UserClass):
|
||||
def has_role(self, *roles):
|
||||
return False
|
||||
|
||||
def has_cap(self, *capabilities):
|
||||
return False
|
||||
|
||||
|
||||
def _load_user(token) -> typing.Union[UserClass, AnonymousUser]:
|
||||
|
@@ -205,6 +205,8 @@ def settings_billing():
|
||||
@blueprint.route('/u/<user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def users_edit(user_id):
|
||||
from pillar.auth import UserClass
|
||||
|
||||
if not current_user.has_role('admin'):
|
||||
return abort(403)
|
||||
api = system_util.pillar_api()
|
||||
@@ -221,7 +223,9 @@ def users_edit(user_id):
|
||||
else:
|
||||
form.roles.data = user.roles
|
||||
form.email.data = user.email
|
||||
return render_template('users/edit_embed.html', user=user, form=form)
|
||||
|
||||
user_ob = UserClass.construct('', db_user=user.to_dict())
|
||||
return render_template('users/edit_embed.html', user=user_ob, form=form)
|
||||
|
||||
|
||||
def _users_edit(form, user, api):
|
||||
|
Reference in New Issue
Block a user