3
0
Fork 0

feat: expose Role permissions for editing

This commit is contained in:
Lance Edgar 2024-08-14 15:10:54 -05:00
parent 97e914c2e0
commit 230e2fd1ab
19 changed files with 736 additions and 34 deletions

View file

@ -135,6 +135,12 @@ def make_pyramid_config(settings):
pyramid_config.include('pyramid_mako')
pyramid_config.include('pyramid_tm')
# add some permissions magic
pyramid_config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
pyramid_config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
return pyramid_config

View file

@ -148,3 +148,93 @@ class WuttaSecurityPolicy:
auth = app.get_auth_handler()
user = self.identity(request)
return auth.has_permission(self.db_session, user, permission)
def add_permission_group(pyramid_config, key, label=None, overwrite=True):
"""
Pyramid directive to add a "permission group" to the app's
awareness.
The app must be made aware of all permissions, so they are exposed
when editing a
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
for discovering permissions is in
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
This is usually called from within a master view's
:meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
the permission group which applies to the view model.
A simple example of usage::
pyramid_config.add_permission_group('widgets', label="Widgets")
:param key: Unique key for the permission group. In the context
of a master view, this will be the same as
:attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
:param label: Optional label for the permission group. If not
specified, it is derived from ``key``.
:param overwrite: If the permission group was already established,
this flag controls whether the group's label should be
overwritten (with ``label``).
See also :func:`add_permission()`.
"""
config = pyramid_config.get_settings()['wutta_config']
app = config.get_app()
def action():
perms = pyramid_config.get_settings().get('wutta_permissions', {})
if overwrite or key not in perms:
group = perms.setdefault(key, {'key': key})
group['label'] = label or app.make_title(key)
pyramid_config.add_settings({'wutta_permissions': perms})
pyramid_config.action(None, action)
def add_permission(pyramid_config, groupkey, key, label=None):
"""
Pyramid directive to add a single "permission" to the app's
awareness.
The app must be made aware of all permissions, so they are exposed
when editing a
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
for discovering permissions is in
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
This is usually called from within a master view's
:meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
"known" permissions based on master view feature flags
(:attr:`~wuttaweb.views.master.MasterView.viewable`,
:attr:`~wuttaweb.views.master.MasterView.editable`, etc.).
A simple example of usage::
pyramid_config.add_permission('widgets', 'widgets.polish',
label="Polish all the widgets")
:param key: Unique key for the permission group. In the context
of a master view, this will be the same as
:attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
:param key: Unique key for the permission. This should be the
"complete" permission name which includes the permission
prefix.
:param label: Optional label for the permission. If not
specified, it is derived from ``key``.
See also :func:`add_permission_group()`.
"""
config = pyramid_config.get_settings()['wutta_config']
app = config.get_app()
def action():
perms = pyramid_config.get_settings().get('wutta_permissions', {})
group = perms.setdefault(groupkey, {'key': groupkey})
group.setdefault('label', app.make_title(groupkey))
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
perm['label'] = label or app.make_title(key)
pyramid_config.add_settings({'wutta_permissions': perms})
pyramid_config.action(None, action)

View file

