88from dataclasses import dataclass , field
99from enum import Enum
1010from io import StringIO , TextIOBase
11+ from typing import Any
1112
1213from asgiref .sync import sync_to_async
1314from django .conf import settings
1718from django .db .utils import Error , IntegrityError
1819from requests import HTTPError
1920
21+ from ansible_base .rbac .models .role import AssignmentBase , RoleDefinition , RoleTeamAssignment , RoleUserAssignment
2022from ansible_base .resource_registry .models import Resource , ResourceType
2123from ansible_base .resource_registry .models .service_identifier import service_id
2224from 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+
76101def 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+
117347def 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 -----" )
0 commit comments