Skip to content

Commit 466a3e2

Browse files
committed
Fix RoleDefinition reverse-sync setting permissions to empty list
1 parent b5f1040 commit 466a3e2

File tree

4 files changed

+240
-24
lines changed

4 files changed

+240
-24
lines changed

ansible_base/rbac/api/views.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from ..models import DABContentType, DABPermission, get_evaluation_model
3939
from ..policies import check_content_obj_permission
4040
from ..remote import RemoteObject, get_resource_prefix
41-
from ..sync import maybe_reverse_sync_assignment, maybe_reverse_sync_unassignment
41+
from ..sync import maybe_reverse_sync_assignment, maybe_reverse_sync_unassignment, delayed_reverse_sync
4242
from .queries import assignment_qs_user_to_obj, assignment_qs_user_to_obj_perm
4343

4444

@@ -120,6 +120,51 @@ def _error_if_managed(self, instance):
120120
if instance.managed is True:
121121
raise ValidationError(_('Role is managed by the system'))
122122

123+
def create(self, request, *args, **kwargs):
124+
"""Create a new RoleDefinition with delayed reverse-sync.
125+
126+
This ensures that the RoleDefinition is fully created with
127+
permissions before syncing to the resource server.
128+
"""
129+
serializer = self.get_serializer(data=request.data)
130+
serializer.is_valid(raise_exception=True)
131+
132+
# Use delayed sync to avoid the timing issue where post_save fires
133+
# before many-to-many permissions are saved
134+
with delayed_reverse_sync(None, "create") as ctx:
135+
# Create the instance but disable auto-sync
136+
instance = serializer.save()
137+
# Update the context manager with the actual instance
138+
ctx.set_instance(instance)
139+
140+
headers = self.get_success_headers(serializer.data)
141+
return Response(serializer.data, status=201, headers=headers)
142+
143+
def update(self, request, *args, **kwargs):
144+
"""Update a RoleDefinition with delayed reverse-sync.
145+
146+
This ensures that the RoleDefinition is fully updated with
147+
permissions before syncing to the resource server.
148+
"""
149+
partial = kwargs.pop('partial', False)
150+
instance = self.get_object()
151+
self._error_if_managed(instance)
152+
153+
serializer = self.get_serializer(instance, data=request.data, partial=partial)
154+
serializer.is_valid(raise_exception=True)
155+
156+
# Use delayed sync to avoid the timing issue where post_save fires
157+
# before many-to-many permissions are saved
158+
with delayed_reverse_sync(instance, "update"):
159+
serializer.save()
160+
161+
if getattr(instance, '_prefetched_objects_cache', None):
162+
# If 'prefetch_related' has been applied to a queryset, we need to
163+
# forcibly invalidate the prefetch cache on the instance.
164+
instance._prefetched_objects_cache = {}
165+
166+
return Response(serializer.data)
167+
123168
def perform_update(self, serializer):
124169
self._error_if_managed(serializer.instance)
125170
return super().perform_update(serializer)

ansible_base/rbac/sync.py

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@
33
44
ansible_base.resource_registry.utils.sync_to_resource_server.sync_to_resource_server
55
6-
However, this only deals with role assignments, which have key differences
7-
- totally immutable model
8-
- have very weird way of referencing related objects
9-
- must run various internal RBAC logic for rebuilding RoleEvaluation entries
6+
This module handles RBAC-specific reverse-sync scenarios:
7+
1. Role assignments - which have key differences:
8+
- totally immutable model
9+
- have very weird way of referencing related objects
10+
- must run various internal RBAC logic for rebuilding RoleEvaluation entries
11+
12+
2. RoleDefinition sync timing - which has timing issues:
13+
- post_save fires before many-to-many relations are saved
14+
- permissions need to be attached before syncing
1015
"""
1116

17+
import logging
18+
from contextlib import contextmanager
19+
20+
logger = logging.getLogger('ansible_base.rbac.sync')
21+
1222

1323
def reverse_sync_enabled_all_conditions(assignment):
1424
"""This checks for basically all cases we do not reverse sync
@@ -51,3 +61,88 @@ def maybe_reverse_sync_unassignment(role_definition, actor, content_object):
5161

