src-auth-perms-sync 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. src_auth_perms_sync/__init__.py +1 -0
  2. src_auth_perms_sync/__main__.py +6 -0
  3. src_auth_perms_sync/cli.py +646 -0
  4. src_auth_perms_sync/orgs/__init__.py +1 -0
  5. src_auth_perms_sync/orgs/command.py +7 -0
  6. src_auth_perms_sync/orgs/queries.py +44 -0
  7. src_auth_perms_sync/orgs/sync.py +1167 -0
  8. src_auth_perms_sync/orgs/types.py +103 -0
  9. src_auth_perms_sync/permissions/__init__.py +1 -0
  10. src_auth_perms_sync/permissions/apply.py +420 -0
  11. src_auth_perms_sync/permissions/command.py +918 -0
  12. src_auth_perms_sync/permissions/full_set.py +880 -0
  13. src_auth_perms_sync/permissions/mapping.py +627 -0
  14. src_auth_perms_sync/permissions/maps.py +291 -0
  15. src_auth_perms_sync/permissions/queries.py +180 -0
  16. src_auth_perms_sync/permissions/restore.py +913 -0
  17. src_auth_perms_sync/permissions/snapshot.py +1502 -0
  18. src_auth_perms_sync/permissions/sourcegraph.py +392 -0
  19. src_auth_perms_sync/permissions/types.py +116 -0
  20. src_auth_perms_sync/permissions/workflow.py +526 -0
  21. src_auth_perms_sync/shared/__init__.py +1 -0
  22. src_auth_perms_sync/shared/backups.py +119 -0
  23. src_auth_perms_sync/shared/id_codec.py +67 -0
  24. src_auth_perms_sync/shared/queries.py +65 -0
  25. src_auth_perms_sync/shared/run_context.py +34 -0
  26. src_auth_perms_sync/shared/saml_groups.py +267 -0
  27. src_auth_perms_sync/shared/site_config.py +366 -0
  28. src_auth_perms_sync/shared/sourcegraph.py +69 -0
  29. src_auth_perms_sync/shared/types.py +69 -0
  30. src_auth_perms_sync-0.2.1.dist-info/METADATA +256 -0
  31. src_auth_perms_sync-0.2.1.dist-info/RECORD +34 -0
  32. src_auth_perms_sync-0.2.1.dist-info/WHEEL +4 -0
  33. src_auth_perms_sync-0.2.1.dist-info/entry_points.txt +2 -0
  34. src_auth_perms_sync-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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