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,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
|
+
)
|