Skip to content

Commit e10884d

Browse files
committed
Support evaluations for remote child objects
1 parent 91e4136 commit e10884d

File tree

3 files changed

+75
-18
lines changed

3 files changed

+75
-18
lines changed

ansible_base/rbac/models/role.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def give_permission(self, actor, content_object):
227227
def remove_permission(self, actor, content_object):
228228
return self.give_or_remove_permission(actor, content_object, giving=False)
229229

230-
def get_or_create_object_role(self, **kwargs):
230+
def get_or_create_object_role(self, kwargs, defaults):
231231
"""Transaction-safe method to create ObjectRole
232232
233233
The UI will assign many permissions concurrently.
@@ -238,13 +238,13 @@ def get_or_create_object_role(self, **kwargs):
238238
if transaction.get_connection().in_atomic_block:
239239
try:
240240
with transaction.atomic():
241-
object_role = ObjectRole.objects.create(**kwargs)
241+
object_role = ObjectRole.objects.create(**kwargs, **defaults)
242242
return (object_role, True)
243243
except IntegrityError:
244244
object_role = ObjectRole.objects.get(**kwargs)
245245
return (object_role, False)
246246
else:
247-
object_role = ObjectRole.objects.create(**kwargs)
247+
object_role = ObjectRole.objects.create(**kwargs, **defaults)
248248
return (object_role, True)
249249

250250
def give_or_remove_permission(self, actor, content_object, giving=True, sync_action=False):
@@ -260,13 +260,19 @@ def give_or_remove_permission(self, actor, content_object, giving=True, sync_act
260260
object_id = content_object._meta.pk.get_db_prep_value(content_object.pk, connection)
261261

262262
kwargs = dict(role_definition=self, content_type=obj_ct, object_id=object_id)
263+
defaults = {}
264+
265+
# For remote objects, add parent reference so we can do evaluations if needed
266+
if isinstance(content_object, RemoteObject):
267+
if content_object.parent_reference:
268+
defaults['parent_reference'] = content_object.parent_reference
263269

264270
created = False
265271
object_role = ObjectRole.objects.filter(**kwargs).first()
266272
if object_role is None:
267273
if not giving:
268274
return # nothing to do
269-
object_role, created = self.get_or_create_object_role(**kwargs)
275+
object_role, created = self.get_or_create_object_role(kwargs, defaults)
270276

271277
from ansible_base.rbac.triggers import needed_updates_on_assignment, update_after_assignment
272278

@@ -564,28 +570,38 @@ def expected_direct_permissions(self, types_prefetch=None) -> set[tuple[str, int
564570
object_id = role_model._meta.pk.to_python(self.object_id)
565571
for permission in types_prefetch.permissions_for_object_role(self):
566572
permission_content_type = types_prefetch.get_content_type(permission.content_type_id)
573+
permission_model = permission_content_type.model_class()
567574

568575
# direct object permission
569576
if permission.content_type_id == self.content_type_id:
570577
expected_evaluations.add((permission.codename, self.content_type_id, object_id))
571578
continue
572579

573-
# add child permission on the parent object, usually only for add permission
580+
# Add evaluation for the parent object, usually only for add permission
574581
if is_add_perm(permission.codename) or settings.ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS:
575582
expected_evaluations.add((permission.codename, self.content_type_id, object_id))
576583

577-
# add child object permission on child objects
578-
# Only propogate add permission to children which are parents of the permission model
584+
# Add evaluations for child objects, where this role gives permission to child objects
579585
filter_path = None
580586
child_model = None
581587
if is_add_perm(permission.codename):
582-
for path, model in permission_registry.get_child_models(role_model):
583-
if '__' in path and model._meta.model_name == permission_content_type.model:
584-
path_to_parent, filter_path = path.split('__', 1)
585-
child_model = permission_content_type.model_class()._meta.get_field(path_to_parent).related_model
586-
eval_ct = DABContentType.objects.get_for_model(child_model).id
587-
if not child_model:
588-
continue
588+
# Add evaluations for add permission to children which are parents of the permission model
589+
# this only matters when for multi-layer inheritance (grandchildren)
590+
if issubclass(permission_model, RemoteObject):
591+
eval_ct = permission_content_type.parent_content_type
592+
if eval_ct == role_content_type:
593+
continue # this permission is for a direct child model
594+
else:
595+
for path, model in permission_registry.get_child_models(role_model):
596+
if '__' in path and model._meta.model_name == permission_content_type.model:
597+
path_to_parent, filter_path = path.split('__', 1)
598+
child_model = permission_model._meta.get_field(path_to_parent).related_model
599+
eval_ct = DABContentType.objects.get_for_model(child_model).id
600+
break
601+
if not child_model:
602+
continue # this permission is for a direct child model
603+
elif issubclass(permission_model, RemoteObject):
604+
eval_ct = permission.content_type_id
589605
else:
590606
for path, model in permission_registry.get_child_models(role_model):
591607
if model._meta.model_name == permission_content_type.model:
@@ -603,8 +619,15 @@ def expected_direct_permissions(self, types_prefetch=None) -> set[tuple[str, int
603619
if eval_ct in cached_id_lists:
604620
id_list = cached_id_lists[eval_ct]
605621
else:
606-
# TODO: build id_list from ObjectRole objects it is remote object
607-
id_list = child_model.objects.filter(**{filter_path: object_id}).values_list('pk', flat=True)
622+
if issubclass(permission_model, RemoteObject):
623+
# Build id_list from ObjectRole objects if it is remote object
624+
id_list = (
625+
ObjectRole.objects.filter(parent_reference=object_id, content_type=eval_ct)
626+
.values_list(Cast('object_id', output_field=permission_model._meta.pk.django_field()), flat=True)
627+
.distinct()
628+
)
629+
else:
630+
id_list = child_model.objects.filter(**{filter_path: object_id}).values_list('pk', flat=True)
608631
cached_id_lists[eval_ct] = list(id_list)
609632

610633
for id in id_list:

ansible_base/rbac/remote.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def to_python(self, value: Union[str, int, uuid.UUID]) -> Union[int, uuid.UUID]:
3838
return uuid.UUID(value)
3939
return int(value)
4040

41+
def django_field(self):
42+
"This gives a mock Django field like what it mimics"
43+
if self.pk_field_type == "uuid":
44+
return models.UUIDField()
45+
return models.IntegerField()
46+
4147

4248
class StandinMeta:
4349
def __init__(self, ct: models.Model, abstract=False):
@@ -52,9 +58,11 @@ def __init__(self, ct: models.Model, abstract=False):
5258
class RemoteObject:
5359
"""Placeholder for objects that live in another project."""
5460

55-
def __init__(self, content_type: models.Model, object_id: Union[int, str]):
61+
def __init__(self, content_type: models.Model, object_id: Union[int, str], parent_reference=None):
5662
self.content_type = content_type
5763
self.object_id = object_id
64+
# Since object is remote, we do not have its properties here, so a pointer to the parent can be specified here
65+
self.parent_reference = parent_reference
5866
if not hasattr(self, '_meta'):
5967
# If object is created without a type-specific subclass, do the best we can
6068
self._meta = StandinMeta(content_type, abstract=True)

test_app/tests/rbac/remote/test_remote_assignment.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from ansible_base.rbac.models import RoleUserAssignment
3+
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition, RoleUserAssignment
44
from ansible_base.rbac.remote import RemoteObject
55
from test_app.models import User
66

@@ -46,3 +46,29 @@ def test_prefetch_related_objects(foo_type, foo_rd, inv_rd, inventory):
4646
assert {assignment.content_object for assignment in RoleUserAssignment.objects.all()} == {a_foo, inventory}
4747
assert {assignment.content_object for assignment in RoleUserAssignment.objects.all()} == {a_foo, inventory}
4848
assert {assignment.content_object for assignment in RoleUserAssignment.objects.prefetch_related('content_object')} == {a_foo, inventory}
49+
50+
51+
@pytest.mark.django_db
52+
def test_organization_permission_remote_object(rando, foo_type, organization):
53+
"""If the remote object is a child of a shared organization object, org roles should evaluate that users have permission
54+
55+
This is supported by loading a reference to the parent in the RemoteObject.
56+
"""
57+
permissions = []
58+
for codename in ('view_foo', 'change_foo', 'foo_foo'):
59+
permissions.append(DABPermission.objects.create(codename=codename, content_type=foo_type))
60+
view_foo_rd = RoleDefinition.objects.create_from_permissions(
61+
name='Foo fooers for the foos in foo service', permissions=[permissions[0].api_slug], content_type=foo_type
62+
)
63+
a_foo = RemoteObject(content_type=foo_type, object_id=42, parent_reference=organization.pk)
64+
assignment = view_foo_rd.give_permission(rando, a_foo)
65+
assert str(assignment.object_role.parent_reference) == str(organization.pk)
66+
assert not rando.has_obj_perm(a_foo, 'change_foo')
67+
68+
org_ct = DABContentType.objects.get_for_model(organization)
69+
org_foo_rd = RoleDefinition.objects.create_from_permissions(
70+
name='Org level foo role', permissions=['view_foo', 'change_foo', 'foo_foo', 'shared.view_organization'], content_type=org_ct
71+
)
72+
org_foo_rd.give_permission(rando, organization)
73+
74+
assert rando.has_obj_perm(a_foo, 'foo')

0 commit comments

Comments
 (0)