5262
client = get_current_user_resource_client()
5363
client.sync_unassignment(role_definition, actor, content_object)
64+
65+
66+
def _should_reverse_sync(instance) -> bool:
67+
"""Check if reverse sync should be performed for the given instance.
68+
69+
This checks the same conditions as the signal handler but without
70+
relying on the global reverse_sync_enabled state being temporarily disabled.
71+
"""
72+
from ansible_base.resource_registry.apps import _should_reverse_sync as _global_should_sync
73+
from ansible_base.resource_registry.utils.sync_to_resource_server import should_skip_reverse_sync
74+
75+
# Check global RESOURCE_SERVER configuration
76+
if not _global_should_sync():
77+
return False
78+
79+
# Check instance-specific skip conditions
80+
if should_skip_reverse_sync(instance):
81+
return False
82+
83+
return True
84+
85+
86+
class DelayedReverseSyncContext:
87+
"""Context class for delayed reverse sync operations."""
88+
89+
def __init__(self, action="update"):
90+
self.action = action
91+
self.instance = None
92+
93+
def set_instance(self, instance):
94+
"""Set the instance to sync after the context exits."""
95+
self.instance = instance
96+
97+
98+
@contextmanager
99+
def delayed_reverse_sync(instance, action="update"):
100+
"""Context manager that disables reverse-sync during execution, then manually syncs afterward.
101+
102+
This is useful for operations where the instance needs to be fully constructed
103+
(including many-to-many relationships) before syncing to the resource server.
104+
105+
This solves the timing issue where post_save fires before many-to-many
106+
relations are saved, causing empty permissions to be synced.
107+
108+
Args:
109+
instance: The Django model instance to sync (can be None for create operations)
110+
action: The action type ("create" or "update")
111+
112+
Usage:
113+
# For updates
114+
with delayed_reverse_sync(role_def, "update"):
115+
# Perform operations that modify the instance
116+
role_def.permissions.set(permissions)
117+
# Sync happens here if appropriate
118+
119+
# For creates
120+
ctx = delayed_reverse_sync(None, "create")
121+
with ctx:
122+
instance = serializer.save()
123+
ctx.set_instance(instance)
124+
# Sync happens here if appropriate
125+
"""
126+
from ansible_base.resource_registry.signals.handlers import no_reverse_sync, sync_to_resource_server_post_save
127+
128+
# Create a context object that can be updated
129+
context = DelayedReverseSyncContext(action)
130+
context.instance = instance
131+
132+
with no_reverse_sync():
133+
yield context
134+
135+
# After the context, check if we should sync and do it manually
136+
final_instance = context.instance
137+
if final_instance and _should_reverse_sync(final_instance):
138+
logger.debug(f"Performing delayed reverse-sync for {final_instance} (action: {action})")
139+
# Use the same logic as the post_save signal handler
140+
created = (action == "create")
141+
sync_to_resource_server_post_save(
142+
sender=type(final_instance),
143+
instance=final_instance,
144+
created=created,
145+
update_fields=None
146+
)
147+
else:
148+
logger.debug(f"Skipping delayed reverse-sync for {final_instance} (action: {action})")
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Generated by Claude Sonnet 4
2+
3+
import uuid
4+
from unittest import mock
5+
6+
import pytest
7+
from django.test.utils import override_settings
8+
9+
from ansible_base.lib.utils.response import get_relative_url
10+
11+
# Import the fixture from resource_registry conftest
12+
pytest_plugins = ["test_app.tests.resource_registry.conftest"]
13+
14+
15+
@pytest.mark.django_db
16+
def test_roledefinition_create_with_permissions_reverse_sync(admin_api_client, enable_reverse_sync):
17+
"""
18+
Test that creating a RoleDefinition with permissions triggers reverse-sync correctly.
19+
20+
This test verifies that the delayed reverse-sync fix works properly.
21+
With the fix, permissions should be included in the sync data instead of being empty.
22+
"""
23+
24+
# Enable reverse-sync and mock the resource server client's _make_request method
25+
with enable_reverse_sync():
26+
with override_settings(RESOURCE_SERVER={'URL': 'http://example.invalid', 'SECRET_KEY': 'test-secret-key'}):
27+
with mock.patch('ansible_base.resource_registry.rest_client.ResourceAPIClient._make_request') as mock_make_request:
28+
# Mock successful response from resource server
29+
mock_response = mock.Mock()
30+
mock_response.status_code = 201
31+
mock_response.json.return_value = {
32+
'ansible_id': str(uuid.uuid4()),
33+
'service_id': str(uuid.uuid4())
34+
}
35+
mock_make_request.return_value = mock_response
36+
37+
# Create RoleDefinition via API (this should trigger reverse-sync)
38+
url = get_relative_url("roledefinition-list")
39+
data = {
40+
'name': 'test-reverse-sync-role',
41+
'description': 'Test role for reverse sync',
42+
'permissions': ['shared.view_organization', 'shared.change_organization'],
43+
'content_type': 'shared.organization'
44+
}
45+
46+
response = admin_api_client.post(url, data=data, format="json")
47+
assert response.status_code == 201, f"Failed to create role: {response.data}"
48+
49+
# Verify that _make_request was called (meaning reverse-sync was attempted)
50+
assert mock_make_request.called, "Resource server sync should have been called"
51+
52+
# Find the POST request that creates the resource
53+
create_calls = [call for call in mock_make_request.call_args_list
54+
if call[0][0].upper() == 'POST'] # First arg is HTTP method
55+
56+
assert len(create_calls) >= 1, (
57+
f"Should have at least one POST call to create resource. "
58+
f"Found calls: {[call[0] for call in mock_make_request.call_args_list]}"
59+
)
60+
61+
# Get the request data from the create call
62+
create_call = create_calls[0]
63+
# The call structure is: (method, url, data)
64+
request_data = create_call[0][2] # Third positional argument is the data
65+
66+
# Extract the resource_data that would be sent to the resource server
67+
resource_data = request_data.get('resource_data', {})
68+
permissions_in_sync = resource_data.get('permissions', [])
69+
70+
# This should now PASS because the delayed sync ensures permissions are included
71+
# The fix ensures that permissions are no longer empty during sync
72+
expected_permissions = {'shared.view_organization', 'shared.change_organization'}
73+
actual_permissions = set(permissions_in_sync)
74+
assert actual_permissions == expected_permissions, (
75+
f"Expected permissions {expected_permissions} in reverse-sync data, "
76+
f"but got {actual_permissions}. The delayed sync fix should include permissions!"
77+
)

