src-auth-perms-sync 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. src_auth_perms_sync/__init__.py +1 -0
  2. src_auth_perms_sync/__main__.py +6 -0
  3. src_auth_perms_sync/cli.py +646 -0
  4. src_auth_perms_sync/orgs/__init__.py +1 -0
  5. src_auth_perms_sync/orgs/command.py +7 -0
  6. src_auth_perms_sync/orgs/queries.py +44 -0
  7. src_auth_perms_sync/orgs/sync.py +1167 -0
  8. src_auth_perms_sync/orgs/types.py +103 -0
  9. src_auth_perms_sync/permissions/__init__.py +1 -0
  10. src_auth_perms_sync/permissions/apply.py +420 -0
  11. src_auth_perms_sync/permissions/command.py +918 -0
  12. src_auth_perms_sync/permissions/full_set.py +880 -0
  13. src_auth_perms_sync/permissions/mapping.py +627 -0
  14. src_auth_perms_sync/permissions/maps.py +291 -0
  15. src_auth_perms_sync/permissions/queries.py +180 -0
  16. src_auth_perms_sync/permissions/restore.py +913 -0
  17. src_auth_perms_sync/permissions/snapshot.py +1502 -0
  18. src_auth_perms_sync/permissions/sourcegraph.py +392 -0
  19. src_auth_perms_sync/permissions/types.py +116 -0
  20. src_auth_perms_sync/permissions/workflow.py +526 -0
  21. src_auth_perms_sync/shared/__init__.py +1 -0
  22. src_auth_perms_sync/shared/backups.py +119 -0
  23. src_auth_perms_sync/shared/id_codec.py +67 -0
  24. src_auth_perms_sync/shared/queries.py +65 -0
  25. src_auth_perms_sync/shared/run_context.py +34 -0
  26. src_auth_perms_sync/shared/saml_groups.py +267 -0
  27. src_auth_perms_sync/shared/site_config.py +366 -0
  28. src_auth_perms_sync/shared/sourcegraph.py +69 -0
  29. src_auth_perms_sync/shared/types.py +69 -0
  30. src_auth_perms_sync-0.2.1.dist-info/METADATA +256 -0
  31. src_auth_perms_sync-0.2.1.dist-info/RECORD +34 -0
  32. src_auth_perms_sync-0.2.1.dist-info/WHEEL +4 -0
  33. src_auth_perms_sync-0.2.1.dist-info/entry_points.txt +2 -0
  34. src_auth_perms_sync-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,880 @@
