Skip to content

Commit 192336b

Browse files
fao89AlanCoding
authored andcommitted
[AAP-51419] Fix remote object handling in RBAC assignments and resource sync
This commit addresses multiple issues with remote object support in RBAC: 1. **Fix prefetch_related issue with remote objects**: Update FederatedForeignKey to properly handle remote objects that were incorrectly cached as None by Django's prefetch_related mechanism. Remote objects are now recreated when needed instead of returning None. 2. **Fix UUID serialization in resource sync**: Resolve TypeError in ResourceAPIClient.sync_unassignment when syncing assignments with UUID primary keys by converting pk values to strings for JSON serialization. 3. **Fix typo in RemoteObject error message**: Correct "Generlized" to "Generalized" in RemoteObject.get_ct_from_type method. 4. **Add comprehensive test coverage**: New test suites covering: - Remote object serialization behavior in assignment serializers - Remote object handling in assignment view operations - ResourceAPIClient remote object sync functionality - UUID serialization fixes for various object types - Integration tests validating end-to-end remote object workflows These changes improve the reliability of remote object assignments and ensure proper synchronization of permissions across distributed systems. Co-Authored-By: Claude <[email protected]> Signed-off-by: Fabricio Aguiar <[email protected]> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent 7af751a commit 192336b

File tree

10 files changed

+541
-29
lines changed

10 files changed

+541
-29
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
- name: Run tox
5454
run: |
5555
echo "::remove-matcher owner=python::" # Disable annoying annotations from setup-python
56-
tox -e ${{ matrix.tests.env }}
56+
tox --colored yes -e ${{ matrix.tests.env }}
5757
5858
- name: Inject PR number into coverage.xml
5959
if: matrix.tests.sonar

ansible_base/rbac/models/fields.py

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,67 @@ def get_content_type(self, obj=None, id=None, using=None, model=None):
6868
def __get__(self, instance, cls=None):
6969
if instance is None:
7070
return self
71+
72+
ct_id, pk_val = self._get_field_values(instance)
73+
rel_obj = self.get_cached_value(instance, default=None)
74+
75+
if self._should_use_cached_object(instance, rel_obj, ct_id):
76+
return rel_obj
77+
78+
if self._is_cached_object_valid(rel_obj, ct_id, pk_val):
79+
return rel_obj
80+
81+
rel_obj = self._fetch_related_object(ct_id, pk_val)
82+
self.set_cached_value(instance, rel_obj)
83+
return rel_obj
84+
85+
def _get_field_values(self, instance):
86+
"""Extract content type ID and primary key values from instance."""
7187
f = instance._meta.get_field(self.ct_field)
7288
ct_id = getattr(instance, f.attname, None)
7389
pk_val = getattr(instance, self.fk_field)
74-
rel_obj = self.get_cached_value(instance, default=None)
75-
if rel_obj is None and self.is_cached(instance):
76-
return rel_obj
77-
if rel_obj is not None:
78-
ct_match = ct_id == self.get_content_type(obj=rel_obj).id
79-
pk_match = ct_match and rel_obj.pk == pk_val
80-
if pk_match:
81-
return rel_obj
82-
else:
83-
rel_obj = None
90+
return ct_id, pk_val
91+
92+
def _should_use_cached_object(self, instance, rel_obj, ct_id):
93+
"""Check if we should return the cached object (even if None)."""
94+
if rel_obj is not None or not self.is_cached(instance):
95+
return False
96+
97+
# Handle prefetch_related cache issue for remote objects
8498
if ct_id is not None:
8599
ct = self.get_content_type(id=ct_id)
86-
if ct.service in (get_local_resource_prefix(), "shared"):
87-
try:
88-
rel_obj = ct.get_object_for_this_type(pk=pk_val)
89-
except (ObjectDoesNotExist, LookupError):
90-
rel_obj = None
91-
else:
92-
rel_obj = ct.get_object_for_this_type(pk=pk_val)
93-
self.set_cached_value(instance, rel_obj)
94-
return rel_obj
100+
local_prefix = get_local_resource_prefix()
101+
if ct.service not in (local_prefix, "shared"):
102+
# Remote object incorrectly cached as None - don't use cache
103+
return False
104+
105+
# Local/shared object genuinely None or no content type
106+
return True
107+
108+
def _is_cached_object_valid(self, rel_obj, ct_id, pk_val):
109+
"""Check if the cached object matches the current field values."""
110+
if rel_obj is None:
111+
return False
112+
113+
ct_match = ct_id == self.get_content_type(obj=rel_obj).id
114+
pk_match = ct_match and str(rel_obj.pk) == str(pk_val)
115+
return pk_match
116+
117+
def _fetch_related_object(self, ct_id, pk_val):
118+
"""Fetch the related object from database or create remote object."""
119+
if ct_id is None:
120+
return None
121+
122+
ct = self.get_content_type(id=ct_id)
123+
local_prefix = get_local_resource_prefix()
124+
if ct.service == local_prefix or ct.service == "shared":
125+
return self._fetch_local_object(ct, pk_val)
126+
else:
127+
return ct.get_object_for_this_type(pk=pk_val)
128+
129+
def _fetch_local_object(self, ct, pk_val):
130+
"""Fetch local/shared object with exception handling."""
131+
try:
132+
return ct.get_object_for_this_type(pk=pk_val)
133+
except (ObjectDoesNotExist, LookupError):
134+
return None

