@@ -66,6 +66,29 @@ def __iter__(self):
6666 return iter ((self .status , self .item ))
6767
6868
69+ @dataclass
70+ class AssignmentTuple :
71+ """Represents an assignment as a 3-tuple for comparison"""
72+
73+ actor_ansible_id : str # user_ansible_id or team_ansible_id
74+ object_id : str | None # object_id or object_ansible_id (None for global)
75+ role_definition_name : str
76+ assignment_type : str # 'user' or 'team'
77+
78+ def __hash__ (self ):
79+ return hash ((self .actor_ansible_id , self .object_id , self .role_definition_name , self .assignment_type ))
80+
81+ def __eq__ (self , other ):
82+ if not isinstance (other , AssignmentTuple ):
83+ return False
84+ return (
85+ self .actor_ansible_id == other .actor_ansible_id
86+ and self .object_id == other .object_id
87+ and self .role_definition_name == other .role_definition_name
88+ and self .assignment_type == other .assignment_type
89+ )
90+
91+
6992def create_api_client () -> ResourceAPIClient :
7093 """Factory for pre-configured ResourceAPIClient."""
7194 params = {"raise_if_bad_request" : False }
@@ -107,6 +130,193 @@ def fetch_manifest(
107130 return [ManifestItem (** row ) for row in csv_reader ]
108131
109132
133+ def get_remote_assignments (api_client : ResourceAPIClient ) -> set [AssignmentTuple ]:
134+ """Fetch remote assignments from the resource server and convert to tuples."""
135+ assignments = set ()
136+
137+ # Fetch user assignments
138+ try :
139+ user_resp = api_client .list_user_assignments ()
140+ if user_resp .status_code == 200 :
141+ user_data = user_resp .json ()
142+ for assignment in user_data .get ('results' , []):
143+ # Handle both object_id and object_ansible_id
144+ object_id = assignment .get ('object_id' ) or assignment .get ('object_ansible_id' )
145+ assignments .add (
146+ AssignmentTuple (
147+ actor_ansible_id = assignment ['user_ansible_id' ],
148+ object_id = object_id ,
149+ role_definition_name = assignment ['role_definition' ],
150+ assignment_type = 'user' ,
151+ )
152+ )
153+ except Exception as e :
154+ logger .warning (f"Failed to fetch remote user assignments: { e } " )
155+
156+ # Fetch team assignments
157+ try :
158+ team_resp = api_client .list_team_assignments ()
159+ if team_resp .status_code == 200 :
160+ team_data = team_resp .json ()
161+ for assignment in team_data .get ('results' , []):
162+ # Handle both object_id and object_ansible_id
163+ object_id = assignment .get ('object_id' ) or assignment .get ('object_ansible_id' )
164+ assignments .add (
165+ AssignmentTuple (
166+ actor_ansible_id = assignment ['team_ansible_id' ],
167+ object_id = object_id ,
168+ role_definition_name = assignment ['role_definition' ],
169+ assignment_type = 'team' ,
170+ )
171+ )
172+ except Exception as e :
173+ logger .warning (f"Failed to fetch remote team assignments: { e } " )
174+
175+ return assignments
176+
177+
178+ def get_local_assignments () -> set [AssignmentTuple ]:
179+ """Get local assignments and convert to tuples."""
180+ from ansible_base .rbac .models .role import RoleTeamAssignment , RoleUserAssignment
181+
182+ assignments = set ()
183+
184+ # Get user assignments
185+ for assignment in RoleUserAssignment .objects .select_related ('user' , 'role_definition' ).all ():
186+ try :
187+ user_resource = Resource .get_resource_for_object (assignment .user )
188+ user_ansible_id = user_resource .ansible_id
189+
190+ # Handle both object-scoped and global assignments
191+ object_id = assignment .object_id
192+ if object_id and assignment .content_type :
193+ # For object-scoped assignments, try to get the object's ansible_id
194+ try :
195+ object_resource = Resource .objects .filter (object_id = object_id , content_type = assignment .content_type ).first ()
196+ if object_resource :
197+ object_id = object_resource .ansible_id
198+ except Exception :
199+ # If we can't get ansible_id, keep the original object_id
200+ pass
201+
202+ assignments .add (
203+ AssignmentTuple (
204+ actor_ansible_id = str (user_ansible_id ),
205+ object_id = str (object_id ) if object_id else None ,
206+ role_definition_name = assignment .role_definition .name ,
207+ assignment_type = 'user' ,
208+ )
209+ )
210+ except (Resource .DoesNotExist , AttributeError ):
211+ # Skip assignments where the user doesn't have a resource
212+ continue
213+
214+ # Get team assignments
215+ for assignment in RoleTeamAssignment .objects .select_related ('team' , 'role_definition' ).all ():
216+ try :
217+ team_resource = Resource .get_resource_for_object (assignment .team )
218+ team_ansible_id = team_resource .ansible_id
219+
220+ # Handle both object-scoped and global assignments
221+ object_id = assignment .object_id
222+ if object_id and assignment .content_type :
223+ # For object-scoped assignments, try to get the object's ansible_id
224+ try :
225+ object_resource = Resource .objects .filter (object_id = object_id , content_type = assignment .content_type ).first ()
226+ if object_resource :
227+ object_id = object_resource .ansible_id
228+ except Exception :
229+ # If we can't get ansible_id, keep the original object_id
230+ pass
231+
232+ assignments .add (
233+ AssignmentTuple (
234+ actor_ansible_id = str (team_ansible_id ),
235+ object_id = str (object_id ) if object_id else None ,
236+ role_definition_name = assignment .role_definition .name ,
237+ assignment_type = 'team' ,
238+ )
239+ )
240+ except (Resource .DoesNotExist , AttributeError ):
241+ # Skip assignments where the team doesn't have a resource
242+ continue
243+
244+ return assignments
245+
246+
247+ def delete_local_assignment (assignment_tuple : AssignmentTuple ) -> bool :
248+ """Delete a local assignment based on the tuple."""
249+ from ansible_base .rbac .models .role import RoleDefinition , RoleTeamAssignment , RoleUserAssignment
250+
251+ try :
252+ role_definition = RoleDefinition .objects .get (name = assignment_tuple .role_definition_name )
253+
254+ # Get the actor (user or team)
255+ resource = Resource .objects .get (ansible_id = assignment_tuple .actor_ansible_id )
256+ actor = resource .content_object
257+
258+ # Get the object if it's not a global assignment
259+ content_object = None
260+ if assignment_tuple .object_id :
261+ try :
262+ # Try to find by ansible_id first
263+ object_resource = Resource .objects .get (ansible_id = assignment_tuple .object_id )
264+ content_object = object_resource .content_object
265+ except Resource .DoesNotExist :
266+ # Fall back to finding by object_id (for cases where we stored the raw object_id)
267+ if assignment_tuple .assignment_type == 'user' :
268+ assignment = RoleUserAssignment .objects .filter (user = actor , role_definition = role_definition , object_id = assignment_tuple .object_id ).first ()
269+ else :
270+ assignment = RoleTeamAssignment .objects .filter (team = actor , role_definition = role_definition , object_id = assignment_tuple .object_id ).first ()
271+
272+ if assignment :
273+ assignment .delete ()
274+ return True
275+ return False
276+
277+ # Use the role definition's remove methods
278+ if content_object :
279+ role_definition .remove_permission (actor , content_object )
280+ else :
281+ role_definition .remove_global_permission (actor )
282+
283+ return True
284+
285+ except Exception as e :
286+ logger .error (f"Failed to delete assignment { assignment_tuple } : { e } " )
287+ return False
288+
289+
290+ def create_local_assignment (assignment_tuple : AssignmentTuple ) -> bool :
291+ """Create a local assignment based on the tuple."""
292+ from ansible_base .rbac .models .role import RoleDefinition
293+
294+ try :
295+ role_definition = RoleDefinition .objects .get (name = assignment_tuple .role_definition_name )
296+
297+ # Get the actor (user or team)
298+ resource = Resource .objects .get (ansible_id = assignment_tuple .actor_ansible_id )
299+ actor = resource .content_object
300+
301+ # Get the object if it's not a global assignment
302+ content_object = None
303+ if assignment_tuple .object_id :
304+ object_resource = Resource .objects .get (ansible_id = assignment_tuple .object_id )
305+ content_object = object_resource .content_object
306+
307+ # Use the role definition's give methods
308+ if content_object :
309+ role_definition .give_permission (actor , content_object )
310+ else :
311+ role_definition .give_global_permission (actor )
312+
313+ return True
314+
315+ except Exception as e :
316+ logger .error (f"Failed to create assignment { assignment_tuple } : { e } " )
317+ return False
318+
319+
110320def get_orphan_resources (
111321 resource_type_name : str ,
112322 manifest_list : list [ManifestItem ],
@@ -330,6 +540,7 @@ class SyncExecutor:
330540 deleted_count : int = 0
331541 asyncio : bool = False
332542 results : dict = field (default_factory = lambda : defaultdict (list ))
543+ sync_assignments : bool = True # New flag to enable/disable assignment sync
333544
334545 def write (self , text : str = "" ):
335546 """Write to assigned IO or simply ignores the text."""
@@ -449,6 +660,57 @@ def _dispatch_sync_process(self, manifest_list: list[ManifestItem]):
449660 self .write ()
450661 self ._process_manifest_list (manifest_list )
451662
663+ def _sync_assignments (self ):
664+ """Synchronize role assignments between local and remote systems."""
665+ if not self .sync_assignments :
666+ return
667+
668+ self .write (">>> Syncing role assignments" )
669+
670+ try :
671+ # Get remote and local assignments
672+ remote_assignments = get_remote_assignments (self .api_client )
673+ local_assignments = get_local_assignments ()
674+
675+ # Calculate differences
676+ to_delete = local_assignments - remote_assignments
677+ to_create = remote_assignments - local_assignments
678+
679+ deleted_count = 0
680+ created_count = 0
681+ error_count = 0
682+
683+ # Delete local assignments that don't exist remotely
684+ for assignment_tuple in to_delete :
685+ if delete_local_assignment (assignment_tuple ):
686+ deleted_count += 1
687+ self .write (
688+ f"DELETED assignment { assignment_tuple .assignment_type } { assignment_tuple .actor_ansible_id } -> { assignment_tuple .role_definition_name } on { assignment_tuple .object_id or 'global' } "
689+ )
690+ else :
691+ error_count += 1
692+
693+ # Create local assignments that exist remotely but not locally
694+ for assignment_tuple in to_create :
695+ if create_local_assignment (assignment_tuple ):
696+ created_count += 1
697+ self .write (
698+ f"CREATED assignment { assignment_tuple .assignment_type } { assignment_tuple .actor_ansible_id } -> { assignment_tuple .role_definition_name } on { assignment_tuple .object_id or 'global' } "
699+ )
700+ else :
701+ error_count += 1
702+
703+ self .write (f"Assignment sync completed: Created { created_count } | Deleted { deleted_count } | Errors { error_count } " )
704+
705+ # Store results for reporting
706+ self .results ["assignment_created" ] = created_count
707+ self .results ["assignment_deleted" ] = deleted_count
708+ self .results ["assignment_errors" ] = error_count
709+
710+ except Exception as e :
711+ self .write (f"Assignment sync failed: { e } " )
712+ logger .error (f"Assignment sync failed: { e } " )
713+
452714 def run (self ):
453715 """Run the sync workflow.
454716
@@ -457,6 +719,7 @@ def run(self):
457719 3. Cleanup orphaned resources (deleted remotely).
458720 4. Process the sync for each item in the manifest.
459721 5. Handle retries.
722+ 6. Sync role assignments.
460723 """
461724 self .write ("----- RESOURCE SYNC STARTED -----" )
462725 self .write ()
@@ -479,4 +742,8 @@ def run(self):
479742
480743 self .write ()
481744
745+ # Sync assignments after all resources are synced
746+ self ._sync_assignments ()
747+ self .write ()
748+
482749 self .write ("----- RESOURCE SYNC FINISHED -----" )
0 commit comments