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,913 @@
1
+ """Repo permission restore workflows."""
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
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 snapshot as permission_snapshot
18
+ from . import types as permission_types
19
+ from .workflow import (
20
+ snapshot_path as snapshot_artifact_path,
21
+ )
22
+ from .workflow import (
23
+ write_snapshot_diff_file,
24
+ write_snapshot_pair,
25
+ write_user_scoped_snapshot_diff_file,
26
+ )
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RestoreSnapshotState:
33
+ """Target and live snapshots needed for a full restore."""
34
+
35
+ target_snapshot: permission_snapshot.Snapshot
36
+ current_snapshot: permission_snapshot.Snapshot
37
+ users: list[permission_snapshot.SnapshotUser]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RestorePlan:
42
+ """Per-repo overwrite plan for a full restore."""
43
+
44
+ overwrites: list[permission_types.RepositoryUsernameOverwrite]
45
+ snapshot_repo_count: int
46
+ extra_repo_count: int
47
+ skipped_repo_count: int
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _UserScopedRestoreState:
52
+ """Target and live snapshots needed for a user-scoped restore."""
53
+
54
+ target_snapshot: permission_snapshot.UserScopedSnapshot
55
+ current_snapshot: permission_snapshot.UserScopedSnapshot
56
+ snapshot_users: list[permission_snapshot.SnapshotUser]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _UserScopedRestorePlan:
61
+ """Add/remove plan for a user-scoped restore."""
62
+
63
+ additions: list[permissions_apply.PermissionAddition]
64
+ removals: list[permissions_apply.PermissionRemoval]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class _UserScopedRestoreMutationResult:
69
+ """Mutation results for both user-scoped restore phases."""
70
+
71
+ additions: shared_types.MutationCounts
72
+ removals: shared_types.MutationCounts
73
+
74
+
75
+ def cmd_restore_user_scoped(
76
+ client: src.SourcegraphClient,
77
+ snapshot_path: Path,
78
+ dry_run: bool,
79
+ parallelism: int,
80
+ bind_id_mode: str,
81
+ do_backup: bool,
82
+ target_snapshot: permission_snapshot.UserScopedSnapshot | None = None,
83
+ worker_pool: ThreadPoolExecutor | None = None,
84
+ ) -> None:
85
+ """Restore explicit permissions for the users present in a scoped snapshot."""
86
+ with src.event(
87
+ "cmd_restore_user_scoped",
88
+ snapshot_path=str(snapshot_path),
89
+ dry_run=dry_run,
90
+ parallelism=parallelism,
91
+ do_backup=do_backup,
92
+ ):
93
+ if target_snapshot is None:
94
+ target_snapshot = permission_snapshot.read_user_scoped_snapshot(snapshot_path)
95
+ _validate_user_scoped_restore_snapshot_context(
96
+ client,
97
+ target_snapshot,
98
+ snapshot_path,
99
+ bind_id_mode,
100
+ )
101
+ snapshot_state = _capture_user_scoped_restore_state(
102
+ client,
103
+ snapshot_path,
104
+ target_snapshot,
105
+ parallelism,
106
+ bind_id_mode,
107
+ worker_pool,
108
+ )
109
+ plan = _plan_user_scoped_restore(
110
+ snapshot_state.current_snapshot,
111
+ snapshot_state.target_snapshot,
112
+ )
113
+ _log_user_scoped_restore_plan(snapshot_state, plan)
114
+
115
+ timestamp = backups.backup_timestamp()
116
+ command_name = _user_scoped_restore_command_name(dry_run)
117
+ if dry_run or do_backup:
118
+ _write_user_scoped_restore_initial_artifacts(
119
+ client,
120
+ snapshot_path,
121
+ timestamp,
122
+ command_name,
123
+ snapshot_state.current_snapshot,
124
+ snapshot_state.target_snapshot,
125
+ dry_run,
126
+ )
127
+
128
+ if dry_run:
129
+ log.info("Dry run complete. Pass --apply to mutate state.")
130
+ return
131
+ if not plan.additions and not plan.removals:
132
+ _finish_empty_user_scoped_restore_plan(
133
+ client,
134
+ snapshot_path,
135
+ timestamp,
136
+ command_name,
137
+ snapshot_state.current_snapshot,
138
+ do_backup,
139
+ )
140
+ return
141
+
142
+ mutations = _apply_user_scoped_restore(client, plan, parallelism, worker_pool)
143
+
144
+ if do_backup:
145
+ _finish_user_scoped_restore_apply_with_backup(
146
+ client,
147
+ snapshot_path,
148
+ timestamp,
149
+ command_name,
150
+ snapshot_state,
151
+ parallelism,
152
+ bind_id_mode,
153
+ worker_pool,
154
+ )
155
+
156
+ _raise_for_failed_user_scoped_restore(mutations)
157
+ _log_user_scoped_restore_done(mutations)
158
+
159
+
160
+ def _snapshot_users_from_user_scoped_snapshot(
161
+ snapshot: permission_snapshot.UserScopedSnapshot,
162
+ ) -> list[permission_snapshot.SnapshotUser]:
163
+ return [
164
+ {"id": user_snapshot["id"], "username": username}
165
+ for username, user_snapshot in sorted(snapshot["users"].items())
166
+ ]
167
+
168
+
169
+ def _plan_user_scoped_restore(
170
+ current_snapshot: permission_snapshot.UserScopedSnapshot,
171
+ target_snapshot: permission_snapshot.UserScopedSnapshot,
172
+ ) -> _UserScopedRestorePlan:
173
+ additions: list[permissions_apply.PermissionAddition] = []
174
+ removals: list[permissions_apply.PermissionRemoval] = []
175
+ for username, target_user in target_snapshot["users"].items():
176
+ current_user = current_snapshot["users"].get(username)
177
+ current_repos = {
178
+ repository["id"]: repository["name"]
179
+ for repository in (current_user["explicit_repositories"] if current_user else [])
180
+ }
181
+ target_repos = {
182
+ repository["id"]: repository["name"]
183
+ for repository in target_user["explicit_repositories"]
184
+ }
185
+ for repo_id in sorted(
186
+ set(target_repos) - set(current_repos),
187
+ key=lambda value: target_repos[value],
188
+ ):
189
+ additions.append(
190
+ permissions_apply.PermissionAddition(
191
+ user_id=target_user["id"],
192
+ username=username,
193
+ repo_id=repo_id,
194
+ repo_name=target_repos[repo_id],
195
+ )
196
+ )
197
+ for repo_id in sorted(
198
+ set(current_repos) - set(target_repos),
199
+ key=lambda value: current_repos[value],
200
+ ):
201
+ removals.append(
202
+ permissions_apply.PermissionRemoval(
203
+ user_id=target_user["id"],
204
+ username=username,
205
+ repo_id=repo_id,
206
+ repo_name=current_repos[repo_id],
207
+ )
208
+ )
209
+ return _UserScopedRestorePlan(additions=additions, removals=removals)
210
+
211
+
212
+ def _user_scoped_restore_command_name(dry_run: bool) -> str:
213
+ return "restore-scoped-dry-run" if dry_run else "restore-scoped-apply"
214
+
215
+
216
+ def _validate_user_scoped_restore_snapshot_context(
217
+ client: src.SourcegraphClient,
218
+ target_snapshot: permission_snapshot.UserScopedSnapshot,
219
+ snapshot_path: Path,
220
+ bind_id_mode: str,
221
+ ) -> None:
222
+ """Warn when a user-scoped restore target differs from the current context."""
223
+ if target_snapshot["bindID_mode"] != bind_id_mode:
224
+ log.warning(
225
+ "Snapshot bindID_mode=%s differs from live bindID_mode=%s — "
226
+ "captured usernames may not resolve to the same users.",
227
+ target_snapshot["bindID_mode"],
228
+ bind_id_mode,
229
+ )
230
+ if target_snapshot["endpoint"] != client.endpoint:
231
+ log.warning(
232
+ "Snapshot endpoint=%s differs from live endpoint=%s — restoring "
233
+ "across instances. Proceeding anyway; review the plan diff.",
234
+ target_snapshot["endpoint"],
235
+ client.endpoint,
236
+ )
237
+
238
+
239
+ def _capture_user_scoped_restore_state(
240
+ client: src.SourcegraphClient,
241
+ snapshot_path: Path,
242
+ target_snapshot: permission_snapshot.UserScopedSnapshot,
243
+ parallelism: int,
244
+ bind_id_mode: str,
245
+ worker_pool: ThreadPoolExecutor | None = None,
246
+ ) -> _UserScopedRestoreState:
247
+ """Capture live state for the users present in a scoped snapshot."""
248
+ snapshot_users = _snapshot_users_from_user_scoped_snapshot(target_snapshot)
249
+ current_snapshot = permission_snapshot.build_user_scoped_snapshot(
250
+ client,
251
+ snapshot_users,
252
+ parallelism,
253
+ bind_id_mode,
254
+ snapshot_path,
255
+ worker_pool=worker_pool,
256
+ )
257
+ return _UserScopedRestoreState(
258
+ target_snapshot=target_snapshot,
259
+ current_snapshot=current_snapshot,
260
+ snapshot_users=snapshot_users,
261
+ )
262
+
263
+
264
+ def _log_user_scoped_restore_plan(
265
+ snapshot_state: _UserScopedRestoreState,
266
+ plan: _UserScopedRestorePlan,
267
+ ) -> None:
268
+ log.info(
269
+ "Scoped restore plan: %d grant(s) to add, %d grant(s) to remove.",
270
+ len(plan.additions),
271
+ len(plan.removals),
272
+ )
273
+ log.info(
274
+ "Diff (current → scoped snapshot):\n%s",
275
+ permission_snapshot.render_user_scoped_diff(
276
+ snapshot_state.current_snapshot,
277
+ snapshot_state.target_snapshot,
278
+ ),
279
+ )
280
+
281
+
282
+ def _write_user_scoped_restore_initial_artifacts(
283
+ client: src.SourcegraphClient,
284
+ snapshot_path: Path,
285
+ timestamp: str,
286
+ command_name: str,
287
+ current_snapshot: permission_snapshot.UserScopedSnapshot,
288
+ target_snapshot: permission_snapshot.UserScopedSnapshot,
289
+ dry_run: bool,
290
+ ) -> None:
291
+ """Write before-snapshot and optional dry-run target artifacts."""
292
+ before_restore_path = snapshot_artifact_path(
293
+ snapshot_path,
294
+ timestamp,
295
+ client.endpoint,
296
+ command_name,
297
+ "before",
298
+ )
299
+ after_restore_path = snapshot_artifact_path(
300
+ snapshot_path,
301
+ timestamp,
302
+ client.endpoint,
303
+ command_name,
304
+ "after",
305
+ )
306
+ permission_snapshot.write_user_scoped_snapshot(before_restore_path, current_snapshot)
307
+ log.info("Wrote scoped restore before-snapshot: %s", before_restore_path)
308
+ if not dry_run:
309
+ return
310
+
311
+ permission_snapshot.write_user_scoped_snapshot(after_restore_path, target_snapshot)
312
+ diff_path = write_user_scoped_snapshot_diff_file(
313
+ snapshot_path,
314
+ timestamp,
315
+ client.endpoint,
316
+ command_name,
317
+ current_snapshot,
318
+ target_snapshot,
319
+ )
320
+ log.info("Wrote scoped restore after-snapshot: %s diff=%s", after_restore_path, diff_path)
321
+
322
+
323
+ def _finish_empty_user_scoped_restore_plan(
324
+ client: src.SourcegraphClient,
325
+ snapshot_path: Path,
326
+ timestamp: str,
327
+ command_name: str,
328
+ current_snapshot: permission_snapshot.UserScopedSnapshot,
329
+ do_backup: bool,
330
+ ) -> None:
331
+ log.info("Scoped restore target already matches current state — nothing to apply.")
332
+ if not do_backup:
333
+ return
334
+
335
+ after_restore_path = snapshot_artifact_path(
336
+ snapshot_path,
337
+ timestamp,
338
+ client.endpoint,
339
+ command_name,
340
+ "after",
341
+ )
342
+ permission_snapshot.write_user_scoped_snapshot(after_restore_path, current_snapshot)
343
+ diff_path = write_user_scoped_snapshot_diff_file(
344
+ snapshot_path,
345
+ timestamp,
346
+ client.endpoint,
347
+ command_name,
348
+ current_snapshot,
349
+ current_snapshot,
350
+ )
351
+ log.info("Wrote scoped restore after-snapshot: %s diff=%s", after_restore_path, diff_path)
352
+
353
+
354
+ def _apply_user_scoped_restore_removals(
355
+ client: src.SourcegraphClient,
356
+ removals: list[permissions_apply.PermissionRemoval],
357
+ parallelism: int,
358
+ worker_pool: ThreadPoolExecutor | None = None,
359
+ ) -> shared_types.MutationCounts:
360
+ if not removals:
361
+ return shared_types.MutationCounts()
362
+
363
+ log.info(
364
+ "Applying %d removeRepositoryPermissionForUser mutation(s) with parallelism=%d ...",
365
+ len(removals),
366
+ parallelism,
367
+ )
368
+ with src.stage("apply_removals"):
369
+ return permissions_apply.apply_removals(
370
+ client,
371
+ removals,
372
+ parallelism=parallelism,
373
+ worker_pool=worker_pool,
374
+ )
375
+
376
+
377
+ def _apply_user_scoped_restore_additions(
378
+ client: src.SourcegraphClient,
379
+ additions: list[permissions_apply.PermissionAddition],
380
+ parallelism: int,
381
+ worker_pool: ThreadPoolExecutor | None = None,
382
+ ) -> shared_types.MutationCounts:
383
+ if not additions:
384
+ return shared_types.MutationCounts()
385
+
386
+ log.info(
387
+ "Applying %d addRepositoryPermissionForUser mutation(s) with parallelism=%d ...",
388
+ len(additions),
389
+ parallelism,
390
+ )
391
+ with src.stage("apply_additions"):
392
+ return permissions_apply.apply_additions(
393
+ client,
394
+ additions,
395
+ parallelism=parallelism,
396
+ worker_pool=worker_pool,
397
+ )
398
+
399
+
400
+ def _apply_user_scoped_restore(
401
+ client: src.SourcegraphClient,
402
+ plan: _UserScopedRestorePlan,
403
+ parallelism: int,
404
+ worker_pool: ThreadPoolExecutor | None = None,
405
+ ) -> _UserScopedRestoreMutationResult:
406
+ """Apply scoped restore removals before additions."""
407
+ removals = _apply_user_scoped_restore_removals(
408
+ client,
409
+ plan.removals,
410
+ parallelism,
411
+ worker_pool,
412
+ )
413
+ additions = _apply_user_scoped_restore_additions(
414
+ client,
415
+ plan.additions,
416
+ parallelism,
417
+ worker_pool,
418
+ )
419
+ return _UserScopedRestoreMutationResult(additions=additions, removals=removals)
420
+
421
+
422
+ def _finish_user_scoped_restore_apply_with_backup(
423
+ client: src.SourcegraphClient,
424
+ snapshot_path: Path,
425
+ timestamp: str,
426
+ command_name: str,
427
+ snapshot_state: _UserScopedRestoreState,
428
+ parallelism: int,
429
+ bind_id_mode: str,
430
+ worker_pool: ThreadPoolExecutor | None = None,
431
+ ) -> None:
432
+ """Capture scoped post-restore state and validate against the target."""
433
+ after_restore_snapshot = permission_snapshot.build_user_scoped_snapshot(
434
+ client,
435
+ snapshot_state.snapshot_users,
436
+ parallelism,
437
+ bind_id_mode,
438
+ snapshot_path,
439
+ worker_pool=worker_pool,
440
+ )
441
+ after_restore_path = snapshot_artifact_path(
442
+ snapshot_path,
443
+ timestamp,
444
+ client.endpoint,
445
+ command_name,
446
+ "after",
447
+ )
448
+ permission_snapshot.write_user_scoped_snapshot(after_restore_path, after_restore_snapshot)
449
+ diff_path = write_user_scoped_snapshot_diff_file(
450
+ snapshot_path,
451
+ timestamp,
452
+ client.endpoint,
453
+ command_name,
454
+ snapshot_state.current_snapshot,
455
+ after_restore_snapshot,
456
+ )
457
+ log.info("Wrote scoped restore after-snapshot: %s diff=%s", after_restore_path, diff_path)
458
+ residual = permission_snapshot.render_user_scoped_diff(
459
+ after_restore_snapshot,
460
+ snapshot_state.target_snapshot,
461
+ )
462
+ if residual != "No changes.":
463
+ log.warning(
464
+ "VALIDATION: scoped restore does NOT match target snapshot. Residual diff:\n%s",
465
+ residual,
466
+ )
467
+ else:
468
+ log.info("VALIDATION OK: scoped restore matches the target snapshot.")
469
+
470
+
471
+ def _raise_for_failed_user_scoped_restore(
472
+ mutations: _UserScopedRestoreMutationResult,
473
+ ) -> None:
474
+ failed = mutations.removals.failed + mutations.additions.failed
475
+ canceled = mutations.removals.canceled + mutations.additions.canceled
476
+ if not (failed or canceled):
477
+ return
478
+ log.error(
479
+ "SCOPED RESTORE FAILED: %d mutation(s) failed, %d canceled by circuit breaker.",
480
+ failed,
481
+ canceled,
482
+ )
483
+ raise SystemExit(1)
484
+
485
+
486
+ def _log_user_scoped_restore_done(mutations: _UserScopedRestoreMutationResult) -> None:
487
+ log.info(
488
+ "Scoped restore done. add=%d remove=%d succeeded.",
489
+ mutations.additions.succeeded,
490
+ mutations.removals.succeeded,
491
+ )
492
+
493
+
494
+ def _restore_command_name(dry_run: bool) -> str:
495
+ return "restore-dry-run" if dry_run else "restore-apply"
496
+
497
+
498
+ def _validate_restore_snapshot_context(
499
+ client: src.SourcegraphClient,
500
+ target_snapshot: permission_snapshot.Snapshot,
501
+ snapshot_path: Path,
502
+ bind_id_mode: str,
503
+ ) -> None:
504
+ """Warn when a full restore target differs from the current context."""
505
+ log.info(
506
+ "Received snapshot %s (captured_at=%s endpoint=%s bindID_mode=%s %d repo(s) %d grant(s)).",
507
+ snapshot_path,
508
+ target_snapshot["captured_at"],
509
+ target_snapshot["endpoint"],
510
+ target_snapshot["bindID_mode"],
511
+ target_snapshot["stats"]["repos_with_explicit_grants"],
512
+ target_snapshot["stats"]["total_grants"],
513
+ )
514
+ if target_snapshot["bindID_mode"] != bind_id_mode:
515
+ log.warning(
516
+ "Snapshot bindID_mode=%s differs from live bindID_mode=%s — "
517
+ "captured bindIDs may not resolve to the same users. Proceeding "
518
+ "anyway; review the plan diff carefully.",
519
+ target_snapshot["bindID_mode"],
520
+ bind_id_mode,
521
+ )
522
+ if target_snapshot["endpoint"] != client.endpoint:
523
+ log.warning(
524
+ "Snapshot endpoint=%s differs from live endpoint=%s — restoring "
525
+ "across instances. Proceeding anyway; review the plan diff.",
526
+ target_snapshot["endpoint"],
527
+ client.endpoint,
528
+ )
529
+
530
+
531
+ def _capture_restore_snapshot_state(
532
+ client: src.SourcegraphClient,
533
+ snapshot_path: Path,
534
+ target_snapshot: permission_snapshot.Snapshot,
535
+ parallelism: int,
536
+ explicit_permissions_batch_size: int,
537
+ bind_id_mode: str,
538
+ worker_pool: ThreadPoolExecutor | None = None,
539
+ ) -> RestoreSnapshotState:
540
+ """Capture the live full-instance state needed to plan a restore."""
541
+ total_users = shared_sourcegraph.count_users(client)
542
+ log.info(
543
+ "Streaming %d users from %s and capturing current explicit-permissions "
544
+ "state in parallel ...",
545
+ total_users,
546
+ client.endpoint,
547
+ )
548
+ users: list[shared_types.User] = []
549
+ current_snapshot = permission_snapshot.build_snapshot(
550
+ client,
551
+ shared_sourcegraph.list_users_streaming(client, collect_into=users),
552
+ parallelism,
553
+ bind_id_mode,
554
+ snapshot_path,
555
+ total_users=total_users,
556
+ explicit_permissions_batch_size=explicit_permissions_batch_size,
557
+ worker_pool=worker_pool,
558
+ )
559
+ log.info(
560
+ "Received %d total users; current state: %d repo(s) with explicit "
561
+ "grants, %d total grant(s).",
562
+ len(users),
563
+ current_snapshot["stats"]["repos_with_explicit_grants"],
564
+ current_snapshot["stats"]["total_grants"],
565
+ )
566
+ return RestoreSnapshotState(
567
+ target_snapshot=target_snapshot,
568
+ current_snapshot=current_snapshot,
569
+ users=permission_snapshot.compact_snapshot_users(users),
570
+ )
571
+
572
+
573
+ def plan_full_restore(snapshot_state: RestoreSnapshotState) -> RestorePlan:
574
+ """Build only the per-repo overwrite plans needed to match the snapshot."""
575
+ target_repos = snapshot_state.target_snapshot["repos"]
576
+ current_repos = snapshot_state.current_snapshot["repos"]
577
+ overwrites: list[permission_types.RepositoryUsernameOverwrite] = []
578
+ skipped_repo_count = 0
579
+ for repo_id, repo_snapshot in target_repos.items():
580
+ target_usernames = repo_snapshot["explicit_permissions_users"]
581
+ current_repo = current_repos.get(repo_id)
582
+ current_usernames = current_repo["explicit_permissions_users"] if current_repo else []
583
+ if current_usernames == target_usernames or sorted(current_usernames) == target_usernames:
584
+ skipped_repo_count += 1
585
+ continue
586
+ overwrites.append(
587
+ permission_types.RepositoryUsernameOverwrite(
588
+ repository_id=repo_id,
589
+ repository_name=repo_snapshot["name"],
590
+ usernames=tuple(target_usernames),
591
+ )
592
+ )
593
+ extra_repo_ids = set(current_repos) - set(target_repos)
594
+ for repo_id in sorted(extra_repo_ids):
595
+ overwrites.append(
596
+ permission_types.RepositoryUsernameOverwrite(
597
+ repository_id=repo_id,
598
+ repository_name=current_repos[repo_id]["name"],
599
+ usernames=(),
600
+ )
601
+ )
602
+ return RestorePlan(
603
+ overwrites=overwrites,
604
+ snapshot_repo_count=len(target_repos),
605
+ extra_repo_count=len(extra_repo_ids),
606
+ skipped_repo_count=skipped_repo_count,
607
+ )
608
+
609
+
610
+ def _finish_empty_restore_plan(
611
+ client: src.SourcegraphClient,
612
+ snapshot_path: Path,
613
+ current_snapshot: permission_snapshot.Snapshot,
614
+ dry_run: bool,
615
+ do_backup: bool,
616
+ ) -> None:
617
+ """Handle a restore where live explicit grants already match the target."""
618
+ log.info("Nothing to restore: current explicit-permissions state already matches snapshot.")
619
+ if not (dry_run or do_backup):
620
+ return
621
+
622
+ timestamp = backups.backup_timestamp()
623
+ command_name = _restore_command_name(dry_run)
624
+ before_restore_path, after_restore_path, diff_path = write_snapshot_pair(
625
+ snapshot_path,
626
+ timestamp,
627
+ client.endpoint,
628
+ command_name,
629
+ current_snapshot,
630
+ current_snapshot,
631
+ )
632
+ run_mode = "dry-run" if dry_run else "apply"
633
+ log.info(
634
+ "Wrote restore %s snapshots: before=%s after=%s diff=%s.",
635
+ run_mode,
636
+ before_restore_path,
637
+ after_restore_path,
638
+ diff_path,
639
+ )
640
+
641
+
642
+ def _log_full_restore_plan(snapshot_state: RestoreSnapshotState, plan: RestorePlan) -> None:
643
+ log.info(
644
+ "Restore plan: %d mutation(s) (%d snapshot repo(s), %d unchanged skipped, "
645
+ "%d extra repo(s) to wipe).",
646
+ len(plan.overwrites),
647
+ plan.snapshot_repo_count,
648
+ plan.skipped_repo_count,
649
+ plan.extra_repo_count,
650
+ )
651
+ log.info(
652
+ "Diff (current → snapshot):\n%s",
653
+ permission_snapshot.render_snapshot_diff(
654
+ snapshot_state.current_snapshot,
655
+ snapshot_state.target_snapshot,
656
+ ),
657
+ )
658
+
659
+
660
+ def _finish_restore_dry_run(
661
+ client: src.SourcegraphClient,
662
+ snapshot_path: Path,
663
+ snapshot_state: RestoreSnapshotState,
664
+ ) -> None:
665
+ """Write dry-run restore artifacts and stop before mutation."""
666
+ timestamp = backups.backup_timestamp()
667
+ before_restore_path, after_restore_path, diff_path = write_snapshot_pair(
668
+ snapshot_path,
669
+ timestamp,
670
+ client.endpoint,
671
+ "restore-dry-run",
672
+ snapshot_state.current_snapshot,
673
+ snapshot_state.target_snapshot,
674
+ )
675
+ log.info(
676
+ "Wrote restore dry-run snapshots: before=%s after=%s diff=%s.",
677
+ before_restore_path,
678
+ after_restore_path,
679
+ diff_path,
680
+ )
681
+ log.info("Dry run complete. Pass --apply to mutate state.")
682
+
683
+
684
+ def _write_restore_apply_before_snapshot(
685
+ snapshot_path: Path,
686
+ timestamp: str,
687
+ endpoint: str,
688
+ current_snapshot: permission_snapshot.Snapshot,
689
+ ) -> Path:
690
+ """Persist the pre-restore state so the restore is reversible."""
691
+ before_restore_path = snapshot_artifact_path(
692
+ snapshot_path,
693
+ timestamp,
694
+ endpoint,
695
+ "restore-apply",
696
+ "before",
697
+ )
698
+ permission_snapshot.write_snapshot(before_restore_path, current_snapshot)
699
+ log.info(
700
+ "Wrote pre-restore snapshot: %s (%d repo(s) with explicit grants, %d total grant(s)).",
701
+ before_restore_path,
702
+ current_snapshot["stats"]["repos_with_explicit_grants"],
703
+ current_snapshot["stats"]["total_grants"],
704
+ )
705
+ return before_restore_path
706
+
707
+
708
+ def _apply_restore_overwrites(
709
+ client: src.SourcegraphClient,
710
+ overwrites: list[permission_types.RepositoryUsernameOverwrite],
711
+ parallelism: int,
712
+ worker_pool: ThreadPoolExecutor | None = None,
713
+ ) -> shared_types.MutationCounts:
714
+ """Apply the full restore overwrite plans."""
715
+ log.info(
716
+ "Applying %d setRepositoryPermissionsForUsers mutation(s) with parallelism=%d ...",
717
+ len(overwrites),
718
+ parallelism,
719
+ )
720
+ with src.stage("apply"):
721
+ mutations = permissions_apply.apply_username_overwrites(
722
+ client,
723
+ overwrites,
724
+ parallelism=parallelism,
725
+ worker_pool=worker_pool,
726
+ )
727
+ log.info(
728
+ "Restore done. %d succeeded, %d failed, %d canceled.",
729
+ mutations.succeeded,
730
+ mutations.failed,
731
+ mutations.canceled,
732
+ )
733
+ return mutations
734
+
735
+
736
+ def _record_restore_event_fields(
737
+ command_event: dict[str, Any],
738
+ snapshot_state: RestoreSnapshotState,
739
+ plan: RestorePlan,
740
+ mutations: shared_types.MutationCounts,
741
+ ) -> None:
742
+ command_event["plan_size"] = len(plan.overwrites)
743
+ command_event["snapshot_repos"] = plan.snapshot_repo_count
744
+ command_event["repos_short_circuited"] = plan.skipped_repo_count
745
+ command_event["snapshot_grants"] = snapshot_state.target_snapshot["stats"]["total_grants"]
746
+ command_event["mutations_succeeded"] = mutations.succeeded
747
+ command_event["mutations_failed"] = mutations.failed
748
+ command_event["mutations_canceled"] = mutations.canceled
749
+
750
+
751
+ def _finish_restore_apply_with_backup(
752
+ client: src.SourcegraphClient,
753
+ snapshot_path: Path,
754
+ timestamp: str,
755
+ snapshot_state: RestoreSnapshotState,
756
+ parallelism: int,
757
+ explicit_permissions_batch_size: int,
758
+ bind_id_mode: str,
759
+ worker_pool: ThreadPoolExecutor | None = None,
760
+ ) -> None:
761
+ """Capture post-restore state, write artifacts, and validate residual diff."""
762
+ log.info("Capturing post-restore snapshot for %d users ...", len(snapshot_state.users))
763
+ after_restore_snapshot = permission_snapshot.build_snapshot(
764
+ client,
765
+ snapshot_state.users,
766
+ parallelism,
767
+ bind_id_mode,
768
+ snapshot_path,
769
+ total_users=len(snapshot_state.users),
770
+ explicit_permissions_batch_size=explicit_permissions_batch_size,
771
+ worker_pool=worker_pool,
772
+ )
773
+ after_restore_path = snapshot_artifact_path(
774
+ snapshot_path,
775
+ timestamp,
776
+ client.endpoint,
777
+ "restore-apply",
778
+ "after",
779
+ )
780
+ permission_snapshot.write_snapshot(after_restore_path, after_restore_snapshot)
781
+ diff_path = write_snapshot_diff_file(
782
+ snapshot_path,
783
+ timestamp,
784
+ client.endpoint,
785
+ "restore-apply",
786
+ snapshot_state.current_snapshot,
787
+ after_restore_snapshot,
788
+ )
789
+ log.info(
790
+ "Wrote post-restore snapshot: %s diff=%s "
791
+ "(%d repo(s) with explicit grants, %d total grant(s)).",
792
+ after_restore_path,
793
+ diff_path,
794
+ after_restore_snapshot["stats"]["repos_with_explicit_grants"],
795
+ after_restore_snapshot["stats"]["total_grants"],
796
+ )
797
+ residual = permission_snapshot.render_snapshot_diff(
798
+ after_restore_snapshot,
799
+ snapshot_state.target_snapshot,
800
+ )
801
+ if residual != "No changes.":
802
+ log.warning(
803
+ "VALIDATION: post-restore state does NOT match the target "
804
+ "snapshot exactly. Residual diff (post-restore → snapshot):\n%s",
805
+ residual,
806
+ )
807
+ else:
808
+ log.info("VALIDATION OK: post-restore state matches the snapshot exactly.")
809
+
810
+
811
+ def _raise_for_failed_restore(mutations: shared_types.MutationCounts, overwrite_count: int) -> None:
812
+ if not (mutations.failed or mutations.canceled):
813
+ return
814
+ log.error(
815
+ "RESTORE FAILED: %d mutation(s) failed, %d canceled by "
816
+ "circuit breaker (out of %d planned). Review the log file "
817
+ "and the pre-/post-restore snapshots for details.",
818
+ mutations.failed,
819
+ mutations.canceled,
820
+ overwrite_count,
821
+ )
822
+ raise SystemExit(1)
823
+
824
+
825
+ def cmd_restore(
826
+ client: src.SourcegraphClient,
827
+ snapshot_path: Path,
828
+ dry_run: bool,
829
+ parallelism: int,
830
+ explicit_permissions_batch_size: int,
831
+ bind_id_mode: str,
832
+ do_backup: bool,
833
+ worker_pool: ThreadPoolExecutor | None = None,
834
+ ) -> None:
835
+ """Restore explicit-permissions state on the instance to match a snapshot."""
836
+ target_snapshot = permission_snapshot.read_snapshot_file(snapshot_path)
837
+ if target_snapshot.get("snapshot_kind") == permission_snapshot.USER_SCOPED_SNAPSHOT_KIND:
838
+ cmd_restore_user_scoped(
839
+ client,
840
+ snapshot_path,
841
+ dry_run,
842
+ parallelism,
843
+ bind_id_mode,
844
+ do_backup,
845
+ target_snapshot=cast(permission_snapshot.UserScopedSnapshot, target_snapshot),
846
+ worker_pool=worker_pool,
847
+ )
848
+ return
849
+ target_full_snapshot = cast(permission_snapshot.Snapshot, target_snapshot)
850
+
851
+ with src.event(
852
+ "cmd_restore",
853
+ snapshot_path=str(snapshot_path),
854
+ dry_run=dry_run,
855
+ parallelism=parallelism,
856
+ do_backup=do_backup,
857
+ ) as command_event:
858
+ _validate_restore_snapshot_context(
859
+ client,
860
+ target_full_snapshot,
861
+ snapshot_path,
862
+ bind_id_mode,
863
+ )
864
+ snapshot_state = _capture_restore_snapshot_state(
865
+ client,
866
+ snapshot_path,
867
+ target_full_snapshot,
868
+ parallelism,
869
+ explicit_permissions_batch_size,
870
+ bind_id_mode,
871
+ worker_pool,
872
+ )
873
+ plan = plan_full_restore(snapshot_state)
874
+ if not plan.overwrites:
875
+ _finish_empty_restore_plan(
876
+ client,
877
+ snapshot_path,
878
+ snapshot_state.current_snapshot,
879
+ dry_run,
880
+ do_backup,
881
+ )
882
+ return
883
+
884
+ _log_full_restore_plan(snapshot_state, plan)
885
+ if dry_run:
886
+ _finish_restore_dry_run(client, snapshot_path, snapshot_state)
887
+ return
888
+
889
+ timestamp = backups.backup_timestamp()
890
+ if do_backup:
891
+ _write_restore_apply_before_snapshot(
892
+ snapshot_path,
893
+ timestamp,
894
+ client.endpoint,
895
+ snapshot_state.current_snapshot,
896
+ )
897
+
898
+ mutations = _apply_restore_overwrites(client, plan.overwrites, parallelism, worker_pool)
899
+ _record_restore_event_fields(command_event, snapshot_state, plan, mutations)
900
+
901
+ if do_backup:
902
+ _finish_restore_apply_with_backup(
903
+ client,
904
+ snapshot_path,
905
+ timestamp,
906
+ snapshot_state,
907
+ parallelism,
908
+ explicit_permissions_batch_size,
909
+ bind_id_mode,
910
+ worker_pool,
911
+ )
912
+
913
+ _raise_for_failed_restore(mutations, len(plan.overwrites))