Skip to content

Commit 7e3928a

Browse files
authored
AAP-52836 Fix RoleDefinition reverse-sync setting permissions to empty list (#833)
## Description This fixes a bug where the reverse-sync created items in Gateway with `"permissions": []` on creation, regardless of what the permissions were. On modification, they were set to before-change. The tests capture the reproducer for this, and were failing before the code change. Left manual verification documentation in related Jira.
1 parent ab4c7a8 commit 7e3928a

File tree

4 files changed

+154
-34
lines changed

4 files changed

+154
-34
lines changed

ansible_base/rbac/api/views.py

Lines changed: 19 additions & 2 deletions
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_role_definition, maybe_reverse_sync_unassignment
4242
from .queries import assignment_qs_user_to_obj, assignment_qs_user_to_obj_perm
4343

4444

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

123+
def perform_create(self, serializer):
124+
from ansible_base.resource_registry.signals.handlers import no_reverse_sync
125+
126+
with no_reverse_sync():
127+
super().perform_create(serializer)
128+
129+
# Manually sync after permissions are fully saved
130+
maybe_reverse_sync_role_definition(serializer.instance, "create")
131+
123132
def perform_update(self, serializer):
133+
from ansible_base.resource_registry.signals.handlers import no_reverse_sync
134+
124135
self._error_if_managed(serializer.instance)
125-
return super().perform_update(serializer)
136+
137+
with no_reverse_sync():
138+
super().perform_update(serializer)
139+
140+
# Manually sync after permissions are fully saved
141+
serializer.instance.refresh_from_db()
142+
maybe_reverse_sync_role_definition(serializer.instance, "update")
126143

127144
def perform_destroy(self, instance):
128145
self._error_if_managed(instance)

ansible_base/rbac/sync.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,41 @@
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
1218

13-
def reverse_sync_enabled_all_conditions(assignment):
19+
logger = logging.getLogger('ansible_base.rbac.sync')
20+
21+
22+
def reverse_sync_enabled_all_conditions(instance):
1423
"""This checks for basically all cases we do not reverse sync
15-
1. object level flag for skipping the sync
16-
2. environment variable to skip sync
17-
3. context manager to disable sync
18-
4. RESOURCE_SERVER setting not actually set
24+
1. global reverse sync enabled flag (including context manager)
25+
2. RESOURCE_SERVER setting not actually set
26+
3. environment variable to skip sync
27+
4. object level flag for skipping the sync
1928
"""
2029
from ansible_base.resource_registry.apps import _should_reverse_sync
2130
from ansible_base.resource_registry.signals.handlers import reverse_sync_enabled
2231
from ansible_base.resource_registry.utils.sync_to_resource_server import should_skip_reverse_sync
2332

24-
if not _should_reverse_sync():
33+
if not reverse_sync_enabled.enabled:
2534
return False
2635

27-
if not reverse_sync_enabled.enabled:
36+
if not _should_reverse_sync():
2837
return False
2938

30-
if should_skip_reverse_sync(assignment):
31-
return
39+
if should_skip_reverse_sync(instance):
40+
return False
3241

3342
return True
3443

@@ -51,3 +60,26 @@ def maybe_reverse_sync_unassignment(role_definition, actor, content_object):
5160

5261
client = get_current_user_resource_client()
5362
client.sync_unassignment(role_definition, actor, content_object)
63+
64+
65+
def maybe_reverse_sync_role_definition(instance, action="update"):
66+
"""Manually trigger reverse-sync for a RoleDefinition if appropriate.
67+
68+
This should be called after the instance is fully constructed with
69+
all many-to-many relationships saved.
70+
71+
Args:
72+
instance: The RoleDefinition instance to sync
73+
action: The action type ("create" or "update")
74+
"""
75+
if not reverse_sync_enabled_all_conditions(instance):
76+
logger.debug(f"Skipping reverse-sync for {instance} (action: {action})")
77+
return
78+
79+
logger.debug(f"Performing reverse-sync for {instance} (action: {action})")
80+
81+
# Use the same logic as the post_save signal handler
82+
from ansible_base.resource_registry.signals.handlers import sync_to_resource_server_post_save
83+
84+
created = action == "create"
85+
sync_to_resource_server_post_save(sender=type(instance), instance=instance, created=created, update_fields=None)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
# Make fixture available
12+
from test_app.tests.resource_registry.conftest import enable_reverse_sync # noqa: F401
13+
14+
15+
@pytest.mark.django_db
16+
def test_roledefinition_create_with_permissions_reverse_sync(admin_api_client, enable_reverse_sync): # noqa: F811
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 = {'ansible_id': str(uuid.uuid4()), 'service_id': str(uuid.uuid4())}
32+
mock_make_request.return_value = mock_response
33+
34+
# Create RoleDefinition via API (this should trigger reverse-sync)
35+
url = get_relative_url("roledefinition-list")
36+
data = {
37+
'name': 'test-reverse-sync-role',
38+
'description': 'Test role for reverse sync',
39+
'permissions': ['shared.view_organization', 'shared.change_organization'],
40+
'content_type': 'shared.organization',
41+
}
42+
43+
response = admin_api_client.post(url, data=data, format="json")
44+
assert response.status_code == 201, f"Failed to create role: {response.data}"
45+
46+
# Verify that _make_request was called (meaning reverse-sync was attempted)
47+
assert mock_make_request.called, "Resource server sync should have been called"
48+
49+
# Find the POST request that creates the resource
50+
create_calls = [call for call in mock_make_request.call_args_list if call[0][0].upper() == 'POST'] # First arg is HTTP method
51+
52+
assert len(create_calls) >= 1, (
53+
f"Should have at least one POST call to create resource. " f"Found calls: {[call[0] for call in mock_make_request.call_args_list]}"
54+
)
55+
56+
# Get the request data from the create call
57+
create_call = create_calls[0]
58+
# The call structure is: (method, url, data)
59+
request_data = create_call[0][2] # Third positional argument is the data
60+
61+
# Extract the resource_data that would be sent to the resource server
62+
resource_data = request_data.get('resource_data', {})
63+
permissions_in_sync = resource_data.get('permissions', [])
64+
65+
# This should now PASS because the delayed sync ensures permissions are included
66+
# The fix ensures that permissions are no longer empty during sync
67+
expected_permissions = {'shared.view_organization', 'shared.change_organization'}
68+
actual_permissions = set(permissions_in_sync)
69+
assert actual_permissions == expected_permissions, (
70+
f"Expected permissions {expected_permissions} in reverse-sync data, "
71+
f"but got {actual_permissions}. The delayed sync fix should include permissions!"
72+
)

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)