From bd964b92668257cb26db2ea8e21e17a359397130 2021-10-14 09:38:07 From: Lance Edgar Date: 2021-10-14 09:38:07 Subject: [PATCH] Add support for syncing roles, with users and permissions for each but only those roles marked for sync. also by default the GlobalRole is *not* included in the handler's default list, so this still requires a bit of setup --- diff --git a/docs/api/index.rst b/docs/api/index.rst index 41a4397fdddb2d1f8e6d1a98707b0d534e310d10..0d2911f616415ec6afeabd7b08f734a45fec5438 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,6 +11,7 @@ attributes and method signatures etc. rattail rattail/app + rattail/auth rattail/batch/custorder rattail/batch/handlers rattail/batch/purchase @@ -30,6 +31,7 @@ attributes and method signatures etc. rattail/db/model.people rattail/db/model.products rattail/db/model.purchase + rattail/db/model.users rattail/db/util rattail/enum rattail/exceptions diff --git a/docs/api/rattail/auth.rst b/docs/api/rattail/auth.rst new file mode 100644 index 0000000000000000000000000000000000000000..d2132bc838af95dc87dd69037e87b8121db9d307 --- /dev/null +++ b/docs/api/rattail/auth.rst @@ -0,0 +1,6 @@ + +``rattail.auth`` +================ + +.. automodule:: rattail.auth + :members: diff --git a/docs/api/rattail/db/model.users.rst b/docs/api/rattail/db/model.users.rst new file mode 100644 index 0000000000000000000000000000000000000000..1bca10d4f7fd02dc6ff6ca1781c78a794eccaf54 --- /dev/null +++ b/docs/api/rattail/db/model.users.rst @@ -0,0 +1,6 @@ + +``rattail.db.model.users`` +========================== + +.. automodule:: rattail.db.model.users + :members: diff --git a/docs/api/rattail/importing/rattail.rst b/docs/api/rattail/importing/rattail.rst index 57d0f90994188e4af4056a819f22e0b48c6ebac4..4e47e5b7b1b046e11b20b34b55fa22ef72d99878 100644 --- a/docs/api/rattail/importing/rattail.rst +++ b/docs/api/rattail/importing/rattail.rst @@ -15,3 +15,9 @@ .. autoclass:: FromRattailToRattailExport :members: + +.. autoclass:: RoleImporter + :members: + +.. autoclass:: GlobalRoleImporter + :members: diff --git a/rattail/auth.py b/rattail/auth.py index 49fd1c880c2b63080d3e208e39d72a054e5f9709..57df0c84be9b8085c5f41e9b128e2d86394f8c22 100644 --- a/rattail/auth.py +++ b/rattail/auth.py @@ -93,3 +93,99 @@ class AuthHandler(GenericHandler): user = model.User(**kwargs) return user + + def grant_permission(self, role, permission): + """ + Grant a permission to the role. + """ + if permission not in role.permissions: + role.permissions.append(permission) + + def revoke_permission(self, role, permission): + """ + Revoke a permission from the role. + """ + if permission in role.permissions: + role.permissions.remove(permission) + + def cache_permissions(self, session, principal, + include_guest=True, + include_authenticated=True): + """ + Return a set of permission names, which represents all + permissions effectively granted to the given principal. + + :param session: A SQLAlchemy session instance. + + :param principal: May be either a + :class:`~rattail.db.model.users.User` or + :class:`~rattail.db.model.users.Role` instance. It is also + expected that this may sometimes be ``None``, in which case + the "Guest" role will typically be assumed. + + :param include_guest: Whether or not the "Guest" role should + be included when checking permissions. If ``False``, then + Guest's permissions will *not* be consulted. + + :param include_authenticated: Whether or not the + "Authenticated" role should be included when checking + permissions. + + Note that if no ``principal`` is provided, and + ``include_guest`` is set to ``False``, then no checks will + actually be done, and the return value will be ``False``. + """ + from rattail.db.auth import guest_role, authenticated_role + + # we will use any `roles` attribute which may be present. in practice we + # would be assuming a User in this case + if hasattr(principal, 'roles'): + + roles = [] + for role in principal.roles: + include = False + if role.node_type: + if role.node_type == self.config.node_type(): + include = True + else: + include = True + if include: + roles.append(role) + + # here our User assumption gets a little more explicit + if include_authenticated: + roles.append(authenticated_role(session)) + + # otherwise a non-null principal is assumed to be a Role + elif principal is not None: + roles = [principal] + + # fallback assumption is "no roles" + else: + roles = [] + + # maybe include guest roles + if include_guest: + roles.append(guest_role(session)) + + # build the permissions cache + cache = set() + for role in roles: + cache.update(role.permissions) + + return cache + + def has_permission(self, session, principal, permission, + include_guest=True, + include_authenticated=True): + """ + Determine if a principal has been granted a permission. + + Under the hood this really just invokes + :meth:`cache_permissions()` for the principal, and then checks + to see if the given permission is included in that set. + """ + perms = self.cache_permissions(session, principal, + include_guest=include_guest, + include_authenticated=include_authenticated) + return permission in perms diff --git a/rattail/batch/inventory.py b/rattail/batch/inventory.py index 05face76c14c6564f8503bda2b30bf7ffede2007..74f5f1d4016778020340ddbe332bc51b565af000 100644 --- a/rattail/batch/inventory.py +++ b/rattail/batch/inventory.py @@ -33,7 +33,7 @@ import six import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import api, model, auth +from rattail.db import api, model from rattail.gpc import GPC from rattail.batch import BatchHandler @@ -118,6 +118,7 @@ class InventoryBatchHandler(BatchHandler): Note that this method should return only those modes which are the given user is *allowed* to create, according to permissions. """ + auth = self.app.get_auth_handler() all_modes = self.get_count_modes() modes = [] diff --git a/rattail/datasync/rattail.py b/rattail/datasync/rattail.py index 13435df509a1f9ef64c5df904f9637c59216af02..3dad110bf59a940998c1b98add2fb5823ebdd4c1 100644 --- a/rattail/datasync/rattail.py +++ b/rattail/datasync/rattail.py @@ -368,6 +368,11 @@ class FromRattailToRattailBase(NewDataSyncImportConsumer): def get_importers(self): importers = super(FromRattailToRattailBase, self).get_importers() + # add the GlobalRole importer, in place of Role, unless latter + # is already present. + if 'Role' not in importers: + importers['Role'] = self.handler.get_importer('GlobalRole') + # maybe add importer to handle product price reference changes if self.get_handle_price_xref() and 'ProductPriceAssociation' not in importers: importers['ProductPriceAssociation'] = self.handler.get_importer('ProductPriceAssociation') diff --git a/rattail/db/alembic/versions/8b78ef45a36c_add_role_sync_me.py b/rattail/db/alembic/versions/8b78ef45a36c_add_role_sync_me.py new file mode 100644 index 0000000000000000000000000000000000000000..2cd4c76d8a379d7aa624c057f0ee5f04adff70db --- /dev/null +++ b/rattail/db/alembic/versions/8b78ef45a36c_add_role_sync_me.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""add role.sync_me + +Revision ID: 8b78ef45a36c +Revises: 675a0034becc +Create Date: 2021-10-13 18:00:46.665524 + +""" + +from __future__ import unicode_literals, absolute_import + +# revision identifiers, used by Alembic. +revision = '8b78ef45a36c' +down_revision = '675a0034becc' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +import rattail.db.types + + + +def upgrade(): + + # role + op.add_column('role', sa.Column('node_type', sa.String(length=100), nullable=True)) + op.add_column('role', sa.Column('sync_me', sa.Boolean(), nullable=True)) + op.add_column('role_version', sa.Column('node_type', sa.String(length=100), autoincrement=False, nullable=True)) + op.add_column('role_version', sa.Column('sync_me', sa.Boolean(), autoincrement=False, nullable=True)) + + +def downgrade(): + + # role + op.drop_column('role_version', 'sync_me') + op.drop_column('role_version', 'node_type') + op.drop_column('role', 'sync_me') + op.drop_column('role', 'node_type') diff --git a/rattail/db/model/users.py b/rattail/db/model/users.py index 5c9d955fa43223cf7aae7dbc267b023dabf5b66b..4821c1358e3c2c6ce795966822c76fbc5adc68d8 100644 --- a/rattail/db/model/users.py +++ b/rattail/db/model/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -63,6 +63,40 @@ class Role(Base): Any arbitrary notes for the role. """) + sync_me = sa.Column(sa.Boolean(), nullable=True, doc=""" + Flag indicating that this Role (and its user-ship, and + permissions) should be synced across all nodes. + + So if set, when the role changes at one node that change should + propagate to all other nodes. This includes "proper" changes e.g. + to the role name, but also when any users are added to or removed + from the role, that fact also should propagate. Additionally, + when permissions are granted to or revoked from the role, that + should propagate. + + See also :attr:`node_type`. + """) + + node_type = sa.Column(sa.String(length=100), nullable=True, doc=""" + Type of node for which this role is applicable. This is probably + only useful if the :attr:`sync_me` flag is set. + + If set, this value must match a node's configured type, or else it + will be ignored by that node. See also + :meth:`~rattail.config.RattailConfig.node_type()`. If there is no + value set for this field then the role will be honored by all + nodes in which it exists (which is just one unless ``sync_me`` is + set, in which case all nodes would have it). + + It is useful in combination with ``sync_me`` in that it allows a + certain role to be "global" (synced) and yet only be "effective" + for certain nodes. Probably the most common scenario is where you + have a host node and several store nodes, and you want to manage + the store roles "globally" but avoid granting unwanted access to + the host node. So you'd set the ``sync_me`` flag but also set + ``node_type`` to e.g. ``'store'``. + """) + def __str__(self): return self.name or '' diff --git a/rattail/importing/importers.py b/rattail/importing/importers.py index 3c2ede829182e33db9d14d0574754017f3592881..aab64d8de7f9483750982a5791423b8adc5a32f5 100644 --- a/rattail/importing/importers.py +++ b/rattail/importing/importers.py @@ -124,6 +124,7 @@ class Importer(object): def __init__(self, config=None, key=None, direction='import', fields=None, exclude_fields=None, **kwargs): self.config = config + self.app = config.get_app() if config else None self.enum = config.get_enum() if config else None self.model = config.get_model() if config else None self.model_class = kwargs.pop('model_class', self.get_model_class()) diff --git a/rattail/importing/model.py b/rattail/importing/model.py index 4821a0d03d6bc5ee59af53fe4f2af9deeec0ce75..50bc74ae46fc77280280df5406ed339ab16279e9 100644 --- a/rattail/importing/model.py +++ b/rattail/importing/model.py @@ -510,6 +510,13 @@ class MergePeopleRequestImporter(ToRattail): model_class = model.MergePeopleRequest +class RoleImporter(ToRattail): + """ + Role data importer. + """ + model_class = model.Role + + class UserImporter(ToRattail): """ User data importer. diff --git a/rattail/importing/rattail.py b/rattail/importing/rattail.py index 190f71f9b072b0172cd6af9285c8943c2d1fd0a1..e88eb18d10f00e00db72e012e56feb5dfe01a175 100644 --- a/rattail/importing/rattail.py +++ b/rattail/importing/rattail.py @@ -100,6 +100,8 @@ class FromRattailToRattailBase(object): importers['PersonPhoneNumber'] = PersonPhoneNumberImporter importers['PersonMailingAddress'] = PersonMailingAddressImporter importers['MergePeopleRequest'] = MergePeopleRequestImporter + importers['Role'] = RoleImporter + importers['GlobalRole'] = GlobalRoleImporter importers['User'] = UserImporter importers['AdminUser'] = AdminUserImporter importers['GlobalUser'] = GlobalUserImporter @@ -151,6 +153,8 @@ class FromRattailToRattailBase(object): keys = self.get_importer_keys() avoid_by_default = [ + 'Role', + 'GlobalRole', 'GlobalPerson', 'AdminUser', 'GlobalUser', @@ -260,6 +264,129 @@ class PersonMailingAddressImporter(FromRattail, model.PersonMailingAddressImport class MergePeopleRequestImporter(FromRattail, model.MergePeopleRequestImporter): pass +class RoleImporter(FromRattail, model.RoleImporter): + pass + + +class GlobalRoleImporter(RoleImporter): + """ + Role importer which only will handle roles which have the + :attr:`~rattail.db.model.users.Role.sync_me` flag set. (So it + syncs those roles but avoids others.) + """ + + @property + def supported_fields(self): + fields = list(super(GlobalRoleImporter, self).supported_fields) + fields.extend([ + 'permissions', + 'users', + ]) + return fields + + # nb. we must override both cache_query() and query() b/c they use + # different sessions! + + def cache_query(self): + """ + Return the query to be used when caching "local" data. + """ + query = super(GlobalRoleImporter, self).cache_query() + model = self.model + + # only want roles which are *meant* to be synced + query = query.filter(model.Role.sync_me == True) + + return query + + def query(self): + query = super(GlobalRoleImporter, self).query() + model = self.model + + # only want roles which are *meant* to be synced + query = query.filter(model.Role.sync_me == True) + + return query + + # nb. we do not need to override normalize_host_object() b/c it + # just calls normalize_local_object() by default + + def normalize_local_object(self, role): + + # only want roles which are *meant* to be synced + if not role.sync_me: + return + + data = super(GlobalRoleImporter, self).normalize_local_object(role) + if data: + + # users + if 'users' in self.fields: + data['users'] = sorted([user.uuid for user in role.users]) + + # permissions + if 'permissions' in self.fields: + auth = self.app.get_auth_handler() + perms = auth.cache_permissions(self.session, role, + include_guest=False) + data['permissions'] = sorted(perms) + + return data + + def update_object(self, role, host_data, local_data=None, **kwargs): + role = super(GlobalRoleImporter, self).update_object(role, host_data, + local_data=local_data, + **kwargs) + model = self.model + + # users + if 'users' in self.fields: + new_users = host_data['users'] + old_users = local_data['users'] if local_data else [] + changed = False + + # add some users + for new_user in new_users: + if new_user not in old_users: + user = self.session.query(model.User).get(new_user) + user.roles.append(role) + changed = True + + # remove some users + for old_user in old_users: + if old_user not in new_users: + user = self.session.query(model.User).get(old_user) + user.roles.remove(role) + changed = True + + if changed: + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.session.rattail_record_changes: + self.session.add(model.Change(class_name='Role', + instance_uuid=role.uuid, + deleted=False)) + + # permissions + if 'permissions' in self.fields: + auth = self.app.get_auth_handler() + new_perms = host_data['permissions'] + old_perms = local_data['permissions'] if local_data else [] + + # grant permissions + for new_perm in new_perms: + if new_perm not in old_perms: + auth.grant_permission(role, new_perm) + + # revoke permissions + for old_perm in old_perms: + if old_perm not in new_perms: + auth.revoke_permission(role, old_perm) + + return role + + class UserImporter(FromRattail, model.UserImporter): pass