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