@ -259,20 +259,38 @@ class PersonRef(ObjectRef):
return query.order_by(self.model_class.full_name)
class RoleRefs(colander.Set):
class WuttaSet(colander.Set):
"""
Form schema type for the User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
association proxy field.
Custom schema type for :class:`python:set` fields.
This is a subclass of :class:`colander.Set`, but adds
Wutta-related params to the constructor.
:param request: Current :term:`request` object.
:param session: Optional :term:`db session` to use instead of
:class:`wuttaweb.db.Session`.
"""
def __init__(self, request, session=None, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, request, session=None):
super().__init__()
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
self.session = session or Session()
class RoleRefs(WuttaSet):
"""
Form schema type for the User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
association proxy field.
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
values for underlying data format.
"""
def widget_maker(self, **kwargs):
"""
Constructs a default widget for the field.
@ -298,3 +316,42 @@ class RoleRefs(colander.Set):
kwargs['values'] = values
return widgets.RoleRefsWidget(self.request, **kwargs)
class Permissions(WuttaSet):
"""
Form schema type for the Role
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
association proxy field.
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
values for underlying data format.
:param permissions: Dict with all possible permissions. Should be
in the same format as returned by
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
"""
def __init__(self, request, permissions, *args, **kwargs):
super().__init__(request, *args, **kwargs)
self.permissions = permissions
def widget_maker(self, **kwargs):
"""
Constructs a default widget for the field.
:returns: Instance of
:class:`~wuttaweb.forms.widgets.PermissionsWidget`.
"""
kwargs.setdefault('session', self.session)
kwargs.setdefault('permissions', self.permissions)
if 'values' not in kwargs:
values = []
for gkey, group in self.permissions.items():
for pkey, perm in group['perms'].items():
values.append((pkey, perm['label']))
kwargs['values'] = values
return widgets.PermissionsWidget(self.request, **kwargs)

View file

@ -103,14 +103,20 @@ class NotesWidget(TextAreaWidget):
readonly_template = 'readonly/notes'
class RoleRefsWidget(CheckboxChoiceWidget):
class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
"""
Widget for use with User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
Custom widget for :class:`python:set` fields.
This is a subclass of
:class:`deform:deform.widget.CheckboxChoiceWidget` and uses these
Deform templates:
:class:`deform:deform.widget.CheckboxChoiceWidget`, but adds
Wutta-related params to the constructor.
:param request: Current :term:`request` object.
:param session: Optional :term:`db session` to use instead of
:class:`wuttaweb.db.Session`.
It uses these Deform templates:
* ``checkbox_choice``
* ``readonly/checkbox_choice``
@ -123,6 +129,15 @@ class RoleRefsWidget(CheckboxChoiceWidget):
self.app = self.config.get_app()
self.session = session or Session()
class RoleRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
"""
def serialize(self, field, cstruct, **kw):
""" """
# special logic when field is editable
@ -143,3 +158,32 @@ class RoleRefsWidget(CheckboxChoiceWidget):
# default logic from here
return super().serialize(field, cstruct, **kw)
class PermissionsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with Role
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
field.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
these Deform templates:
* ``permissions``
* ``readonly/permissions``
"""
template = 'permissions'
readonly_template = 'readonly/permissions'
def serialize(self, field, cstruct, **kw):
""" """
kw.setdefault('permissions', self.permissions)
if 'values' not in kw:
values = []
for gkey, group in self.permissions.items():
for pkey, perm in group['perms'].items():
values.append((pkey, perm['label']))
kw['values'] = values
return super().serialize(field, cstruct, **kw)

View file

@ -0,0 +1,23 @@
<div>
${field.start_sequence()}
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
<div class="card block"
tal:define="perms permissions[groupkey]['perms'];">
<header class="card-header">
<p class="card-header-title">${permissions[groupkey]['label']}</p>
</header>
<div class="card-content">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
<b-checkbox name="checkbox"
native-value="${key}"
tal:attributes=":value 'true' if key in cstruct else 'false';">
${perms[key]['label']}
</b-checkbox>
</tal:loop>
</div>
</div>
</div>
</tal:loop>
${field.end_sequence()}
</div>

View file

@ -0,0 +1,18 @@
<div>
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
<div class="card block"
tal:condition="any([key in cstruct for key in permissions[groupkey]['perms']])"
tal:define="perms permissions[groupkey]['perms'];">
<header class="card-header">
<p class="card-header-title">${permissions[groupkey]['label']}</p>
</header>
<div class="card-content">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
<span tal:condition="key in cstruct">${perms[key]['label']}</span>
</tal:loop>
</div>
</div>
</div>
</tal:loop>
</div>

View file

@ -139,6 +139,14 @@ class MasterView(View):
Code should not access this directly but instead call
:meth:`get_route_prefix()`.
.. attribute:: permission_prefix
Optional override for the view's permission prefix,
e.g. ``'wutta_widgets'``.
Code should not access this directly but instead call
:meth:`get_permission_prefix()`.
.. attribute:: url_prefix
Optional override for the view's URL prefix,
@ -1442,6 +1450,29 @@ class MasterView(View):
model_name = cls.get_model_name_normalized()
return f'{model_name}s'
@classmethod
def get_permission_prefix(cls):
"""
Returns the "permission prefix" for the master view. This
prefix is used for all permissions defined by the view class.
For instance if permission prefix is ``'widgets'`` then a view
might have these permissions:
* ``'widgets.list'``
* ``'widgets.create'``
* ``'widgets.edit'``
* ``'widgets.delete'``
The default logic will call :meth:`get_route_prefix()` and use
that value as-is. A subclass may override by assigning
:attr:`permission_prefix`.
"""
if hasattr(cls, 'permission_prefix'):
return cls.permission_prefix
return cls.get_route_prefix()
@classmethod
def get_url_prefix(cls):
"""
@ -1581,13 +1612,24 @@ class MasterView(View):
@classmethod
def _defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural()
# permission group
config.add_wutta_permission_group(permission_prefix,
model_title_plural,
overwrite=False)
# index
if cls.listable:
config.add_route(route_prefix, f'{url_prefix}/')
config.add_view(cls, attr='index',
route_name=route_prefix)
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.list',
f"Browse / search {model_title_plural}")
# create
if cls.creatable:
@ -1595,6 +1637,9 @@ class MasterView(View):
f'{url_prefix}/new')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.create',
f"Create new {model_title}")
# view
if cls.viewable:
@ -1602,6 +1647,9 @@ class MasterView(View):
config.add_route(f'{route_prefix}.view', instance_url_prefix)
config.add_view(cls, attr='view',
route_name=f'{route_prefix}.view')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.view',
f"View {model_title}")
# edit
if cls.editable:
@ -1610,6 +1658,9 @@ class MasterView(View):
f'{instance_url_prefix}/edit')
config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.edit')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.edit',
f"Edit {model_title}")
# delete
if cls.deletable:
@ -1618,6 +1669,9 @@ class MasterView(View):
f'{instance_url_prefix}/delete')
config.add_view(cls, attr='delete',
route_name=f'{route_prefix}.delete')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.delete',
f"Delete {model_title}")
# configure
if cls.configurable:
@ -1625,3 +1679,6 @@ class MasterView(View):
f'{url_prefix}/configure')
config.add_view(cls, attr='configure',
route_name=f'{route_prefix}.configure')
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.configure',
f"Configure {model_title_plural}")

View file

@ -32,6 +32,8 @@ class PersonView(MasterView):
"""
Master view for people.
Default route prefix is ``people``.
Notable URLs provided by this class:
* ``/people/``

