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.
- src_auth_perms_sync/__init__.py +1 -0
- src_auth_perms_sync/__main__.py +6 -0
- src_auth_perms_sync/cli.py +646 -0
- src_auth_perms_sync/orgs/__init__.py +1 -0
- src_auth_perms_sync/orgs/command.py +7 -0
- src_auth_perms_sync/orgs/queries.py +44 -0
- src_auth_perms_sync/orgs/sync.py +1167 -0
- src_auth_perms_sync/orgs/types.py +103 -0
- src_auth_perms_sync/permissions/__init__.py +1 -0
- src_auth_perms_sync/permissions/apply.py +420 -0
- src_auth_perms_sync/permissions/command.py +918 -0
- src_auth_perms_sync/permissions/full_set.py +880 -0
- src_auth_perms_sync/permissions/mapping.py +627 -0
- src_auth_perms_sync/permissions/maps.py +291 -0
- src_auth_perms_sync/permissions/queries.py +180 -0
- src_auth_perms_sync/permissions/restore.py +913 -0
- src_auth_perms_sync/permissions/snapshot.py +1502 -0
- src_auth_perms_sync/permissions/sourcegraph.py +392 -0
- src_auth_perms_sync/permissions/types.py +116 -0
- src_auth_perms_sync/permissions/workflow.py +526 -0
- src_auth_perms_sync/shared/__init__.py +1 -0
- src_auth_perms_sync/shared/backups.py +119 -0
- src_auth_perms_sync/shared/id_codec.py +67 -0
- src_auth_perms_sync/shared/queries.py +65 -0
- src_auth_perms_sync/shared/run_context.py +34 -0
- src_auth_perms_sync/shared/saml_groups.py +267 -0
- src_auth_perms_sync/shared/site_config.py +366 -0
- src_auth_perms_sync/shared/sourcegraph.py +69 -0
- src_auth_perms_sync/shared/types.py +69 -0
- src_auth_perms_sync-0.2.1.dist-info/METADATA +256 -0
- src_auth_perms_sync-0.2.1.dist-info/RECORD +34 -0
- src_auth_perms_sync-0.2.1.dist-info/WHEEL +4 -0
- src_auth_perms_sync-0.2.1.dist-info/entry_points.txt +2 -0
- 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
|