From a88c5016706ce7c48e2f4868090d00b2c5eec498 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 12 Oct 2022 10:52:43 +0200 Subject: [PATCH 2/2] rbac: handle inheritance between model in get_all_permissions (#70152) For global and ou scoped permissions, equivalent permissions on the child classes are added, i.e. if you have authentic2.admin_service permission then you also have authentic2_idp_oidc.admin_oidcclient permission (globally or scoped by an organizational unit). For instance scoped permissions, equivalent permissions on the parent classes are added, i.e. if you have permission authentic2_idp_oidc.admin_oidcclient on OIDCClient(pk=1), you also have authentic2.admin_service on the same object. --- src/authentic2/custom_user/backends.py | 60 +++++++++++++++++++++++++- tests/test_rbac.py | 41 ++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/authentic2/custom_user/backends.py b/src/authentic2/custom_user/backends.py index 631958fd..e9db037e 100644 --- a/src/authentic2/custom_user/backends.py +++ b/src/authentic2/custom_user/backends.py @@ -1,9 +1,11 @@ import copy import functools +from django.apps import apps from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist +from django.db import models from django.db.models.query import Q from django_rbac import utils @@ -20,6 +22,34 @@ def get_fk_model(model, fieldname): return field.related_model +_MODEL_CHILDREN = None +_MODEL_PARENTS = None + + +def get_model_inheritance(): + global _MODEL_CHILDREN # pylint: disable=global-statement + global _MODEL_PARENTS # pylint: disable=global-statement + + if _MODEL_CHILDREN is None or _MODEL_PARENTS is None: + _MODEL_CHILDREN = {} + _MODEL_PARENTS = {} + for app in apps.get_app_configs(): + for child in app.get_models(): + for parent in child.__bases__: + if issubclass(parent, models.Model) and hasattr(parent, '_meta'): + _MODEL_CHILDREN.setdefault(parent, set()).add(child) + _MODEL_PARENTS.setdefault(child, set()).add(parent) + return _MODEL_CHILDREN, _MODEL_PARENTS + + +def get_model_child_classes(model): + return get_model_inheritance()[0].get(model) or () + + +def get_model_parent_classes(model): + return get_model_inheritance()[1].get(model) or () + + class DjangoRBACBackend: _DEFAULT_DJANGO_RBAC_PERMISSIONS_HIERARCHY = { 'view': ['search'], @@ -59,6 +89,7 @@ class DjangoRBACBackend: target = ContentType.objects.get_for_id(permission.target_id) app_label = target.app_label model = target.model + model_child_classes = get_model_child_classes(target.model_class()) if permission.ou_id: key = 'ou.%s' % permission.ou_id else: @@ -66,9 +97,14 @@ class DjangoRBACBackend: else: app_label = target_ct.app_label model = target_ct.model + model_child_classes = get_model_child_classes(target_ct.model_class) key = '%s.%s' % (permission.target_ct_id, permission.target_id) slug = permission.operation.slug - perms = [str('%s.%s_%s' % (app_label, slug, model))] + perms = ['%s.%s_%s' % (app_label, slug, model)] + for model_child_class in model_child_classes: + perms.append( + f'{model_child_class._meta.app_label}.{slug}_{model_child_class._meta.model_name}' + ) perm_hierarchy = getattr( settings, 'DJANGO_RBAC_PERMISSIONS_HIERARCHY', @@ -77,6 +113,11 @@ class DjangoRBACBackend: if slug in perm_hierarchy: for other_perm in perm_hierarchy[slug]: perms.append(str('%s.%s_%s' % (app_label, other_perm, model))) + for model_child_class in model_child_classes: + for other_perm in perm_hierarchy[slug]: + perms.append( + f'{model_child_class._meta.app_label}.{other_perm}_{model_child_class._meta.model_name}' + ) permissions = perms_cache.setdefault(key, set()) permissions.update(perms) # optimization for has_module_perms @@ -93,15 +134,32 @@ class DjangoRBACBackend: ct = ContentType.objects.get_for_model(obj) key = '%s.%s' % (ct.id, obj.pk) if key in perms_cache: + object_permissions = perms_cache[key] permissions.update(perms_cache[key]) + # add equivalent permissions with parent app_label.model_name + for parent in get_model_parent_classes(ct.model_class()): + for permission in object_permissions: + app_label, rest = permission.split('.') + operation, model_name = rest.rsplit('_', 1) + permissions.add(f'{parent._meta.app_label}.{operation}_{parent._meta.model_name}') for permission in perms_cache.get('__all__', set()): if permission.startswith('%s.' % ct.app_label) and permission.endswith('_%s' % ct.model): permissions.add(permission) + for parent in get_model_parent_classes(ct.model_class()): + if permission.startswith(parent._meta.app_label) and permission.endswith( + f'_{parent._meta.model_name}' + ): + permissions.add(permission) if hasattr(obj, 'ou_id') and obj.ou_id: key = 'ou.%s' % obj.ou_id for permission in perms_cache.get(key, ()): if permission.startswith('%s.' % ct.app_label) and permission.endswith('_%s' % ct.model): permissions.add(permission) + for parent in get_model_parent_classes(ct.model_class()): + if permission.startswith(parent._meta.app_label) and permission.endswith( + f'_{parent._meta.model_name}' + ): + permissions.add(permission) return permissions else: return perms_cache.get('__all__', []) diff --git a/tests/test_rbac.py b/tests/test_rbac.py index eb0eb2e0..423d1d98 100644 --- a/tests/test_rbac.py +++ b/tests/test_rbac.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import pytest from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import connection @@ -347,3 +348,43 @@ def test_random_role_parenting(db): m[a][b] = False print('duration', time() - t) check(i) + + +class TestInheritance: + @pytest.fixture + def role(self, db): + return Role.objects.create(name='role') + + @pytest.fixture + def user(self, simple_user, role): + simple_user.roles.add(role) + return simple_user + + @pytest.fixture + def backend(self): + return backends.DjangoRBACBackend() + + @pytest.fixture + def oidc_client_ou1(self, ou1): + from authentic2_idp_oidc.models import OIDCClient + + return OIDCClient.objects.create(ou=ou1, slug='oidclient') + + def test_global(self, role, user, backend): + role.permissions.add(Permission.from_str('authentic2.admin_service')) + assert user.has_perm('authentic2_idp_oidc.admin_oidcclient') + assert user.has_perm('authentic2_idp_oidc.search_oidcclient') + + def test_ou_scoped(self, role, user, backend, ou1, oidc_client_ou1): + role.permissions.add(Permission.from_str('ou1 authentic2.admin_service')) + assert user.has_perm('authentic2_idp_oidc.admin_oidcclient', oidc_client_ou1) + assert user.has_perm('authentic2_idp_oidc.search_oidcclient', oidc_client_ou1) + assert user.has_perm('authentic2.admin_service', oidc_client_ou1) + assert user.has_perm('authentic2.search_service', oidc_client_ou1) + + def test_instance_scoped(self, role, user, backend, oidc_client_ou1): + role.permissions.add(Permission.from_str('authentic2.admin_service', instance=oidc_client_ou1)) + assert user.has_perm('authentic2_idp_oidc.admin_oidcclient', oidc_client_ou1) + assert user.has_perm('authentic2_idp_oidc.search_oidcclient', oidc_client_ou1) + assert user.has_perm('authentic2.admin_service', oidc_client_ou1) + assert user.has_perm('authentic2.search_service', oidc_client_ou1) -- 2.37.2