ansible_base/rbac/remote.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def __hash__(self):
9090
@classmethod
9191
def get_ct_from_type(cls):
9292
if not hasattr(cls, '_meta'):
93-
raise ValueError('Generlized RemoteObject can not obtain content_type from its class')
93+
raise ValueError('Generalized RemoteObject can not obtain content_type from its class')
9494
ct_model = apps.get_model('dab_rbac', 'DABContentType')
9595
return ct_model.objects.get_by_natural_key(cls._meta.service, cls._meta.app_label, cls._meta.model_name)
9696

@@ -117,7 +117,10 @@ def summary_fields(self):
117117
This placeholder should be cleary identifable by a client or by the RBAC resource server.
118118
Then, the idea, is that it can make a request to the remote server to get the summary data.
119119
"""
120-
return {'<remote_object_placeholder>': True, 'model_name': self._meta.model_name, 'service': self._meta.service, 'pk': self.pk}
120+
pk_val = self.pk
121+
if not isinstance(pk_val, int):
122+
pk_val = str(pk_val)
123+
return {'<remote_object_placeholder>': True, 'model_name': self._meta.model_name, 'service': self._meta.service, 'pk': pk_val}
121124

122125

123126
def get_remote_base_class() -> Type[RemoteObject]:

ansible_base/resource_registry/rest_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ def sync_unassignment(self, role_definition, actor, content_object):
207207
if ct.service == 'shared':
208208
data['object_ansible_id'] = str(content_object.resource.ansible_id)
209209
else:
210-
data['object_id'] = content_object.pk
210+
# Convert pk to string to handle UUID objects for JSON serialization
211+
data["object_id"] = str(content_object.pk)
211212

212213
return self._sync_assignment(data, giving=False)
213214

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ legacy_tox_ini = """
111111
-r{toxinidir}/requirements/requirements_all.txt
112112
-r{toxinidir}/requirements/requirements_dev.txt
113113
allowlist_externals = sh
114-
commands = pytest -n auto --cov=. --cov-report=xml:coverage.xml --cov-report=html --cov-report=json --cov-branch --junit-xml=django-ansible-base-test-results.xml {env:ANSIBLE_BASE_PYTEST_ARGS} {env:ANSIBLE_BASE_TEST_DIRS:test_app/tests} {posargs}
114+
commands = pytest -n auto --color=yes --cov=. --cov-report=xml:coverage.xml --cov-report=html --cov-report=json --cov-branch --junit-xml=django-ansible-base-test-results.xml {env:ANSIBLE_BASE_PYTEST_ARGS} {env:ANSIBLE_BASE_TEST_DIRS:test_app/tests} {posargs}
115115
docker = db
116116
117117
[testenv:check]

test_app/tests/rbac/remote/test_public_api_compat.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
from ansible_base.lib.utils.response import get_relative_url
66
from ansible_base.rbac import permission_registry
7+
from ansible_base.rbac.models import RoleUserAssignment
78
from ansible_base.rbac.remote import RemoteObject
9+
from ansible_base.rbac.service_api.serializers import ServiceRoleUserAssignmentSerializer
810
from test_app.models import Organization
911

