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,918 @@
1
+ """Repo-permission sync command handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import logging
7
+ import time
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ import src_py_lib as src
14
+
15
+ from ..shared import backups, id_codec, run_context, saml_groups
16
+ from ..shared import sourcegraph as shared_sourcegraph
17
+ from ..shared import types as shared_types
18
+ from . import apply as permissions_apply
19
+ from . import full_set as permissions_full_set
20
+ from . import mapping as permissions_mapping
21
+ from . import maps as permissions_maps
22
+ from . import restore as permissions_restore
23
+ from . import snapshot as permission_snapshot
24
+ from . import sourcegraph as permissions_sourcegraph
25
+ from . import types as permission_types
26
+ from .workflow import (
27
+ load_discovery,
28
+ load_mapping_context,
29
+ parse_cli_date,
30
+ snapshot_path,
31
+ sourcegraph_datetime_filter,
32
+ user_ids_created_on_or_after,
33
+ write_maps_backup,
34
+ write_user_scoped_snapshot_diff_file,
35
+ )
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class _ResolvedMapping:
42
+ """A mapping rule with its repository side pre-resolved."""
43
+
44
+ index: int
45
+ name: str
46
+ users_section: dict[str, object]
47
+ repos: list[permission_types.Repository]
48
+
49
+
50
+ def resolve_additive_mappings(context: permission_types.MappingContext) -> list[_ResolvedMapping]:
51
+ """Pre-resolve the repository side of every mapping rule."""
52
+ resolved: list[_ResolvedMapping] = []
53
+ for mapping_index, mapping in enumerate(context.mapping_rules, start=1):
54
+ name = mapping.get("name", f"<unnamed mapping #{mapping_index}>")
55
+ repos_section = cast(dict[str, object], mapping["repos"])
56
+ matched_repos = permissions_mapping.resolve_repos(
57
+ repos_section,
58
+ context.services_by_id,
59
+ context.repos_by_external_service_id,
60
+ context.all_repos_by_id,
61
+ )
62
+ log.info(
63
+ "Mapping %d / %d %s: repo side matched %d repo(s).",
64
+ mapping_index,
65
+ len(context.mapping_rules),
66
+ name,
67
+ len(matched_repos),
68
+ )
69
+ if not matched_repos:
70
+ continue
71
+ resolved.append(
72
+ _ResolvedMapping(
73
+ index=mapping_index,
74
+ name=name,
75
+ users_section=cast(dict[str, object], mapping["users"]),
76
+ repos=matched_repos,
77
+ )
78
+ )
79
+ return resolved
80
+
81
+
82
+ def cmd_get(
83
+ client: src.SourcegraphClient,
84
+ code_hosts_path: Path,
85
+ auth_providers_path: Path,
86
+ maps_path: Path,
87
+ *,
88
+ user_identifier: str | None,
89
+ users_without_explicit_perms: bool,
90
+ user_created_after: str | None,
91
+ parallelism: int,
92
+ explicit_permissions_batch_size: int,
93
+ bind_id_mode: str,
94
+ saml_groups_attribute_name_by_config_id: dict[str, str],
95
+ auth_providers_by_config_id: dict[str, dict[str, Any]],
96
+ retain_saml_group_users: bool = False,
97
+ worker_pool: ThreadPoolExecutor | None = None,
98
+ ) -> run_context.CommandData:
99
+ """Refresh the generated discovery YAML files.
100
+
101
+ `code_hosts_path` receives Sourcegraph code host connection configs,
102
+ `auth_providers_path` receives auth provider configs, and `maps_path`
103
+ is used for the generated get-snapshot name.
104
+
105
+ `saml_groups_attribute_name_by_config_id` is the per-`configID`
106
+ override map produced by `validate_site_config`; non-default
107
+ `groupsAttributeName` values from `auth.providers[*]` flow through
108
+ here so per-group counts are computed against the same SAML
109
+ attribute Sourcegraph itself reads at sign-in time.
110
+
111
+ `auth_providers_by_config_id` carries the parsed `auth.providers[*]`
112
+ site-config entries (secrets stripped) keyed by explicit `configID`,
113
+ so every non-secret provider attribute (e.g.
114
+ `identityProviderMetadataURL`, `serviceProviderIssuer`) shows up in
115
+ `auth-providers.yaml` alongside the GraphQL-discovered fields.
116
+ Providers without an explicit `configID` get only the GraphQL-derived view.
117
+ """
118
+ with src.event(
119
+ "cmd_get",
120
+ code_hosts_path=str(code_hosts_path),
121
+ auth_providers_path=str(auth_providers_path),
122
+ maps_path=str(maps_path),
123
+ user_identifier=user_identifier,
124
+ users_without_explicit_perms=users_without_explicit_perms,
125
+ user_created_after=user_created_after,
126
+ parallelism=parallelism,
127
+ ) as cmd_event:
128
+ raw_providers, raw_services, attribute_names_by_provider = load_discovery(
129
+ client, saml_groups_attribute_name_by_config_id
130
+ )
131
+ services = [permissions_maps.external_service_to_yaml(service) for service in raw_services]
132
+ cmd_event["auth_provider_count"] = len(raw_providers)
133
+ cmd_event["external_service_count"] = len(services)
134
+
135
+ users = _load_get_users(
136
+ client,
137
+ user_identifier=user_identifier,
138
+ users_without_explicit_perms=users_without_explicit_perms,
139
+ user_created_after=user_created_after,
140
+ )
141
+ counts = permissions_maps.count_users_per_provider(users)
142
+ # SAML-only: tally distinct users per (serviceID, clientID, group)
143
+ # by parsing each user's SAML AssertionInfo `accountData`. Surfaced
144
+ # in the YAML so operators can size groups before authoring a
145
+ # `authProvider.samlGroup` mapping rule. See
146
+ # `src/src_auth_perms_sync/shared/saml_groups.py`.
147
+ saml_group_counts = saml_groups.count_users_per_saml_group(
148
+ users, attribute_names_by_provider
149
+ )
150
+ cmd_event["user_count"] = len(users)
151
+ cmd_event["saml_providers_with_groups"] = len(saml_group_counts)
152
+
153
+ providers = [
154
+ permissions_maps.auth_provider_to_yaml(
155
+ provider,
156
+ counts.get(
157
+ (provider["serviceType"], provider["serviceID"], provider["clientID"]), 0
158
+ ),
159
+ # SAML providers always get the field (possibly empty) so
160
+ # operators can see at a glance whether the IdP is releasing
161
+ # a groups claim. Non-SAML providers get None → field omitted.
162
+ saml_group_user_counts=(
163
+ saml_group_counts.get((provider["serviceID"], provider["clientID"]), {})
164
+ if provider["serviceType"] == saml_groups.SAML_SERVICE_TYPE
165
+ else None
166
+ ),
167
+ # Match by explicit `configID` only — Sourcegraph
168
+ # synthesizes one for entries that omit it, but the synth
169
+ # is a content-addressed hash we can't safely replicate.
170
+ # Such providers get only the GraphQL-derived view.
171
+ site_config_entry=auth_providers_by_config_id.get(provider["configID"]),
172
+ )
173
+ for provider in raw_providers
174
+ ]
175
+
176
+ permissions_maps.dump_code_hosts_yaml(code_hosts_path, services)
177
+ permissions_maps.dump_auth_providers_yaml(auth_providers_path, providers)
178
+ log.info("Wrote %s and %s", code_hosts_path, auth_providers_path)
179
+
180
+ timestamp = backups.backup_timestamp()
181
+ before_snapshot = permission_snapshot.build_snapshot(
182
+ client,
183
+ users,
184
+ parallelism,
185
+ bind_id_mode,
186
+ maps_path,
187
+ total_users=len(users),
188
+ explicit_permissions_batch_size=explicit_permissions_batch_size,
189
+ worker_pool=worker_pool,
190
+ )
191
+ before_path = snapshot_path(maps_path, timestamp, client.endpoint, "get", "before")
192
+ permission_snapshot.write_snapshot(before_path, before_snapshot)
193
+ cmd_event["beforesnapshot_path"] = str(before_path)
194
+ maps_backup_path = write_maps_backup(maps_path, timestamp, client.endpoint, "get")
195
+ if maps_backup_path is not None:
196
+ cmd_event["maps_backup_path"] = str(maps_backup_path)
197
+ log.info(
198
+ "Wrote before-snapshot: %s (%d repo(s) with explicit grants, %d total grant(s)).",
199
+ before_path,
200
+ before_snapshot["stats"]["repos_with_explicit_grants"],
201
+ before_snapshot["stats"]["total_grants"],
202
+ )
203
+ saml_group_users = (
204
+ saml_groups.compact_saml_group_users(
205
+ users,
206
+ raw_providers,
207
+ attribute_names_by_provider,
208
+ )
209
+ if user_identifier is None
210
+ and not users_without_explicit_perms
211
+ and user_created_after is None
212
+ and retain_saml_group_users
213
+ else None
214
+ )
215
+ return run_context.CommandData(
216
+ auth_providers=raw_providers,
217
+ saml_group_users=saml_group_users,
218
+ )
219
+
220
+
221
+ def _load_get_users(
222
+ client: src.SourcegraphClient,
223
+ *,
224
+ user_identifier: str | None,
225
+ users_without_explicit_perms: bool,
226
+ user_created_after: str | None,
227
+ ) -> list[shared_types.User]:
228
+ """Load the Sourcegraph users selected by get/set-compatible user filters."""
229
+ if user_identifier is not None:
230
+ user = _resolve_user_identifier(client, user_identifier)
231
+ if user_created_after is None:
232
+ return [user]
233
+ candidate_user_ids = user_ids_created_on_or_after(client, user_created_after)
234
+ if user["id"] in candidate_user_ids:
235
+ return [user]
236
+ log.info(
237
+ "User %s was not created on or after %s — no user metadata selected.",
238
+ user["username"],
239
+ user_created_after,
240
+ )
241
+ return []
242
+
243
+ if users_without_explicit_perms or user_created_after is not None:
244
+ created_after_filter: str | None = None
245
+ if user_created_after is not None:
246
+ created_after_filter = sourcegraph_datetime_filter(
247
+ parse_cli_date(user_created_after, "--created-after")
248
+ )
249
+ candidates = permissions_sourcegraph.list_site_user_candidates(client, created_after_filter)
250
+ log.info("Received %d non-deleted user candidate(s).", len(candidates))
251
+
252
+ users: list[shared_types.User] = []
253
+ for candidate in candidates:
254
+ if users_without_explicit_perms and permissions_sourcegraph.user_has_explicit_repos(
255
+ client, candidate["id"]
256
+ ):
257
+ continue
258
+ user = permissions_sourcegraph.get_user_by_id(client, candidate["id"])
259
+ if user is None:
260
+ log.warning(
261
+ "Skipping user candidate %s: user no longer exists.",
262
+ candidate["username"],
263
+ )
264
+ continue
265
+ users.append(user)
266
+ log.info("Selected %d user(s) for get output.", len(users))
267
+ return users
268
+
269
+ return _load_all_get_users(client)
270
+
271
+
272
+ def _load_all_get_users(client: src.SourcegraphClient) -> list[shared_types.User]:
273
+ """Load all users for get output, with progress logs for large instances."""
274
+ total_users = shared_sourcegraph.count_users(client)
275
+ page_count = (
276
+ total_users + shared_sourcegraph.DEFAULT_PAGE_SIZE - 1
277
+ ) // shared_sourcegraph.DEFAULT_PAGE_SIZE
278
+ log.info(
279
+ "Querying metadata for %d users (%d page(s) of %d users / page) ...",
280
+ total_users,
281
+ page_count,
282
+ shared_sourcegraph.DEFAULT_PAGE_SIZE,
283
+ )
284
+ users: list[shared_types.User] = []
285
+ load_started = time.perf_counter()
286
+ progress_step = max(1, total_users // 10)
287
+ for completed, user in enumerate(shared_sourcegraph.list_users_streaming(client), start=1):
288
+ users.append(user)
289
+ if completed % progress_step == 0 or completed == total_users:
290
+ elapsed = time.perf_counter() - load_started
291
+ rate = completed / elapsed if elapsed > 0 else 0.0
292
+ remaining = max(total_users - completed, 0)
293
+ eta_seconds = remaining / rate if rate > 0 else 0.0
294
+ log.info(
295
+ "Received user metadata for %d / %d users (%.0f%%) "
296
+ "in %.0fs (%.0f users/sec, ETA %.0fs).",
297
+ completed,
298
+ total_users,
299
+ 100.0 * completed / total_users,
300
+ elapsed,
301
+ rate,
302
+ eta_seconds,
303
+ )
304
+ return users
305
+
306
+
307
+ def cmd_set(
308
+ client: src.SourcegraphClient,
309
+ input_path: Path,
310
+ options: permission_types.SetCommandOptions,
311
+ dry_run: bool,
312
+ parallelism: int,
313
+ explicit_permissions_batch_size: int,
314
+ bind_id_mode: str,
315
+ saml_groups_attribute_name_by_config_id: dict[str, str],
316
+ do_backup: bool,
317
+ retain_saml_group_users: bool = False,
318
+ worker_pool: ThreadPoolExecutor | None = None,
319
+ ) -> run_context.CommandData:
320
+ """Dispatch the selected `--set` mode."""
321
+ if options.mode == "full":
322
+ return permissions_full_set.cmd_set_full(
323
+ client,
324
+ input_path,
325
+ options.user_created_after,
326
+ dry_run,
327
+ parallelism,
328
+ explicit_permissions_batch_size,
329
+ bind_id_mode,
330
+ saml_groups_attribute_name_by_config_id,
331
+ do_backup,
332
+ retain_saml_group_users,
333
+ worker_pool,
334
+ )
335
+ if options.mode == "user":
336
+ assert options.user_identifier is not None
337
+ return cmd_set_additive_user(
338
+ client,
339
+ input_path,
340
+ options.user_identifier,
341
+ options.user_created_after,
342
+ dry_run,
343
+ parallelism,
344
+ bind_id_mode,
345
+ saml_groups_attribute_name_by_config_id,
346
+ do_backup,
347
+ worker_pool,
348
+ )
349
+ if options.mode == "users_without_explicit_perms":
350
+ return cmd_set_additive_users_without_explicit_perms(
351
+ client,
352
+ input_path,
353
+ options.user_created_after,
354
+ dry_run,
355
+ parallelism,
356
+ bind_id_mode,
357
+ saml_groups_attribute_name_by_config_id,
358
+ do_backup,
359
+ worker_pool,
360
+ )
361
+ return run_context.CommandData()
362
+
363
+
364
+ def cmd_set_additive_user(
365
+ client: src.SourcegraphClient,
366
+ input_path: Path,
367
+ user_identifier: str,
368
+ user_created_after: str | None,
369
+ dry_run: bool,
370
+ parallelism: int,
371
+ bind_id_mode: str,
372
+ saml_groups_attribute_name_by_config_id: dict[str, str],
373
+ do_backup: bool,
374
+ worker_pool: ThreadPoolExecutor | None = None,
375
+ ) -> run_context.CommandData:
376
+ """Add missing mapped permissions for one resolved user."""
377
+ with src.event(
378
+ "cmd_set_additive_user",
379
+ input_path=str(input_path),
380
+ user_identifier=user_identifier,
381
+ user_created_after=user_created_after,
382
+ dry_run=dry_run,
383
+ parallelism=parallelism,
384
+ do_backup=do_backup,
385
+ ):
386
+ context = load_mapping_context(client, input_path, saml_groups_attribute_name_by_config_id)
387
+ if context is None:
388
+ return run_context.CommandData()
389
+ user = _resolve_user_identifier(client, user_identifier)
390
+ if user_created_after is not None:
391
+ candidate_user_ids = user_ids_created_on_or_after(client, user_created_after)
392
+ if user["id"] not in candidate_user_ids:
393
+ log.info(
394
+ "User %s was not created on or after %s — nothing to do.",
395
+ user["username"],
396
+ user_created_after,
397
+ )
398
+ return run_context.CommandData(auth_providers=context.providers)
399
+ resolved_mappings = resolve_additive_mappings(context)
400
+ additions = _plan_additions_for_user(
401
+ client,
402
+ context,
403
+ resolved_mappings,
404
+ user,
405
+ )
406
+ _run_additive_apply(
407
+ client,
408
+ input_path,
409
+ [user],
410
+ additions,
411
+ dry_run=dry_run,
412
+ parallelism=parallelism,
413
+ bind_id_mode=bind_id_mode,
414
+ do_backup=do_backup,
415
+ command_name="set-add-user",
416
+ worker_pool=worker_pool,
417
+ )
418
+ return run_context.CommandData(auth_providers=context.providers)
419
+
420
+
421
+ def cmd_set_additive_users_without_explicit_perms(
422
+ client: src.SourcegraphClient,
423
+ input_path: Path,
424
+ user_created_after: str | None,
425
+ dry_run: bool,
426
+ parallelism: int,
427
+ bind_id_mode: str,
428
+ saml_groups_attribute_name_by_config_id: dict[str, str],
429
+ do_backup: bool,
430
+ worker_pool: ThreadPoolExecutor | None = None,
431
+ ) -> run_context.CommandData:
432
+ """Add mapped permissions for users with no explicit API grants."""
433
+ created_after_filter: str | None = None
434
+ if user_created_after is not None:
435
+ created_after_filter = sourcegraph_datetime_filter(
436
+ parse_cli_date(user_created_after, "--created-after")
437
+ )
438
+ with src.event(
439
+ "cmd_set_additive_users_without_explicit_perms",
440
+ input_path=str(input_path),
441
+ user_created_after=user_created_after,
442
+ dry_run=dry_run,
443
+ parallelism=parallelism,
444
+ do_backup=do_backup,
445
+ ):
446
+ context = load_mapping_context(client, input_path, saml_groups_attribute_name_by_config_id)
447
+ if context is None:
448
+ return run_context.CommandData()
449
+ resolved_mappings = resolve_additive_mappings(context)
450
+ candidates = permissions_sourcegraph.list_site_user_candidates(client, created_after_filter)
451
+ log.info("Received %d non-deleted user candidate(s).", len(candidates))
452
+
453
+ users: list[shared_types.User] = []
454
+ additions: list[permissions_apply.PermissionAddition] = []
455
+ for candidate in candidates:
456
+ if permissions_sourcegraph.user_has_explicit_repos(client, candidate["id"]):
457
+ continue
458
+ user = permissions_sourcegraph.get_user_by_id(client, candidate["id"])
459
+ if user is None:
460
+ log.warning(
461
+ "Skipping user candidate %s: user no longer exists.",
462
+ candidate["username"],
463
+ )
464
+ continue
465
+ user_additions = _plan_additions_for_user(
466
+ client,
467
+ context,
468
+ resolved_mappings,
469
+ user,
470
+ existing_repo_ids=set(),
471
+ )
472
+ users.append(user)
473
+ additions.extend(user_additions)
474
+
475
+ log.info(
476
+ "Planned additive grants for %d user(s) with no explicit grants.",
477
+ len(users),
478
+ )
479
+ _run_additive_apply(
480
+ client,
481
+ input_path,
482
+ users,
483
+ additions,
484
+ dry_run=dry_run,
485
+ parallelism=parallelism,
486
+ bind_id_mode=bind_id_mode,
487
+ do_backup=do_backup,
488
+ command_name="set-add-users-without-explicit-perms",
489
+ worker_pool=worker_pool,
490
+ )
491
+ return run_context.CommandData(auth_providers=context.providers)
492
+
493
+
494
+ def _resolve_user_identifier(
495
+ client: src.SourcegraphClient, user_identifier: str
496
+ ) -> shared_types.User:
497
+ """Resolve username/email input to one Sourcegraph user."""
498
+ user: shared_types.User | None
499
+ if "@" in user_identifier:
500
+ user = permissions_sourcegraph.get_user_by_email(
501
+ client, user_identifier
502
+ ) or permissions_sourcegraph.get_user_by_username(client, user_identifier)
503
+ else:
504
+ user = permissions_sourcegraph.get_user_by_username(
505
+ client, user_identifier
506
+ ) or permissions_sourcegraph.get_user_by_email(client, user_identifier)
507
+ if user is None:
508
+ raise SystemExit(f"No Sourcegraph user found for {user_identifier!r}.")
509
+ if user["username"] != user_identifier:
510
+ log.info("Resolved %s to Sourcegraph username %s.", user_identifier, user["username"])
511
+ return user
512
+
513
+
514
+ def _plan_additions_for_user(
515
+ client: src.SourcegraphClient,
516
+ context: permission_types.MappingContext,
517
+ resolved_mappings: list[_ResolvedMapping],
518
+ user: shared_types.User,
519
+ existing_repo_ids: set[str] | None = None,
520
+ ) -> list[permissions_apply.PermissionAddition]:
521
+ """Return missing additive permission edges for one user."""
522
+ desired_repos: dict[str, permission_types.Repository] = {}
523
+ for resolved_mapping in resolved_mappings:
524
+ if not permissions_mapping.user_matches_users_section(
525
+ resolved_mapping.users_section,
526
+ user,
527
+ context.providers,
528
+ context.saml_groups_attribute_names,
529
+ ):
530
+ continue
531
+ for repository in resolved_mapping.repos:
532
+ desired_repos[repository["id"]] = repository
533
+
534
+ if existing_repo_ids is None:
535
+ existing_repo_ids = set(
536
+ permissions_sourcegraph.list_user_explicit_repo_ids(client, user["id"])
537
+ )
538
+ additions = [
539
+ permissions_apply.PermissionAddition(
540
+ user_id=user["id"],
541
+ username=user["username"],
542
+ repo_id=repository["id"],
543
+ repo_name=repository["name"],
544
+ )
545
+ for repository_id, repository in desired_repos.items()
546
+ if repository_id not in existing_repo_ids
547
+ ]
548
+ additions.sort(key=lambda addition: (addition.username, addition.repo_name))
549
+ log.info(
550
+ "User %s: %d desired repo grant(s), %d already explicit, %d to add.",
551
+ user["username"],
552
+ len(desired_repos),
553
+ len(existing_repo_ids & set(desired_repos)),
554
+ len(additions),
555
+ )
556
+ return additions
557
+
558
+
559
+ def _additive_run_label(command_name: str, dry_run: bool) -> str:
560
+ return f"{command_name}-dry-run" if dry_run else f"{command_name}-apply"
561
+
562
+
563
+ def _write_additive_initial_artifacts(
564
+ client: src.SourcegraphClient,
565
+ input_path: Path,
566
+ snapshot_users: list[permission_snapshot.SnapshotUser],
567
+ additions: list[permissions_apply.PermissionAddition],
568
+ timestamp: str,
569
+ *,
570
+ dry_run: bool,
571
+ parallelism: int,
572
+ bind_id_mode: str,
573
+ command_name: str,
574
+ worker_pool: ThreadPoolExecutor | None = None,
575
+ ) -> permission_snapshot.UserScopedSnapshot:
576
+ """Capture before-snapshot and write dry-run/no-op additive artifacts."""
577
+ before_snapshot = permission_snapshot.build_user_scoped_snapshot(
578
+ client,
579
+ snapshot_users,
580
+ parallelism,
581
+ bind_id_mode,
582
+ input_path,
583
+ worker_pool=worker_pool,
584
+ )
585
+ run_label = _additive_run_label(command_name, dry_run)
586
+ before_path = snapshot_path(input_path, timestamp, client.endpoint, run_label, "before")
587
+ after_path = snapshot_path(input_path, timestamp, client.endpoint, run_label, "after")
588
+ permission_snapshot.write_user_scoped_snapshot(before_path, before_snapshot)
589
+ after_planned_snapshot = _user_scoped_snapshot_with_additions(
590
+ before_snapshot,
591
+ additions,
592
+ )
593
+ diff_path: Path | None = None
594
+ if dry_run or not additions:
595
+ permission_snapshot.write_user_scoped_snapshot(after_path, after_planned_snapshot)
596
+ diff_path = write_user_scoped_snapshot_diff_file(
597
+ input_path,
598
+ timestamp,
599
+ client.endpoint,
600
+ run_label,
601
+ before_snapshot,
602
+ after_planned_snapshot,
603
+ )
604
+ maps_backup_path = write_maps_backup(input_path, timestamp, client.endpoint, run_label)
605
+ log.info("Wrote scoped before-snapshot: %s", before_path)
606
+ if dry_run or not additions:
607
+ log.info("Wrote scoped after-snapshot: %s diff=%s", after_path, diff_path)
608
+ if maps_backup_path is not None:
609
+ log.info("Wrote maps backup for additive run: %s", maps_backup_path)
610
+ log.info(
611
+ "Diff (before → planned after):\n%s",
612
+ permission_snapshot.render_user_scoped_diff(before_snapshot, after_planned_snapshot),
613
+ )
614
+ return before_snapshot
615
+
616
+
617
+ def _finish_additive_dry_run(
618
+ additions: list[permissions_apply.PermissionAddition],
619
+ ) -> None:
620
+ """Log the additive dry-run mutation plan."""
621
+ for addition in additions:
622
+ log.info(
623
+ "[DRY RUN] Would add %s to %s (id=%d).",
624
+ addition.username,
625
+ addition.repo_name,
626
+ id_codec.decode_repository_id(addition.repo_id),
627
+ )
628
+ log.info("Dry run complete. Pass --apply to mutate state.")
629
+
630
+
631
+ def _apply_additive_permissions(
632
+ client: src.SourcegraphClient,
633
+ additions: list[permissions_apply.PermissionAddition],
634
+ parallelism: int,
635
+ worker_pool: ThreadPoolExecutor | None = None,
636
+ ) -> shared_types.MutationCounts:
637
+ """Apply additive repo-permission mutations."""
638
+ log.info(
639
+ "Applying %d addRepositoryPermissionForUser mutation(s) with parallelism=%d ...",
640
+ len(additions),
641
+ parallelism,
642
+ )
643
+ with src.stage("apply"):
644
+ mutations = permissions_apply.apply_additions(
645
+ client,
646
+ additions,
647
+ parallelism=parallelism,
648
+ worker_pool=worker_pool,
649
+ )
650
+ log.info(
651
+ "Additive apply done. %d succeeded, %d failed, %d canceled.",
652
+ mutations.succeeded,
653
+ mutations.failed,
654
+ mutations.canceled,
655
+ )
656
+ return mutations
657
+
658
+
659
+ def _finish_additive_apply_with_backup(
660
+ client: src.SourcegraphClient,
661
+ input_path: Path,
662
+ snapshot_users: list[permission_snapshot.SnapshotUser],
663
+ before_snapshot: permission_snapshot.UserScopedSnapshot,
664
+ additions: list[permissions_apply.PermissionAddition],
665
+ timestamp: str,
666
+ *,
667
+ parallelism: int,
668
+ bind_id_mode: str,
669
+ command_name: str,
670
+ worker_pool: ThreadPoolExecutor | None = None,
671
+ ) -> None:
672
+ """Capture and validate additive post-apply state."""
673
+ after_snapshot = permission_snapshot.build_user_scoped_snapshot(
674
+ client,
675
+ snapshot_users,
676
+ parallelism,
677
+ bind_id_mode,
678
+ input_path,
679
+ worker_pool=worker_pool,
680
+ )
681
+ after_path = snapshot_path(
682
+ input_path,
683
+ timestamp,
684
+ client.endpoint,
685
+ f"{command_name}-apply",
686
+ "after",
687
+ )
688
+ permission_snapshot.write_user_scoped_snapshot(after_path, after_snapshot)
689
+ diff_path = write_user_scoped_snapshot_diff_file(
690
+ input_path,
691
+ timestamp,
692
+ client.endpoint,
693
+ f"{command_name}-apply",
694
+ before_snapshot,
695
+ after_snapshot,
696
+ )
697
+ log.info("Wrote scoped after-snapshot: %s diff=%s", after_path, diff_path)
698
+ log.info(
699
+ "Diff (before → after):\n%s",
700
+ permission_snapshot.render_user_scoped_diff(before_snapshot, after_snapshot),
701
+ )
702
+ _validate_additive_after(after_snapshot, additions)
703
+
704
+
705
+ def _raise_for_failed_additive(mutations: shared_types.MutationCounts) -> None:
706
+ if not (mutations.failed or mutations.canceled):
707
+ return
708
+ log.error(
709
+ "ADDITIVE RUN FAILED: %d mutation(s) failed, %d canceled by circuit breaker.",
710
+ mutations.failed,
711
+ mutations.canceled,
712
+ )
713
+ raise SystemExit(1)
714
+
715
+
716
+ def _run_additive_apply(
717
+ client: src.SourcegraphClient,
718
+ input_path: Path,
719
+ users: list[shared_types.User],
720
+ additions: list[permissions_apply.PermissionAddition],
721
+ *,
722
+ dry_run: bool,
723
+ parallelism: int,
724
+ bind_id_mode: str,
725
+ do_backup: bool,
726
+ command_name: str,
727
+ worker_pool: ThreadPoolExecutor | None = None,
728
+ ) -> None:
729
+ """Snapshot, dry-run, apply, and validate an additive permission plan."""
730
+ if not users:
731
+ log.info("No users selected — nothing to do.")
732
+ return
733
+
734
+ snapshot_users = _snapshot_users_from_users(users)
735
+ timestamp = backups.backup_timestamp()
736
+ before_snapshot: permission_snapshot.UserScopedSnapshot | None = None
737
+ if dry_run or do_backup:
738
+ before_snapshot = _write_additive_initial_artifacts(
739
+ client,
740
+ input_path,
741
+ snapshot_users,
742
+ additions,
743
+ timestamp,
744
+ dry_run=dry_run,
745
+ parallelism=parallelism,
746
+ bind_id_mode=bind_id_mode,
747
+ command_name=command_name,
748
+ worker_pool=worker_pool,
749
+ )
750
+
751
+ log.info("Additive plan: %d grant(s) to add for %d user(s).", len(additions), len(users))
752
+ if dry_run:
753
+ _finish_additive_dry_run(additions)
754
+ return
755
+
756
+ if not additions:
757
+ log.info("All selected users already have the mapped explicit grants — nothing to apply.")
758
+ return
759
+
760
+ mutations = _apply_additive_permissions(client, additions, parallelism, worker_pool)
761
+
762
+ if do_backup:
763
+ assert before_snapshot is not None
764
+ _finish_additive_apply_with_backup(
765
+ client,
766
+ input_path,
767
+ snapshot_users,
768
+ before_snapshot,
769
+ additions,
770
+ timestamp,
771
+ parallelism=parallelism,
772
+ bind_id_mode=bind_id_mode,
773
+ command_name=command_name,
774
+ worker_pool=worker_pool,
775
+ )
776
+
777
+ _raise_for_failed_additive(mutations)
778
+
779
+
780
+ def _snapshot_users_from_users(
781
+ users: list[shared_types.User],
782
+ ) -> list[permission_snapshot.SnapshotUser]:
783
+ """Return deduplicated snapshot users sorted by username."""
784
+ users_by_id = {user["id"]: user for user in users}
785
+ return [
786
+ {"id": user["id"], "username": user["username"]}
787
+ for user in sorted(users_by_id.values(), key=lambda item: item["username"])
788
+ ]
789
+
790
+
791
+ def _user_scoped_snapshot_with_additions(
792
+ before_snapshot: permission_snapshot.UserScopedSnapshot,
793
+ additions: list[permissions_apply.PermissionAddition],
794
+ ) -> permission_snapshot.UserScopedSnapshot:
795
+ """Return a copy of a scoped snapshot with planned additions applied."""
796
+ users = _copy_user_scoped_users(before_snapshot)
797
+ for addition in additions:
798
+ user_snapshot = users.setdefault(
799
+ addition.username,
800
+ {"id": addition.user_id, "explicit_repositories": []},
801
+ )
802
+ repositories = {
803
+ repository["id"]: repository for repository in user_snapshot["explicit_repositories"]
804
+ }
805
+ repositories[addition.repo_id] = {"id": addition.repo_id, "name": addition.repo_name}
806
+ user_snapshot["explicit_repositories"] = sorted(
807
+ repositories.values(),
808
+ key=lambda repository: repository["name"],
809
+ )
810
+ return _copy_user_scoped_snapshot_with_users(before_snapshot, users)
811
+
812
+
813
+ def _copy_user_scoped_users(
814
+ snapshot: permission_snapshot.UserScopedSnapshot,
815
+ ) -> dict[str, permission_snapshot.UserScopedUserSnapshot]:
816
+ return {
817
+ username: {
818
+ "id": user_snapshot["id"],
819
+ "explicit_repositories": list(user_snapshot["explicit_repositories"]),
820
+ }
821
+ for username, user_snapshot in snapshot["users"].items()
822
+ }
823
+
824
+
825
+ def _copy_user_scoped_snapshot_with_users(
826
+ snapshot: permission_snapshot.UserScopedSnapshot,
827
+ users: dict[str, permission_snapshot.UserScopedUserSnapshot],
828
+ ) -> permission_snapshot.UserScopedSnapshot:
829
+ total_grants = sum(
830
+ len(user_snapshot["explicit_repositories"]) for user_snapshot in users.values()
831
+ )
832
+ return {
833
+ "schema_version": snapshot["schema_version"],
834
+ "snapshot_kind": snapshot["snapshot_kind"],
835
+ "captured_at": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
836
+ "endpoint": snapshot["endpoint"],
837
+ "bindID_mode": snapshot["bindID_mode"],
838
+ "config_file": snapshot["config_file"],
839
+ "config_sha256": snapshot["config_sha256"],
840
+ "stats": {
841
+ "total_users_scanned": len(users),
842
+ "users_with_explicit_grants": sum(
843
+ 1 for user_snapshot in users.values() if user_snapshot["explicit_repositories"]
844
+ ),
845
+ "total_grants": total_grants,
846
+ },
847
+ "users": dict(sorted(users.items())),
848
+ }
849
+
850
+
851
+ def _validate_additive_after(
852
+ after_snapshot: permission_snapshot.UserScopedSnapshot,
853
+ additions: list[permissions_apply.PermissionAddition],
854
+ ) -> None:
855
+ """Validate that every requested additive edge exists after apply."""
856
+ missing: list[permissions_apply.PermissionAddition] = []
857
+ repos_by_username = {
858
+ username: {repository["id"] for repository in user_snapshot["explicit_repositories"]}
859
+ for username, user_snapshot in after_snapshot["users"].items()
860
+ }
861
+ for addition in additions:
862
+ if addition.repo_id not in repos_by_username.get(addition.username, set()):
863
+ missing.append(addition)
864
+ if missing:
865
+ log.warning("VALIDATION: %d requested additive grant(s) are missing.", len(missing))
866
+ for addition in missing[:20]:
867
+ log.warning(
868
+ " missing %s → %s (id=%d)",
869
+ addition.username,
870
+ addition.repo_name,
871
+ id_codec.decode_repository_id(addition.repo_id),
872
+ )
873
+ return
874
+ log.info("VALIDATION OK: all %d requested additive grant(s) are present.", len(additions))
875
+
876
+
877
+ def cmd_restore_user_scoped(
878
+ client: src.SourcegraphClient,
879
+ snapshot_path: Path,
880
+ dry_run: bool,
881
+ parallelism: int,
882
+ bind_id_mode: str,
883
+ do_backup: bool,
884
+ worker_pool: ThreadPoolExecutor | None = None,
885
+ ) -> None:
886
+ """Restore explicit permissions for the users present in a scoped snapshot."""
887
+ permissions_restore.cmd_restore_user_scoped(
888
+ client,
889
+ snapshot_path,
890
+ dry_run,
891
+ parallelism,
892
+ bind_id_mode,
893
+ do_backup,
894
+ worker_pool=worker_pool,
895
+ )
896
+
897
+
898
+ def cmd_restore(
899
+ client: src.SourcegraphClient,
900
+ snapshot_path: Path,
901
+ dry_run: bool,
902
+ parallelism: int,
903
+ explicit_permissions_batch_size: int,
904
+ bind_id_mode: str,
905
+ do_backup: bool,
906
+ worker_pool: ThreadPoolExecutor | None = None,
907
+ ) -> None:
908
+ """Restore explicit-permissions state on the instance to match a snapshot."""
909
+ permissions_restore.cmd_restore(
910
+ client,
911
+ snapshot_path,
912
+ dry_run,
913
+ parallelism,
914
+ explicit_permissions_batch_size,
915
+ bind_id_mode,
916
+ do_backup,
917
+ worker_pool,
918
+ )