Changeset - bd964b926682
[Not reviewed]
0 9 3
Lance Edgar (lance) - 3 years ago 2021-10-14 09:38:07
lance@edbob.org
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
12 files changed with 332 insertions and 2 deletions:
0 comments (0 inline, 0 general)
docs/api/index.rst
Show inline comments
 
@@ -8,12 +8,13 @@ attributes and method signatures etc.
 

	
 
.. toctree::
 
   :maxdepth: 1
 

	
 
   rattail
 
   rattail/app
 
   rattail/auth
 
   rattail/batch/custorder
 
   rattail/batch/handlers
 
   rattail/batch/purchase
 
   rattail/config
 
   rattail/csvutil
 
   rattail/datasync/index
 
@@ -27,12 +28,13 @@ attributes and method signatures etc.
 
   rattail/db/model.batch.purchase
 
   rattail/db/model.batch.vendorcatalog
 
   rattail/db/model.datasync
 
   rattail/db/model.people
 
   rattail/db/model.products
 
   rattail/db/model.purchase
 
   rattail/db/model.users
 
   rattail/db/util
 
   rattail/enum
 
   rattail/exceptions
 
   rattail/filemon/index
 
   rattail/filemon/actions
 
   rattail/filemon/config
docs/api/rattail/auth.rst
Show inline comments
 
new file 100644
 

	
 
``rattail.auth``
 
================
 

	
 
.. automodule:: rattail.auth
 
   :members:
docs/api/rattail/db/model.users.rst
Show inline comments
 
new file 100644
 

	
 
``rattail.db.model.users``
 
==========================
 

	
 
.. automodule:: rattail.db.model.users
 
  :members:
docs/api/rattail/importing/rattail.rst
Show inline comments
 
@@ -12,6 +12,12 @@
 

	
 
.. autoclass:: FromRattailToRattailImport
 
   :members:
 

	
 
.. autoclass:: FromRattailToRattailExport
 
   :members:
 

	
 
.. autoclass:: RoleImporter
 
   :members:
 

	
 
.. autoclass:: GlobalRoleImporter
 
   :members:
rattail/auth.py
Show inline comments
 
@@ -90,6 +90,102 @@ class AuthHandler(GenericHandler):
 

	
 
        if 'username' not in kwargs:
 
            kwargs['username'] = self.generate_unique_username(session, **kwargs)
 

	
 
        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
rattail/batch/inventory.py
Show inline comments
 
@@ -30,13 +30,13 @@ import decimal
 
import logging
 

	
 
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
 

	
 

	
 
log = logging.getLogger(__name__)
 

	
 
@@ -115,12 +115,13 @@ class InventoryBatchHandler(BatchHandler):
 
        select when creating a new inventory batch.  Each mode should be
 
        represented as a JSON-serializable dict.
 

	
 
        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 = []
 

	
 
        def has_perm(name):
 
            name = '{}.{}'.format(permission_prefix, name)
 
            return auth.has_permission(session, user, name)
rattail/datasync/rattail.py
Show inline comments
 
@@ -365,12 +365,17 @@ class FromRattailToRattailBase(NewDataSyncImportConsumer):
 
            'rattail.datasync', 'rattail.handle_price_xref',
 
            default=False)
 

	
 
    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')
 

	
 
        return importers
 

	
rattail/db/alembic/versions/8b78ef45a36c_add_role_sync_me.py
Show inline comments
 
new file 100644
 
# -*- 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')
rattail/db/model/users.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2020 Lance Edgar
 
#  Copyright © 2010-2021 Lance Edgar
 
#
 
#  This file is part of Rattail.
 
#
 
#  Rattail is free software: you can redistribute it and/or modify it under the
 
#  terms of the GNU General Public License as published by the Free Software
 
#  Foundation, either version 3 of the License, or (at your option) any later
 
@@ -60,12 +60,46 @@ class Role(Base):
 
    """)
 

	
 
    notes = sa.Column(sa.Text(), nullable=True, doc="""
 
    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 ''
 

	
 

	
 
@six.python_2_unicode_compatible
 
class Permission(Base):
rattail/importing/importers.py
Show inline comments
 
@@ -121,12 +121,13 @@ class Importer(object):
 
    # ``Column`` instance overrides, keyed by fieldname.  Any field not
 
    # represented here will be given the default column type (string).
 
    field_coltypes = {}
 

	
 
    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())
 
        if key is not None:
 
            self.key = key
 
        self.direction = direction
rattail/importing/model.py
Show inline comments
 
@@ -507,12 +507,19 @@ class MergePeopleRequestImporter(ToRattail):
 
    """
 
    Importer for MergePeopleRequest
 
    """
 
    model_class = model.MergePeopleRequest
 

	
 

	
 
class RoleImporter(ToRattail):
 
    """
 
    Role data importer.
 
    """
 
    model_class = model.Role
 

	
 

	
 
class UserImporter(ToRattail):
 
    """
 
    User data importer.
 
    """
 
    model_class = model.User
 

	
rattail/importing/rattail.py
Show inline comments
 
@@ -97,12 +97,14 @@ class FromRattailToRattailBase(object):
 
        importers['Person'] = PersonImporter
 
        importers['GlobalPerson'] = GlobalPersonImporter
 
        importers['PersonEmailAddress'] = PersonEmailAddressImporter
 
        importers['PersonPhoneNumber'] = PersonPhoneNumberImporter
 
        importers['PersonMailingAddress'] = PersonMailingAddressImporter
 
        importers['MergePeopleRequest'] = MergePeopleRequestImporter
 
        importers['Role'] = RoleImporter
 
        importers['GlobalRole'] = GlobalRoleImporter
 
        importers['User'] = UserImporter
 
        importers['AdminUser'] = AdminUserImporter
 
        importers['GlobalUser'] = GlobalUserImporter
 
        importers['Message'] = MessageImporter
 
        importers['MessageRecipient'] = MessageRecipientImporter
 
        importers['Store'] = StoreImporter
 
@@ -148,12 +150,14 @@ class FromRattailToRattailBase(object):
 
        return importers
 

	
 
    def get_default_keys(self):
 
        keys = self.get_importer_keys()
 

	
 
        avoid_by_default = [
 
            'Role',
 
            'GlobalRole',
 
            'GlobalPerson',
 
            'AdminUser',
 
            'GlobalUser',
 
            'ProductImage',
 
            'ProductPriceAssociation',
 
        ]
 
@@ -257,12 +261,135 @@ class PersonPhoneNumberImporter(FromRattail, model.PersonPhoneNumberImporter):
 
class PersonMailingAddressImporter(FromRattail, model.PersonMailingAddressImporter):
 
    pass
 

	
 
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
 

	
 

	
 
class GlobalUserImporter(FromRattail, model.GlobalUserImporter):
 
    """
0 comments (0 inline, 0 general)