├── .gitignore ├── LICENSE ├── README.md ├── django_entity_rbac ├── __init__.py ├── constants.py ├── exceptions.py └── models.py ├── poetry.lock ├── pyproject.toml └── roletestapp ├── __init__.py ├── admin.py ├── apps.py ├── migrations ├── 0001_initial.py └── __init__.py ├── mock_settings.py ├── models.py ├── test ├── __init__.py └── test_basic.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__/ 3 | *.pyc 4 | dist/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Theori Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Entity-Relationship-based Access Control 2 | 3 | django-entity-rbac is an implementation of Entity-Relationship-based Access Control for Django. 4 | 5 | This project attempts to satisfy the follow requirements: 6 | 7 | * Table-less role assignment 8 | * Elimination of [the role explosion problem][role-explosion] 9 | * Hierarchical object graphs 10 | * Row-level access control 11 | 12 | **django-entity-rbac is currently under heavy development.** 13 | 14 | 15 | ## Authors 16 | Minkyo Seo ([@0xsaika](https://github.com/0xsaika)), Jinoh Kang ([@iamahuman](https://github.com/iamahuman)) 17 | 18 | ## Quick start 19 | Compatible with Django 3.x. 20 | ``` 21 | pip install django-entity-rbac 22 | ``` 23 | 24 | ## Usage 25 | See [`roletestapp`](https://github.com/theori-io/django-entity-rbac/tree/main/roletestapp) 26 | 27 | ## Documentation 28 | TODO 29 | 30 | [PyCon 2022 talk](https://2022.pycon.kr/program/talks/26) 31 | 32 | ## Roadmap 33 | - [x] Release unstable API (v0.1) as proof-of-concept (kudos to Jinoh) 34 | - [ ] Improve API usability 35 | - [ ] Redesign internal APIs 36 | - [ ] Add separate permission spec classes for compose-able role declaration 37 | - [ ] Replace bit fields with something less error-prone and foolproof 38 | - [ ] Release stable v1 39 | 40 | ## License 41 | django-entity-rbac is licensed under the MIT license. 42 | 43 | [role-explosion]: https://blog.plainid.com/role-explosion-unintended-consequence-rbac 44 | -------------------------------------------------------------------------------- /django_entity_rbac/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theori-io/django-entity-rbac/92ade54ab44e28b4cd05216ebc1e842670005084/django_entity_rbac/__init__.py -------------------------------------------------------------------------------- /django_entity_rbac/constants.py: -------------------------------------------------------------------------------- 1 | 2 | CONTEXT_USER_FIELD = 'cONTEXTUSEr' 3 | ACCESS_ROLE_FIELD = 'aCCESSROLe' 4 | CURRENT_TIME_FIELD = 'cURRENTTIMe' 5 | 6 | -------------------------------------------------------------------------------- /django_entity_rbac/exceptions.py: -------------------------------------------------------------------------------- 1 | class InconsistentDatabaseValuesError(Exception): 2 | """ 3 | Raises when a database query returned unexpected values. 4 | Shall never happen in normal circumstances. 5 | """ 6 | -------------------------------------------------------------------------------- /django_entity_rbac/models.py: -------------------------------------------------------------------------------- 1 | 2 | import operator 3 | import copy 4 | import string 5 | from collections import OrderedDict 6 | 7 | from itertools import product 8 | from functools import reduce 9 | 10 | from django.db import models 11 | from django.contrib.auth import get_user_model 12 | from django.apps import apps as django_apps 13 | from django.utils import timezone 14 | from django.core.exceptions import EmptyResultSet 15 | from django.db.models.sql import Query 16 | 17 | from .constants import CONTEXT_USER_FIELD, ACCESS_ROLE_FIELD, CURRENT_TIME_FIELD 18 | from .exceptions import InconsistentDatabaseValuesError 19 | 20 | try: 21 | from django.db.models.constants import LOOKUP_SEP 22 | except ImportError: 23 | LOOKUP_SEP = '__' 24 | 25 | 26 | class Superuser: 27 | pass 28 | 29 | class NotSuperuser: 30 | pass 31 | 32 | class Anonymous: 33 | pass 34 | 35 | SPECIAL_ROLE_KEYS = frozenset((Superuser, NotSuperuser, Anonymous)) 36 | 37 | class ConditionGroup: 38 | def expand(self, roles): 39 | raise NotImplementedError 40 | 41 | def get_basis_conditions(self): 42 | raise NotImplementedError 43 | 44 | 45 | class SimpleConditionGroup(ConditionGroup): 46 | __slots__ = ('lookup_table', 'basis', 'mask') 47 | 48 | def __init__(self, lookup_table, basis): 49 | self.lookup_table = lookup_table 50 | self.basis = basis 51 | self.mask = reduce(operator.or_, basis, 0) 52 | 53 | def expand(self, roles): 54 | return self.lookup_table[roles] 55 | 56 | def get_basis_conditions(self): 57 | lookup_table = self.lookup_table 58 | return ((key, lookup_table[key]) for key in self.basis) 59 | 60 | def _get_tuple(self): 61 | return (self.lookup_table, self.basis, self.mask) 62 | 63 | def __eq__(self, other): 64 | return self is other or ( 65 | isinstance(other, SimpleConditionGroup) and 66 | self._get_tuple() == other._get_tuple() 67 | ) 68 | 69 | def __ne__(self, other): 70 | return self is not other and ( 71 | not isinstance(other, SimpleConditionGroup) or 72 | self._get_tuple() != other._get_tuple() 73 | ) 74 | 75 | def __hash__(self): 76 | return hash(self._get_tuple()) 77 | 78 | def __repr__(self): 79 | return '{}(lookup_table={!r}, basis={!r})'.format( 80 | self.__class__.__name__, 81 | self.lookup_table, 82 | self.basis 83 | ) 84 | 85 | def __or__(self, other): 86 | if self == other: 87 | return self 88 | return NotImplemented 89 | 90 | 91 | class BackModelReference: 92 | __slots__ = ('prefix_id',) 93 | 94 | def __init__(self, prefix_id): 95 | self.prefix_id = prefix_id 96 | 97 | def __eq__(self, other): 98 | return ( 99 | isinstance(other, BackReference) and 100 | self.prefix_id == other.prefix_id 101 | ) 102 | 103 | def __ne__(self, other): 104 | return ( 105 | not isinstance(other, BackReference) or 106 | self.prefix_id != other.prefix_id 107 | ) 108 | 109 | def __hash__(self): 110 | return hash(self.prefix_id) 111 | 112 | def __repr__(self): 113 | return '{}(prefix_id={!r})'.format( 114 | self.__class__.__name__, 115 | self.prefix_id, 116 | ) 117 | 118 | 119 | class BackReference: 120 | __slots__ = ('prefix_id', 'name', 'only_roles') 121 | 122 | def __init__(self, prefix_id, name, only_roles=None): 123 | self.prefix_id = prefix_id 124 | self.name = name 125 | self.only_roles = only_roles 126 | 127 | def __eq__(self, other): 128 | return ( 129 | isinstance(other, BackReference) and 130 | self.prefix_id == other.prefix_id and 131 | self.name == other.name and 132 | self.only_roles == other.only_roles 133 | ) 134 | 135 | def __ne__(self, other): 136 | return ( 137 | not isinstance(other, BackReference) or 138 | self.prefix_id != other.prefix_id or 139 | self.name != other.name or 140 | self.only_roles != other.only_roles 141 | ) 142 | 143 | def __hash__(self): 144 | return hash((self.prefix_id, self.name, self.only_roles)) 145 | 146 | def __repr__(self): 147 | return '{}(prefix_id={!r}, name={!r}, only_roles={!r})'.format( 148 | self.__class__.__name__, 149 | self.prefix_id, 150 | self.name, 151 | self.only_roles 152 | ) 153 | 154 | 155 | class UnresolvedFilterRoles: 156 | conditional = True 157 | 158 | def __init__(self, ref): 159 | self.ref = ref 160 | 161 | def resolve_expression(self, *args, **kwargs): 162 | raise ValueError('unresolved filter roles: ' + repr(self.ref)) 163 | 164 | def __eq__(self, other): 165 | return isinstance(other, UnresolvedFilterRoles) and self.ref == other.ref 166 | 167 | def __ne__(self, other): 168 | return not isinstance(other, UnresolvedFilterRoles) or self.ref != other.ref 169 | 170 | def __hash__(self): 171 | return hash(self.ref) 172 | 173 | def __repr__(self): 174 | return '{}(refs={!r})'.format(self.__class__.__name__, self.ref) 175 | 176 | 177 | class FalseExpression(models.Expression): 178 | output_field = models.BooleanField() 179 | conditional = True 180 | contains_aggregate = False 181 | 182 | def resolve_expression(self, *args, **kwargs): 183 | return self 184 | 185 | def as_sql(self, compiler, connection): 186 | raise EmptyResultSet 187 | 188 | def wrap_condition(condition): 189 | if condition is None or condition is False: 190 | return FalseExpression() 191 | return models.ExpressionWrapper( 192 | condition, 193 | output_field=models.BooleanField() 194 | ) 195 | 196 | def generate_alphabet_sequence(charset=string.ascii_uppercase, separator=''): 197 | joiner = str(separator).join 198 | length = 1 199 | while True: 200 | yield from map(joiner, product(charset, repeat=length)) 201 | length += 1 202 | 203 | 204 | def flatten_condition_map(condition_map): 205 | for key, cond in condition_map.items(): 206 | if isinstance(cond, ConditionGroup): 207 | yield from cond.get_basis_conditions() 208 | else: 209 | yield key, cond 210 | 211 | def combine_or(a, b): 212 | ''' 213 | Compute a | b, with special casing for Python booleans. 214 | This is a workaround for Django not having proper ~Q(). 215 | ''' 216 | return ( 217 | True if a is True or b is True else 218 | b if a is False else a if b is False else 219 | a | b 220 | ) 221 | 222 | def combine_and(a, b): 223 | ''' 224 | Compute a & b, with special casing for Python booleans. 225 | This is a workaround for Django not having proper ~Q(). 226 | ''' 227 | return ( 228 | False if a is False or b is False else 229 | b if a is True else a if b is True else 230 | a & b 231 | ) 232 | 233 | def collect_unconditional_roles(conditional_role_masks, global_role_mask=~0): 234 | result = [] 235 | unconditional_roles = 0 236 | for filt_roles, filt_cond in conditional_role_masks: 237 | if filt_cond is True and not isinstance(filt_roles, BackReference): 238 | if filt_roles is None: 239 | unconditional_roles = None 240 | else: 241 | assert not isinstance(filt_roles, (list, tuple)), 'unflattened rule found' 242 | if unconditional_roles is not None: 243 | unconditional_roles |= int(filt_roles) & global_role_mask 244 | else: 245 | result.append((filt_roles, filt_cond)) 246 | return unconditional_roles, result 247 | 248 | def flatten_nested_filter_roles(value): 249 | while isinstance(value, (list, tuple)): 250 | if len(value) == 1: 251 | ((filt_roles, filt_cond),) = value 252 | if filt_cond is True: 253 | value = filt_roles 254 | continue 255 | elif not value: 256 | value = 0 257 | break 258 | return value 259 | 260 | def preoptimize_nested_conditional_role_masks(conditional_role_masks, global_role_mask=~0): 261 | unconditional_roles, conditional_role_masks = collect_unconditional_roles( 262 | conditional_role_masks, 263 | global_role_mask, 264 | ) 265 | if unconditional_roles is None: 266 | return [(None, True)] # optimization success (trivial case) 267 | 268 | global_role_mask &= ~unconditional_roles 269 | 270 | result = [] 271 | if unconditional_roles: 272 | result.append((unconditional_roles, True)) 273 | 274 | for filt_roles, filt_cond in conditional_role_masks: 275 | if isinstance(filt_roles, (list, tuple)): 276 | filt_roles = preoptimize_nested_conditional_role_masks(filt_roles, global_role_mask) 277 | filt_roles = flatten_nested_filter_roles(filt_roles) 278 | if filt_roles is None or isinstance(filt_roles, (list, tuple, BackReference)): 279 | result.append((filt_roles, filt_cond)) 280 | else: 281 | filt_roles = int(filt_roles) & global_role_mask 282 | if filt_roles: 283 | result.append((filt_roles, filt_cond)) 284 | 285 | return result 286 | 287 | def compute_access_role_filter_q(flag_exprs, conditional_role_masks, 288 | global_role_mask=~0): 289 | # mask: (mask_cond & bigwedge[spec_cond...]) 290 | mask_map = {} 291 | id_to_obj = {} 292 | 293 | result = False 294 | 295 | # First, collect all unconditionally accepted roles 296 | unconditional_roles, conditional_role_masks = collect_unconditional_roles( 297 | conditional_role_masks, 298 | global_role_mask, 299 | ) 300 | 301 | optimized_role_masks = {} 302 | if unconditional_roles is None: 303 | result = True 304 | elif unconditional_roles: 305 | optimized_role_masks[unconditional_roles] = True 306 | 307 | global_role_mask &= ~unconditional_roles 308 | 309 | # Second, filter out unconditional roles from the rest and merge 310 | for filt_roles, filt_cond in conditional_role_masks: 311 | if isinstance(filt_cond, BackReference): 312 | return UnresolvedFilterRoles(filt_roles) 313 | if isinstance(filt_roles, (list, tuple)): 314 | # nested filter spec 315 | sub_result = compute_access_role_filter_q( 316 | flag_exprs, filt_roles, 317 | global_role_mask=global_role_mask) 318 | result = combine_or(result, combine_and(filt_cond, sub_result)) 319 | elif filt_roles is None: # bypass role check 320 | result = combine_or(result, filt_cond) 321 | else: 322 | filt_roles = int(filt_roles) & global_role_mask 323 | if filt_roles: 324 | optimized_role_masks[filt_roles] = combine_or( 325 | optimized_role_masks.get(filt_roles, False), 326 | filt_cond 327 | ) 328 | 329 | # Third, split conditional roles with respect to the condition 330 | # FIXME O(nm) computation time -- memoize? 331 | for filt_roles, filt_cond in optimized_role_masks.items(): 332 | # Resort to identity comparsion, since models.Q.__eq__ is 333 | # too slow and spec_cond duplicates are unlikely. 334 | key = id(filt_cond) 335 | id_to_obj[key] = filt_cond 336 | for spec_roles, spec_cond in flag_exprs.items(): 337 | mask = filt_roles & int(spec_roles) 338 | if mask: 339 | if isinstance(spec_cond, ConditionGroup): 340 | spec_cond = spec_cond.expand(mask) 341 | mask_map[key] = combine_or(mask_map.get(key, False), spec_cond) 342 | 343 | # Fourth, combine into one predicate 344 | result = reduce( 345 | combine_or, 346 | (combine_and(id_to_obj[filt_cond_id], spec_cond) 347 | for filt_cond_id, spec_cond in mask_map.items()), 348 | result 349 | ) 350 | 351 | return result 352 | 353 | def compute_anonymous_access_role_filter_q(conditional_role_masks, special_roles): 354 | result = False 355 | try: 356 | anonymous_role = special_roles[Anonymous] 357 | except KeyError: 358 | return result 359 | for filt_roles, filt_cond in conditional_role_masks: 360 | if isinstance(filt_roles, BackReference): 361 | return UnresolvedFilterRoles(filt_roles) 362 | if isinstance(filt_roles, (list, tuple)): 363 | # nested filter spec 364 | sub_result = compute_anonymous_access_role_filter_q(filt_roles) 365 | result = combine_or(result, combine_and(filt_cond, sub_result)) 366 | elif filt_roles is None or (filt_roles & anonymous_role) != 0: 367 | result = combine_or(result, filt_cond) 368 | return result 369 | 370 | def resolve_model(base_module, model): 371 | if not isinstance(model, str): 372 | return model 373 | 374 | if '.' in model: 375 | app_label, model_name = model.split('.') 376 | else: 377 | app_config = django_apps.get_containing_app_config(base_module) 378 | 379 | app_label = app_config.label 380 | model_name = model 381 | 382 | return django_apps.get_model(app_label, model_name) 383 | 384 | def rewrite_expression(expression, annotation_names, fn_p, fn_a, prefix_map): 385 | def remap(identifier): 386 | head, sep, tail = identifier.partition(LOOKUP_SEP) 387 | if head == 'cur_user': 388 | return prefix_map['user'] + sep + tail 389 | head_a = fn_a(head) 390 | if head_a in annotation_names: 391 | return head_a + sep + tail 392 | return fn_p(head) + sep + tail 393 | 394 | def rewrite_q_item(item, depth): 395 | if not hasattr(item, 'resolve_expression'): 396 | arg, value = item 397 | return (remap(arg), rewrite(value, depth)) 398 | return rewrite(item, depth) 399 | 400 | def rewrite(expression, depth): 401 | if isinstance(expression, models.FilteredRelation): 402 | new_expression = expression.clone() 403 | new_expression.relation_name = remap(new_expression.relation_name) # FIXME 404 | new_expression.condition = rewrite(new_expression.condition, depth) 405 | return new_expression 406 | if isinstance(expression, models.Q): 407 | return models.Q(*(rewrite_q_item(item, depth) for item in expression.children), 408 | _connector=expression.connector, 409 | _negated=expression.negated) 410 | if isinstance(expression, models.OuterRef): 411 | ref_name = expression.name 412 | ref_depth = 1 413 | while isinstance(ref_name, models.OuterRef): 414 | ref_name = ref_name.name 415 | ref_depth += 1 416 | if ref_depth != depth: 417 | return expression 418 | new_expression = models.OuterRef(remap(ref_name)) 419 | for _ in range(1, ref_depth): 420 | new_expression = models.OuterRef(new_expression) 421 | return new_expression 422 | if isinstance(expression, models.F) and not isinstance(expression, models.OuterRef): 423 | if depth != 0: 424 | return expression 425 | return models.F(remap(expression.name)) if depth == 0 else expression 426 | if isinstance(expression, str): 427 | return models.F(remap(expression)) if depth == 0 else expression 428 | if not hasattr(expression, 'resolve_expression'): 429 | return expression 430 | if hasattr(expression, 'get_source_expressions'): 431 | old_source_expressions = expression.get_source_expressions() 432 | new_source_expressions = [rewrite(item, depth) for item in old_source_expressions] 433 | new_expression = copy.copy(expression) 434 | new_expression.set_source_expressions(new_source_expressions) 435 | return new_expression 436 | if isinstance(expression, Query): 437 | new_query = expression.clone() 438 | new_query.where = rewrite(new_query.where, depth + 1) 439 | new_query.annotations = {key: rewrite(value, depth + 1) for key, value in new_query.annotations} 440 | return new_query 441 | raise TypeError('cannot handle expression of type ' + type(expression).__name__) 442 | 443 | return rewrite(expression, 0) 444 | 445 | def replace_condition_sentinels(conditions): 446 | special_roles = {} 447 | new_conditions = {} 448 | for role, expression in conditions.items(): 449 | if expression in SPECIAL_ROLE_KEYS: 450 | if expression in special_roles: 451 | raise ValueError('multiple role value for ' + expression.__name__) 452 | special_roles[expression] = role 453 | else: 454 | new_conditions[role] = expression 455 | return new_conditions, special_roles 456 | 457 | def rewrite_filter_roles(spec, annotation_names, fn_p, fn_a, prefix_map): 458 | def rewrite_filter_role_entry(item): 459 | if not isinstance(item, (list, tuple)): 460 | return item 461 | role, cond = item 462 | if isinstance(role, (list, tuple)): 463 | role = list(map(rewrite_filter_role_entry, value)) 464 | if cond == models.Q(): 465 | cond = True 466 | if cond == ~models.Q(): 467 | cond = False 468 | cond = rewrite_expression(cond, annotation_names, fn_p, fn_a, prefix_map) 469 | return role, cond 470 | if not isinstance(spec, (list, tuple)): 471 | spec = [(spec, True)] 472 | return list(map(rewrite_filter_role_entry, spec)) 473 | 474 | 475 | class AccessAnnotateMixin: 476 | 477 | access_role_field = models.IntegerField() 478 | access_role_linked_models = () 479 | access_role_prefix_id = None 480 | access_role_user_field_name = CONTEXT_USER_FIELD 481 | access_role_link_spec = {} 482 | 483 | @staticmethod 484 | def get_access_role_annotations(p, a, prefix_map): 485 | return {} 486 | 487 | @staticmethod 488 | def get_access_role_conditions(p, a, prefix_map): 489 | return {} 490 | 491 | @staticmethod 492 | def get_access_role_filter_roles(p, a, prefix_map, include, inherit): 493 | return inherit('*') 494 | 495 | @classmethod 496 | def _get_access_role_base_conditions(cls, special_roles): 497 | try: 498 | superuser_role = special_roles[Superuser] 499 | except KeyError: 500 | superuser_role = 0 501 | try: 502 | public_role = special_roles[NotSuperuser] 503 | except KeyError: 504 | public_role = 0 505 | user_masks = superuser_role | public_role 506 | user_field = cls.access_role_user_field_name 507 | if user_field == CONTEXT_USER_FIELD: 508 | user_qs = get_user_model()._base_manager.filter(pk=models.OuterRef(CONTEXT_USER_FIELD)) 509 | superuser_cond = models.Exists(user_qs.filter(is_superuser=True)) 510 | public_cond = models.Exists(user_qs.filter(is_superuser=False)) 511 | anyuser_cond = models.Exists(user_qs) 512 | else: 513 | superuser_cond = models.Q(**{ user_field + '__is_superuser': True }) 514 | public_cond = models.Q(**{ user_field + '__is_superuser': False }) 515 | anyuser_cond = models.Q(**{ user_field + '__isnull': False }) 516 | lookup_table = {} 517 | if superuser_role: 518 | lookup_table[superuser_role] = superuser_cond 519 | if public_role: 520 | lookup_table[public_role] = public_cond 521 | if superuser_role and public_role: 522 | lookup_table[superuser_role | public_role] = anyuser_cond 523 | if not lookup_table: 524 | return {} 525 | return { 526 | user_masks: SimpleConditionGroup( 527 | lookup_table=lookup_table, 528 | basis=tuple(filter(None, (superuser_role, public_role))), 529 | ) 530 | } 531 | 532 | @classmethod 533 | def _collect_access_role_linked_models(cls, model): 534 | linked_models = OrderedDict() 535 | link_spec = {} 536 | source_modules = {} 537 | for model_base_cls in reversed(model.__mro__): 538 | if not issubclass(model_base_cls, AccessControlledModelMixin): 539 | continue 540 | for model, alias in getattr(model_base_cls, 'role_linked_models', ()): 541 | source_modules[alias] = model_base_cls.__module__ 542 | if model is None: 543 | linked_models.pop(alias, None) 544 | else: 545 | linked_models[alias] = model 546 | for alias, spec in getattr(model_base_cls, 'role_link_spec', {}).items(): 547 | source_modules[alias] = model_base_cls.__module__ 548 | link_spec.setdefault(alias, {}).update(spec) 549 | for base_cls in reversed(cls.__mro__): 550 | if not issubclass(base_cls, AccessAnnotateMixin): 551 | continue 552 | for model, alias in base_cls.access_role_linked_models: 553 | source_modules[alias] = base_cls.__module__ 554 | if model is None: 555 | linked_models.pop(alias, None) 556 | else: 557 | linked_models[alias] = model 558 | for alias, spec in base_cls.access_role_link_spec.items(): 559 | source_modules[alias] = base_cls.__module__ 560 | link_spec.setdefault(alias, {}).update(spec) 561 | return [ 562 | (source_modules[alias], model, alias, link_spec.get(alias, {})) 563 | for alias, model in linked_models.items() 564 | ] 565 | 566 | @classmethod 567 | def _compute_access_role_expressions(cls, model, prefix, annotation_prefix, 568 | prefix_map): 569 | if cls in prefix_map: 570 | raise RecursionError('loop detected in access_role_linked_models') 571 | 572 | fn_p = lambda ident: prefix + ident 573 | fn_a = lambda ident: annotation_prefix + ident 574 | 575 | try: 576 | model_annotations = model.role_annotations 577 | except AttributeError: 578 | model_annotations = {} 579 | 580 | try: 581 | model_conditions = model.role_conditions 582 | except AttributeError: 583 | model_conditions = {} 584 | 585 | annotation_names = set(map(fn_a, model_annotations.keys())) 586 | annotations = {} 587 | annotations.update({ 588 | fn_a(name): rewrite_expression(expression, annotation_names, fn_p, fn_a, prefix_map) 589 | for name, expression in model_annotations.items() 590 | }) 591 | annotations.update(cls.get_access_role_annotations(fn_p, fn_a, prefix_map)) 592 | 593 | annotation_names = annotations.keys() 594 | conditions = {} 595 | conditions.update(cls.get_access_role_conditions(fn_p, fn_a, prefix_map)) 596 | model_conditions, special_roles = replace_condition_sentinels(model_conditions) 597 | conditions.update({ 598 | role: rewrite_expression(expression, annotation_names, fn_p, fn_a, prefix_map) 599 | for role, expression in model_conditions.items() 600 | }) 601 | parent_filter_roles = {} 602 | 603 | prefix_id = cls.access_role_prefix_id 604 | if prefix_id is not None: 605 | prefix_map = { 606 | **prefix_map, 607 | prefix_id: {'p': fn_p, 'a': fn_a} 608 | } 609 | 610 | for (source_module, linked_model, alias, spec), annotation_alias in zip( 611 | cls._collect_access_role_linked_models(model), 612 | generate_alphabet_sequence()): 613 | if isinstance(linked_model, BackModelReference): 614 | parent_filter_roles[alias] = linked_model 615 | continue 616 | 617 | manager_name = spec.get('manager', '_default_manager') 618 | 619 | linked_model = resolve_model(source_module, linked_model) 620 | manager = getattr(linked_model, manager_name) 621 | 622 | sub_annotations, sub_conditions, sub_filter_roles, sub_special_roles = ( 623 | manager.all()._compute_access_role_expressions( 624 | linked_model, 625 | prefix=( 626 | (annotation_prefix 627 | if spec.get('is_annotation') else 628 | prefix) + 629 | alias + LOOKUP_SEP 630 | ), 631 | annotation_prefix=( 632 | annotation_prefix + annotation_alias + '_' 633 | ), 634 | prefix_map=prefix_map 635 | ) 636 | ) 637 | if any(key in special_roles and key in sub_special_roles and 638 | special_roles[key] != sub_special_roles[key] 639 | for key in SPECIAL_ROLE_KEYS): 640 | raise ValueError('current model\'s special roles are not subset of parent special roles') 641 | special_roles.update(sub_special_roles) 642 | annotations.update(sub_annotations) 643 | for flag, cond in sub_conditions.items(): 644 | if flag in conditions: 645 | conditions[flag] |= cond 646 | else: 647 | conditions[flag] = cond 648 | parent_filter_roles[alias] = sub_filter_roles 649 | 650 | def normalize_parents_ref(parents, require_key=None): 651 | if not isinstance(parents, str): 652 | return list(parents) 653 | if parents == '*': 654 | parents = parent_filter_roles.keys() 655 | if require_key is None: 656 | return list(parents) 657 | return [k for k, v in parents.items() if require_key in v] 658 | return [parents] 659 | 660 | def include(parents, name, only_roles=None): 661 | result = [] 662 | parents = normalize_parents_ref(parents, name) 663 | for parent_name in parents: 664 | specmap = parent_filter_roles[parent_name] 665 | if isinstance(specmap, BackModelReference): 666 | ref = BackReference( 667 | specmap.prefix_id, name, only_roles=only_roles) 668 | result.append((ref, True)) 669 | continue 670 | for role, condition in specmap[name]: 671 | if only_roles is not None: 672 | role = ( 673 | role & only_roles 674 | if role is not None else 675 | only_roles 676 | ) 677 | result.append((role, condition)) 678 | return result 679 | 680 | def inherit(parents): 681 | result = {} 682 | for parent_name in normalize_parents_ref(parents): 683 | value = parent_filter_roles[parent_name] 684 | if not isinstance(value, BackModelReference): 685 | result.update(value) 686 | return result 687 | 688 | filter_roles = {} 689 | filter_roles.update( 690 | cls.get_access_role_filter_roles(fn_p, fn_a, prefix_map, 691 | include=include, 692 | inherit=inherit) 693 | ) 694 | filter_roles.update({ 695 | name: rewrite_filter_roles(spec, annotation_names, fn_p, fn_a, prefix_map) 696 | for name, spec in getattr(model, 'role_permissions', {}).items() 697 | }) 698 | 699 | def flatten_filter_roles(spec): 700 | for item in spec: 701 | role, cond = item 702 | if isinstance(role, (list, tuple)) and cond is True: 703 | yield from flatten_filter_roles(role) 704 | else: 705 | yield item 706 | 707 | def normalize_filter_roles(spec): 708 | while isinstance(spec, BackReference): 709 | if spec.prefix_id != prefix_id or spec.name not in filter_roles: 710 | return spec 711 | spec = filter_roles[spec.name] 712 | result = OrderedDict() 713 | for role, condition in flatten_filter_roles(spec): 714 | if isinstance(role, (list, tuple, BackReference)): 715 | role = normalize_filter_roles(role) 716 | result[(role, condition)] = None 717 | return tuple(result.keys()) 718 | 719 | filter_roles = { 720 | name: normalize_filter_roles(spec) 721 | for name, spec in filter_roles.items() 722 | } 723 | 724 | return annotations, conditions, filter_roles, special_roles 725 | 726 | @classmethod 727 | def _get_access_role_expresions(cls, model): 728 | try: 729 | return cls.__dict__['_cached_access_role_expressions'] 730 | except KeyError: 731 | pass 732 | 733 | annotations, conditions, filter_roles, special_roles = ( 734 | cls._compute_access_role_expressions( 735 | model=model, 736 | prefix='', 737 | annotation_prefix='', 738 | prefix_map={ 739 | 'user': cls.access_role_user_field_name 740 | } 741 | ) 742 | ) 743 | 744 | base_conditions = cls._get_access_role_base_conditions(special_roles) 745 | if any(key in conditions for key in base_conditions): 746 | raise RuntimeError('conditions not disjoint with base') 747 | conditions.update(base_conditions) 748 | 749 | filter_roles = { 750 | key: ( 751 | compute_access_role_filter_q( 752 | conditions, 753 | preoptimize_nested_conditional_role_masks(value), 754 | ), 755 | compute_anonymous_access_role_filter_q( 756 | value, 757 | special_roles, 758 | ), 759 | ) 760 | for key, value in filter_roles.items() 761 | } 762 | 763 | result = annotations, conditions, filter_roles, special_roles 764 | cls._cached_access_role_expressions = result 765 | return result 766 | 767 | @classmethod 768 | def _get_current_access_role_annotations(cls, model): 769 | return cls._get_access_role_expresions(model)[0] 770 | 771 | @classmethod 772 | def _get_current_access_role_conditions(cls, model): 773 | return cls._get_access_role_expresions(model)[1] 774 | 775 | @classmethod 776 | def _get_current_access_role_filter_q_dict(cls, model): 777 | return cls._get_access_role_expresions(model)[2] 778 | 779 | @classmethod 780 | def _get_current_access_role_special_roles(cls, model): 781 | return cls._get_access_role_expresions(model)[3] 782 | 783 | @classmethod 784 | def _get_filter_q(cls, model, name, is_anonymous): 785 | return cls._get_current_access_role_filter_q_dict(model)[name][is_anonymous] 786 | 787 | @classmethod 788 | def _get_current_access_role_expr(cls, model): 789 | try: 790 | return cls.__dict__['_cached_access_role_expr'] 791 | except KeyError: 792 | pass 793 | 794 | # NOTE This function is not in hot path itself, but the resolution of 795 | # NOTE subsequent expressions is. Save output_field resolution time 796 | # NOTE by resolving them early. 797 | role_conditions = cls._get_current_access_role_conditions(model) 798 | output_field = cls.access_role_field 799 | zero = models.Value(0, output_field=output_field) 800 | expr = None 801 | for flag, cond in flatten_condition_map(role_conditions): 802 | flag_val = models.Value(flag, output_field=output_field) 803 | cur_expr = models.Case( 804 | models.When(cond, flag_val), 805 | default=zero, output_field=output_field 806 | ) 807 | expr = cur_expr if expr is None else expr.bitor(cur_expr) 808 | 809 | cls._cached_access_role_expr = expr 810 | return expr 811 | 812 | # FIXME filter_q is workaround for Django bug duplicating LEFT OUTER JOIN 813 | # FIXME when an annotation is specified ealier than a filter, both using 814 | # FIXME the FilteredRelation. 815 | # FIXME Example: qs.annotate(fr=FR()).annotate(x=fr__v).filter(fr__j=1) 816 | def annotate_current_access(self, user, *, 817 | use_roles_field=True, 818 | current_time=None, 819 | filter_roles=None, 820 | filter_q=None, 821 | alias_role_conds={}): 822 | is_empty = False 823 | annotations = None 824 | role_q = True 825 | if current_time is None: 826 | current_time = timezone.now() 827 | if user is not None: 828 | UserModel = get_user_model() 829 | if isinstance(user, UserModel): 830 | user = user.pk 831 | if not hasattr(user, 'resolve_expression'): 832 | user = models.Value(user, output_field=UserModel._meta.pk) 833 | if not hasattr(current_time, 'resolve_expression'): 834 | current_time = models.Value(current_time, 835 | output_field=models.DateTimeField()) 836 | qs = self.alias(**{ CURRENT_TIME_FIELD: current_time }) 837 | if use_roles_field: 838 | acc_expr = self._get_current_access_role_expr(self.model) 839 | if models.F(self.access_role_user_field_name) != user: 840 | user_annotations = { self.access_role_user_field_name: user } 841 | qs = ( 842 | qs.annotate(**user_annotations) 843 | if use_roles_field else 844 | qs.alias(**user_annotations) 845 | ) 846 | if use_roles_field or filter_roles is not None: 847 | qs = qs.annotate(**self._get_current_access_role_annotations(self.model)) 848 | else: 849 | qs = self 850 | if use_roles_field: 851 | anonymous_role = self._get_current_access_role_special_roles(self.model).get(Anonymous, 0) 852 | acc_expr = models.Value(anonymous_role, output_field=self.access_role_field) 853 | 854 | is_anonymous = user is None 855 | if filter_roles is not None: 856 | role_q = self._get_filter_q(self.model, filter_roles, is_anonymous=is_anonymous) 857 | 858 | if role_q is False: 859 | qs = qs.none() 860 | elif filter_q is not None and role_q is not True: 861 | qs = qs.filter(filter_q & role_q) 862 | elif filter_q is not None or role_q is not True: 863 | qs = qs.filter(filter_q if role_q is True else role_q) 864 | 865 | if alias_role_conds: 866 | qs = qs.alias(**{ 867 | key: wrap_condition( 868 | self._get_filter_q(self.model, value, is_anonymous=is_anonymous) 869 | ) 870 | for key, value in alias_role_conds.items() 871 | }) 872 | 873 | if use_roles_field: 874 | qs = qs.annotate(**{ACCESS_ROLE_FIELD: acc_expr}) 875 | return qs 876 | 877 | def filter_by_access(self, user, permission): 878 | return self.annotate_current_access(user, filter_roles=permission) 879 | 880 | def annotate_dummy_access(self): 881 | acc_expr = models.Value(None, output_field=self.access_role_field) 882 | return self.annotate(**{ACCESS_ROLE_FIELD: acc_expr}) 883 | 884 | 885 | class AccessControlledModelMixin: 886 | 887 | def get_access_roles(self, user, default=0, using=None): 888 | if user is None: 889 | return self.__class__._default_manager.all()._get_current_access_role_special_roles(self.__class__).get(Anonymous, 0) 890 | if isinstance(user, models.Model): 891 | user = user.pk 892 | try: 893 | ctx_user = getattr(self, CONTEXT_USER_FIELD) 894 | cache_exists = True 895 | except AttributeError: 896 | cache_exists = False 897 | if cache_exists and ctx_user == user: 898 | access_role = getattr(self, ACCESS_ROLE_FIELD) 899 | else: 900 | qs = self.__class__._default_manager.db_manager(using=using) \ 901 | .order_by().filter(pk=self.pk).annotate_current_access(user) 902 | try: 903 | ctx_user, access_role = qs.values_list( 904 | CONTEXT_USER_FIELD, ACCESS_ROLE_FIELD).get() 905 | except qs.model.DoesNotExist: 906 | # TODO really cache negative? 907 | ctx_user = user 908 | access_role = None 909 | else: 910 | if ctx_user != user: 911 | raise InconsistentDatabaseValuesError(CONTEXT_USER_FIELD) 912 | if not cache_exists: 913 | setattr(self, CONTEXT_USER_FIELD, ctx_user) 914 | setattr(self, ACCESS_ROLE_FIELD, access_role) 915 | if access_role is None: 916 | return default 917 | return access_role 918 | 919 | def invalidate_access_roles_cache(self, user=None): 920 | if user is not None: 921 | if isinstance(user, models.Model): 922 | user = user.pk 923 | try: 924 | ctx_user = getattr(self, CONTEXT_USER_FIELD) 925 | except AttributeError: 926 | return 927 | if ctx_user != user: 928 | return 929 | try: 930 | delattr(self, CONTEXT_USER_FIELD) 931 | except AttributeError: 932 | pass 933 | try: 934 | delattr(self, ACCESS_ROLE_FIELD) 935 | except AttributeError: 936 | pass 937 | 938 | _name_counter = 0 939 | def create_queryset_class(): 940 | global _name_counter 941 | _name_counter += 1 # FIXME potential race condition 942 | name = 'AccessAnnotatedQuerySet' + str(_name_counter) 943 | cls = type(name, (AccessAnnotateMixin, models.QuerySet), {}) # FIXME use proper class instantiaton method 944 | return cls 945 | 946 | def create_manager_class(): 947 | return create_queryset_class().as_manager() -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "asgiref" 3 | version = "3.5.2" 4 | description = "ASGI specs, helper code, and adapters" 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.7" 8 | 9 | [package.extras] 10 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 11 | 12 | [[package]] 13 | name = "Django" 14 | version = "3.2.15" 15 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 16 | category = "dev" 17 | optional = false 18 | python-versions = ">=3.6" 19 | 20 | [package.dependencies] 21 | asgiref = ">=3.3.2,<4" 22 | pytz = "*" 23 | sqlparse = ">=0.2.2" 24 | 25 | [package.extras] 26 | argon2 = ["argon2-cffi (>=19.1.0)"] 27 | bcrypt = ["bcrypt"] 28 | 29 | [[package]] 30 | name = "pytz" 31 | version = "2022.2.1" 32 | description = "World timezone definitions, modern and historical" 33 | category = "dev" 34 | optional = false 35 | python-versions = "*" 36 | 37 | [[package]] 38 | name = "sqlparse" 39 | version = "0.4.3" 40 | description = "A non-validating SQL parser." 41 | category = "dev" 42 | optional = false 43 | python-versions = ">=3.5" 44 | 45 | [metadata] 46 | lock-version = "1.1" 47 | python-versions = "^3.8" 48 | content-hash = "00d7a8cd416e114f986e550a6a9b86b54018ee5ad3c963bd59bf3e46decf616b" 49 | 50 | [metadata.files] 51 | asgiref = [ 52 | {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, 53 | {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, 54 | ] 55 | Django = [ 56 | {file = "Django-3.2.15-py3-none-any.whl", hash = "sha256:115baf5049d5cf4163e43492cdc7139c306ed6d451e7d3571fe9612903903713"}, 57 | {file = "Django-3.2.15.tar.gz", hash = "sha256:f71934b1a822f14a86c9ac9634053689279cd04ae69cb6ade4a59471b886582b"}, 58 | ] 59 | pytz = [ 60 | {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"}, 61 | {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"}, 62 | ] 63 | sqlparse = [ 64 | {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, 65 | {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, 66 | ] 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-entity-rbac" 3 | version = "0.1.0" 4 | description = "Description" 5 | authors = ["Your Name "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "django_entity_rbac"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | Django = "^3.0" 16 | 17 | [build-system] 18 | requires = ["poetry-core"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /roletestapp/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | default_app_config = 'roletestapp.apps.RoleTestAppConfig' 3 | -------------------------------------------------------------------------------- /roletestapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /roletestapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RoleTestAppConfig(AppConfig): 5 | name = __name__.rsplit('.', 1)[0] 6 | label = 'roletestapp' 7 | default_auto_field = 'django.db.models.AutoField' 8 | -------------------------------------------------------------------------------- /roletestapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-30 12:46 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django_entity_rbac.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Membership', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='ParentModel', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('name', models.CharField(blank=True, max_length=15)), 29 | ('members', models.ManyToManyField(related_name='pd_roletestapp_parentmodel', through='roletestapp.Membership', to=settings.AUTH_USER_MODEL)), 30 | ], 31 | bases=(django_entity_rbac.models.AccessControlledModelMixin, models.Model), 32 | ), 33 | migrations.AddField( 34 | model_name='membership', 35 | name='parentmodel', 36 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='roletestapp.parentmodel'), 37 | ), 38 | migrations.AddField( 39 | model_name='membership', 40 | name='user', 41 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pd_roletestapp_memberships', to=settings.AUTH_USER_MODEL), 42 | ), 43 | migrations.CreateModel( 44 | name='ChildModel', 45 | fields=[ 46 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 47 | ('name', models.CharField(blank=True, max_length=15)), 48 | ('visibility', models.IntegerField()), 49 | ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 50 | ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='roletestapp.parentmodel')), 51 | ], 52 | bases=(django_entity_rbac.models.AccessControlledModelMixin, models.Model), 53 | ), 54 | migrations.AddConstraint( 55 | model_name='membership', 56 | constraint=models.UniqueConstraint(fields=('parentmodel', 'user'), name='memb_uniq'), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /roletestapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theori-io/django-entity-rbac/92ade54ab44e28b4cd05216ebc1e842670005084/roletestapp/migrations/__init__.py -------------------------------------------------------------------------------- /roletestapp/mock_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for proj project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.15. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'DO NOT USE THIS KEY IN PRODUCTION' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'roletestapp', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | TEMPLATES = [ 54 | { 55 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 56 | 'DIRS': [], 57 | 'APP_DIRS': True, 58 | 'OPTIONS': { 59 | 'context_processors': [ 60 | 'django.template.context_processors.debug', 61 | 'django.template.context_processors.request', 62 | 'django.contrib.auth.context_processors.auth', 63 | 'django.contrib.messages.context_processors.messages', 64 | ], 65 | }, 66 | }, 67 | ] 68 | 69 | 70 | # Database 71 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 72 | 73 | DATABASES = { 74 | 'default': { 75 | 'ENGINE': 'django.db.backends.sqlite3', 76 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 77 | } 78 | } 79 | 80 | 81 | # Password validation 82 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 83 | 84 | AUTH_PASSWORD_VALIDATORS = [ 85 | { 86 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 87 | }, 88 | { 89 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 90 | }, 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 96 | }, 97 | ] 98 | 99 | 100 | # Internationalization 101 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 102 | 103 | LANGUAGE_CODE = 'en-us' 104 | 105 | TIME_ZONE = 'UTC' 106 | 107 | USE_I18N = True 108 | 109 | USE_L10N = True 110 | 111 | USE_TZ = True 112 | 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 116 | 117 | STATIC_URL = '/static/' 118 | 119 | # Default primary key field type 120 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 121 | 122 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 123 | -------------------------------------------------------------------------------- /roletestapp/models.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from django.db import models 4 | from django.conf import settings 5 | from django_entity_rbac.models import ( 6 | AccessControlledModelMixin, 7 | create_manager_class, 8 | Superuser, 9 | NotSuperuser, 10 | Anonymous, 11 | ) 12 | 13 | 14 | @enum.unique 15 | class AccessRole(enum.IntFlag): 16 | ''' 17 | ANONYMOUS, PUBLIC and SUPERUSER are exclusive 18 | ''' 19 | 20 | ANONYMOUS = 1 << 0 21 | PUBLIC = 1 << 1 22 | AUTHOR = 1 << 2 23 | MODERATOR = 1 << 3 24 | SUPERUSER = 1 << 4 25 | 26 | NONE = 0 27 | ALL = ~0 28 | 29 | 30 | class ParentModel(AccessControlledModelMixin, models.Model): 31 | role_annotations = { 32 | 'current_membership': models.FilteredRelation( 33 | 'memberships', condition=models.Q( 34 | memberships__user='cur_user' 35 | ) 36 | ) 37 | } 38 | role_conditions = { 39 | AccessRole.MODERATOR: models.Q(current_membership__isnull=False), 40 | AccessRole.ANONYMOUS: Anonymous, 41 | AccessRole.PUBLIC: NotSuperuser, 42 | AccessRole.SUPERUSER: Superuser, 43 | } 44 | role_permissions = { 45 | 'test': AccessRole.MODERATOR, 46 | } 47 | 48 | name = models.CharField(max_length=15, blank=True) 49 | members = models.ManyToManyField(settings.AUTH_USER_MODEL, 50 | through='Membership', 51 | related_name='pd_roletestapp_parentmodel') 52 | 53 | objects = create_manager_class() 54 | 55 | def __str__(self): 56 | return '' % (self.pk, self.name) 57 | 58 | 59 | class Membership(models.Model): 60 | class Meta: 61 | constraints = ( 62 | models.UniqueConstraint(fields=('parentmodel', 'user'), name='memb_uniq'), 63 | ) 64 | 65 | parentmodel = models.ForeignKey(ParentModel, on_delete=models.CASCADE, related_name='memberships') 66 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='pd_roletestapp_memberships') 67 | 68 | def __str__(self): 69 | return '' % (self.parentmodel, self.user) 70 | 71 | 72 | class ChildModel(AccessControlledModelMixin, models.Model): 73 | role_linked_models = ( 74 | ('ParentModel', 'parent'), 75 | ) 76 | role_conditions = { 77 | AccessRole.AUTHOR: models.Q(author='cur_user'), 78 | } 79 | role_permissions = { 80 | 'parent.test': AccessRole.MODERATOR, 81 | 'test': [ 82 | (AccessRole.SUPERUSER, models.Q(visibility=0)), 83 | (AccessRole.SUPERUSER | AccessRole.MODERATOR, models.Q(visibility=1)), 84 | (AccessRole.SUPERUSER | AccessRole.MODERATOR | AccessRole.AUTHOR, models.Q(visibility=2)), 85 | (AccessRole.SUPERUSER | AccessRole.MODERATOR | AccessRole.AUTHOR | AccessRole.PUBLIC, models.Q(visibility=3)), 86 | ] 87 | } 88 | 89 | parent = models.ForeignKey(ParentModel, on_delete=models.CASCADE) 90 | author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) 91 | name = models.CharField(max_length=15, blank=True) 92 | visibility = models.IntegerField() 93 | 94 | objects = create_manager_class() 95 | 96 | def __str__(self): 97 | return '' % (self.pk, self.name) 98 | -------------------------------------------------------------------------------- /roletestapp/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theori-io/django-entity-rbac/92ade54ab44e28b4cd05216ebc1e842670005084/roletestapp/test/__init__.py -------------------------------------------------------------------------------- /roletestapp/test/test_basic.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from uuid import UUID 3 | from django.test import TestCase 4 | from django.db.models import Q 5 | from django_entity_rbac.constants import CONTEXT_USER_FIELD, ACCESS_ROLE_FIELD 6 | from django.contrib.auth.models import User 7 | 8 | from ..models import AccessRole, ParentModel, Membership, ChildModel 9 | 10 | 11 | class TestBasicRoleFetch(TestCase): 12 | 13 | def setUp(self): 14 | defaults = {'is_active': True} 15 | 16 | def global_roles(user): 17 | if not (user and user.is_authenticated): 18 | return AccessRole.ANONYMOUS 19 | if user.is_superuser: 20 | return AccessRole.SUPERUSER 21 | return AccessRole.PUBLIC 22 | 23 | self.user_admin = User.objects.create( 24 | username='admin', is_superuser=True, 25 | **defaults 26 | ) 27 | self.user_alice = User.objects.create( 28 | username='alice', **defaults 29 | ) 30 | self.user_bob = User.objects.create( 31 | username='bob', **defaults 32 | ) 33 | self.user_carol = User.objects.create( 34 | username='carol', **defaults 35 | ) 36 | self.user_ted = User.objects.create( 37 | username='ted', **defaults 38 | ) 39 | self.users = [ 40 | self.user_admin, 41 | self.user_alice, 42 | self.user_bob, 43 | self.user_carol, 44 | self.user_ted 45 | ] 46 | user_roles = {user.pk: global_roles(user) for user in self.users} 47 | 48 | self.parent_alpha = ParentModel.objects.create(name='alpha') 49 | self.parent_beta = ParentModel.objects.create(name='beta') 50 | self.parent_insts = [ 51 | self.parent_alpha, 52 | self.parent_beta 53 | ] 54 | self.parent_roles = { 55 | parent.pk: user_roles.copy() 56 | for parent in self.parent_insts 57 | } 58 | 59 | self.membership_alpha_carol = Membership.objects.create( 60 | parentmodel=self.parent_alpha, 61 | user=self.user_carol 62 | ) 63 | self.parent_roles[self.parent_alpha.pk][self.user_carol.pk] |= AccessRole.MODERATOR 64 | 65 | self.membership_beta_ted = Membership.objects.create( 66 | parentmodel=self.parent_beta, 67 | user=self.user_ted 68 | ) 69 | self.parent_roles[self.parent_beta.pk][self.user_ted.pk] |= AccessRole.MODERATOR 70 | 71 | self.child_roles = {} 72 | 73 | self.child_foo = ChildModel.objects.create( 74 | parent=self.parent_alpha, 75 | name='foo', 76 | visibility=0 77 | ) 78 | self.child_roles[self.child_foo.pk] = self.parent_roles[self.parent_alpha.pk].copy() 79 | 80 | self.child_bar = ChildModel.objects.create( 81 | parent=self.parent_alpha, 82 | author=self.user_carol, 83 | name='bar', 84 | visibility=1 85 | ) 86 | self.child_roles[self.child_bar.pk] = self.parent_roles[self.parent_alpha.pk].copy() 87 | self.child_roles[self.child_bar.pk][self.user_carol.pk] |= AccessRole.AUTHOR 88 | 89 | self.child_baz = ChildModel.objects.create( 90 | parent=self.parent_beta, 91 | author=self.user_alice, 92 | name='baz', 93 | visibility=2 94 | ) 95 | self.child_roles[self.child_baz.pk] = self.parent_roles[self.parent_beta.pk].copy() 96 | self.child_roles[self.child_baz.pk][self.user_alice.pk] |= AccessRole.AUTHOR 97 | 98 | self.child_qux = ChildModel.objects.create( 99 | parent=self.parent_beta, 100 | author=self.user_alice, 101 | name='qux', 102 | visibility=3 103 | ) 104 | self.child_roles[self.child_qux.pk] = self.parent_roles[self.parent_beta.pk].copy() 105 | self.child_roles[self.child_qux.pk][self.user_alice.pk] |= AccessRole.AUTHOR 106 | 107 | self.child_insts = [ 108 | self.child_foo, 109 | self.child_bar, 110 | self.child_baz, 111 | self.child_qux, 112 | ] 113 | 114 | self.instances = self.parent_insts + self.child_insts 115 | 116 | def checkrole(self, model, pk, user, roles): 117 | if isinstance(user, User): 118 | self.assertEqual(roles, model.objects.annotate_current_access(user).get(pk=pk).get_access_roles(user.id)) 119 | self.assertEqual(roles, model.objects.annotate_current_access(user.id).get(pk=pk).get_access_roles(user)) 120 | self.assertEqual(roles, model.objects.annotate_current_access(user.id).get(pk=pk).get_access_roles(user.id)) 121 | 122 | self.assertFalse(model.objects.annotate_current_access(user, filter_q=Q(pk__in=[])).exists()) 123 | self.assertTrue(model.objects.annotate_current_access(user, filter_q=Q()).exists()) 124 | 125 | def assert_role_not_cached(self, value): 126 | self.assertNotIn(CONTEXT_USER_FIELD, value.__dict__.keys()) 127 | self.assertNotIn(ACCESS_ROLE_FIELD, value.__dict__.keys()) 128 | 129 | def assert_context_user_only(self, value, user): 130 | if isinstance(user, User): 131 | user = user.pk 132 | if user is None: 133 | self.assertNotIn(CONTEXT_USER_FIELD, value.__dict__.keys()) 134 | else: 135 | self.assertEqual(getattr(value, CONTEXT_USER_FIELD), user) 136 | self.assertNotIn(ACCESS_ROLE_FIELD, value.__dict__.keys()) 137 | 138 | def assert_role_cached(self, value, user): 139 | if isinstance(user, User): 140 | user = user.pk 141 | if user is None: 142 | self.assertNotIn(CONTEXT_USER_FIELD, value.__dict__.keys()) 143 | else: 144 | self.assertEqual(getattr(value, CONTEXT_USER_FIELD), user) 145 | self.assertIn(ACCESS_ROLE_FIELD, value.__dict__.keys()) 146 | 147 | def getrole(self, model, pk, user): 148 | raw_inst = model.objects.get(pk=pk) 149 | self.assert_role_not_cached(raw_inst) 150 | 151 | fresh_roles = raw_inst.get_access_roles(user) 152 | if user is not None: 153 | self.assert_role_cached(raw_inst, user) 154 | else: 155 | self.assert_role_not_cached(raw_inst) 156 | 157 | if isinstance(user, User): 158 | self.assertEqual(fresh_roles, model.objects.get(pk=pk).get_access_roles(user.id)) 159 | 160 | annot_inst = model.objects.annotate_current_access(user).get(pk=pk) 161 | self.assert_role_cached(annot_inst, user) 162 | cached_roles = annot_inst.get_access_roles(user) 163 | 164 | self.assertEqual(fresh_roles, cached_roles) 165 | self.checkrole(model, pk, user, cached_roles) 166 | 167 | no_annot_inst = model.objects.annotate_current_access( 168 | user, use_roles_field=False 169 | ).get(pk=pk) 170 | self.assert_role_not_cached(no_annot_inst) 171 | 172 | return cached_roles 173 | 174 | def test_null_user(self): 175 | for inst in self.instances: 176 | roles = self.getrole(inst.__class__, inst.pk, None) 177 | self.assertEqual(roles, AccessRole.ANONYMOUS) 178 | 179 | def test_public(self): 180 | mask = AccessRole.SUPERUSER | AccessRole.PUBLIC | AccessRole.ANONYMOUS 181 | for user in self.users: 182 | if user.is_superuser: 183 | continue 184 | for inst in self.instances: 185 | roles = self.getrole(inst.__class__, inst.pk, user) 186 | self.assertEqual(roles & mask, AccessRole.PUBLIC) 187 | 188 | def test_superuser(self): 189 | mask = AccessRole.SUPERUSER | AccessRole.PUBLIC | AccessRole.ANONYMOUS 190 | for inst in self.instances: 191 | roles = self.getrole(inst.__class__, inst.pk, self.user_admin) 192 | self.assertEqual(roles & mask, AccessRole.SUPERUSER) 193 | 194 | def test_parent_roles(self): 195 | for parent_pk, role_map in self.parent_roles.items(): 196 | for user_pk, roles in role_map.items(): 197 | with self.subTest('Access ParentModel %s from User %s' % (parent_pk, user_pk)): 198 | roles_from_db = self.getrole(ParentModel, parent_pk, user_pk) 199 | self.assertEqual(roles_from_db, roles) 200 | 201 | def test_child_roles(self): 202 | for child_pk, role_map in self.child_roles.items(): 203 | for user_pk, roles in role_map.items(): 204 | with self.subTest('Access ChildModel %s from User %s' % (child_pk, user_pk)): 205 | roles_from_db = self.getrole(ChildModel, child_pk, user_pk) 206 | self.assertEqual(roles_from_db, roles) 207 | 208 | def test_filter_roles(self): 209 | rolelist = [AccessRole.SUPERUSER] 210 | rolelist.append(rolelist[-1] | AccessRole.MODERATOR) 211 | rolelist.append(rolelist[-1] | AccessRole.AUTHOR) 212 | rolelist.append(rolelist[-1] | AccessRole.PUBLIC) 213 | 214 | def dotest(model, user, expect_objs): 215 | self.assertEqual(set(model.objects.filter_by_access(user, 'test')), expect_objs) 216 | self.assertEqual(set(model.objects.annotate_current_access(user, filter_roles='test')), expect_objs) 217 | self.assertEqual(set(model.objects.annotate_current_access(user, filter_roles='test', filter_q=Q())), expect_objs) 218 | self.assertEqual(set(model.objects.annotate_current_access(user, use_roles_field=False, filter_roles='test')), expect_objs) 219 | self.assertEqual(set(model.objects.annotate_current_access(user, use_roles_field=False, filter_roles='test', filter_q=Q())), expect_objs) 220 | self.assertFalse(model.objects.annotate_current_access(user, filter_roles='test', filter_q=Q(pk__in=[])).exists()) 221 | self.assertFalse(model.objects.annotate_current_access(user, use_roles_field=False, filter_roles='test', filter_q=Q(pk__in=[])).exists()) 222 | 223 | for user in self.users: 224 | with self.subTest('Parent access from User %s' % (user.pk,)): 225 | parent_expect_objs = { 226 | parent for parent in self.parent_insts 227 | if (self.parent_roles[parent.pk][user.pk] 228 | & AccessRole.MODERATOR) != 0 229 | } 230 | dotest(ParentModel, user, parent_expect_objs) 231 | with self.subTest('Child access from User %s' % (user.pk,)): 232 | child_expect_objs = { 233 | child for child in self.child_insts 234 | if (self.child_roles[child.pk][user.pk] 235 | & rolelist[child.visibility]) != 0 236 | } 237 | dotest(ChildModel, user, child_expect_objs) 238 | 239 | with self.subTest('Parent anonymous access'): 240 | dotest(ParentModel, None, set()) 241 | 242 | with self.subTest('Child anonymous access'): 243 | child_expect_objs = { 244 | child for child in self.child_insts 245 | if (AccessRole.ANONYMOUS & rolelist[child.visibility]) != 0 246 | } 247 | dotest(ChildModel, None, child_expect_objs) 248 | -------------------------------------------------------------------------------- /roletestapp/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | --------------------------------------------------------------------------------