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,291 @@
1
+ """Maps YAML: load mapping rules and dump read-only discovery references."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ import json5
9
+ import src_py_lib as src
10
+ import yaml
11
+
12
+ from ..shared import id_codec, site_config
13
+ from ..shared import types as shared_types
14
+ from . import types as permission_types
15
+
16
+
17
+ def _strip_redacted(value: Any) -> Any:
18
+ """Recursively drop any dict key whose value is exactly `"REDACTED"`.
19
+
20
+ Sourcegraph's `ExternalService.config` resolver replaces secrets with
21
+ that literal sentinel before returning the JSONC blob (see
22
+ internal/types/secret.go in sourcegraph/sourcegraph). Some redactions
23
+ live in nested arrays — e.g. GitHub `webhooks[].secret`,
24
+ `gitSSHCredential.privateKey` — so the strip is recursive.
25
+
26
+ Lists / scalars pass through unchanged. The redaction sentinel itself,
27
+ if it appears as a top-level scalar (it shouldn't, but defensively),
28
+ is replaced with `None`.
29
+ """
30
+ if isinstance(value, dict):
31
+ return {
32
+ field_name: _strip_redacted(field_value)
33
+ for field_name, field_value in cast(dict[str, Any], value).items()
34
+ if field_value != site_config.REDACTED_SENTINEL
35
+ }
36
+ if isinstance(value, list):
37
+ return [_strip_redacted(item) for item in cast(list[Any], value)]
38
+ return value
39
+
40
+
41
+ def auth_provider_to_yaml(
42
+ provider: shared_types.AuthProvider,
43
+ user_count: int,
44
+ saml_group_user_counts: dict[str, int] | None = None,
45
+ site_config_entry: dict[str, Any] | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Render an auth provider for the YAML config.
48
+
49
+ Keys mirror the Sourcegraph site-config schema (`type`, `clientID`,
50
+ `displayName`, `configID`). `serviceID` has no direct site-config field
51
+ so we use the GraphQL name. `isBuiltin` is dropped (redundant with
52
+ `type == "builtin"`). `userCount` is our addition.
53
+
54
+ `site_config_entry`, when provided, is the matching `auth.providers[*]`
55
+ JSONC entry (already stripped of redacted/secret fields by
56
+ `src/src_auth_perms_sync/shared/site_config.py`). Any
57
+ fields it carries that aren't already emitted from GraphQL are
58
+ surfaced verbatim, so operators see the full provider config in the
59
+ YAML — e.g. `identityProviderMetadataURL`, `serviceProviderIssuer`,
60
+ `requireEmailDomain`, `allowSignup`. Order: GraphQL-derived identity
61
+ keys first, then site-config extras, then observation-derived metadata.
62
+
63
+ For SAML providers, `saml_group_user_counts` (group name → distinct
64
+ user count) is ALWAYS surfaced under `samlGroupUserCounts:`, even
65
+ when the mapping is empty. The empty case (`{}`) tells the operator
66
+ the feature is supported but the IdP didn't release any
67
+ `groupsAttributeName` (default `groups`) claim in this provider's
68
+ assertions — typically because the IdP hasn't been configured to do
69
+ so. Operators authoring `authProvider.samlGroup` mapping rules can use this
70
+ field to size groups before writing rules, or to learn that they
71
+ need to fix their IdP first. Pass `None` (the default for non-SAML
72
+ providers) to omit the field entirely.
73
+
74
+ Empty-string fields are omitted — the builtin provider has no
75
+ serviceID / clientID / configID, so those keys would just be noise.
76
+ """
77
+ rendered: dict[str, Any] = {"type": provider["serviceType"]}
78
+ if provider["serviceID"]:
79
+ rendered["serviceID"] = provider["serviceID"]
80
+ if provider["clientID"]:
81
+ rendered["clientID"] = provider["clientID"]
82
+ rendered["displayName"] = provider["displayName"]
83
+ if provider["configID"]:
84
+ rendered["configID"] = provider["configID"]
85
+ if site_config_entry is not None:
86
+ # Merge in every non-secret site-config field that isn't already
87
+ # represented by a GraphQL-derived key above. The GraphQL value
88
+ # wins on overlaps (`type`, `displayName`, `clientID`, `configID`)
89
+ # since it's the resolved view the server actually uses.
90
+ for field_name, value in site_config_entry.items():
91
+ if field_name in rendered:
92
+ continue
93
+ rendered[field_name] = value
94
+ rendered["userCount"] = user_count
95
+ if saml_group_user_counts is not None:
96
+ # Sort by descending count, then group name, so the largest groups
97
+ # surface first when an operator skims the file.
98
+ rendered["samlGroupUserCounts"] = dict(
99
+ sorted(
100
+ saml_group_user_counts.items(),
101
+ key=lambda item: (-item[1], item[0]),
102
+ )
103
+ )
104
+ return rendered
105
+
106
+
107
+ BUILTIN_PROVIDER_KEY: tuple[str, str, str] = ("builtin", "", "")
108
+
109
+
110
+ def count_users_per_provider(
111
+ users: list[shared_types.User],
112
+ ) -> dict[tuple[str, str, str], int]:
113
+ """Distinct user count keyed by (serviceType, serviceID, clientID).
114
+
115
+ A user contributes to:
116
+ - each external-account provider key for which they have an account, AND
117
+ - the synthetic builtin key ("builtin", "", "") when builtinAuth==true
118
+ (they have a password set on the builtin provider).
119
+
120
+ A user can therefore be counted under multiple providers (e.g. SAML +
121
+ builtin) — this matches reality: such a user can sign in either way.
122
+ """
123
+ seen: dict[tuple[str, str, str], set[str]] = {}
124
+ for user in users:
125
+ if user.get("builtinAuth"):
126
+ seen.setdefault(BUILTIN_PROVIDER_KEY, set()).add(user["id"])
127
+ for account in user["externalAccounts"]["nodes"]:
128
+ key = (account["serviceType"], account["serviceID"], account["clientID"])
129
+ seen.setdefault(key, set()).add(user["id"])
130
+ return {provider_key: len(user_ids) for provider_key, user_ids in seen.items()}
131
+
132
+
133
+ def external_service_to_yaml(service: permission_types.ExternalService) -> dict[str, Any]:
134
+ """Render an external service for the YAML config.
135
+
136
+ Keys mirror Sourcegraph GraphQL `ExternalService` field names directly
137
+ (camelCase). Every scalar field exposed by the GraphQL schema is
138
+ surfaced here, including the JSONC `config` blob (parsed and emitted
139
+ as a nested mapping). Sourcegraph's `config` resolver redacts secrets
140
+ by replacing their values with the literal string `"REDACTED"`; we
141
+ strip those keys recursively via `_strip_redacted` so the YAML
142
+ contains no useless redaction placeholders. Nested arrays
143
+ (e.g. `webhooks[]`, `exclude[]`) are walked too.
144
+
145
+ `id` is the decoded integer DB primary key, NOT the opaque base64
146
+ GraphQL Node ID — operators copy this into mapping rules' `repos.
147
+ codeHostConnection.id` field, where the integer form is much
148
+ friendlier than `RXh0ZXJuYWxTZXJ2aWNlOjU=`.
149
+
150
+ Optional / nullable fields are omitted when null/empty so the YAML
151
+ stays readable. Booleans are always emitted (true or false) so the
152
+ discovered state is explicit.
153
+ """
154
+ rendered: dict[str, Any] = {
155
+ "id": id_codec.decode_external_service_id(service["id"]),
156
+ "kind": service["kind"],
157
+ "displayName": service["displayName"],
158
+ "url": service["url"],
159
+ "repoCount": service["repoCount"],
160
+ "createdAt": service["createdAt"],
161
+ "updatedAt": service["updatedAt"],
162
+ "unrestricted": bool(service.get("unrestricted")),
163
+ "suspended": bool(service.get("suspended")),
164
+ "hasConnectionCheck": bool(service.get("hasConnectionCheck")),
165
+ "supportsRepoExclusion": bool(service.get("supportsRepoExclusion")),
166
+ }
167
+ if service.get("lastSyncAt"):
168
+ rendered["lastSyncAt"] = service["lastSyncAt"]
169
+ if service.get("nextSyncAt"):
170
+ rendered["nextSyncAt"] = service["nextSyncAt"]
171
+ if service.get("lastSyncError"):
172
+ rendered["lastSyncError"] = service["lastSyncError"]
173
+ if service.get("warning"):
174
+ rendered["warning"] = service["warning"]
175
+ creator = service.get("creator")
176
+ if creator and creator.get("username"):
177
+ rendered["creator"] = creator["username"]
178
+ last_updater = service.get("lastUpdater")
179
+ if last_updater and last_updater.get("username"):
180
+ rendered["lastUpdater"] = last_updater["username"]
181
+ raw_config = service.get("config")
182
+ if raw_config:
183
+ try:
184
+ parsed_config = cast(dict[str, Any], json5.loads(raw_config))
185
+ except ValueError:
186
+ # Unparsable JSONC: surface the raw string verbatim so the
187
+ # operator can still see what's there. Stripping doesn't
188
+ # apply since we have no structure to walk.
189
+ rendered["config"] = raw_config
190
+ else:
191
+ rendered["config"] = _strip_redacted(parsed_config)
192
+ return rendered
193
+
194
+
195
+ def dump_auth_providers_yaml(path: Path, providers: list[dict[str, Any]]) -> None:
196
+ header = (
197
+ "# Sourcegraph auth provider configs.\n"
198
+ "# Generated/refreshed by: src-auth-perms-sync --get\n"
199
+ "# Use these values when writing maps.yaml rules under `users.authProvider`.\n"
200
+ "# This file is read-only reference data; edit maps.yaml, not this file.\n"
201
+ )
202
+ _dump_readonly_discovery_yaml(path, header, "authProviders", providers)
203
+
204
+
205
+ def dump_code_hosts_yaml(path: Path, code_hosts: list[dict[str, Any]]) -> None:
206
+ header = (
207
+ "# Sourcegraph code host connection configs.\n"
208
+ "# Generated/refreshed by: src-auth-perms-sync --get\n"
209
+ "# Use these values when writing maps.yaml rules under `repos.codeHostConnection`.\n"
210
+ "# Secrets from ExternalService.config are stripped.\n"
211
+ "# This file is read-only reference data; edit maps.yaml, not this file.\n"
212
+ )
213
+ _dump_readonly_discovery_yaml(path, header, "codeHostConnections", code_hosts)
214
+
215
+
216
+ def _dump_readonly_discovery_yaml(
217
+ path: Path,
218
+ header: str,
219
+ section_name: str,
220
+ entries: list[dict[str, Any]],
221
+ ) -> None:
222
+ with src.event(
223
+ "disk_io",
224
+ level="DEBUG",
225
+ op="write",
226
+ path=str(path),
227
+ file_kind="yaml",
228
+ ) as disk_event:
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ with path.open("w") as output_file:
231
+ output_file.write(header)
232
+ output_file.write(f"{section_name}:\n")
233
+ for entry in entries:
234
+ output_file.write("\n")
235
+ output_file.write(
236
+ yaml.safe_dump(
237
+ [entry],
238
+ sort_keys=True,
239
+ default_flow_style=False,
240
+ allow_unicode=True,
241
+ )
242
+ )
243
+ disk_event["bytes"] = path.stat().st_size
244
+
245
+
246
+ def create_maps_yaml_if_missing(path: Path) -> bool:
247
+ """Create the operator-edited maps file once, preserving existing files."""
248
+ content = (
249
+ "# Auth provider → code host connection mapping rules\n"
250
+ "# Maintain this file, using values from auth-providers.yaml "
251
+ "and code-hosts.yaml as references\n"
252
+ "\n"
253
+ "maps:\n"
254
+ "\n"
255
+ "- name: Map 1\n"
256
+ )
257
+ with src.event(
258
+ "disk_io",
259
+ level="DEBUG",
260
+ op="write",
261
+ path=str(path),
262
+ file_kind="yaml",
263
+ ) as disk_event:
264
+ try:
265
+ path.parent.mkdir(parents=True, exist_ok=True)
266
+ with path.open("x") as output_file:
267
+ output_file.write(content)
268
+ except FileExistsError:
269
+ disk_event["skipped"] = True
270
+ disk_event["bytes"] = 0
271
+ return False
272
+ disk_event["bytes"] = path.stat().st_size
273
+ return True
274
+
275
+
276
+ def load_maps_yaml(path: Path) -> permission_types.ConfigFile:
277
+ with src.event(
278
+ "disk_io",
279
+ level="DEBUG",
280
+ op="read",
281
+ path=str(path),
282
+ file_kind="yaml",
283
+ ) as disk_event:
284
+ raw_bytes = path.read_bytes()
285
+ disk_event["bytes"] = len(raw_bytes)
286
+ loaded: Any = yaml.safe_load(raw_bytes)
287
+ if loaded is None:
288
+ return cast(permission_types.ConfigFile, {})
289
+ if not isinstance(loaded, dict):
290
+ raise SystemExit(f"{path}: top-level YAML must be a mapping, got {type(loaded).__name__}")
291
+ return cast(permission_types.ConfigFile, loaded)
@@ -0,0 +1,180 @@
1
+ """GraphQL operations for repo-permission sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ QUERY_EXTERNAL_SERVICES = """
6
+ query ListExternalServices($first: Int!, $after: String) {
7
+ externalServices(first: $first, after: $after) {
8
+ nodes {
9
+ id
10
+ kind
11
+ displayName
12
+ url
13
+ repoCount
14
+ createdAt
15
+ updatedAt
16
+ lastSyncAt
17
+ nextSyncAt
18
+ lastSyncError
19
+ warning
20
+ unrestricted
21
+ suspended
22
+ hasConnectionCheck
23
+ supportsRepoExclusion
24
+ creator { username }
25
+ lastUpdater { username }
26
+ config
27
+ }
28
+ pageInfo { hasNextPage endCursor }
29
+ }
30
+ }
31
+ """
32
+
33
+ QUERY_REPOS_BY_EXTERNAL_SERVICE = """
34
+ query ReposByExternalService($esID: ID!, $first: Int!, $after: String) {
35
+ repositories(
36
+ first: $first
37
+ after: $after
38
+ externalService: $esID
39
+ cloned: true
40
+ notCloned: true
41
+ ) {
42
+ nodes {
43
+ id
44
+ name
45
+ }
46
+ pageInfo { hasNextPage endCursor }
47
+ }
48
+ }
49
+ """
50
+
51
+ MUTATION_SET_REPO_PERMISSIONS = """
52
+ mutation SetRepoPerms($repo: ID!, $userPerms: [UserPermissionInput!]!) {
53
+ setRepositoryPermissionsForUsers(repository: $repo, userPermissions: $userPerms) {
54
+ alwaysNil
55
+ }
56
+ }
57
+ """
58
+
59
+ MUTATION_ADD_REPO_PERMISSION = """
60
+ mutation AddRepoPerm($repo: ID!, $user: ID!) {
61
+ addRepositoryPermissionForUser(
62
+ permission: { repository: $repo }
63
+ userID: $user
64
+ ) {
65
+ alwaysNil
66
+ }
67
+ }
68
+ """
69
+
70
+ MUTATION_REMOVE_REPO_PERMISSION = """
71
+ mutation RemoveRepoPerm($repo: ID!, $user: ID!) {
72
+ removeRepositoryPermissionForUser(repository: $repo, userID: $user) {
73
+ alwaysNil
74
+ }
75
+ }
76
+ """
77
+
78
+ USER_FIELDS = """
79
+ id
80
+ username
81
+ builtinAuth
82
+ externalAccounts(first: 50) {
83
+ nodes {
84
+ serviceType
85
+ serviceID
86
+ clientID
87
+ accountData
88
+ }
89
+ }
90
+ """
91
+
92
+ QUERY_USER_BY_USERNAME = f"""
93
+ query UserByUsername($username: String!) {{
94
+ user(username: $username) {{
95
+ {USER_FIELDS}
96
+ }}
97
+ }}
98
+ """
99
+
100
+ QUERY_USER_BY_EMAIL = f"""
101
+ query UserByEmail($email: String!) {{
102
+ user(email: $email) {{
103
+ {USER_FIELDS}
104
+ }}
105
+ }}
106
+ """
107
+
108
+ QUERY_USER_BY_ID = f"""
109
+ query UserByID($id: ID!) {{
110
+ node(id: $id) {{
111
+ ... on User {{
112
+ {USER_FIELDS}
113
+ }}
114
+ }}
115
+ }}
116
+ """
117
+
118
+ QUERY_SITE_USERS = """
119
+ query SiteUsers($limit: Int!, $offset: Int!, $createdAt: SiteUsersDateRangeInput) {
120
+ site {
121
+ users(createdAt: $createdAt, deletedAt: { empty: true }) {
122
+ totalCount
123
+ nodes(limit: $limit, offset: $offset, orderBy: CREATED_AT) {
124
+ id
125
+ username
126
+ email
127
+ createdAt
128
+ deletedAt
129
+ }
130
+ }
131
+ }
132
+ }
133
+ """
134
+
135
+ # Server-side filtered to PermissionSource.API — explicit grants only, never
136
+ # code-host-synced. We always invert (user→repos) here because
137
+ # Repository.permissionsInfo.users does NOT accept a `source` filter on this
138
+ # SG version, so the repo-centric direction can't cleanly distinguish
139
+ # explicit-API grants from sync/site-admin grants.
140
+ QUERY_USER_EXPLICIT_REPOS = """
141
+ query UserExplicitRepos($id: ID!, $first: Int!, $after: String) {
142
+ node(id: $id) {
143
+ ... on User {
144
+ permissionsInfo {
145
+ repositories(source: API, first: $first, after: $after) {
146
+ nodes {
147
+ id
148
+ }
149
+ pageInfo { hasNextPage endCursor }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+ """
156
+
157
+ QUERY_USER_EXPLICIT_REPO_EXISTS = """
158
+ query UserExplicitRepoExists($id: ID!) {
159
+ node(id: $id) {
160
+ ... on User {
161
+ permissionsInfo {
162
+ repositories(source: API, first: 1) {
163
+ nodes { id }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ """
170
+
171
+ # Used as part of post-apply validation: any of OUR bindIDs appearing in
172
+ # this list means the bindID didn't resolve to a real user (typically a
173
+ # username typo or a recent rename — would fail for our case since we
174
+ # only ever pass usernames the script already enumerated from the users
175
+ # query).
176
+ QUERY_PENDING_BINDIDS = """
177
+ query PendingBindIDs {
178
+ usersWithPendingPermissions
179
+ }
180
+ """