From edd48ef66739f7f038f1aee8015e7a4fd3b26cef Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Mar 2020 11:39:52 -0500 Subject: [PATCH] Misc. changes to User, Role permissions and management thereof * only "root" can edit the Administrator role * edit of Authenticated and Guest roles requires dedicated permission * edit of role(s) to which current user belongs, requires dedicated permission * delete is not allowed for any built-in role * when editing a role, user can only add/remove permissions they themselves have * settings can define some "protected" users, which only "root" can edit/delete --- tailbone/views/roles.py | 123 ++++++++++++++++++++++++++++++++++++++-- tailbone/views/users.py | 48 ++++++++++++---- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 3d10d349..5e9b0887 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,8 @@ import six from sqlalchemy import orm from rattail.db import model -from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role +from rattail.db.auth import (has_permission, grant_permission, revoke_permission, + administrator_role, guest_role, authenticated_role) import colander from deform import widget as dfwidget @@ -65,6 +66,42 @@ class RolesView(PrincipalMasterView): g.set_sort_defaults('name') g.set_link('name') + def editable_instance(self, role): + """ + We must prevent edit for certain built-in roles etc., depending on + current user's permissions. + """ + # only "root" can edit Administrator + if role is administrator_role(self.Session()): + return self.request.is_root + + # can edit Authenticated only if user has permission + if role is authenticated_role(self.Session()): + return self.has_perm('edit_authenticated') + + # can edit Guest only if user has permission + if role is guest_role(self.Session()): + return self.has_perm('edit_guest') + + # current user can edit their own roles, only if they have permission + user = self.request.user + if user and role in user.roles: + return self.has_perm('edit_my') + + return True + + def deletable_instance(self, role): + """ + We must prevent deletion for all built-in roles. + """ + if role is administrator_role(self.Session()): + return False + if role is authenticated_role(self.Session()): + return False + if role is guest_role(self.Session()): + return False + return True + def unique_name(self, node, value): query = self.Session.query(model.Role)\ .filter(model.Role.name == value) @@ -82,7 +119,7 @@ class RolesView(PrincipalMasterView): f.set_validator('name', self.unique_name) # permissions - self.tailbone_permissions = self.request.registry.settings.get('tailbone_permissions', {}) + self.tailbone_permissions = self.get_available_permissions() f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) f.set_widget('permissions', PermissionsWidget(permissions=self.tailbone_permissions)) @@ -101,6 +138,44 @@ class RolesView(PrincipalMasterView): if self.editing and role is guest_role(self.Session()): f.set_readonly('session_timeout') + def get_available_permissions(self): + """ + Should return a dictionary with all "available" permissions. The + result of this will vary depending on the current user, because a user + is only allowed to "manage" permissions which they themselves have been + granted. + + In other words the return value will be the "full set" of permissions, + if the current user is an admin; otherwise it will be the "subset" of + permissions which the current user has been granted. + """ + # fetch full set of permissions registered in the app + permissions = self.request.registry.settings.get('tailbone_permissions', {}) + + # admin user gets to manage all permissions + if self.request.is_admin: + return permissions + + # when viewing, we allow all permissions to be exposed for all users + if self.viewing: + return permissions + + # non-admin user can only manage permissions they've been granted + # TODO: it seems a bit ugly, to "rebuild" permission groups like this, + # but not sure if there's a better way? + available = {} + for gkey, group in six.iteritems(permissions): + for pkey, perm in six.iteritems(group['perms']): + if self.request.has_perm(pkey): + if gkey not in available: + available[gkey] = { + 'key': gkey, + 'label': group['label'], + 'perms': {}, + } + available[gkey]['perms'][pkey] = perm + return available + def render_session_timeout(self, role, field): if role is guest_role(self.Session()): return "(not applicable)" @@ -109,12 +184,34 @@ class RolesView(PrincipalMasterView): return six.text_type(role.session_timeout) def objectify(self, form, data=None): + """ + Supplements the default logic, as follows: + + The role is updated as per usual, and then we also invoke + :meth:`update_permissions()` in order to correctly handle that part, + i.e. ensure the user can't modify permissions which they do not have. + """ if data is None: data = form.validated role = super(RolesView, self).objectify(form, data) - role.permissions = data['permissions'] + self.update_permissions(role, data['permissions']) return role + def update_permissions(self, role, permissions): + """ + Update the given role's permissions, according to those specified. + Note that this will not simply "clobber" the role's existing + permissions, but rather each "available" permission (depends on current + user) will be examined individually, and updated as needed. + """ + available = self.tailbone_permissions + for gkey, group in six.iteritems(available): + for pkey, perm in six.iteritems(group['perms']): + if pkey in permissions: + grant_permission(role, pkey) + else: + revoke_permission(role, pkey) + def template_kwargs_view(self, **kwargs): role = kwargs['instance'] if role.users: @@ -152,6 +249,24 @@ class RolesView(PrincipalMasterView): roles.append(role) return roles + @classmethod + def defaults(cls, config): + cls._principal_defaults(config) + cls._role_defaults(config) + cls._defaults(config) + + @classmethod + def _role_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() + + # extra permissions for editing built-in roles etc. + config.add_tailbone_permission(permission_prefix, '{}.edit_authenticated'.format(permission_prefix), + "Edit the \"Authenticated\" Role") + config.add_tailbone_permission(permission_prefix, '{}.edit_guest'.format(permission_prefix), + "Edit the \"Guest\" Role") + config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix), + "Edit Role(s) to which current user belongs") + class PermissionsWidget(dfwidget.Widget): template = 'permissions' diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 21e7538f..8b45ca55 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -132,6 +132,44 @@ class UsersView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') + def editable_instance(self, user): + """ + If the given user is "protected" then we only allow edit if current + user is "root". But if the given user is not protected, this simply + returns ``True``. + """ + if self.user_is_protected(user): + return self.request.is_root + return True + + def deletable_instance(self, user): + """ + If the given user is "protected" then we only allow delete if current + user is "root". But if the given user is not protected, this simply + returns ``True``. + """ + if self.user_is_protected(user): + return self.request.is_root + return True + + def user_is_protected(self, user): + """ + This logic will consult the settings, for a list of "protected" + usernames, which should require root privileges to edit. If no setting + is found, or the given ``user`` is not represented in the setting, then + edit is allowed. + + But if there is a setting, and the ``user`` is represented in it, then + this method will return ``True`` only if the "current" app user is + "root", otherwise will return ``False``. + """ + if not hasattr(self, 'protected_usernames'): + self.protected_usernames = self.rattail_config.getlist( + 'tailbone', 'protected_usernames') + if self.protected_usernames and user.username in self.protected_usernames: + return True + return False + def unique_username(self, node, value): query = self.Session.query(model.User)\ .filter(model.User.username == value) @@ -328,16 +366,6 @@ class UsersView(PrincipalMasterView): items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) - def editable_instance(self, user): - if self.rattail_config.demo(): - return user.username != 'chuck' - return True - - def deletable_instance(self, user): - if self.rattail_config.demo(): - return user.username != 'chuck' - return True - def get_row_data(self, user): return self.Session.query(model.UserEvent)\ .filter(model.UserEvent.user == user)