From 47669a23bc02834620a30395941273df931c2331 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Oct 2019 22:31:19 -0500 Subject: [PATCH] Add support for "local only" Person, User, plus related security also add "view / edit roles for user" permissions --- tailbone/views/master.py | 65 +++++++++++++++++++++++++-- tailbone/views/users.py | 97 +++++++++++++++++++++++++++++++--------- 2 files changed, 138 insertions(+), 24 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 334c6df7..5e103d2d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -98,6 +98,10 @@ class MasterView(View): supports_prev_next = False supports_import_batch_from_file = False + # set to True to add "View *global* Objects" permission, and + # expose / leverage the ``local_only`` object flag + secure_global_objects = False + # quickie (search) supports_quickie_search = False expose_quickie_search = False @@ -272,6 +276,16 @@ class MasterView(View): labels.update(cls.row_labels) return labels + def has_perm(self, name): + """ + Convenience function which returns boolean which should indicate + whether the current user has been granted the named permission. Note + that this method actually assembles the permission name, using the + ``name`` provided, but also :meth:`get_permission_prefix()`. + """ + return self.request.has_perm('{}.{}'.format( + self.get_permission_prefix(), name)) + ############################## # Available Views ############################## @@ -390,8 +404,16 @@ class MasterView(View): return defaults def configure_grid(self, grid): + """ + Perform "final" configuration for the main data grid. + """ self.set_labels(grid) + # hide "local only" grid filter, unless global access allowed + if self.secure_global_objects: + if not self.has_perm('view_global'): + grid.remove_filter('local_only') + def grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to @@ -1362,6 +1384,11 @@ class MasterView(View): self.set_labels(form) + # hide "local only" field, unless global access allowed + if self.secure_global_objects: + if not self.has_perm('view_global'): + form.remove_field('local_only') + def configure_mobile_form(self, form): """ Configure the main "mobile" form for the view's data model. @@ -2542,7 +2569,15 @@ class MasterView(View): users. You would modify the base query to hide what you wanted, regardless of the user's filter selections. """ - return session.query(self.get_model_class()) + model_class = self.get_model_class() + query = session.query(model_class) + + # only show "local only" objects, unless global access allowed + if self.secure_global_objects: + if not self.has_perm('view_global'): + query = query.filter(model_class.local_only == True) + + return query def get_effective_query(self, session=None, **kwargs): return self.get_effective_data(session=session, **kwargs) @@ -2802,7 +2837,6 @@ class MasterView(View): obj = self.Session.query(self.get_model_class()).get(key) if not obj: raise self.notfound() - return obj else: # composite key; fetch accordingly # TODO: should perhaps use filter() instead of get() here? @@ -2814,7 +2848,14 @@ class MasterView(View): obj = query.one() except NoResultFound: raise self.notfound() - return obj + + # pretend global object doesn't exist, unless access allowed + if self.secure_global_objects: + if not obj.local_only: + if not self.has_perm('view_global'): + raise self.notfound() + + return obj def get_instance_title(self, instance): """ @@ -2966,11 +3007,26 @@ class MasterView(View): return False def objectify(self, form, data=None): + """ + Create and/or update the model instance from the given form, and return + this object. + + .. todo:: + This needs a better explanation. And probably tests. + """ if data is None: data = form.validated + obj = form.schema.objectify(data, context=form.model_instance) + if self.is_contact: obj = self.objectify_contact(obj, data) + + # force "local only" flag unless global access granted + if self.secure_global_objects: + if not self.has_perm('view_global'): + obj.local_only = True + return obj def objectify_contact(self, contact, data): @@ -3598,6 +3654,9 @@ class MasterView(View): if cls.has_pk_fields: config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix), "View all PK-type fields for {}".format(model_title_plural)) + if cls.secure_global_objects: + config.add_tailbone_permission(permission_prefix, '{}.view_global'.format(permission_prefix), + "View *global* {}".format(model_title_plural)) # view by grid index config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix)) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index be4b1ea7..91c2d194 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -32,7 +32,7 @@ import six from sqlalchemy import orm from rattail.db import model -from rattail.db.auth import guest_role, authenticated_role, set_user_password, has_permission +from rattail.db.auth import administrator_role, guest_role, authenticated_role, set_user_password, has_permission import colander from deform import widget as dfwidget @@ -96,9 +96,13 @@ class UsersView(PrincipalMasterView): ] def query(self, session): - return session.query(model.User)\ - .outerjoin(model.Person)\ - .options(orm.joinedload(model.User.person)) + query = super(UsersView, self).query(session) + + # bring in the related Person(s) + query = query.outerjoin(model.Person)\ + .options(orm.joinedload(model.User.person)) + + return query def configure_grid(self, g): super(UsersView, self).configure_grid(g) @@ -194,17 +198,22 @@ class UsersView(PrincipalMasterView): # roles f.set_renderer('roles', self.render_roles) if self.creating or self.editing: - roles = self.get_possible_roles().all() - role_values = [(s.uuid, six.text_type(s)) for s in roles] - f.set_node('roles', colander.Set()) - size = len(roles) - if size < 3: - size = 3 - f.set_widget('roles', dfwidget.SelectWidget(multiple=True, - size=size, - values=role_values)) - if self.editing: - f.set_default('roles', [r.uuid for r in user.roles]) + if not self.has_perm('edit_roles'): + f.remove_field('roles') + else: + roles = self.get_possible_roles().all() + role_values = [(s.uuid, six.text_type(s)) for s in roles] + f.set_node('roles', colander.Set()) + size = len(roles) + if size < 3: + size = 3 + f.set_widget('roles', dfwidget.SelectWidget(multiple=True, + size=size, + values=role_values)) + if self.editing: + f.set_default('roles', [r.uuid for r in user.roles]) + elif not self.has_perm('view_roles'): + f.remove_field('roles') f.set_label('display_name', "Full Name") @@ -225,10 +234,17 @@ class UsersView(PrincipalMasterView): f.remove('set_password') def get_possible_roles(self): + + # some roles should never have users "belong" to them excluded = [ guest_role(self.Session()).uuid, authenticated_role(self.Session()).uuid, ] + + # only allow "root" user to change admin role membership + if not self.request.is_root: + excluded.append(administrator_role(self.Session()).uuid) + return self.Session.query(model.Role)\ .filter(~model.Role.uuid.in_(excluded))\ .order_by(model.Role.name) @@ -259,6 +275,11 @@ class UsersView(PrincipalMasterView): if 'display' in names: user.person.display_name = names['display'] + # force "local only" flag unless global access granted + if self.secure_global_objects: + if not self.has_perm('view_global'): + user.person.local_only = True + # maybe set user password if data['set_password']: set_user_password(user, data['set_password']) @@ -271,13 +292,22 @@ class UsersView(PrincipalMasterView): def update_roles(self, user, data): old_roles = set([r.uuid for r in user.roles]) new_roles = data['roles'] + admin = administrator_role(self.Session()) + + # add any new roles for the user, taking care not to add the admin role + # unless acting as root for uuid in new_roles: if uuid not in old_roles: - user._roles.append(model.UserRole(role_uuid=uuid)) + if self.request.is_root or uuid != admin.uuid: + user._roles.append(model.UserRole(role_uuid=uuid)) + + # remove any roles which were *not* specified, although must take care + # not to remove admin role, unless acting as root for uuid in old_roles: if uuid not in new_roles: - role = self.Session.query(model.Role).get(uuid) - user.roles.remove(role) + if self.request.is_root or uuid != admin.uuid: + role = self.Session.query(model.Role).get(uuid) + user.roles.remove(role) def render_person(self, user, field): person = user.person @@ -373,6 +403,34 @@ class UsersView(PrincipalMasterView): @classmethod def defaults(cls, config): + + # TODO: probably should stop doing this one + cls._vue_index_defaults(config) + + cls._user_defaults(config) + cls._principal_defaults(config) + cls._defaults(config) + + @classmethod + def _user_defaults(cls, config): + """ + Provide extra default configuration for the User master view. + """ + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + + # view/edit roles + config.add_tailbone_permission(permission_prefix, '{}.view_roles'.format(permission_prefix), + "View the Roles to which a {} belongs".format(model_title)) + config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), + "Edit the Roles to which a {} belongs".format(model_title)) + + @classmethod + def _vue_index_defaults(cls, config): + """ + Provide default configuration for the "Vue.js index" view. This was + essentially an experiment and probably should be abandoned. + """ rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() @@ -383,9 +441,6 @@ class UsersView(PrincipalMasterView): config.add_view(cls, attr='vue_index', route_name='{}.vue_index'.format(route_prefix), permission='{}.list'.format(permission_prefix)) - cls._principal_defaults(config) - cls._defaults(config) - class UserEventsView(MasterView): """