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,103 @@
1
+ """TypedDict shapes for Sourcegraph organization sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal, TypeAlias, TypedDict
7
+
8
+ from ..shared import types as shared_types
9
+
10
+ OrganizationChangeKind: TypeAlias = Literal["add", "remove"]
11
+
12
+
13
+ class OrgMember(TypedDict):
14
+ id: str
15
+ username: str
16
+
17
+
18
+ class CreatedOrganization(TypedDict):
19
+ id: str
20
+ name: str
21
+
22
+
23
+ class OrganizationSnapshotStats(TypedDict):
24
+ target_organizations: int
25
+ existing_organizations: int
26
+ total_current_members: int
27
+ total_desired_members: int
28
+
29
+
30
+ class OrganizationSnapshotEntry(TypedDict):
31
+ id: str | None
32
+ provider_config_id: str
33
+ saml_group: str
34
+ members: list[OrgMember]
35
+ desired_members: list[OrgMember]
36
+
37
+
38
+ class OrganizationSnapshot(TypedDict):
39
+ schema_version: int
40
+ captured_at: str
41
+ endpoint: str
42
+ stats: OrganizationSnapshotStats
43
+ organizations: dict[str, OrganizationSnapshotEntry]
44
+
45
+
46
+ class OrganizationSnapshotDiffSummary(TypedDict):
47
+ organizations_changed: int
48
+ organizations_created: int
49
+ members_added: int
50
+ members_removed: int
51
+
52
+
53
+ class OrganizationSnapshotDiffEntry(TypedDict):
54
+ name: str
55
+ id: str | None
56
+ provider_config_id: str
57
+ saml_group: str
58
+ created: bool
59
+ before_count: int
60
+ after_count: int
61
+ added: list[str]
62
+ removed: list[str]
63
+
64
+
65
+ class OrganizationSnapshotDiff(TypedDict):
66
+ schema_version: int
67
+ diff_kind: Literal["saml_organizations"]
68
+ before_captured_at: str
69
+ after_captured_at: str
70
+ endpoint: str
71
+ summary: OrganizationSnapshotDiffSummary
72
+ organizations: list[OrganizationSnapshotDiffEntry]
73
+
74
+
75
+ @dataclass
76
+ class TargetOrganization:
77
+ name: str
78
+ provider_config_id: str
79
+ saml_group: str
80
+ desired_members_by_id: dict[str, OrgMember] = field(default_factory=dict[str, OrgMember])
81
+
82
+
83
+ @dataclass
84
+ class OrganizationState:
85
+ id: str | None
86
+ name: str
87
+ members_by_id: dict[str, OrgMember]
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class OrganizationUserChange(shared_types.UserIdentity):
92
+ organization_name: str
93
+
94
+
95
+ class OrganizationBatchLookup(TypedDict):
96
+ current_user: OrgMember
97
+ states: dict[str, OrganizationState]
98
+
99
+
100
+ class OrganizationPlan(TypedDict):
101
+ create_names: list[str]
102
+ additions: list[OrganizationUserChange]
103
+ removals: list[OrganizationUserChange]
@@ -0,0 +1 @@
1
+ """Repo-permission sync workflow."""
@@ -0,0 +1,420 @@
1
+ """Repo-permission mutation application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from collections import deque
8
+ from collections.abc import Sequence
9
+ from concurrent.futures import (
10
+ FIRST_COMPLETED,
11
+ CancelledError,
12
+ Future,
13
+ ThreadPoolExecutor,
14
+ as_completed,
15
+ wait,
16
+ )
17
+ from dataclasses import dataclass, field
18
+ from typing import TypeAlias, cast
19
+
20
+ import src_py_lib as src
21
+
22
+ from ..shared import id_codec, run_context
23
+ from ..shared import types as shared_types
24
+ from . import queries
25
+ from . import types as permission_types
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class CircuitBreaker:
32
+ """Sliding-window circuit breaker for the apply phase.
33
+
34
+ Tracks the most recent `window_size` mutation outcomes (success or
35
+ failure). Once `failure_rate` over that window exceeds
36
+ `failure_threshold` AND we have at least `min_samples` outcomes
37
+ recorded, the breaker opens and `is_open()` returns True for the rest
38
+ of the run (no half-open / reset logic — once we decide the backend
39
+ is too unhealthy, we stay tripped).
40
+
41
+ Designed to bail out of a hopeless run (e.g., backend down or
42
+ severely rate-limiting) instead of grinding through every remaining
43
+ mutation, retrying each request repeatedly, and burning hours of
44
+ wall-clock in retries while making things worse for the server.
45
+
46
+ Used by the apply helpers: each completed mutation calls
47
+ `breaker.record(success=...)`, then `is_open()` is checked between
48
+ completions; once open, the remaining queued futures are cancelled
49
+ and the loop exits, leaving the operator a clear ERROR log + a
50
+ non-zero exit code.
51
+ """
52
+
53
+ window_size: int = 50
54
+ failure_threshold: float = 0.25 # fraction; 0.25 == 25%
55
+ min_samples: int = 20
56
+ _outcomes: deque[bool] = field(init=False)
57
+ _lock: threading.Lock = field(init=False, default_factory=threading.Lock)
58
+ _opened: bool = field(init=False, default=False)
59
+ total_successes: int = field(init=False, default=0)
60
+ total_failures: int = field(init=False, default=0)
61
+
62
+ def __post_init__(self) -> None:
63
+ self._outcomes = deque(maxlen=self.window_size)
64
+
65
+ def record(self, success: bool) -> None:
66
+ with self._lock:
67
+ self._outcomes.append(success)
68
+ if success:
69
+ self.total_successes += 1
70
+ else:
71
+ self.total_failures += 1
72
+ if self._opened:
73
+ return
74
+ if len(self._outcomes) < self.min_samples:
75
+ return
76
+ failures = sum(1 for outcome in self._outcomes if not outcome)
77
+ rate = failures / len(self._outcomes)
78
+ if rate >= self.failure_threshold:
79
+ self._opened = True
80
+ src.info(
81
+ "circuit_breaker_open",
82
+ window_size=len(self._outcomes),
83
+ recent_failures=failures,
84
+ failure_rate=round(rate, 3),
85
+ total_successes=self.total_successes,
86
+ total_failures=self.total_failures,
87
+ )
88
+ log.error(
89
+ "Circuit breaker OPEN: %d/%d (%.0f%%) of last %d "
90
+ "mutations failed; halting apply to avoid hammering "
91
+ "a struggling instance. Remaining work will be "
92
+ "cancelled; the run will continue with the after-"
93
+ "snapshot+validation, then exit 1.",
94
+ failures,
95
+ len(self._outcomes),
96
+ 100 * rate,
97
+ len(self._outcomes),
98
+ )
99
+
100
+ def is_open(self) -> bool:
101
+ with self._lock:
102
+ return self._opened
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class PermissionAddition(shared_types.UserIdentity):
107
+ """One explicit repository permission to add for one user."""
108
+
109
+ repo_id: str
110
+ repo_name: str
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class PermissionRemoval(shared_types.UserIdentity):
115
+ """One explicit repository permission to remove for one user."""
116
+
117
+ repo_id: str
118
+ repo_name: str
119
+
120
+
121
+ PermissionChange: TypeAlias = PermissionAddition | PermissionRemoval
122
+
123
+
124
+ def set_repo_permissions(
125
+ client: src.SourcegraphClient,
126
+ repo_id: str,
127
+ user_perms: list[dict[str, str]],
128
+ ) -> None:
129
+ """Overwrite a repo's explicit permissions with `user_perms` in one call.
130
+
131
+ `user_perms` is a list of `{"bindID": <username>, "permission": "READ"}`.
132
+ bindID is always the Sourcegraph username — validate_site_config()
133
+ enforces that the site is configured with `bindID: "username"`.
134
+
135
+ Retries on transient transport failures (network errors, HTTP 408/429/5xx)
136
+ happen inside the shared Sourcegraph client — every GraphQL call goes through the
137
+ same retry plumbing. Application-level GraphQL errors (auth, validation,
138
+ schema) are NOT retried — they propagate on the first attempt.
139
+ """
140
+ with src.event(
141
+ "set_repo_perms",
142
+ repo_id=repo_id,
143
+ user_count=len(user_perms),
144
+ ):
145
+ client.graphql(
146
+ queries.MUTATION_SET_REPO_PERMISSIONS,
147
+ cast(src.JSONDict, {"repo": repo_id, "userPerms": user_perms}),
148
+ )
149
+
150
+
151
+ def set_repo_permissions_for_usernames(
152
+ client: src.SourcegraphClient,
153
+ repo_id: str,
154
+ usernames: Sequence[str],
155
+ ) -> None:
156
+ """Overwrite a repo's explicit permissions, building GraphQL input lazily."""
157
+ set_repo_permissions(
158
+ client,
159
+ repo_id,
160
+ [{"bindID": username, "permission": "READ"} for username in usernames],
161
+ )
162
+
163
+
164
+ def _mutate_repo_permission_for_user(
165
+ client: src.SourcegraphClient,
166
+ change: PermissionChange,
167
+ mutation: str,
168
+ event_name: str,
169
+ ) -> None:
170
+ """Apply one additive repo-permission edge mutation."""
171
+ with src.event(
172
+ event_name,
173
+ repo_id=change.repo_id,
174
+ username=change.username,
175
+ ):
176
+ client.graphql(
177
+ mutation,
178
+ cast(src.JSONDict, {"repo": change.repo_id, "user": change.user_id}),
179
+ )
180
+
181
+
182
+ def _apply_permission_changes(
183
+ client: src.SourcegraphClient,
184
+ changes: Sequence[PermissionChange],
185
+ *,
186
+ parallelism: int,
187
+ worker_pool: ThreadPoolExecutor | None = None,
188
+ mutation: str,
189
+ event_name: str,
190
+ action: str,
191
+ ) -> shared_types.MutationCounts:
192
+ """Dispatch additive edge mutations across a thread pool."""
193
+ with src.event(
194
+ f"apply_{action}_payloads",
195
+ payload_count=len(changes),
196
+ parallelism=parallelism,
197
+ ) as batch_event:
198
+ succeeded = 0
199
+ failed = 0
200
+ canceled = 0
201
+ breaker = CircuitBreaker()
202
+ with run_context.thread_pool(parallelism, worker_pool) as executor:
203
+ futures = {
204
+ src.submit_with_log_context(
205
+ executor,
206
+ _mutate_repo_permission_for_user,
207
+ client,
208
+ change,
209
+ mutation,
210
+ event_name,
211
+ ): change
212
+ for change in changes
213
+ }
214
+ for future in as_completed(futures):
215
+ change = futures[future]
216
+ try:
217
+ future.result()
218
+ succeeded += 1
219
+ breaker.record(success=True)
220
+ log.info(
221
+ " OK %s %s → %s (id=%d).",
222
+ action,
223
+ change.username,
224
+ change.repo_name,
225
+ id_codec.decode_repository_id(change.repo_id),
226
+ )
227
+ except CancelledError:
228
+ canceled += 1
229
+ continue
230
+ except Exception as exception:
231
+ failed += 1
232
+ breaker.record(success=False)
233
+ log.error(
234
+ " FAIL %s %s → %s (id=%d): %s",
235
+ action,
236
+ change.username,
237
+ change.repo_name,
238
+ id_codec.decode_repository_id(change.repo_id),
239
+ exception,
240
+ )
241
+
242
+ if breaker.is_open():
243
+ for pending_future in futures:
244
+ if not pending_future.done():
245
+ pending_future.cancel()
246
+ batch_event["succeeded"] = succeeded
247
+ batch_event["failed"] = failed
248
+ batch_event["canceled"] = canceled
249
+ batch_event["circuit_broken"] = breaker.is_open()
250
+ return shared_types.MutationCounts(
251
+ succeeded=succeeded,
252
+ failed=failed,
253
+ canceled=canceled,
254
+ )
255
+
256
+
257
+ def apply_additions(
258
+ client: src.SourcegraphClient,
259
+ additions: Sequence[PermissionAddition],
260
+ *,
261
+ parallelism: int,
262
+ worker_pool: ThreadPoolExecutor | None = None,
263
+ ) -> shared_types.MutationCounts:
264
+ """Add explicit repo permissions while preserving existing grants."""
265
+ return _apply_permission_changes(
266
+ client,
267
+ additions,
268
+ parallelism=parallelism,
269
+ worker_pool=worker_pool,
270
+ mutation=queries.MUTATION_ADD_REPO_PERMISSION,
271
+ event_name="add_repo_permission",
272
+ action="add",
273
+ )
274
+
275
+
276
+ def apply_removals(
277
+ client: src.SourcegraphClient,
278
+ removals: Sequence[PermissionRemoval],
279
+ *,
280
+ parallelism: int,
281
+ worker_pool: ThreadPoolExecutor | None = None,
282
+ ) -> shared_types.MutationCounts:
283
+ """Remove explicit repo permissions while preserving other grants."""
284
+ return _apply_permission_changes(
285
+ client,
286
+ removals,
287
+ parallelism=parallelism,
288
+ worker_pool=worker_pool,
289
+ mutation=queries.MUTATION_REMOVE_REPO_PERMISSION,
290
+ event_name="remove_repo_permission",
291
+ action="remove",
292
+ )
293
+
294
+
295
+ def _apply_repo_overwrite_plans(
296
+ client: src.SourcegraphClient,
297
+ overwrites: Sequence[permission_types.RepositoryUsernameOverwrite],
298
+ *,
299
+ parallelism: int,
300
+ worker_pool: ThreadPoolExecutor | None = None,
301
+ ) -> shared_types.MutationCounts:
302
+ """Dispatch per-repo overwrite mutations with bounded in-flight work."""
303
+ max_pending_futures = max(1, parallelism * 2)
304
+ total_users = sum(len(overwrite.usernames) for overwrite in overwrites)
305
+ with src.event(
306
+ "apply_username_overwrites",
307
+ payload_count=len(overwrites),
308
+ parallelism=parallelism,
309
+ total_users=total_users,
310
+ max_pending_futures=max_pending_futures,
311
+ ) as batch_event:
312
+ succeeded = 0
313
+ failed = 0
314
+ canceled = 0
315
+ submitted_count = 0
316
+ submissions_stopped = False
317
+ breaker = CircuitBreaker()
318
+ overwrite_iterator = iter(overwrites)
319
+ futures: dict[Future[None], permission_types.RepositoryUsernameOverwrite] = {}
320
+
321
+ def _submit_next(executor: ThreadPoolExecutor) -> bool:
322
+ nonlocal submitted_count
323
+ try:
324
+ overwrite = next(overwrite_iterator)
325
+ except StopIteration:
326
+ return False
327
+ future = cast(
328
+ Future[None],
329
+ src.submit_with_log_context(
330
+ executor,
331
+ set_repo_permissions_for_usernames,
332
+ client,
333
+ overwrite.repository_id,
334
+ overwrite.usernames,
335
+ ),
336
+ )
337
+ futures[future] = overwrite
338
+ submitted_count += 1
339
+ return True
340
+
341
+ def _stop_submissions() -> None:
342
+ nonlocal submissions_stopped
343
+ if submissions_stopped:
344
+ return
345
+ submissions_stopped = True
346
+ for pending_future in futures:
347
+ if not pending_future.done():
348
+ pending_future.cancel()
349
+
350
+ with run_context.thread_pool(parallelism, worker_pool) as executor:
351
+ while len(futures) < max_pending_futures and _submit_next(executor):
352
+ pass
353
+
354
+ while futures:
355
+ done_futures, _ = wait(futures, return_when=FIRST_COMPLETED)
356
+ for future in done_futures:
357
+ overwrite = futures.pop(future)
358
+ try:
359
+ future.result()
360
+ succeeded += 1
361
+ breaker.record(success=True)
362
+ log.info(
363
+ " OK %s (id=%d) — %d users.",
364
+ overwrite.repository_name,
365
+ id_codec.decode_repository_id(overwrite.repository_id),
366
+ len(overwrite.usernames),
367
+ )
368
+ except CancelledError:
369
+ # Cancelled by the breaker; not counted as a failure
370
+ # because we never gave the server a chance to apply it.
371
+ canceled += 1
372
+ continue
373
+ except Exception as exception:
374
+ failed += 1
375
+ breaker.record(success=False)
376
+ log.error(
377
+ " FAIL %s (id=%d): %s",
378
+ overwrite.repository_name,
379
+ id_codec.decode_repository_id(overwrite.repository_id),
380
+ exception,
381
+ )
382
+
383
+ if breaker.is_open():
384
+ _stop_submissions()
385
+
386
+ while (
387
+ not submissions_stopped
388
+ and len(futures) < max_pending_futures
389
+ and _submit_next(executor)
390
+ ):
391
+ pass
392
+
393
+ if submissions_stopped:
394
+ canceled += len(overwrites) - submitted_count
395
+ batch_event["succeeded"] = succeeded
396
+ batch_event["failed"] = failed
397
+ batch_event["canceled"] = canceled
398
+ batch_event["circuit_broken"] = breaker.is_open()
399
+ batch_event["submitted"] = submitted_count
400
+ return shared_types.MutationCounts(
401
+ succeeded=succeeded,
402
+ failed=failed,
403
+ canceled=canceled,
404
+ )
405
+
406
+
407
+ def apply_username_overwrites(
408
+ client: src.SourcegraphClient,
409
+ overwrites: Sequence[permission_types.RepositoryUsernameOverwrite],
410
+ *,
411
+ parallelism: int,
412
+ worker_pool: ThreadPoolExecutor | None = None,
413
+ ) -> shared_types.MutationCounts:
414
+ """Dispatch repo overwrite mutations, building GraphQL dicts in workers."""
415
+ return _apply_repo_overwrite_plans(
416
+ client,
417
+ overwrites,
418
+ parallelism=parallelism,
419
+ worker_pool=worker_pool,
420
+ )