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
This commit is contained in:
Lance Edgar 2020-03-15 11:39:52 -05:00
parent 413e9b0f1e
commit edd48ef667
2 changed files with 157 additions and 14 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -30,7 +30,8 @@ import six
from sqlalchemy import orm from sqlalchemy import orm
from rattail.db import model 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 import colander
from deform import widget as dfwidget from deform import widget as dfwidget
@ -65,6 +66,42 @@ class RolesView(PrincipalMasterView):
g.set_sort_defaults('name') g.set_sort_defaults('name')
g.set_link('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): def unique_name(self, node, value):
query = self.Session.query(model.Role)\ query = self.Session.query(model.Role)\
.filter(model.Role.name == value) .filter(model.Role.name == value)
@ -82,7 +119,7 @@ class RolesView(PrincipalMasterView):
f.set_validator('name', self.unique_name) f.set_validator('name', self.unique_name)
# permissions # 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_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions))
f.set_node('permissions', colander.Set()) f.set_node('permissions', colander.Set())
f.set_widget('permissions', PermissionsWidget(permissions=self.tailbone_permissions)) 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()): if self.editing and role is guest_role(self.Session()):
f.set_readonly('session_timeout') 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): def render_session_timeout(self, role, field):
if role is guest_role(self.Session()): if role is guest_role(self.Session()):
return "(not applicable)" return "(not applicable)"
@ -109,12 +184,34 @@ class RolesView(PrincipalMasterView):
return six.text_type(role.session_timeout) return six.text_type(role.session_timeout)
def objectify(self, form, data=None): 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: if data is None:
data = form.validated data = form.validated
role = super(RolesView, self).objectify(form, data) role = super(RolesView, self).objectify(form, data)
role.permissions = data['permissions'] self.update_permissions(role, data['permissions'])
return role 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): def template_kwargs_view(self, **kwargs):
role = kwargs['instance'] role = kwargs['instance']
if role.users: if role.users:
@ -152,6 +249,24 @@ class RolesView(PrincipalMasterView):
roles.append(role) roles.append(role)
return roles 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): class PermissionsWidget(dfwidget.Widget):
template = 'permissions' template = 'permissions'

View file

@ -132,6 +132,44 @@ class UsersView(PrincipalMasterView):
g.set_link('last_name') g.set_link('last_name')
g.set_link('display_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): def unique_username(self, node, value):
query = self.Session.query(model.User)\ query = self.Session.query(model.User)\
.filter(model.User.username == value) .filter(model.User.username == value)
@ -328,16 +366,6 @@ class UsersView(PrincipalMasterView):
items.append(HTML.tag('li', c=[tags.link_to(text, url)])) items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
return HTML.tag('ul', c=items) 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): def get_row_data(self, user):
return self.Session.query(model.UserEvent)\ return self.Session.query(model.UserEvent)\
.filter(model.UserEvent.user == user) .filter(model.UserEvent.user == user)