1
+ """Full-overwrite repo permission set workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ import src_py_lib as src
12
+
13
+ from ..shared import backups, id_codec, run_context, saml_groups
14
+ from ..shared import sourcegraph as shared_sourcegraph
15
+ from ..shared import types as shared_types
16
+ from . import apply as permissions_apply
17
+ from . import mapping as permissions_mapping
18
+ from . import snapshot as permission_snapshot
19
+ from . import types as permission_types
20
+ from .workflow import (
21
+ load_mapping_context_for_rules,
22
+ load_mapping_rules,
23
+ render_projected_snapshot_diff,
24
+ snapshot_path,
25
+ user_ids_created_on_or_after,
26
+ validate_post_apply,
27
+ write_maps_backup,
28
+ write_projected_snapshot,
29
+ write_projected_snapshot_diff_file,
30
+ write_snapshot_diff_file,
31
+ write_snapshot_pair,
32
+ )
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _FullSetUserState:
39
+ """Full users and optional before-snapshot captured for planning."""
40
+
41
+ users: list[shared_types.User]
42
+ before_snapshot: permission_snapshot.Snapshot | None = None
43
+ before_timestamp: str | None = None
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _FullSetSnapshotState:
48
+ """Compact users and optional before-snapshot retained after planning."""
49
+
50
+ users: list[permission_snapshot.SnapshotUser]
51
+ before_snapshot: permission_snapshot.Snapshot | None = None
52
+ before_timestamp: str | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class _FullSetPlan:
57
+ """Resolved full-set permission plan."""
58
+
59
+ expected_users: dict[str, tuple[str, ...]]
60
+ repo_names: dict[str, str]
61
+ total_grants: int
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _FullSetLoadedPlan:
66
+ """Loaded full-set state plus reusable command data."""
67
+
68
+ snapshot_state: _FullSetSnapshotState
69
+ plan: _FullSetPlan
70
+ command_data: run_context.CommandData
71
+ apply_before_path: Path | None = None
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class _FullSetPlanFilter:
76
+ """Full-set plans after removing repos that already match desired state."""
77
+
78
+ overwrites: list[permission_types.RepositoryUsernameOverwrite]
79
+ skipped_repo_ids: set[str]
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _FullSetApplyResult:
84
+ """Full-set mutation outcome."""
85
+
86
+ mutations: shared_types.MutationCounts
87
+ full_short_circuit: bool
88
+
89
+
90
+ def _set_full_command_name(dry_run: bool) -> str:
91
+ return "set-dry-run" if dry_run else "set-apply"
92
+
93
+
94
+ def _capture_full_set_snapshot_state(
95
+ client: src.SourcegraphClient,
96
+ input_path: Path,
97
+ parallelism: int,
98
+ explicit_permissions_batch_size: int,
99
+ bind_id_mode: str,
100
+ worker_pool: ThreadPoolExecutor | None = None,
101
+ ) -> _FullSetUserState:
102
+ """Load users while capturing the before-snapshot."""
103
+ total_users = shared_sourcegraph.count_users(client)
104
+ users: list[shared_types.User] = []
105
+ log.info(
106
+ "Streaming %d users from %s while capturing before-snapshot in parallel ...",
107
+ total_users,
108
+ client.endpoint,
109
+ )
110
+ before_timestamp = backups.backup_timestamp()
111
+ before_snapshot = permission_snapshot.build_snapshot(
112
+ client,
113
+ shared_sourcegraph.list_users_streaming(client, collect_into=users),
114
+ parallelism,
115
+ bind_id_mode,
116
+ input_path,
117
+ total_users=total_users,
118
+ explicit_permissions_batch_size=explicit_permissions_batch_size,
119
+ worker_pool=worker_pool,
120
+ )
121
+ log.info(
122
+ "Received %d total users; before-snapshot has %d repo(s) "
123
+ "with explicit grants, %d total grant(s).",
124
+ len(users),
125
+ before_snapshot["stats"]["repos_with_explicit_grants"],
126
+ before_snapshot["stats"]["total_grants"],
127
+ )
128
+ return _FullSetUserState(
129
+ users=users,
130
+ before_snapshot=before_snapshot,
131
+ before_timestamp=before_timestamp,
132
+ )
133
+
134
+
135
+ def _load_full_set_snapshot_state(
136
+ client: src.SourcegraphClient,
137
+ input_path: Path,
138
+ parallelism: int,
139
+ explicit_permissions_batch_size: int,
140
+ bind_id_mode: str,
141
+ capture_before: bool,
142
+ worker_pool: ThreadPoolExecutor | None = None,
143
+ ) -> _FullSetUserState:
144
+ """Load all users, optionally with a before-snapshot."""
145
+ if capture_before:
146
+ return _capture_full_set_snapshot_state(
147
+ client,
148
+ input_path,
149
+ parallelism,
150
+ explicit_permissions_batch_size,
151
+ bind_id_mode,
152
+ worker_pool,
153
+ )
154
+
155
+ log.info("Loading users from %s ...", client.endpoint)
156
+ users = shared_sourcegraph.list_users_with_accounts(client)
157
+ log.info("Received %d total users.", len(users))
158
+ return _FullSetUserState(users=users)
159
+
160
+
161
+ def _filter_full_set_users_by_created_at(
162
+ client: src.SourcegraphClient,
163
+ users: list[shared_types.User],
164
+ user_created_after: str | None,
165
+ ) -> list[shared_types.User]:
166
+ """Apply the optional created-after user filter."""
167
+ if user_created_after is None:
168
+ return users
169
+
170
+ candidate_user_ids = user_ids_created_on_or_after(client, user_created_after)
171
+ filtered_users = [user for user in users if user["id"] in candidate_user_ids]
172
+ log.info(
173
+ "Restricted users to %d / %d created on or after %s.",
174
+ len(filtered_users),
175
+ len(users),
176
+ user_created_after,
177
+ )
178
+ return filtered_users
179
+
180
+
181
+ def _compact_full_set_snapshot_state(
182
+ snapshot_state: _FullSetUserState,
183
+ users: list[shared_types.User],
184
+ ) -> _FullSetSnapshotState:
185
+ """Return snapshot state with only fields needed for later capture."""
186
+ return _FullSetSnapshotState(
187
+ users=permission_snapshot.compact_snapshot_users(users),
188
+ before_snapshot=snapshot_state.before_snapshot,
189
+ before_timestamp=snapshot_state.before_timestamp,
190
+ )
191
+
192
+
193
+ def _require_before_snapshot(
194
+ snapshot_state: _FullSetUserState | _FullSetSnapshotState,
195
+ ) -> tuple[permission_snapshot.Snapshot, str]:
196
+ assert snapshot_state.before_snapshot is not None, (
197
+ "snapshot writes require a prefetched before snapshot"
198
+ )
199
+ assert snapshot_state.before_timestamp is not None, (
200
+ "snapshot writes require a prefetched before timestamp"
201
+ )
202
+ return snapshot_state.before_snapshot, snapshot_state.before_timestamp
203
+
204
+
205
+ def _write_full_set_snapshot_pair(
206
+ input_path: Path,
207
+ timestamp: str,
208
+ endpoint: str,
209
+ command_name: str,
210
+ before_snapshot: permission_snapshot.Snapshot,
211
+ after_snapshot: permission_snapshot.Snapshot,
212
+ ) -> tuple[Path, Path, Path, Path | None]:
213
+ """Write before/after/diff snapshots and the companion maps backup."""
214
+ before_path, after_path, diff_path = write_snapshot_pair(
215
+ input_path,
216
+ timestamp,
217
+ endpoint,
218
+ command_name,
219
+ before_snapshot,
220
+ after_snapshot,
221
+ )
222
+ maps_backup_path = write_maps_backup(input_path, timestamp, endpoint, command_name)
223
+ return before_path, after_path, diff_path, maps_backup_path
224
+
225
+
226
+ def _recordmaps_backup_path(command_event: dict[str, Any], maps_backup_path: Path | None) -> None:
227
+ if maps_backup_path is not None:
228
+ command_event["maps_backup_path"] = str(maps_backup_path)
229
+
230
+
231
+ def _write_noop_full_set_snapshots(
232
+ input_path: Path,
233
+ timestamp: str,
234
+ endpoint: str,
235
+ command_name: str,
236
+ before_snapshot: permission_snapshot.Snapshot,
237
+ dry_run: bool,
238
+ ) -> tuple[Path, Path, Path, Path | None]:
239
+ """Write identical before/after snapshots for a no-op full-set run."""
240
+ before_path, after_path, diff_path, maps_backup_path = _write_full_set_snapshot_pair(
241
+ input_path,
242
+ timestamp,
243
+ endpoint,
244
+ command_name,
245
+ before_snapshot,
246
+ before_snapshot,
247
+ )
248
+ run_mode = "dry-run" if dry_run else "apply"
249
+ log.info(
250
+ "Wrote %s snapshots: before=%s after=%s diff=%s.",
251
+ run_mode,
252
+ before_path,
253
+ after_path,
254
+ diff_path,
255
+ )
256
+ return before_path, after_path, diff_path, maps_backup_path
257
+
258
+
259
+ def _plan_full_set_permissions(
260
+ context: permission_types.MappingContext,
261
+ users: list[shared_types.User],
262
+ ) -> _FullSetPlan:
263
+ """Resolve mapping rules into one repo-to-users overwrite plan."""
264
+ repo_usernames: dict[str, set[str]] = {}
265
+ repo_names: dict[str, str] = {}
266
+
267
+ for mapping_index, mapping in enumerate(context.mapping_rules, start=1):
268
+ name = mapping.get("name", f"<unnamed mapping #{mapping_index}>")
269
+ log.info("=== Mapping %d / %d: %s ===", mapping_index, len(context.mapping_rules), name)
270
+
271
+ users_section = cast(dict[str, object], mapping["users"])
272
+ repos_section = cast(dict[str, object], mapping["repos"])
273
+
274
+ matched_users = permissions_mapping.resolve_users(
275
+ users_section,
276
+ users,
277
+ context.providers,
278
+ context.saml_groups_attribute_names,
279
+ )
280
+ log.info(" Matched %d user(s).", len(matched_users))
281
+ if not matched_users:
282
+ log.warning(" No users matched — skipping rule.")
283
+ continue
284
+
285
+ matched_repos = permissions_mapping.resolve_repos(
286
+ repos_section,
287
+ context.services_by_id,
288
+ context.repos_by_external_service_id,
289
+ context.all_repos_by_id,
290
+ )
291
+ log.info(" Matched %d repo(s).", len(matched_repos))
292
+ if not matched_repos:
293
+ log.warning(" No repos matched — skipping rule.")
294
+ continue
295
+
296
+ matched_usernames = tuple(user["username"] for user in matched_users)
297
+ for repo in matched_repos:
298
+ bucket = repo_usernames.setdefault(repo["id"], set())
299
+ repo_names[repo["id"]] = repo["name"]
300
+ bucket.update(matched_usernames)
301
+
302
+ expected_users = {
303
+ repo_id: tuple(sorted(usernames)) for repo_id, usernames in repo_usernames.items()
304
+ }
305
+ total_grants = sum(len(usernames) for usernames in expected_users.values())
306
+ if expected_users:
307
+ log.info(
308
+ "Resolved %d repo(s) covering %d (repo, user) grant(s) across %d mapping(s).",
309
+ len(expected_users),
310
+ total_grants,
311
+ len(context.mapping_rules),
312
+ )
313
+ return _FullSetPlan(
314
+ expected_users=expected_users,
315
+ repo_names=repo_names,
316
+ total_grants=total_grants,
317
+ )
318
+
319
+
320
+ def _full_set_username_overwrites(
321
+ plan: _FullSetPlan,
322
+ ) -> list[permission_types.RepositoryUsernameOverwrite]:
323
+ """Return per-repo overwrite plans without GraphQL payload dicts."""
324
+ return [
325
+ permission_types.RepositoryUsernameOverwrite(
326
+ repository_id=repo_id,
327
+ repository_name=plan.repo_names[repo_id],
328
+ usernames=usernames,
329
+ )
330
+ for repo_id, usernames in plan.expected_users.items()
331
+ ]
332
+
333
+
334
+ def _finish_full_set_dry_run(
335
+ input_path: Path,
336
+ endpoint: str,
337
+ snapshot_state: _FullSetSnapshotState,
338
+ plan: _FullSetPlan,
339
+ command_event: dict[str, Any],
340
+ ) -> None:
341
+ """Write dry-run artifacts and log the planned mutations."""
342
+ before_snapshot, timestamp = _require_before_snapshot(snapshot_state)
343
+ before_path = snapshot_path(input_path, timestamp, endpoint, "set-dry-run", "before")
344
+ after_path = snapshot_path(input_path, timestamp, endpoint, "set-dry-run", "after")
345
+ after_snapshot = write_projected_snapshot(
346
+ after_path,
347
+ before_snapshot,
348
+ plan.expected_users,
349
+ plan.repo_names,
350
+ )
351
+ diff_path = write_projected_snapshot_diff_file(
352
+ input_path,
353
+ timestamp,
354
+ endpoint,
355
+ "set-dry-run",
356
+ before_snapshot,
357
+ after_snapshot,
358
+ plan.expected_users,
359
+ plan.repo_names,
360
+ )
361
+ log.info(
362
+ "Wrote dry-run snapshots: before=%s after=%s diff=%s.",
363
+ before_path,
364
+ after_path,
365
+ diff_path,
366
+ )
367
+ log.info(
368
+ "Diff (before → dry-run after):\n%s",
369
+ render_projected_snapshot_diff(
370
+ before_snapshot,
371
+ after_snapshot,
372
+ plan.expected_users,
373
+ plan.repo_names,
374
+ ),
375
+ )
376
+ for repo_id, usernames in plan.expected_users.items():
377
+ log.info(
378
+ "[DRY RUN] Would set %d users on repo %s (id=%d).",
379
+ len(usernames),
380
+ plan.repo_names[repo_id],
381
+ id_codec.decode_repository_id(repo_id),
382
+ )
383
+ log.info("Dry run complete. Pass --apply to mutate state.")
384
+
385
+
386
+ def _filter_full_set_plans(
387
+ before_snapshot: permission_snapshot.Snapshot | None,
388
+ plan: _FullSetPlan,
389
+ command_event: dict[str, Any],
390
+ ) -> _FullSetPlanFilter:
391
+ """Drop mutation plans for repos already at desired state."""
392
+ overwrites = _full_set_username_overwrites(plan)
393
+ if before_snapshot is None or not overwrites:
394
+ return _FullSetPlanFilter(overwrites=overwrites, skipped_repo_ids=set())
395
+
396
+ skipped_repo_ids: set[str] = set()
397
+ with src.event(
398
+ "short_circuit_filter",
399
+ repos_planned=len(overwrites),
400
+ ) as short_circuit_event:
401
+ before_repos_map = before_snapshot["repos"]
402
+ pending_overwrites: list[permission_types.RepositoryUsernameOverwrite] = []
403
+ for overwrite in overwrites:
404
+ current_repo = before_repos_map.get(overwrite.repository_id)
405
+ current_usernames = current_repo["explicit_permissions_users"] if current_repo else []
406
+ expected_list = list(overwrite.usernames)
407
+ if current_usernames == expected_list or sorted(current_usernames) == expected_list:
408
+ skipped_repo_ids.add(overwrite.repository_id)
409
+ else:
410
+ pending_overwrites.append(overwrite)
411
+ short_circuit_event["repos_skipped"] = len(skipped_repo_ids)
412
+ short_circuit_event["repos_to_apply"] = len(pending_overwrites)
413
+
414
+ if skipped_repo_ids:
415
+ log.info(
416
+ "Short-circuit: %d / %d planned repo(s) already at the "
417
+ "desired explicit-permissions state — skipping their "
418
+ "setRepositoryPermissionsForUsers calls.",
419
+ len(skipped_repo_ids),
420
+ len(overwrites),
421
+ )
422
+ command_event["repos_short_circuited"] = len(skipped_repo_ids)
423
+ return _FullSetPlanFilter(
424
+ overwrites=pending_overwrites,
425
+ skipped_repo_ids=skipped_repo_ids,
426
+ )
427
+
428
+
429
+ def _write_full_set_before_snapshot(
430
+ input_path: Path,
431
+ timestamp: str,
432
+ endpoint: str,
433
+ command_name: str,
434
+ before_snapshot: permission_snapshot.Snapshot,
435
+ command_event: dict[str, Any],
436
+ ) -> Path:
437
+ """Persist the before-snapshot and maps backup before planning mutations."""
438
+ before_path = snapshot_path(input_path, timestamp, endpoint, command_name, "before")
439
+ permission_snapshot.write_snapshot(before_path, before_snapshot)
440
+ maps_backup_path = write_maps_backup(input_path, timestamp, endpoint, command_name)
441
+ _recordmaps_backup_path(command_event, maps_backup_path)
442
+ log.info(
443
+ "Wrote before-snapshot: %s (%d repo(s) with explicit grants, %d total grant(s)).",
444
+ before_path,
445
+ before_snapshot["stats"]["repos_with_explicit_grants"],
446
+ before_snapshot["stats"]["total_grants"],
447
+ )
448
+ return before_path
449
+
450
+
451
+ def _apply_full_set_plans(
452
+ client: src.SourcegraphClient,
453
+ overwrites: list[permission_types.RepositoryUsernameOverwrite],
454
+ skipped_repo_ids: set[str],
455
+ parallelism: int,
456
+ worker_pool: ThreadPoolExecutor | None = None,
457
+ ) -> _FullSetApplyResult:
458
+ """Apply full-set plans unless all were short-circuited."""
459
+ full_short_circuit = bool(skipped_repo_ids) and not overwrites
460
+ if full_short_circuit:
461
+ log.info(
462
+ "All %d planned repo(s) already at the desired state — nothing to apply.",
463
+ len(skipped_repo_ids),
464
+ )
465
+ return _FullSetApplyResult(
466
+ mutations=shared_types.MutationCounts(),
467
+ full_short_circuit=True,
468
+ )
469
+
470
+ log.info(
471
+ "Applying %d setRepositoryPermissionsForUsers mutation(s) with parallelism=%d ...",
472
+ len(overwrites),
473
+ parallelism,
474
+ )
475
+ with src.stage("apply"):
476
+ mutations = permissions_apply.apply_username_overwrites(
477
+ client,
478
+ overwrites,
479
+ parallelism=parallelism,
480
+ worker_pool=worker_pool,
481
+ )
482
+ log.info(
483
+ "Apply done. %d succeeded, %d failed, %d canceled.",
484
+ mutations.succeeded,
485
+ mutations.failed,
486
+ mutations.canceled,
487
+ )
488
+ return _FullSetApplyResult(
489
+ mutations=mutations,
490
+ full_short_circuit=False,
491
+ )
492
+
493
+
494
+ def _record_full_set_event_fields(
495
+ command_event: dict[str, Any],
496
+ mapping_count: int,
497
+ plan: _FullSetPlan,
498
+ apply_result: _FullSetApplyResult,
499
+ ) -> None:
500
+ command_event["mapping_count"] = mapping_count
501
+ command_event["repo_count"] = len(plan.expected_users)
502
+ command_event["total_grants"] = plan.total_grants
503
+ command_event["mutations_succeeded"] = apply_result.mutations.succeeded
504
+ command_event["mutations_failed"] = apply_result.mutations.failed
505
+ command_event["mutations_canceled"] = apply_result.mutations.canceled
506
+ command_event["full_short_circuit"] = apply_result.full_short_circuit
507
+
508
+
509
+ def _finish_full_set_apply_with_backup(
510
+ client: src.SourcegraphClient,
511
+ input_path: Path,
512
+ timestamp: str,
513
+ before_path: Path,
514
+ before_snapshot: permission_snapshot.Snapshot,
515
+ snapshot_state: _FullSetSnapshotState,
516
+ plan: _FullSetPlan,
517
+ apply_result: _FullSetApplyResult,
518
+ parallelism: int,
519
+ explicit_permissions_batch_size: int,
520
+ bind_id_mode: str,
521
+ worker_pool: ThreadPoolExecutor | None = None,
522
+ ) -> None:
523
+ """Capture after-snapshot, write diff, validate, and print rollback hint."""
524
+ if apply_result.full_short_circuit:
525
+ after_snapshot = before_snapshot
526
+ else:
527
+ log.info("Capturing after-snapshot for %d users ...", len(snapshot_state.users))
528
+ after_snapshot = permission_snapshot.build_snapshot(
529
+ client,
530
+ snapshot_state.users,
531
+ parallelism,
532
+ bind_id_mode,
533
+ input_path,
534
+ total_users=len(snapshot_state.users),
535
+ explicit_permissions_batch_size=explicit_permissions_batch_size,
536
+ worker_pool=worker_pool,
537
+ )
538
+
539
+ after_path = snapshot_path(input_path, timestamp, client.endpoint, "set-apply", "after")
540
+ permission_snapshot.write_snapshot(after_path, after_snapshot)
541
+ diff_path = write_snapshot_diff_file(
542
+ input_path,
543
+ timestamp,
544
+ client.endpoint,
545
+ "set-apply",
546
+ before_snapshot,
547
+ after_snapshot,
548
+ )
549
+ log.info(
550
+ "Wrote after-snapshot: %s diff=%s (%d repo(s) with explicit grants, %d total grant(s)).",
551
+ after_path,
552
+ diff_path,
553
+ after_snapshot["stats"]["repos_with_explicit_grants"],
554
+ after_snapshot["stats"]["total_grants"],
555
+ )
556
+ log.info(
557
+ "Diff (before → after):\n%s",
558
+ permission_snapshot.render_snapshot_diff(before_snapshot, after_snapshot),
559
+ )
560
+
561
+ validate_post_apply(after_snapshot, plan.expected_users, set(plan.expected_users))
562
+ log.info(
563
+ "To roll back the explicit-permissions state captured in "
564
+ "the before-snapshot, run:\n"
565
+ " uv run src-auth-perms-sync --restore %s --apply",
566
+ before_path,
567
+ )
568
+
569
+
570
+ def _raise_for_failed_full_set_apply(
571
+ apply_result: _FullSetApplyResult,
572
+ overwrite_count: int,
573
+ ) -> None:
574
+ if not (apply_result.mutations.failed or apply_result.mutations.canceled):
575
+ return
576
+ log.error(
577
+ "RUN FAILED: %d mutation(s) failed, %d canceled by circuit "
578
+ "breaker (out of %d planned). Review the log file and the "
579
+ "before/after snapshots for details, then re-run --set --apply "
580
+ "(after addressing the underlying cause) to retry the "
581
+ "remaining work.",
582
+ apply_result.mutations.failed,
583
+ apply_result.mutations.canceled,
584
+ overwrite_count,
585
+ )
586
+ raise SystemExit(1)
587
+
588
+
589
+ def _write_noop_full_set_artifacts(
590
+ input_path: Path,
591
+ endpoint: str,
592
+ command_name: str,
593
+ snapshot_state: _FullSetUserState | _FullSetSnapshotState,
594
+ dry_run: bool,
595
+ command_event: dict[str, Any],
596
+ ) -> None:
597
+ """Write no-op before/after snapshots for an empty full-set run."""
598
+ before_snapshot, timestamp = _require_before_snapshot(snapshot_state)
599
+ *_, maps_backup_path = _write_noop_full_set_snapshots(
600
+ input_path,
601
+ timestamp,
602
+ endpoint,
603
+ command_name,
604
+ before_snapshot,
605
+ dry_run,
606
+ )
607
+ _recordmaps_backup_path(command_event, maps_backup_path)
608
+
609
+
610
+ def _finish_empty_full_set_mapping_rules(
611
+ client: src.SourcegraphClient,
612
+ input_path: Path,
613
+ command_name: str,
614
+ dry_run: bool,
615
+ parallelism: int,
616
+ explicit_permissions_batch_size: int,
617
+ bind_id_mode: str,
618
+ do_backup: bool,
619
+ command_event: dict[str, Any],
620
+ worker_pool: ThreadPoolExecutor | None = None,
621
+ ) -> None:
622
+ log.warning("No maps defined in %s — nothing to do.", input_path)
623
+ if not (dry_run or do_backup):
624
+ return
625
+
626
+ snapshot_state = _capture_full_set_snapshot_state(
627
+ client,
628
+ input_path,
629
+ parallelism,
630
+ explicit_permissions_batch_size,
631
+ bind_id_mode,
632
+ worker_pool,
633
+ )
634
+ _write_noop_full_set_artifacts(
635
+ input_path,
636
+ client.endpoint,
637
+ command_name,
638
+ snapshot_state,
639
+ dry_run,
640
+ command_event,
641
+ )
642
+
643
+
644
+ def _load_full_set_plan(
645
+ client: src.SourcegraphClient,
646
+ input_path: Path,
647
+ mapping_rules: list[permission_types.MappingRule],
648
+ user_created_after: str | None,
649
+ parallelism: int,
650
+ explicit_permissions_batch_size: int,
651
+ bind_id_mode: str,
652
+ saml_groups_attribute_name_by_config_id: dict[str, str],
653
+ capture_before: bool,
654
+ command_name: str,
655
+ command_event: dict[str, Any],
656
+ retain_saml_group_users: bool,
657
+ worker_pool: ThreadPoolExecutor | None = None,
658
+ ) -> _FullSetLoadedPlan:
659
+ user_state = _load_full_set_snapshot_state(
660
+ client,
661
+ input_path,
662
+ parallelism,
663
+ explicit_permissions_batch_size,
664
+ bind_id_mode,
665
+ capture_before=capture_before,
666
+ worker_pool=worker_pool,
667
+ )
668
+ before_path: Path | None = None
669
+ if capture_before:
670
+ before_snapshot, before_timestamp = _require_before_snapshot(user_state)
671
+ before_path = _write_full_set_before_snapshot(
672
+ input_path,
673
+ before_timestamp,
674
+ client.endpoint,
675
+ command_name,
676
+ before_snapshot,
677
+ command_event,
678
+ )
679
+
680
+ context = load_mapping_context_for_rules(
681
+ client,
682
+ mapping_rules,
683
+ saml_groups_attribute_name_by_config_id,
684
+ )
685
+ users = _filter_full_set_users_by_created_at(
686
+ client,
687
+ user_state.users,
688
+ user_created_after,
689
+ )
690
+ plan = _plan_full_set_permissions(context, users)
691
+ snapshot_state = _compact_full_set_snapshot_state(user_state, users)
692
+ saml_group_users = (
693
+ saml_groups.compact_saml_group_users(
694
+ user_state.users,
695
+ context.providers,
696
+ context.saml_groups_attribute_names,
697
+ )
698
+ if retain_saml_group_users
699
+ else None
700
+ )
701
+ return _FullSetLoadedPlan(
702
+ snapshot_state=snapshot_state,
703
+ plan=plan,
704
+ command_data=run_context.CommandData(
705
+ auth_providers=context.providers,
706
+ saml_group_users=saml_group_users,
707
+ ),
708
+ apply_before_path=before_path,
709
+ )
710
+
711
+
712
+ def _finish_empty_full_set_plan(
713
+ input_path: Path,
714
+ endpoint: str,
715
+ command_name: str,
716
+ snapshot_state: _FullSetSnapshotState,
717
+ dry_run: bool,
718
+ do_backup: bool,
719
+ command_event: dict[str, Any],
720
+ ) -> None:
721
+ log.warning("No repos resolved across any mapping — nothing to do.")
722
+ if dry_run or do_backup:
723
+ _write_noop_full_set_artifacts(
724
+ input_path,
725
+ endpoint,
726
+ command_name,
727
+ snapshot_state,
728
+ dry_run,
729
+ command_event,
730
+ )
731
+
732
+
733
+ def _run_full_set_apply(
734
+ client: src.SourcegraphClient,
735
+ input_path: Path,
736
+ snapshot_state: _FullSetSnapshotState,
737
+ plan: _FullSetPlan,
738
+ mapping_count: int,
739
+ parallelism: int,
740
+ explicit_permissions_batch_size: int,
741
+ bind_id_mode: str,
742
+ do_backup: bool,
743
+ before_path: Path | None,
744
+ command_event: dict[str, Any],
745
+ worker_pool: ThreadPoolExecutor | None = None,
746
+ ) -> None:
747
+ """Filter, apply, snapshot, validate, and raise for a full-set apply."""
748
+ filtered_plans = _filter_full_set_plans(
749
+ snapshot_state.before_snapshot,
750
+ plan,
751
+ command_event,
752
+ )
753
+ before_snapshot: permission_snapshot.Snapshot | None = None
754
+ if do_backup:
755
+ before_snapshot, before_timestamp = _require_before_snapshot(snapshot_state)
756
+ assert before_path is not None
757
+ else:
758
+ before_timestamp = backups.backup_timestamp()
759
+
760
+ apply_result = _apply_full_set_plans(
761
+ client,
762
+ filtered_plans.overwrites,
763
+ filtered_plans.skipped_repo_ids,
764
+ parallelism,
765
+ worker_pool,
766
+ )
767
+ _record_full_set_event_fields(command_event, mapping_count, plan, apply_result)
768
+ if do_backup:
769
+ assert before_path is not None and before_snapshot is not None
770
+ _finish_full_set_apply_with_backup(
771
+ client,
772
+ input_path,
773
+ before_timestamp,
774
+ before_path,
775
+ before_snapshot,
776
+ snapshot_state,
777
+ plan,
778
+ apply_result,
779
+ parallelism,
780
+ explicit_permissions_batch_size,
781
+ bind_id_mode,
782
+ worker_pool,
783
+ )
784
+
785
+ _raise_for_failed_full_set_apply(apply_result, len(filtered_plans.overwrites))
786
+
787
+
788
+ def cmd_set_full(
789
+ client: src.SourcegraphClient,
790
+ input_path: Path,
791
+ user_created_after: str | None,
792
+ dry_run: bool,
793
+ parallelism: int,
794
+ explicit_permissions_batch_size: int,
795
+ bind_id_mode: str,
796
+ saml_groups_attribute_name_by_config_id: dict[str, str],
797
+ do_backup: bool,
798
+ retain_saml_group_users: bool,
799
+ worker_pool: ThreadPoolExecutor | None = None,
800
+ ) -> run_context.CommandData:
801
+ """Overwrite each mapped repo with the union of users from all rules."""
802
+ with src.event(
803
+ "cmd_set",
804
+ input_path=str(input_path),
805
+ user_created_after=user_created_after,
806
+ dry_run=dry_run,
807
+ parallelism=parallelism,
808
+ do_backup=do_backup,
809
+ ) as command_event:
810
+ mapping_rules = load_mapping_rules(input_path)
811
+ command_name = _set_full_command_name(dry_run)
812
+ if not mapping_rules:
813
+ _finish_empty_full_set_mapping_rules(
814
+ client,
815
+ input_path,
816
+ command_name,
817
+ dry_run,
818
+ parallelism,
819
+ explicit_permissions_batch_size,
820
+ bind_id_mode,
821
+ do_backup,
822
+ command_event,
823
+ worker_pool,
824
+ )
825
+ return run_context.CommandData()
826
+
827
+ loaded_plan = _load_full_set_plan(
828
+ client,
829
+ input_path,
830
+ mapping_rules,
831
+ user_created_after,
832
+ parallelism,
833
+ explicit_permissions_batch_size,
834
+ bind_id_mode,
835
+ saml_groups_attribute_name_by_config_id,
836
+ capture_before=dry_run or do_backup,
837
+ command_name=command_name,
838
+ command_event=command_event,
839
+ retain_saml_group_users=retain_saml_group_users,
840
+ worker_pool=worker_pool,
841
+ )
842
+ snapshot_state = loaded_plan.snapshot_state
843
+ plan = loaded_plan.plan
844
+ if not plan.expected_users:
845
+ _finish_empty_full_set_plan(
846
+ input_path,
847
+ client.endpoint,
848
+ command_name,
849
+ snapshot_state,
850
+ dry_run,
851
+ do_backup,
852
+ command_event,
853
+ )
854
+ return loaded_plan.command_data
855
+
856
+ if dry_run:
857
+ _finish_full_set_dry_run(
858
+ input_path,
859
+ client.endpoint,
860
+ snapshot_state,
861
+ plan,
862
+ command_event,
863
+ )
864
+ return loaded_plan.command_data
865
+
866
+ _run_full_set_apply(
867
+ client,
868
+ input_path,
869
+ snapshot_state,
870
+ plan,
871
+ len(mapping_rules),
872
+ parallelism,
873
+ explicit_permissions_batch_size,
874
+ bind_id_mode,
875
+ do_backup,
876
+ loaded_plan.apply_before_path,
877
+ command_event,
878
+ worker_pool,
879
+ )
880
+ return loaded_plan.command_data