Add support for "local only" Person, User, plus related security

also add "view / edit roles for user" permissions
This commit is contained in:
Lance Edgar 2019-10-04 22:31:19 -05:00
parent 4d1fa4f2d6
commit 47669a23bc
2 changed files with 138 additions and 24 deletions

View file

@ -98,6 +98,10 @@ class MasterView(View):
supports_prev_next = False supports_prev_next = False
supports_import_batch_from_file = 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) # quickie (search)
supports_quickie_search = False supports_quickie_search = False
expose_quickie_search = False expose_quickie_search = False
@ -272,6 +276,16 @@ class MasterView(View):
labels.update(cls.row_labels) labels.update(cls.row_labels)
return 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 # Available Views
############################## ##############################
@ -390,8 +404,16 @@ class MasterView(View):
return defaults return defaults
def configure_grid(self, grid): def configure_grid(self, grid):
"""
Perform "final" configuration for the main data grid.
"""
self.set_labels(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): def grid_extra_class(self, obj, i):
""" """
Returns string of extra class(es) for the table row corresponding to Returns string of extra class(es) for the table row corresponding to
@ -1362,6 +1384,11 @@ class MasterView(View):
self.set_labels(form) 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): def configure_mobile_form(self, form):
""" """
Configure the main "mobile" form for the view's data model. 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, users. You would modify the base query to hide what you wanted,
regardless of the user's filter selections. 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): def get_effective_query(self, session=None, **kwargs):
return self.get_effective_data(session=session, **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) obj = self.Session.query(self.get_model_class()).get(key)
if not obj: if not obj:
raise self.notfound() raise self.notfound()
return obj
else: # composite key; fetch accordingly else: # composite key; fetch accordingly
# TODO: should perhaps use filter() instead of get() here? # TODO: should perhaps use filter() instead of get() here?
@ -2814,6 +2848,13 @@ class MasterView(View):
obj = query.one() obj = query.one()
except NoResultFound: except NoResultFound:
raise self.notfound() raise self.notfound()
# 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 return obj
def get_instance_title(self, instance): def get_instance_title(self, instance):
@ -2966,11 +3007,26 @@ class MasterView(View):
return False return False
def objectify(self, form, data=None): 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: if data is None:
data = form.validated data = form.validated
obj = form.schema.objectify(data, context=form.model_instance) obj = form.schema.objectify(data, context=form.model_instance)
if self.is_contact: if self.is_contact:
obj = self.objectify_contact(obj, data) 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 return obj
def objectify_contact(self, contact, data): def objectify_contact(self, contact, data):
@ -3598,6 +3654,9 @@ class MasterView(View):
if cls.has_pk_fields: if cls.has_pk_fields:
config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix),
"View all PK-type fields for {}".format(model_title_plural)) "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 # view by grid index
config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix)) config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix))

View file

@ -32,7 +32,7 @@ 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 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 import colander
from deform import widget as dfwidget from deform import widget as dfwidget
@ -96,10 +96,14 @@ class UsersView(PrincipalMasterView):
] ]
def query(self, session): def query(self, session):
return session.query(model.User)\ query = super(UsersView, self).query(session)
.outerjoin(model.Person)\
# bring in the related Person(s)
query = query.outerjoin(model.Person)\
.options(orm.joinedload(model.User.person)) .options(orm.joinedload(model.User.person))
return query
def configure_grid(self, g): def configure_grid(self, g):
super(UsersView, self).configure_grid(g) super(UsersView, self).configure_grid(g)
@ -194,6 +198,9 @@ class UsersView(PrincipalMasterView):
# roles # roles
f.set_renderer('roles', self.render_roles) f.set_renderer('roles', self.render_roles)
if self.creating or self.editing: if self.creating or self.editing:
if not self.has_perm('edit_roles'):
f.remove_field('roles')
else:
roles = self.get_possible_roles().all() roles = self.get_possible_roles().all()
role_values = [(s.uuid, six.text_type(s)) for s in roles] role_values = [(s.uuid, six.text_type(s)) for s in roles]
f.set_node('roles', colander.Set()) f.set_node('roles', colander.Set())
@ -205,6 +212,8 @@ class UsersView(PrincipalMasterView):
values=role_values)) values=role_values))
if self.editing: if self.editing:
f.set_default('roles', [r.uuid for r in user.roles]) 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") f.set_label('display_name', "Full Name")
@ -225,10 +234,17 @@ class UsersView(PrincipalMasterView):
f.remove('set_password') f.remove('set_password')
def get_possible_roles(self): def get_possible_roles(self):
# some roles should never have users "belong" to them
excluded = [ excluded = [
guest_role(self.Session()).uuid, guest_role(self.Session()).uuid,
authenticated_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)\ return self.Session.query(model.Role)\
.filter(~model.Role.uuid.in_(excluded))\ .filter(~model.Role.uuid.in_(excluded))\
.order_by(model.Role.name) .order_by(model.Role.name)
@ -259,6 +275,11 @@ class UsersView(PrincipalMasterView):
if 'display' in names: if 'display' in names:
user.person.display_name = names['display'] 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 # maybe set user password
if data['set_password']: if data['set_password']:
set_user_password(user, data['set_password']) set_user_password(user, data['set_password'])
@ -271,11 +292,20 @@ class UsersView(PrincipalMasterView):
def update_roles(self, user, data): def update_roles(self, user, data):
old_roles = set([r.uuid for r in user.roles]) old_roles = set([r.uuid for r in user.roles])
new_roles = data['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: for uuid in new_roles:
if uuid not in old_roles: if uuid not in old_roles:
if self.request.is_root or uuid != admin.uuid:
user._roles.append(model.UserRole(role_uuid=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: for uuid in old_roles:
if uuid not in new_roles: if uuid not in new_roles:
if self.request.is_root or uuid != admin.uuid:
role = self.Session.query(model.Role).get(uuid) role = self.Session.query(model.Role).get(uuid)
user.roles.remove(role) user.roles.remove(role)
@ -373,6 +403,34 @@ class UsersView(PrincipalMasterView):
@classmethod @classmethod
def defaults(cls, config): 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') rattail_config = config.registry.settings.get('rattail_config')
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_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), config.add_view(cls, attr='vue_index', route_name='{}.vue_index'.format(route_prefix),
permission='{}.list'.format(permission_prefix)) permission='{}.list'.format(permission_prefix))
cls._principal_defaults(config)
cls._defaults(config)
class UserEventsView(MasterView): class UserEventsView(MasterView):
""" """