g1t/packages/contracts/src/scopes.ts

284 lines12,734 bytesCodeBlame
1/**
2 * Scopes: what an access token may do on its owner's behalf. Mirrors
3 * `crates/contracts/src/scopes.rs`, which is the source of truth; a Rust
4 * test keeps the tables here the same.
5 *
6 * A token reaches whatever its owner can reach (a workspace's token, that
7 * workspace); what a request may do is the intersection of the owner's
8 * role and the token's scopes.
9 */
10
11export type ScopeResource =
12 | "repo"
13 | "code"
14 | "issues"
15 | "pull_requests"
16 | "agents"
17 | "workflows"
18 | "memory"
19 | "account"
20 | "workspace"
21 | "access"
22 | "webhooks"
23 | "secrets";
24
25export type ScopeLevel = "read" | "write" | "run" | "admin";
26
27/** Every scope, grouped by resource, least first. */
28export const SCOPES = [
29 { scope: "repo:read", description: "See repositories, their settings, labels and timelines, and search" },
30 { scope: "repo:write", description: "Create repositories, rename branches and change how pull requests merge" },
31 { scope: "repo:admin", description: "Rename, archive, transfer, delete or change who can see a repository" },
32 { scope: "code:read", description: "Clone and fetch private repositories with git" },
33 { scope: "code:write", description: "Push commits with git" },
34 { scope: "issues:read", description: "Read issues, comments and plans" },
35 { scope: "issues:write", description: "Open, edit, close and comment on issues" },
36 { scope: "pull_requests:read", description: "Read pull requests, their changes, sessions and merge queues" },
37 { scope: "pull_requests:write", description: "Open, review, close and merge pull requests" },
38 { scope: "agents:run", description: "Put g1t agents to work and message them, which uses the workspace's money" },
39 { scope: "workflows:read", description: "Read workflows, runs and logs" },
40 { scope: "workflows:write", description: "Run, cancel, rerun and turn workflows on or off" },
41 { scope: "memory:read", description: "Recall memory and search the workspace's context" },
42 { scope: "memory:write", description: "Save memory for the next agent" },
43 { scope: "account:read", description: "Read your email addresses, invites and invitations" },
44 { scope: "account:write", description: "Change your email addresses, make invites and answer invitations" },
45 { scope: "workspace:read", description: "Read workspace invites, integrations and model routes" },
46 { scope: "workspace:admin", description: "Create and delete workspaces, invite members, connect integrations" },
47 { scope: "access:read", description: "See who has access to repositories" },
48 { scope: "access:admin", description: "Give and take away access to repositories" },
49 { scope: "webhooks:read", description: "See webhooks and their deliveries" },
50 { scope: "webhooks:admin", description: "Create, change and delete webhooks" },
51 { scope: "secrets:read", description: "List secrets (never their values) and read variables" },
52 { scope: "secrets:admin", description: "Set and delete secrets and variables" },
53] as const;
54
55export type Scope = (typeof SCOPES)[number]["scope"];
56
57/** Resources in the order settings show them, with their names for people. */
58export const SCOPE_RESOURCES: { resource: ScopeResource; label: string }[] = [
59 { resource: "repo", label: "Repositories" },
60 { resource: "code", label: "Code" },
61 { resource: "issues", label: "Issues" },
62 { resource: "pull_requests", label: "Pull requests" },
63 { resource: "agents", label: "g1t agents" },
64 { resource: "workflows", label: "Workflows" },
65 { resource: "memory", label: "Memory and context" },
66 { resource: "account", label: "Your account" },
67 { resource: "workspace", label: "Workspaces" },
68 { resource: "access", label: "Who has access" },
69 { resource: "webhooks", label: "Webhooks" },
70 { resource: "secrets", label: "Secrets and variables" },
71];
72
73const LEVEL_ORDER: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, admin: 3 };
74
75export function scopeResource(scope: Scope): ScopeResource {
76 return scope.split(":")[0] as ScopeResource;
77}
78
79export function scopeLevel(scope: Scope): ScopeLevel {
80 return scope.split(":")[1] as ScopeLevel;
81}
82
83export function isScope(text: string): text is Scope {
84 return SCOPES.some((row) => row.scope === text);
85}
86
87/** Changes that are hard to undo, or decide who can reach what. */
88export function isDangerous(scope: Scope): boolean {
89 return scopeLevel(scope) === "admin";
90}
91
92export function describeScope(scope: Scope): string {
93 return SCOPES.find((row) => row.scope === scope)?.description ?? scope;
94}
95
96/** Whether holding `held` gives `needed`: the same resource, at its level or lower. */
97export function scopeIncludes(held: Scope, needed: Scope): boolean {
98 return (
99 scopeResource(held) === scopeResource(needed) &&
100 LEVEL_ORDER[scopeLevel(held)] >= LEVEL_ORDER[scopeLevel(needed)]
101 );
102}
103
104/** The levels a resource has, least first. */
105export function levelsOf(resource: ScopeResource): ScopeLevel[] {
106 return SCOPES.filter((row) => scopeResource(row.scope) === resource).map((row) => scopeLevel(row.scope));
107}
108
109/** Scopes from text separated by spaces or commas, in table order; unknown ones are left out. */
110export function parseScopes(text: string): Scope[] {
111 const given = new Set(text.split(/[\s,]+/).map((part) => part.trim().toLowerCase()));
112 return SCOPES.map((row) => row.scope).filter((scope) => given.has(scope));
113}
114
115/** What a token stores for full access. */
116export const FULL_ACCESS = "*";
117
118export type PresetId = "read_only" | "agent" | "ci" | "full";
119
120/** Starting points for choosing scopes. `*` is full access. */
121export const PRESET_SCOPES = {
122 read_only: [
123 "repo:read", "code:read", "issues:read", "pull_requests:read", "workflows:read", "memory:read", "account:read", "workspace:read", "access:read", "webhooks:read", "secrets:read",
124 ] as const,
125 agent: [
126 "repo:read", "code:read", "code:write", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "memory:read", "memory:write", "account:read", "workspace:read", "access:read", "webhooks:read", "secrets:read",
127 ] as const,
128 ci: [
129 "repo:read", "code:read", "code:write", "workflows:read", "workflows:write",
130 ] as const,
131 full: [
132 "*",
133 ] as const,
134};
135
136export const PRESETS: { id: PresetId; label: string; description: string }[] = [
137 { id: "read_only", label: "Read only", description: "Read everything you can read; change nothing." },
138 { id: "agent", label: "Agent", description: "Read everything, work on issues and pull requests, push code and run g1t agents." },
139 { id: "ci", label: "CI", description: "Clone and push code, and run workflows." },
140 { id: "full", label: "Full access", description: "Everything you can do, including deleting repositories and changing who has access." },
141];
142
143/** The scopes of a preset, or null for full access. */
144export function presetScopes(id: PresetId): Scope[] | null {
145 if (id === "full") return null;
146 return [...PRESET_SCOPES[id]] as Scope[];
147}
148
149/** What an OAuth client gets when it asks for nothing in particular. */
150export const OAUTH_DEFAULT_SCOPES: Scope[] = [...PRESET_SCOPES.agent];
151
152/** The operation each scope gates, by the API's operation names. */
153export const OPERATION_SCOPES = [
154 ["list_emails", "account:read"],
155 ["add_email", "account:write"],
156 ["remove_email", "account:write"],
157 ["update_email_settings", "account:write"],
158 ["list_invites", "account:read"],
159 ["create_invite", "account:write"],
160 ["revoke_invite", "account:write"],
161 ["list_my_repo_invitations", "account:read"],
162 ["accept_repo_invitation", "account:write"],
163 ["decline_repo_invitation", "account:write"],
164 ["create_workspace", "workspace:admin"],
165 ["delete_workspace", "workspace:admin"],
166 ["list_workspace_invites", "workspace:read"],
167 ["invite_member", "workspace:admin"],
168 ["revoke_workspace_invite", "workspace:admin"],
169 ["list_integrations", "workspace:read"],
170 ["connect_integration", "workspace:admin"],
171 ["disconnect_integration", "workspace:admin"],
172 ["test_integration", "workspace:admin"],
173 ["get_model_routes", "workspace:read"],
174 ["set_model_routes", "workspace:admin"],
175 ["list_repos", "repo:read"],
176 ["get_repo", "repo:read"],
177 ["search", "repo:read"],
178 ["list_events", "repo:read"],
179 ["list_labels", "repo:read"],
180 ["get_repo_settings", "repo:read"],
181 ["list_deleted_repos", "repo:read"],
182 ["create_repo", "repo:write"],
183 ["update_repo", "repo:write"],
184 ["update_repo_settings", "repo:write"],
185 ["rename_branch", "repo:write"],
186 ["rename_repo", "repo:admin"],
187 ["transfer_repo", "repo:admin"],
188 ["archive_repo", "repo:admin"],
189 ["unarchive_repo", "repo:admin"],
190 ["set_repo_visibility", "repo:admin"],
191 ["delete_repo", "repo:admin"],
192 ["restore_repo", "repo:admin"],
193 ["purge_repo", "repo:admin"],
194 ["list_issues", "issues:read"],
195 ["get_issue", "issues:read"],
196 ["get_plan", "issues:read"],
197 ["create_issue", "issues:write"],
198 ["update_issue", "issues:write"],
199 ["close_issue", "issues:write"],
200 ["reopen_issue", "issues:write"],
201 ["add_comment", "issues:write"],
202 ["import_issue", "issues:write"],
203 ["apply_plan", "issues:write"],
204 ["list_pull_requests", "pull_requests:read"],
205 ["get_pull_request", "pull_requests:read"],
206 ["get_pull_request_changes", "pull_requests:read"],
207 ["read_session", "pull_requests:read"],
208 ["get_merge_queue", "pull_requests:read"],
209 ["create_pull_request", "pull_requests:write"],
210 ["record_session", "pull_requests:write"],
211 ["mark_pull_request_ready", "pull_requests:write"],
212 ["close_pull_request", "pull_requests:write"],
213 ["review_pull_request", "pull_requests:write"],
214 ["merge_pull_request", "pull_requests:write"],
215 ["assign_issue", "agents:run"],
216 ["delegate", "agents:run"],
217 ["plan_work", "agents:run"],
218 ["message_agent", "agents:run"],
219 ["answer_message", "agents:run"],
220 ["take_messages", "agents:run"],
221 ["list_workflows", "workflows:read"],
222 ["list_workflow_runs", "workflows:read"],
223 ["get_workflow_run", "workflows:read"],
224 ["get_job_logs", "workflows:read"],
225 ["dispatch_workflow", "workflows:write"],
226 ["cancel_workflow_run", "workflows:write"],
227 ["rerun_workflow_run", "workflows:write"],
228 ["update_workflow", "workflows:write"],
229 ["recall", "memory:read"],
230 ["search_context", "memory:read"],
231 ["get_entity", "memory:read"],
232 ["get_context", "memory:read"],
233 ["remember", "memory:write"],
234 ["list_collaborators", "access:read"],
235 ["get_collaborator_permission", "access:read"],
236 ["list_repo_invitations", "access:read"],
237 ["list_outside_collaborators", "access:read"],
238 ["add_collaborator", "access:admin"],
239 ["update_collaborator", "access:admin"],
240 ["remove_collaborator", "access:admin"],
241 ["revoke_repo_invitation", "access:admin"],
242 ["set_base_permission", "access:admin"],
243 ["list_webhooks", "webhooks:read"],
244 ["list_webhook_deliveries", "webhooks:read"],
245 ["create_webhook", "webhooks:admin"],
246 ["update_webhook", "webhooks:admin"],
247 ["delete_webhook", "webhooks:admin"],
248 ["ping_webhook", "webhooks:admin"],
249 ["redeliver_webhook", "webhooks:admin"],
250 ["list_actions_secrets", "secrets:read"],
251 ["list_actions_variables", "secrets:read"],
252 ["set_actions_secret", "secrets:admin"],
253 ["delete_actions_secret", "secrets:admin"],
254 ["set_actions_variable", "secrets:admin"],
255 ["delete_actions_variable", "secrets:admin"],
256] as const;
257
258/**
259 * The scopes a token or grant holds, as stored: null for full access, or
260 * the list. `legacy` marks a token made before scopes, which has full
261 * access until someone narrows it.
262 */
263export type TokenScopes = {
264 scopes: Scope[] | null;
265 legacy: boolean;
266};
267
268/**
269 * How settings group scopes into a checklist: each group's scopes, least
270 * first. Admin scopes are not here; they are under "Dangerous" on their
271 * own (see `DANGEROUS_SCOPES`). Every other scope is in exactly one group.
272 */
273export const SCOPE_GROUPS: { id: string; label: string; scopes: Scope[] }[] = [
274 { id: "code", label: "Repositories & code", scopes: ["repo:read", "repo:write", "code:read", "code:write"] },
275 { id: "work", label: "Issues & pull requests", scopes: ["issues:read", "issues:write", "pull_requests:read", "pull_requests:write"] },
276 { id: "agents", label: "Agents", scopes: ["agents:run"] },
277 { id: "workflows", label: "Workflows", scopes: ["workflows:read", "workflows:write"] },
278 { id: "memory", label: "Memory & search", scopes: ["memory:read", "memory:write"] },
279 { id: "account", label: "Account", scopes: ["account:read", "account:write"] },
280 { id: "workspace", label: "Workspace", scopes: ["workspace:read", "access:read", "webhooks:read", "secrets:read"] },
281];
282
283/** The admin scopes, shown under "Dangerous" behind a warning. */
284export const DANGEROUS_SCOPES: Scope[] = SCOPES.map((row) => row.scope).filter(isDangerous);