8
8
from dataclasses import dataclass , field
9
9
from enum import Enum
10
10
from io import StringIO , TextIOBase
11
+ from typing import Any
11
12
12
13
from asgiref .sync import sync_to_async
13
14
from django .conf import settings
17
18
from django .db .utils import Error , IntegrityError
18
19
from requests import HTTPError
19
20
21
+ from ansible_base .rbac .models .role import AssignmentBase , RoleDefinition , RoleTeamAssignment , RoleUserAssignment
20
22
from ansible_base .resource_registry .models import Resource , ResourceType
21
23
from ansible_base .resource_registry .models .service_identifier import service_id
22
24
from ansible_base .resource_registry .registry import get_registry
@@ -73,6 +75,29 @@ def __iter__(self):
73
75
return iter ((self .status , self .item ))
74
76
75
77
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
+
76
101
def create_api_client () -> ResourceAPIClient :
77
102
"""Factory for pre-configured ResourceAPIClient."""
78
103
params = {"raise_if_bad_request" : False }
@@ -114,6 +139,211 @@ def fetch_manifest(
114
139
return [ManifestItem (** row ) for row in csv_reader ]
115
140
116
141
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
+
117
347
def get_orphan_resources (
118
348
resource_type_name : str ,
119
349
manifest_list : list [ManifestItem ],
@@ -339,6 +569,7 @@ class SyncExecutor:
339
569
deleted_count : int = 0
340
570
asyncio : bool = False
341
571
results : dict = field (default_factory = lambda : defaultdict (list ))
572
+ sync_assignments : bool = True
342
573
343
574
def write (self , text : str = "" ):
344
575
"""Write to assigned IO or simply ignores the text."""
@@ -458,6 +689,59 @@ def _dispatch_sync_process(self, manifest_list: list[ManifestItem]):
458
689
self .write ()
459
690
self ._process_manifest_list (manifest_list )
460
691
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
+
461
745
def run (self ):
462
746
"""Run the sync workflow.
463
747
@@ -466,6 +750,7 @@ def run(self):
466
750
3. Cleanup orphaned resources (deleted remotely).
467
751
4. Process the sync for each item in the manifest.
468
752
5. Handle retries.
753
+ 6. Sync role assignments.
469
754
"""
470
755
self .write ("----- RESOURCE SYNC STARTED -----" )
471
756
self .write ()
@@ -488,4 +773,8 @@ def run(self):
488
773
489
774
self .write ()
490
775
776
+ # Sync assignments after all resources are synced
777
+ self ._sync_assignments ()
778
+ self .write ()
779
+
491
780
self .write ("----- RESOURCE SYNC FINISHED -----" )
0 commit comments