test_app/tests/resource_registry/conftest.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,37 @@
22
from unittest import mock
33

44
import pytest
5+
from django.test.utils import override_settings
56

67
from ansible_base.rbac import permission_registry
78
from ansible_base.resource_registry import apps
89
from test_app.models import Organization
910

1011

1112
@pytest.fixture
12-
def enable_reverse_sync(settings):
13+
def enable_reverse_sync():
1314
"""
1415
Useful for tests that deal with testing the reverse sync logic
1516
"""
1617

1718
@contextmanager
1819
def f(mock_away_sync=False):
19-
# This is kind of a dance. We don't want to break other tests by
20-
# leaving the save method monkeypatched when they are expecting syncing
21-
# to be disabled. So we patch the save method, yield, reset
22-
# RESOURCE_SERVER_SYNC_ENABLED, undo the patch (disconnect_resource_signals),
23-
# and then reconnect signals (so the resource registry stuff still works) but
24-
# this time we don't monkeypatch the save method since RESOURCE_SERVER_SYNC_ENABLED
25-
# is back to its original value.
26-
is_enabled = settings.RESOURCE_SERVER_SYNC_ENABLED
27-
settings.RESOURCE_SERVER_SYNC_ENABLED = True
28-
apps.connect_resource_signals(sender=None)
29-
if mock_away_sync:
30-
with mock.patch('ansible_base.resource_registry.utils.sync_to_resource_server.get_resource_server_client'):
31-
yield
32-
else:
33-
yield
34-
apps.disconnect_resource_signals(sender=None)
35-
settings.RESOURCE_SERVER_SYNC_ENABLED = is_enabled
36-
apps.connect_resource_signals(sender=None)
20+
# Use Django's override_settings to properly scope the setting change
21+
# This avoids the scope issues with directly modifying the settings object
22+
try:
23+
with override_settings(RESOURCE_SERVER_SYNC_ENABLED=True):
24+
# Connect signals with the new setting in effect
25+
apps.connect_resource_signals(sender=None)
26+
if mock_away_sync:
27+
with mock.patch('ansible_base.resource_registry.utils.sync_to_resource_server.get_resource_server_client'):
28+
yield
29+
else:
30+
yield
31+
finally:
32+
# Clean up signals when exiting the context
33+
apps.disconnect_resource_signals(sender=None)
34+
# Reconnect signals with the original setting restored
35+
apps.connect_resource_signals(sender=None)
3736

3837
return f
3938

0 commit comments

Comments
 (0)