Skip to content

Commit 56bfaf4

Browse files
jessicamackAlanCodingfosterseth
authored
[AAP-48398] Add periodic role assignment fallback sync (#810)
Gateway is the source of truth for RBAC. This change adds a method to sync the Role Team and Role User assignments between Controller and Gateway using the resource server. This ensures that all Role assignments locally match what Gateway reports. --------- Signed-off-by: Seth Foster <[email protected]> Co-authored-by: Alan Rominger <[email protected]> Co-authored-by: Seth Foster <[email protected]> Co-authored-by: Seth Foster <[email protected]>
1 parent 6b2efe3 commit 56bfaf4

File tree

2 files changed

+332
-1
lines changed

2 files changed

+332
-1
lines changed

ansible_base/resource_registry/tasks/sync.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from dataclasses import dataclass, field
99
from enum import Enum
1010
from io import StringIO, TextIOBase
11+
from typing import Any
1112

1213
from asgiref.sync import sync_to_async
1314
from django.conf import settings
@@ -17,6 +18,7 @@
1718
from django.db.utils import Error, IntegrityError
1819
from requests import HTTPError
1920

21+
from ansible_base.rbac.models.role import AssignmentBase, RoleDefinition, RoleTeamAssignment, RoleUserAssignment
2022
from ansible_base.resource_registry.models import Resource, ResourceType
2123
from ansible_base.resource_registry.models.service_identifier import service_id
2224
from ansible_base.resource_registry.registry import get_registry
@@ -73,6 +75,29 @@ def __iter__(self):
7375
return iter((self.status, self.item))
7476

7577

78+
@dataclass
79+
class AssignmentTuple:
80+
"""Represents an assignment as a 3-tuple for comparison"""
81+
82+
actor_ansible_id: str # user_ansible_id or team_ansible_id
83+
ansible_id_or_pk: str | None # object_id or object_ansible_id (None for global)
84+
role_definition_name: str
85+
assignment_type: str # 'user' or 'team'
86+
87+
def __hash__(self):
88+
return hash((self.actor_ansible_id, self.ansible_id_or_pk, self.role_definition_name, self.assignment_type))
89+
90+
def __eq__(self, other):
91+
if not isinstance(other, AssignmentTuple):
92+
return False
93+
return (
94+
self.actor_ansible_id == other.actor_ansible_id
95+
and self.ansible_id_or_pk == other.ansible_id_or_pk
96+
and self.role_definition_name == other.role_definition_name
97+
and self.assignment_type == other.assignment_type
98+
)
99+
100+
76101
def create_api_client() -> ResourceAPIClient:
77102
"""Factory for pre-configured ResourceAPIClient."""
78103
params = {"raise_if_bad_request": False}
@@ -114,6 +139,211 @@ def fetch_manifest(
114139
return [ManifestItem(**row) for row in csv_reader]
115140

116141

142+
def get_ansible_id_or_pk(assignment: AssignmentBase) -> str:
143+
# For object-scoped assignments, try to get the object's ansible_id
144+
if assignment.content_type.model in ('organization', 'team'):
145+
object_resource = Resource.objects.filter(object_id=assignment.object_id, content_type__model=assignment.content_type.model).first()
146+
if object_resource:
147+
ansible_id_or_pk = object_resource.ansible_id
148+
else:
149+
raise RuntimeError(f"Error: {assignment.content_type.model} {assignment.object_id} was found without an associated Resource.")
150+
else:
151+
ansible_id_or_pk = assignment.object_id
152+
153+
return str(ansible_id_or_pk)
154+
155+
156+
def get_content_object(role_definition: RoleDefinition, assignment_tuple: AssignmentTuple) -> Any:
157+
content_object = None
158+
if role_definition.content_type.model in ('organization', 'team'):
159+
object_resource = Resource.objects.get(ansible_id=assignment_tuple.ansible_id_or_pk)
160+
content_object = object_resource.content_object
161+
else:
162+
model = role_definition.content_type.model_class()
163+
content_object = model.objects.get(pk=assignment_tuple.ansible_id_or_pk)
164+
165+
return content_object
166+
167+
168+
def get_remote_assignments(api_client: ResourceAPIClient) -> set[AssignmentTuple]:
169+
"""Fetch remote assignments from the resource server and convert to tuples."""
170+
assignments = set()
171+
172+
# Fetch user assignments with pagination
173+
try:
174+
page = 1
175+
while True:
176+
filters = {'page': page}
177+
user_resp = api_client.list_user_assignments(filters=filters)
178+
if user_resp.status_code == 200:
179+
user_data = user_resp.json()
180+
for assignment in user_data.get('results', []):
181+
# Handle both object_id and object_ansible_id
182+
ansible_id_or_pk = assignment.get('object_ansible_id') or assignment.get('object_id')
183+
assignments.add(
184+
AssignmentTuple(
185+
actor_ansible_id=assignment['user_ansible_id'],
186+
ansible_id_or_pk=ansible_id_or_pk,
187+
role_definition_name=assignment['role_definition'],
188+
assignment_type='user',
189+
)
190+
)
191+
192+
# Check if there's a next page
193+
if not user_data.get('next'):
194+
break
195+
196+
page += 1
197+
logger.debug(f"Fetching next page {page} of user assignments")
198+
else:
199+
logger.warning(f"Failed to fetch user assignments page {page}: HTTP {user_resp.status_code}")
200+
break
201+
except Exception as e:
202+
logger.warning(f"Failed to fetch remote user assignments: {e}")
203+
204+
# Fetch team assignments with pagination
205+
try:
206+
page = 1
207+
while True:
208+
filters = {'page': page}
209+
team_resp = api_client.list_team_assignments(filters=filters)
210+
if team_resp.status_code == 200:
211+
team_data = team_resp.json()
212+
for assignment in team_data.get('results', []):
213+
# Handle both object_id and object_ansible_id
214+
ansible_id_or_pk = assignment.get('object_ansible_id') or assignment.get('object_id')
215+
assignments.add(
216+
AssignmentTuple(
217+
actor_ansible_id=assignment['team_ansible_id'],
218+
ansible_id_or_pk=ansible_id_or_pk,
219+
role_definition_name=assignment['role_definition'],
220+
assignment_type='team',
221+
)
222+
)
223+
224+
# Check if there's a next page
225+
if not team_data.get('next'):
226+
break
227+
228+
page += 1
229+
logger.debug(f"Fetching next page {page} of team assignments")
230+
else:
231+
logger.warning(f"Failed to fetch team assignments page {page}: HTTP {team_resp.status_code}")
232+
break
233+
except Exception as e:
234+
logger.warning(f"Failed to fetch remote team assignments: {e}")
235+
236+
return assignments
237+
238+
239+
def get_local_assignments() -> set[AssignmentTuple]:
240+
"""Get local assignments and convert to tuples."""
241+
assignments = set()
242+
243+
# Get user assignments
244+
for assignment in RoleUserAssignment.objects.select_related('user', 'role_definition').all():
245+
try:
246+
user_resource = Resource.get_resource_for_object(assignment.user)
247+
except Resource.DoesNotExist:
248+
# Skip assignments where the user doesn't have a resource
249+
continue
250+
251+
user_ansible_id = user_resource.ansible_id
252+
# Handle both object-scoped and global assignments
253+
object_id = assignment.object_id
254+
ansible_id_or_pk = None
255+
if object_id and assignment.content_type:
256+
ansible_id_or_pk = get_ansible_id_or_pk(assignment)
257+
258+
assignments.add(
259+
AssignmentTuple(
260+
actor_ansible_id=str(user_ansible_id),
261+
ansible_id_or_pk=ansible_id_or_pk if ansible_id_or_pk else None,
262+
role_definition_name=assignment.role_definition.name,
263+
assignment_type='user',
264+
)
265+
)
266+
267+
# Get team assignments
268+
for assignment in RoleTeamAssignment.objects.select_related('team', 'role_definition').all():
269+
try:
270+
team_resource = Resource.get_resource_for_object(assignment.team)
271+
except Resource.DoesNotExist:
272+
# Skip assignments where the user doesn't have a resource
273+
continue
274+
team_ansible_id = team_resource.ansible_id
275+
276+
# Handle both object-scoped and global assignments
277+
object_id = assignment.object_id
278+
ansible_id_or_pk = None
279+
if object_id and assignment.content_type:
280+
# For object-scoped assignments, try to get the object's ansible_id
281+
ansible_id_or_pk = get_ansible_id_or_pk(assignment)
282+
283+
assignments.add(
284+
AssignmentTuple(
285+
actor_ansible_id=str(team_ansible_id),
286+
ansible_id_or_pk=ansible_id_or_pk if ansible_id_or_pk else None,
287+
role_definition_name=assignment.role_definition.name,
288+
assignment_type='team',
289+
)
290+
)
291+
292+
return assignments
293+
294+
295+
def delete_local_assignment(assignment_tuple: AssignmentTuple) -> bool:
296+
"""Delete a local assignment based on the tuple."""
297+
try:
298+
role_definition = RoleDefinition.objects.get(name=assignment_tuple.role_definition_name)
299+
300+
# Get the actor (user or team)
301+
resource = Resource.objects.get(ansible_id=assignment_tuple.actor_ansible_id)
302+
actor = resource.content_object
303+
304+
# Get the object if it's not a global assignment
305+
content_object = None
306+
if assignment_tuple.ansible_id_or_pk:
307+
content_object = get_content_object(role_definition, assignment_tuple)
308+
# Use the role definition's remove methods
309+
if content_object:
310+
role_definition.remove_permission(actor, content_object)
311+
else:
312+
role_definition.remove_global_permission(actor)
313+
314+
return True
315+
316+
except Exception as e:
317+
logger.error(f"Failed to delete assignment {assignment_tuple}: {e}")
318+
return False
319+
320+
321+
def create_local_assignment(assignment_tuple: AssignmentTuple) -> bool:
322+
"""Create a local assignment based on the tuple."""
323+
try:
324+
role_definition = RoleDefinition.objects.get(name=assignment_tuple.role_definition_name)
325+
326+
# Get the actor (user or team)
327+
resource = Resource.objects.get(ansible_id=assignment_tuple.actor_ansible_id)
328+
actor = resource.content_object
329+
330+
# Get the object if it's not a global assignment
331+
content_object = None
332+
if assignment_tuple.ansible_id_or_pk:
333+
content_object = get_content_object(role_definition, assignment_tuple)
334+
# Use the role definition's give methods
335+
if content_object:
336+
role_definition.give_permission(actor, content_object)
337+
else:
338+
role_definition.give_global_permission(actor)
339+
340+
return True
341+
342+
except Exception as e:
343+
logger.error(f"Failed to create assignment {assignment_tuple}: {e}")
344+
return False
345+
346+
117347
def get_orphan_resources(
118348
resource_type_name: str,
119349
manifest_list: list[ManifestItem],
@@ -339,6 +569,7 @@ class SyncExecutor:
339569
deleted_count: int = 0
340570
asyncio: bool = False
341571
results: dict = field(default_factory=lambda: defaultdict(list))
572+
sync_assignments: bool = True
342573

343574
def write(self, text: str = ""):
344575
"""Write to assigned IO or simply ignores the text."""
@@ -458,6 +689,59 @@ def _dispatch_sync_process(self, manifest_list: list[ManifestItem]):
458689
self.write()
459690
self._process_manifest_list(manifest_list)
460691

692+
def _sync_assignments(self):
693+
"""Synchronize role assignments between local and remote systems."""
694+
if not self.sync_assignments:
695+
return
696+
697+
self.write(">>> Syncing role assignments")
698+
699+
try:
700+
# Get remote and local assignments
701+
remote_assignments = get_remote_assignments(self.api_client)
702+
local_assignments = get_local_assignments()
703+
704+
# Calculate differences
705+
to_delete = local_assignments - remote_assignments
706+
to_create = remote_assignments - local_assignments
707+
708+
deleted_count = 0
709+
created_count = 0
710+
error_count = 0
711+
712+
# Delete local assignments that don't exist remotely
713+
for assignment_tuple in to_delete:
714+
if delete_local_assignment(assignment_tuple):
715+
deleted_count += 1
716+
self.write(
717+
f"DELETED assignment {assignment_tuple.assignment_type} {assignment_tuple.actor_ansible_id}"
718+
" -> {assignment_tuple.role_definition_name} on {assignment_tuple.ansible_id_or_pk or 'global'}"
719+
)
720+
else:
721+
error_count += 1
722+
723+
# Create local assignments that exist remotely but not locally
724+
for assignment_tuple in to_create:
725+
if create_local_assignment(assignment_tuple):
726+
created_count += 1
727+
self.write(
728+
f"CREATED assignment {assignment_tuple.assignment_type} {assignment_tuple.actor_ansible_id}"
729+
" -> {assignment_tuple.role_definition_name} on {assignment_tuple.ansible_id_or_pk or 'global'}"
730+
)
731+
else:
732+
error_count += 1
733+
734+
self.write(f"Assignment sync completed: Created {created_count} | Deleted {deleted_count} | Errors {error_count}")
735+
736+
# Store results for reporting
737+
self.results["assignments_created"] = [created_count]
738+
self.results["assignments_deleted"] = [deleted_count]
739+
self.results["assignment_errors"] = [error_count]
740+
741+
except Exception as e:
742+
self.write(f"Assignment sync failed: {e}")
743+
logger.exception("Assignment sync failed")
744+
461745
def run(self):
462746
"""Run the sync workflow.
463747
@@ -466,6 +750,7 @@ def run(self):
466750
3. Cleanup orphaned resources (deleted remotely).
467751
4. Process the sync for each item in the manifest.
468752
5. Handle retries.
753+
6. Sync role assignments.
469754
"""
470755
self.write("----- RESOURCE SYNC STARTED -----")
471756
self.write()
@@ -488,4 +773,8 @@ def run(self):
488773

489774
self.write()
490775

776+
# Sync assignments after all resources are synced
777+
self._sync_assignments()
778+
self.write()
779+
491780
self.write("----- RESOURCE SYNC FINISHED -----")

test_app/tests/resource_registry/test_resource_sync.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ansible_base.rbac.models import RoleDefinition
1111
from ansible_base.resource_registry.models import Resource, ResourceType
1212
from ansible_base.resource_registry.models.service_identifier import service_id
13-
from ansible_base.resource_registry.tasks.sync import ManifestItem, ResourceSyncHTTPError, SyncExecutor, _attempt_create_resource
13+
from ansible_base.resource_registry.tasks.sync import AssignmentTuple, ManifestItem, ResourceSyncHTTPError, SyncExecutor, _attempt_create_resource
1414

1515

1616
@pytest.fixture(scope="function")
@@ -254,3 +254,45 @@ def test_sync_error_handling_create(static_api_client, stdout):
254254
executor = SyncExecutor(api_client=static_api_client, stdout=stdout)
255255
executor.run()
256256
any('Errors 1' in line for line in stdout.lines)
257+
258+
259+
@mock.patch('ansible_base.resource_registry.tasks.sync.create_local_assignment')
260+
@mock.patch('ansible_base.resource_registry.tasks.sync.delete_local_assignment')
261+
@pytest.mark.django_db
262+
def test_role_assignment_resource_sync(mock_delete, mock_create, static_api_client, stdout):
263+
mock_delete.return_value = True
264+
mock_create.return_value = True
265+
266+
# Mock a remote assignment that does not exist locally to test creation
267+
with mock.patch(
268+
"ansible_base.resource_registry.tasks.sync.get_remote_assignments",
269+
return_value={
270+
AssignmentTuple(
271+
actor_ansible_id='97447387-8596-404f-b0d0-6429b04c8d22', ansible_id_or_pk='1', role_definition_name='Team Member', assignment_type='user'
272+
),
273+
},
274+
):
275+
executor = SyncExecutor(api_client=static_api_client, stdout=stdout)
276+
executor._sync_assignments()
277+
278+
assert '>>> Syncing role assignments' in stdout.lines
279+
assert executor.results["assignments_created"] == [1]
280+
assert executor.results["assignments_deleted"] == [0]
281+
assert executor.results["assignment_errors"] == [0]
282+
283+
# Mock a local assignment with no matching remote assignment to test deletion
284+
with mock.patch(
285+
"ansible_base.resource_registry.tasks.sync.get_local_assignments",
286+
return_value={
287+
AssignmentTuple(
288+
actor_ansible_id='97447387-8596-404f-b0d0-6429b04c8d22', ansible_id_or_pk='1', role_definition_name='Team Member', assignment_type='user'
289+
),
290+
},
291+
):
292+
executor = SyncExecutor(api_client=static_api_client, stdout=stdout)
293+
executor._sync_assignments()
294+
295+
assert '>>> Syncing role assignments' in stdout.lines
296+
assert executor.results["assignments_created"] == [0]
297+
assert executor.results["assignments_deleted"] == [1]
298+
assert executor.results["assignment_errors"] == [0]

0 commit comments

Comments
 (0)