View file

@ -28,12 +28,15 @@ from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView
from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import Permissions
class RoleView(MasterView):
"""
Master view for roles.
Default route prefix is ``roles``.
Notable URLs provided by this class:
* ``/roles/``
@ -69,6 +72,7 @@ class RoleView(MasterView):
def configure_form(self, f):
""" """
super().configure_form(f)
role = f.model_instance
# never show these
f.remove('permission_refs',
@ -80,6 +84,13 @@ class RoleView(MasterView):
# notes
f.set_widget('notes', widgets.NotesWidget())
# permissions
f.append('permissions')
self.wutta_permissions = self.get_available_permissions()
f.set_node('permissions', Permissions(self.request, permissions=self.wutta_permissions))
if not self.creating:
f.set_default('permissions', list(role.permissions))
def unique_name(self, node, value):
""" """
model = self.app.model
@ -95,6 +106,113 @@ class RoleView(MasterView):
if query.count():
node.raise_invalid("Name must be unique")
def get_available_permissions(self):
"""
Returns all "available" permissions. This is used when
viewing or editing a role; the result is passed into the
:class:`~wuttaweb.forms.schema.Permissions` field schema.
The app itself must be made aware of each permission, in order
for them to found by this method. This is done via
:func:`~wuttaweb.auth.add_permission_group()` and
:func:`~wuttaweb.auth.add_permission()`.
When in "view" (readonly) mode, this method will return the
full set of known permissions.
However in "edit" mode, it will prune the set to remove any
permissions which the current user does not also have. The
idea here is to allow "many" users to manage roles, but ensure
they cannot "break out" of their own role by assigning extra
permissions to it.
The permissions returned will also be grouped, and each single
permission is also represented as a simple dict, e.g.::
{
'books': {
'key': 'books',
'label': "Books",
'perms': {
'books.list': {
'key': 'books.list',
'label': "Browse / search Books",
},
'books.view': {
'key': 'books.view',
'label': "View Book",
},
},
},
'widgets': {
'key': 'widgets',
'label': "Widgets",
'perms': {
'widgets.list': {
'key': 'widgets.list',
'label': "Browse / search Widgets",
},
'widgets.view': {
'key': 'widgets.view',
'label': "View Widget",
},
},
},
}
"""
# get all known permissions from settings cache
permissions = self.request.registry.settings.get('wutta_permissions', {})
# when viewing, we allow all permissions to be exposed for all users
if self.viewing:
return permissions
# admin user gets to manage all permissions
if self.request.is_admin:
return permissions
# non-admin user can only see permissions they're granted
available = {}
for gkey, group in permissions.items():
for pkey, perm in group['perms'].items():
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 objectify(self, form):
""" """
# normal logic first
role = super().objectify(form)
# update permissions for role
self.update_permissions(role, form)
return role
def update_permissions(self, role, form):
""" """
if 'permissions' not in form.validated:
return
auth = self.app.get_auth_handler()
available = self.wutta_permissions
permissions = form.validated['permissions']
for gkey, group in available.items():
for pkey, perm in group['perms'].items():
if pkey in permissions:
auth.grant_permission(role, pkey)
else:
auth.revoke_permission(role, pkey)
def defaults(config, **kwargs):
base = globals()

View file

@ -35,6 +35,8 @@ class AppInfoView(MasterView):
"""
Master view for the core app info, to show/edit config etc.
Default route prefix is ``appinfo``.
Notable URLs provided by this class:
* ``/appinfo/``
@ -137,6 +139,8 @@ class SettingView(MasterView):
"""
Master view for the "raw" settings table.
Default route prefix is ``settings``.
Notable URLs provided by this class:
* ``/settings/``

View file

@ -36,6 +36,8 @@ class UserView(MasterView):
"""
Master view for users.
Default route prefix is ``users``.
Notable URLs provided by this class:
* ``/users/``