1012
# Role Definitions
@@ -111,5 +113,28 @@ def test_give_permission_to_remote_object_uuid(admin_api_client, rando, foo_type
111113
data = {"role_definition": foo_rd_uuid.id, "user": rando.pk, "object_id": pk_value}
112114
response = admin_api_client.post(path=url, data=data)
113115
assert response.status_code == 201, response.data
116+
assignment = RoleUserAssignment.objects.get(pk=response.data['id'])
117+
118+
# Test that we can serialize the assignment in a GET
119+
response = admin_api_client.get(url)
120+
assert response.status_code == 200, response.data
121+
valid_items = [item for item in response.data['results'] if item['id'] == assignment.id]
122+
assert len(valid_items) == 1
123+
assignment_data = valid_items[0]
124+
assert 'content_object' in assignment_data['summary_fields']
125+
assert assignment_data['summary_fields']['content_object']['pk'] == str(a_foo.object_id)
114126

115127
assert rando.has_obj_perm(a_foo, 'foo')
128+
129+
# Test that we can serialize the assignment in a GET to the service-index endpoint
130+
service_url = get_relative_url('serviceuserassignment-list')
131+
response = admin_api_client.get(service_url + f'?user={rando.id}', format="json")
132+
assert response.status_code == 200, response.data
133+
assert response.data['count'] == 1
134+
assignment_data = response.data['results'][0]
135+
assert assignment_data['object_id'] == str(assignment.object_id)
136+
137+
# Direct serialization is used for synchronizing, so test that as well here
138+
serializer = ServiceRoleUserAssignmentSerializer(assignment)
139+
assignment_data = serializer.data
140+
assert assignment_data['object_id'] == str(assignment.object_id)

test_app/tests/rbac/remote/test_remote_assignment.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
import pytest
24

35
from ansible_base.rbac.models import DABContentType, DABPermission, RoleDefinition, RoleUserAssignment
@@ -34,18 +36,23 @@ def test_give_remote_permission(rando, foo_type, foo_permission, foo_rd):
3436

3537

3638
@pytest.mark.django_db
37-
def test_prefetch_related_objects(foo_type, foo_rd, inv_rd, inventory):
39+
def test_prefetch_related_objects(django_assert_num_queries, foo_type, foo_type_uuid, foo_rd, foo_rd_uuid, inv_rd, inventory):
3840
users = [User.objects.create(username=f'user{i}') for i in range(10)]
3941

4042
a_foo = RemoteObject(content_type=foo_type, object_id=42)
43+
a_foo_uuid = RemoteObject(content_type=foo_type_uuid, object_id=str(uuid.uuid4()))
4144
for u in users:
4245
foo_rd.give_permission(u, a_foo)
46+
foo_rd_uuid.give_permission(u, a_foo_uuid)
4347
inv_rd.give_permission(u, inventory)
4448

45-
assert RoleUserAssignment.objects.count() == 20
46-
assert {assignment.content_object for assignment in RoleUserAssignment.objects.all()} == {a_foo, inventory}
47-
assert {assignment.content_object for assignment in RoleUserAssignment.objects.all()} == {a_foo, inventory}
48-
assert {assignment.content_object for assignment in RoleUserAssignment.objects.prefetch_related('content_object')} == {a_foo, inventory}
49+
assert RoleUserAssignment.objects.count() == 10 * 3
50+
for _ in range(2):
51+
with django_assert_num_queries(11):
52+
assert {assignment.content_object for assignment in RoleUserAssignment.objects.all()} == {a_foo, inventory, a_foo_uuid}
53+
for _ in range(2):
54+
with django_assert_num_queries(2):
55+
assert {assignment.content_object for assignment in RoleUserAssignment.objects.prefetch_related('content_object')} == {a_foo, inventory, a_foo_uuid}
4956

5057

5158
@pytest.mark.django_db

test_app/tests/resource_registry/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
import pytest
55

6+
from ansible_base.rbac import permission_registry
67
from ansible_base.resource_registry import apps
8+
from test_app.models import Organization
79

810

911
@pytest.fixture
@@ -34,3 +36,10 @@ def f(mock_away_sync=False):
3436
apps.connect_resource_signals(sender=None)
3537

3638
return f
39+
40+
41+
@pytest.fixture
42+
def foo_type():
43+
"Idea is that this is a remote type, in this case, the foo type"
44+
org_ct = permission_registry.content_type_model.objects.get_for_model(Organization)
45+
return permission_registry.content_type_model.objects.create(service='foo', model='foo', app_label='foo', parent_content_type=org_ct)

0 commit comments

Comments
 (0)