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,366 @@
|
|
|
1
|
+
"""Site config validation shared by mutating workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import json5
|
|
10
|
+
import src_py_lib as src
|
|
11
|
+
|
|
12
|
+
from . import queries, saml_groups
|
|
13
|
+
|
|
14
|
+
# HTTP statuses that genuinely indicate the access token can't read site
|
|
15
|
+
# config (missing Site Admin role / SITE_CONFIG#READ). Everything else
|
|
16
|
+
# (5xx, network, parse, etc.) is a transport / server failure, not an
|
|
17
|
+
# authorization problem — say so instead of misleading the operator.
|
|
18
|
+
AUTHORIZATION_HTTP_STATUSES = frozenset({401, 403})
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SiteConfig:
|
|
25
|
+
"""Site-config-derived values needed by the mutation paths.
|
|
26
|
+
|
|
27
|
+
Returned by `validate_site_config` after it confirms the safety
|
|
28
|
+
invariants. Holds the bits we'd otherwise re-parse out of
|
|
29
|
+
`effectiveContents` from multiple call sites.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
bind_id_mode: str
|
|
33
|
+
"""`"USERNAME"` (the only value validate_site_config accepts) — kept
|
|
34
|
+
for downstream snapshot / apply layers that pass it through."""
|
|
35
|
+
|
|
36
|
+
auth_providers_by_config_id: dict[str, dict[str, Any]]
|
|
37
|
+
"""Raw `auth.providers[*]` site-config entries keyed by explicit
|
|
38
|
+
`configID`, with redacted/secret fields stripped (see
|
|
39
|
+
`_strip_sensitive_provider_fields`). Entries without an explicit
|
|
40
|
+
`configID` are dropped — Sourcegraph synthesizes one as a content-
|
|
41
|
+
addressed hash we can't safely replicate from Python.
|
|
42
|
+
|
|
43
|
+
Surfaced to `cmd_get` so the YAML config carries every non-secret
|
|
44
|
+
provider attribute (e.g. `identityProviderMetadataURL`,
|
|
45
|
+
`serviceProviderIssuer`, `requireEmailDomain`) alongside the
|
|
46
|
+
GraphQL-discovered fields."""
|
|
47
|
+
|
|
48
|
+
saml_groups_attribute_name_by_config_id: dict[str, str]
|
|
49
|
+
"""Per-SAML-provider override of the SAML assertion attribute name
|
|
50
|
+
that holds group memberships, keyed by the auth-provider's explicit
|
|
51
|
+
`configID` (e.g. `"okta"`). Joins against the discovered
|
|
52
|
+
`AuthProvider.configID` field exactly.
|
|
53
|
+
|
|
54
|
+
Only populated for SAML site-config entries that set BOTH a non-
|
|
55
|
+
default `groupsAttributeName` AND an explicit `configID`. Entries
|
|
56
|
+
that customize `groupsAttributeName` without setting `configID`
|
|
57
|
+
are skipped (with a warning) — Sourcegraph synthesizes a `configID`
|
|
58
|
+
of `<type>:<index>` for them internally, but that synthesis is an
|
|
59
|
+
implementation detail and order-dependent. Operators who need this
|
|
60
|
+
override should set explicit `configID` on each affected SAML
|
|
61
|
+
provider in site config.
|
|
62
|
+
|
|
63
|
+
Providers without an entry fall back to
|
|
64
|
+
`DEFAULT_GROUPS_ATTRIBUTE_NAME` (`"groups"`) — the same default
|
|
65
|
+
Sourcegraph itself uses when `groupsAttributeName` is unset, so
|
|
66
|
+
the fallback is safe."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_site_config(client: src.SourcegraphClient) -> SiteConfig:
|
|
70
|
+
"""Verify required site-config invariants for safe explicit-permissions use.
|
|
71
|
+
|
|
72
|
+
Hard-fails (SystemExit) unless ALL of the following are true:
|
|
73
|
+
|
|
74
|
+
1. `permissions.userMapping.enabled: true`
|
|
75
|
+
The explicit permissions API will not accept mutations otherwise.
|
|
76
|
+
Read from `site.configuration.effectiveContents` JSONC (no dedicated
|
|
77
|
+
GraphQL field).
|
|
78
|
+
|
|
79
|
+
2. `permissionsUserMappingBindID == USERNAME`
|
|
80
|
+
Read directly from the GraphQL enum
|
|
81
|
+
`PermissionsUserMappingBindID = USERNAME | EMAIL`, which is the
|
|
82
|
+
server-side resolved value (no JSONC parse needed). Sourcegraph
|
|
83
|
+
allows multiple users to share the same email, so an email-keyed
|
|
84
|
+
bindID can collide and silently grant permissions to the wrong
|
|
85
|
+
user. Usernames are guaranteed unique.
|
|
86
|
+
|
|
87
|
+
3. `auth.enableUsernameChanges: false` (or unset; default is false)
|
|
88
|
+
If users can rename themselves, username-keyed permissions become
|
|
89
|
+
unstable: a user could rename into another user's old name and
|
|
90
|
+
inherit their permissions. Read from JSONC (no dedicated GraphQL
|
|
91
|
+
field).
|
|
92
|
+
|
|
93
|
+
Also surfaces site-level config validationMessages as warnings.
|
|
94
|
+
"""
|
|
95
|
+
bind_id_enum, contents = _query_site_configuration(client, "validate_site_config")
|
|
96
|
+
|
|
97
|
+
user_mapping = cast(dict[str, Any], contents.get("permissions.userMapping", {}))
|
|
98
|
+
enabled = bool(user_mapping.get("enabled", False))
|
|
99
|
+
enable_username_changes = bool(contents.get("auth.enableUsernameChanges", False))
|
|
100
|
+
|
|
101
|
+
log.info(
|
|
102
|
+
"Site config: permissions.userMapping.enabled=%s bindID=%s auth.enableUsernameChanges=%s",
|
|
103
|
+
enabled,
|
|
104
|
+
bind_id_enum,
|
|
105
|
+
enable_username_changes,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
safety_errors: list[str] = []
|
|
109
|
+
|
|
110
|
+
if not enabled:
|
|
111
|
+
safety_errors.append(
|
|
112
|
+
"permissions.userMapping.enabled must be `true` (currently false). "
|
|
113
|
+
"The explicit permissions API rejects mutations otherwise."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if bind_id_enum != "USERNAME":
|
|
117
|
+
safety_errors.append(
|
|
118
|
+
f'permissions.userMapping.bindID must be "username" '
|
|
119
|
+
f"(GraphQL enum currently {bind_id_enum}). Multiple Sourcegraph "
|
|
120
|
+
f"users can share the same email, so email-keyed bindIDs can "
|
|
121
|
+
f"silently grant permissions to the wrong user."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if enable_username_changes:
|
|
125
|
+
safety_errors.append(
|
|
126
|
+
"auth.enableUsernameChanges must be `false` (currently true). "
|
|
127
|
+
"Username-keyed permissions become unstable if users can rename "
|
|
128
|
+
"themselves — a user could rename into another user's old name "
|
|
129
|
+
"and inherit their permissions."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
overrides, saml_groups_errors = _extract_saml_groups_attribute_names(contents)
|
|
133
|
+
|
|
134
|
+
# Two distinct error buckets so each gets its own targeted fix
|
|
135
|
+
# guidance. Bundling them under one footer (as we did before) made
|
|
136
|
+
# the existing-and-correct safety settings look like the failure
|
|
137
|
+
# whenever the only real problem was a missing SAML configID.
|
|
138
|
+
if safety_errors or saml_groups_errors:
|
|
139
|
+
message_sections: list[str] = []
|
|
140
|
+
bullet = "\n - "
|
|
141
|
+
if safety_errors:
|
|
142
|
+
message_sections.append(
|
|
143
|
+
"Site-config safety requirements not met:"
|
|
144
|
+
+ bullet
|
|
145
|
+
+ bullet.join(safety_errors)
|
|
146
|
+
+ "\n\nFix: edit site config (Site admin → Configuration) so it "
|
|
147
|
+
"includes:\n"
|
|
148
|
+
' "permissions.userMapping": { "enabled": true, "bindID": "username" },\n'
|
|
149
|
+
' "auth.enableUsernameChanges": false'
|
|
150
|
+
)
|
|
151
|
+
if saml_groups_errors:
|
|
152
|
+
message_sections.append(
|
|
153
|
+
"SAML auth provider(s) need an explicit `configID`:"
|
|
154
|
+
+ bullet
|
|
155
|
+
+ bullet.join(saml_groups_errors)
|
|
156
|
+
)
|
|
157
|
+
raise SystemExit("FATAL: " + "\n\n".join(message_sections))
|
|
158
|
+
|
|
159
|
+
return SiteConfig(
|
|
160
|
+
bind_id_mode=bind_id_enum,
|
|
161
|
+
auth_providers_by_config_id=_extract_auth_providers_by_config_id(contents),
|
|
162
|
+
saml_groups_attribute_name_by_config_id=overrides,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _query_site_configuration(
|
|
167
|
+
client: src.SourcegraphClient, event_name: str
|
|
168
|
+
) -> tuple[str, dict[str, Any]]:
|
|
169
|
+
"""Fetch and parse site configuration once, with consistent errors."""
|
|
170
|
+
# Wrap the read-the-site-config call in its own event so a failure
|
|
171
|
+
# surfaces in the structured log with phase context (http_status,
|
|
172
|
+
# error_reason) instead of just bubbling up as an un-annotated
|
|
173
|
+
# `error_type=SystemExit` on the run end event. Each underlying GraphQL/HTTP
|
|
174
|
+
# attempt still emits shared-library `graphql_query` / `http_request` events.
|
|
175
|
+
with src.event(event_name) as site_config_event:
|
|
176
|
+
try:
|
|
177
|
+
data = client.graphql(queries.QUERY_VALIDATE_PERMISSIONS_CONFIG)
|
|
178
|
+
except src.GraphQLError as exception:
|
|
179
|
+
if exception.status_code in AUTHORIZATION_HTTP_STATUSES:
|
|
180
|
+
reason = (
|
|
181
|
+
f"The access token was rejected (HTTP {exception.status_code}). "
|
|
182
|
+
"It must belong to a Site Admin (or have SITE_CONFIG#READ)."
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
reason = (
|
|
186
|
+
"Request to the Sourcegraph instance failed before site config "
|
|
187
|
+
"could be read. This is a transport / server-side error, not an "
|
|
188
|
+
"authorization failure."
|
|
189
|
+
)
|
|
190
|
+
site_config_event["http_status"] = exception.status_code
|
|
191
|
+
site_config_event["error_reason"] = reason
|
|
192
|
+
raise SystemExit(
|
|
193
|
+
f"FATAL: could not query site configuration. {reason}\n {exception}"
|
|
194
|
+
) from exception
|
|
195
|
+
|
|
196
|
+
site = cast(dict[str, Any], data["site"])
|
|
197
|
+
bind_id_enum = cast(str, site["permissionsUserMappingBindID"])
|
|
198
|
+
config = cast(dict[str, Any], site["configuration"])
|
|
199
|
+
contents_str = cast(str, config["effectiveContents"])
|
|
200
|
+
|
|
201
|
+
# bindID comes straight from the resolved-by-the-server enum
|
|
202
|
+
# (PermissionsUserMappingBindID = USERNAME | EMAIL). The remaining
|
|
203
|
+
# settings aren't exposed via dedicated GraphQL fields, so we parse
|
|
204
|
+
# effectiveContents for those.
|
|
205
|
+
try:
|
|
206
|
+
contents = cast(dict[str, Any], json5.loads(contents_str))
|
|
207
|
+
except Exception as exception:
|
|
208
|
+
raise SystemExit(
|
|
209
|
+
f"FATAL: could not parse site config effectiveContents as JSONC: {exception}"
|
|
210
|
+
) from exception
|
|
211
|
+
return bind_id_enum, contents
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Sourcegraph's `effectiveContents` resolver redacts secrets by replacing
|
|
215
|
+
# them with this literal sentinel string (see internal/conf/validate.go
|
|
216
|
+
# in sourcegraph/sourcegraph). Any field carrying this value is stripped
|
|
217
|
+
# from the YAML — value-based, so it stays correct if Sourcegraph adds
|
|
218
|
+
# more redactions in the future without us having to enumerate them.
|
|
219
|
+
REDACTED_SENTINEL = "REDACTED"
|
|
220
|
+
|
|
221
|
+
# SAML fields Sourcegraph does NOT redact but we still drop:
|
|
222
|
+
# private keys / certs / inline IdP metadata blobs are large secrets that
|
|
223
|
+
# don't belong in a config-discovery YAML. The URL-form
|
|
224
|
+
# (`identityProviderMetadataURL`) is kept — it's a reference, not a blob.
|
|
225
|
+
_DROPPED_PROVIDER_FIELDS: frozenset[str] = frozenset(
|
|
226
|
+
{
|
|
227
|
+
"serviceProviderPrivateKey",
|
|
228
|
+
"serviceProviderCertificate",
|
|
229
|
+
"identityProviderMetadata",
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _strip_sensitive_provider_fields(entry: dict[str, Any]) -> dict[str, Any]:
|
|
235
|
+
"""Return a shallow copy of an `auth.providers[*]` entry with redacted
|
|
236
|
+
and explicitly-dropped fields removed."""
|
|
237
|
+
return {
|
|
238
|
+
field_name: value
|
|
239
|
+
for field_name, value in entry.items()
|
|
240
|
+
if field_name not in _DROPPED_PROVIDER_FIELDS and value != REDACTED_SENTINEL
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _extract_auth_providers_by_config_id(
|
|
245
|
+
contents: dict[str, Any],
|
|
246
|
+
) -> dict[str, dict[str, Any]]:
|
|
247
|
+
"""`auth.providers[*]` site-config entries keyed by explicit `configID`,
|
|
248
|
+
with secrets stripped. Entries without an explicit `configID` are
|
|
249
|
+
silently skipped — see `SiteConfig.auth_providers_by_config_id` for
|
|
250
|
+
the rationale."""
|
|
251
|
+
by_config_id: dict[str, dict[str, Any]] = {}
|
|
252
|
+
raw_providers = contents.get("auth.providers")
|
|
253
|
+
if not isinstance(raw_providers, list):
|
|
254
|
+
return by_config_id
|
|
255
|
+
for raw_entry in cast(list[Any], raw_providers):
|
|
256
|
+
if not isinstance(raw_entry, dict):
|
|
257
|
+
continue
|
|
258
|
+
entry = cast(dict[str, Any], raw_entry)
|
|
259
|
+
config_id = entry.get("configID")
|
|
260
|
+
if not isinstance(config_id, str) or not config_id:
|
|
261
|
+
continue
|
|
262
|
+
by_config_id[config_id] = _strip_sensitive_provider_fields(entry)
|
|
263
|
+
return by_config_id
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _extract_saml_groups_attribute_names(
|
|
267
|
+
contents: dict[str, Any],
|
|
268
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
269
|
+
"""Per-SAML-provider `groupsAttributeName` keyed by explicit `configID`.
|
|
270
|
+
|
|
271
|
+
Returns `(overrides, errors)`:
|
|
272
|
+
|
|
273
|
+
- `overrides` covers SAML site-config entries that set BOTH a
|
|
274
|
+
non-default `groupsAttributeName` AND an explicit `configID`,
|
|
275
|
+
keyed by `configID` so the consumer can join against the
|
|
276
|
+
discovered `AuthProvider.configID` field.
|
|
277
|
+
|
|
278
|
+
- `errors` is one human-readable line per SAML provider that sets
|
|
279
|
+
`groupsAttributeName` but omits `configID`. Returned (rather
|
|
280
|
+
than raised) so the caller can fold them into its existing
|
|
281
|
+
site-config errors batch and surface every problem at once.
|
|
282
|
+
|
|
283
|
+
We refuse the missing-`configID` case because Sourcegraph
|
|
284
|
+
synthesizes the configID from a SHA-256 hash of the provider's
|
|
285
|
+
JSON-marshalled struct (see saml/config.go's `providerConfigID`).
|
|
286
|
+
Replicating that hash from Python is fragile (Go struct field
|
|
287
|
+
order, omitempty semantics, etc.) and the hash rotates whenever
|
|
288
|
+
any provider field changes, so we'd silently misattribute group
|
|
289
|
+
overrides on the next config edit. Easier to make the operator
|
|
290
|
+
set an explicit `configID`.
|
|
291
|
+
"""
|
|
292
|
+
overrides: dict[str, str] = {}
|
|
293
|
+
errors: list[str] = []
|
|
294
|
+
raw_providers = contents.get("auth.providers")
|
|
295
|
+
if not isinstance(raw_providers, list):
|
|
296
|
+
return overrides, errors
|
|
297
|
+
for raw_entry in cast(list[Any], raw_providers):
|
|
298
|
+
if not isinstance(raw_entry, dict):
|
|
299
|
+
continue
|
|
300
|
+
entry = cast(dict[str, Any], raw_entry)
|
|
301
|
+
if entry.get("type") != saml_groups.SAML_SERVICE_TYPE:
|
|
302
|
+
continue
|
|
303
|
+
attribute_name = entry.get("groupsAttributeName")
|
|
304
|
+
if not isinstance(attribute_name, str) or not attribute_name:
|
|
305
|
+
continue
|
|
306
|
+
if attribute_name == saml_groups.DEFAULT_GROUPS_ATTRIBUTE_NAME:
|
|
307
|
+
continue
|
|
308
|
+
config_id = entry.get("configID")
|
|
309
|
+
if not isinstance(config_id, str) or not config_id:
|
|
310
|
+
errors.append(_missing_config_id_error(entry, attribute_name))
|
|
311
|
+
continue
|
|
312
|
+
overrides[config_id] = attribute_name
|
|
313
|
+
return overrides, errors
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# auth.providers SAML fields most useful for identifying which entry an
|
|
317
|
+
# operator needs to fix when their site config is missing `configID`.
|
|
318
|
+
# Includes both human-set fields (displayName) and content-addressed
|
|
319
|
+
# fields (issuer/metadata URL) so the entry is unambiguous in any
|
|
320
|
+
# realistic deployment.
|
|
321
|
+
_SAML_PROVIDER_IDENTITY_FIELDS: tuple[str, ...] = (
|
|
322
|
+
"displayName",
|
|
323
|
+
"identityProviderMetadataURL",
|
|
324
|
+
"identityProviderMetadata",
|
|
325
|
+
"serviceProviderIssuer",
|
|
326
|
+
"serviceProviderCertificate",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _missing_config_id_error(entry: dict[str, Any], attribute_name: str) -> str:
|
|
331
|
+
"""Multi-line error describing a SAML provider that needs `configID` set.
|
|
332
|
+
|
|
333
|
+
Each non-leading line is indented to look right when the result is
|
|
334
|
+
joined under the `\\n - ` bullet in `validate_site_config`'s
|
|
335
|
+
SystemExit. The first line carries the bullet body; subsequent lines
|
|
336
|
+
sit at column 6 so they line up under that body.
|
|
337
|
+
"""
|
|
338
|
+
identity_lines: list[str] = []
|
|
339
|
+
for field_name in _SAML_PROVIDER_IDENTITY_FIELDS:
|
|
340
|
+
raw_value = entry.get(field_name)
|
|
341
|
+
if isinstance(raw_value, str) and raw_value:
|
|
342
|
+
# Trim long values (PEM cert blobs, multi-line metadata) so
|
|
343
|
+
# the error stays scannable.
|
|
344
|
+
display_value = raw_value if len(raw_value) <= 80 else raw_value[:77] + "..."
|
|
345
|
+
identity_lines.append(f" {field_name}: {display_value}")
|
|
346
|
+
identity_block = (
|
|
347
|
+
"\n".join(identity_lines) if identity_lines else " <no identifying fields>"
|
|
348
|
+
)
|
|
349
|
+
return (
|
|
350
|
+
f'auth.providers SAML entry sets `groupsAttributeName: "{attribute_name}"`\n'
|
|
351
|
+
" but is missing an explicit `configID`.\n"
|
|
352
|
+
"\n"
|
|
353
|
+
" Identifying fields:\n"
|
|
354
|
+
f"{identity_block}\n"
|
|
355
|
+
"\n"
|
|
356
|
+
" Fix: in site config, add a `configID` to that auth.providers\n"
|
|
357
|
+
' entry, e.g. `"configID": "okta-prod"`. Pick any short string\n'
|
|
358
|
+
" that uniquely names this SAML provider.\n"
|
|
359
|
+
"\n"
|
|
360
|
+
" Why: this script needs a stable name to refer to your SAML\n"
|
|
361
|
+
" provider. If you don't set `configID`, Sourcegraph generates\n"
|
|
362
|
+
" one for you, but that auto-generated value silently changes\n"
|
|
363
|
+
" whenever you edit any field on the provider — which would\n"
|
|
364
|
+
" break this script the next time you re-run it after a\n"
|
|
365
|
+
" site-config edit."
|
|
366
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Typed Sourcegraph GraphQL auth-provider/user helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import src_py_lib as src
|
|
9
|
+
|
|
10
|
+
from . import queries
|
|
11
|
+
from . import types as shared_types
|
|
12
|
+
|
|
13
|
+
# Default page size for every paginated GraphQL connection. Sourcegraph
|
|
14
|
+
# caps `first:` at 5000 across most schemas; 100 is a reasonable middle
|
|
15
|
+
# ground (small enough to keep p50 latency low and to limit memory per
|
|
16
|
+
# response, large enough to bound round-trips on big instances).
|
|
17
|
+
DEFAULT_PAGE_SIZE: int = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_auth_providers(client: src.SourcegraphClient) -> list[shared_types.AuthProvider]:
|
|
21
|
+
data = cast(dict[str, Any], client.graphql(queries.QUERY_AUTH_PROVIDERS))
|
|
22
|
+
return cast(list[shared_types.AuthProvider], data["site"]["authProviders"]["nodes"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def count_users(client: src.SourcegraphClient) -> int:
|
|
26
|
+
"""Return the total number of users on the instance via `users.totalCount`.
|
|
27
|
+
|
|
28
|
+
Cheap single-page query used to inform the user up-front how many users
|
|
29
|
+
the subsequent `list_users_with_accounts()` pagination will iterate over.
|
|
30
|
+
"""
|
|
31
|
+
data = cast(dict[str, Any], client.graphql(queries.QUERY_USER_COUNT))
|
|
32
|
+
return cast(int, data["users"]["totalCount"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_users_with_accounts(client: src.SourcegraphClient) -> list[shared_types.User]:
|
|
36
|
+
return [
|
|
37
|
+
cast(shared_types.User, node)
|
|
38
|
+
for node in client.stream_connection_nodes(
|
|
39
|
+
queries.QUERY_USERS,
|
|
40
|
+
connection_path=("users",),
|
|
41
|
+
page_size=DEFAULT_PAGE_SIZE,
|
|
42
|
+
)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_users_streaming(
|
|
47
|
+
client: src.SourcegraphClient,
|
|
48
|
+
collect_into: list[shared_types.User] | None = None,
|
|
49
|
+
) -> Iterator[shared_types.User]:
|
|
50
|
+
"""Stream ListUsers pages one at a time, yielding each User as it arrives.
|
|
51
|
+
|
|
52
|
+
The caller can dispatch per-user work from inside the iteration loop;
|
|
53
|
+
while the iterator blocks on the next ListUsers page, workers continue
|
|
54
|
+
processing already-submitted tasks. Net effect is that a long ListUsers
|
|
55
|
+
pagination overlaps with whatever per-user work the consumer has queued.
|
|
56
|
+
|
|
57
|
+
If `collect_into` is provided, every yielded user is appended to that
|
|
58
|
+
list, so the caller ends up with the materialized list AND the
|
|
59
|
+
streaming benefit in one pass — no double-pagination.
|
|
60
|
+
"""
|
|
61
|
+
for node in client.stream_connection_nodes(
|
|
62
|
+
queries.QUERY_USERS,
|
|
63
|
+
connection_path=("users",),
|
|
64
|
+
page_size=DEFAULT_PAGE_SIZE,
|
|
65
|
+
):
|
|
66
|
+
user = cast(shared_types.User, node)
|
|
67
|
+
if collect_into is not None:
|
|
68
|
+
collect_into.append(user)
|
|
69
|
+
yield user
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared Sourcegraph GraphQL response shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, NotRequired, TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthProvider(TypedDict):
|
|
10
|
+
serviceType: str
|
|
11
|
+
serviceID: str
|
|
12
|
+
clientID: str
|
|
13
|
+
displayName: str
|
|
14
|
+
isBuiltin: bool
|
|
15
|
+
configID: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExternalAccount(TypedDict):
|
|
19
|
+
serviceType: str
|
|
20
|
+
serviceID: str
|
|
21
|
+
clientID: str
|
|
22
|
+
# Provider-specific JSON; for SAML this is the gosaml2 AssertionInfo
|
|
23
|
+
# (Assertions[].AttributeStatement.Attributes[].{Name,Values[].Value}).
|
|
24
|
+
# See `src/src_auth_perms_sync/shared/saml_groups.py` for the parser. Site-admin only;
|
|
25
|
+
# null for accounts where the server does not expose it.
|
|
26
|
+
accountData: NotRequired[dict[str, Any] | None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExternalAccountConnection(TypedDict):
|
|
30
|
+
nodes: list[ExternalAccount]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class User(TypedDict):
|
|
34
|
+
id: str
|
|
35
|
+
username: str
|
|
36
|
+
builtinAuth: bool
|
|
37
|
+
externalAccounts: ExternalAccountConnection
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class UserIdentity:
|
|
42
|
+
user_id: str
|
|
43
|
+
username: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class MutationCounts:
|
|
48
|
+
succeeded: int = 0
|
|
49
|
+
failed: int = 0
|
|
50
|
+
canceled: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class SamlGroupMembership:
|
|
55
|
+
provider_config_id: str
|
|
56
|
+
group_name: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class SamlGroupUser(UserIdentity):
|
|
61
|
+
saml_group_memberships: tuple[SamlGroupMembership, ...]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SiteUserCandidate(TypedDict):
|
|
65
|
+
id: str
|
|
66
|
+
username: str
|
|
67
|
+
email: str | None
|
|
68
|
+
createdAt: str
|
|
69
|
+
deletedAt: str | None
|