Skip to content
1,067 linesCodeBlameRaw
1//! Scopes: what an access token may do on its owner's behalf.
2//!
3//! A personal access token, a workspace's token and an application signed
4//! in with OAuth each carry a set of scopes. A token reaches whatever the
5//! one it acts as can reach: a person's token, that person's workspaces and
6//! repositories; a workspace's token, that workspace. What a request may do
7//! is the intersection of two things: the role of whoever the token acts as
8//! (see [`crate::access`]) and the token's scopes.
9//!
10//! Each scope is a resource and a level, written `resource:level`, such as
11//! `issues:write`. A higher level of a resource includes the lower ones:
12//! `repo:admin` includes `repo:write`, which includes `repo:read`.
13//!
14//! This module is the one source of truth: the API (REST and MCP) and git
15//! enforce it, and identity stores it. `packages/contracts/src/scopes.ts`
16//! mirrors the table for the site; a test keeps the two the same.
17
18use serde::{Deserialize, Serialize};
19
20use crate::credentials::Decision;
21
22/// Something a token can be given access to.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24pub enum Resource {
25 Account,
26 Notifications,
27 Workspace,
28 Billing,
29 Repo,
30 Code,
31 Security,
32 Packages,
33 Issues,
34 PullRequests,
35 Agents,
36 Workflows,
37 Memory,
38 Access,
39 Webhooks,
40 Secrets,
41 Runners,
42}
43
44impl Resource {
45 pub const ALL: [Resource; 17] = [
46 Resource::Repo,
47 Resource::Code,
48 Resource::Security,
49 Resource::Packages,
50 Resource::Issues,
51 Resource::PullRequests,
52 Resource::Agents,
53 Resource::Workflows,
54 Resource::Memory,
55 Resource::Account,
56 Resource::Notifications,
57 Resource::Workspace,
58 Resource::Billing,
59 Resource::Access,
60 Resource::Webhooks,
61 Resource::Secrets,
62 Resource::Runners,
63 ];
64
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Resource::Account => "account",
68 Resource::Notifications => "notifications",
69 Resource::Workspace => "workspace",
70 Resource::Billing => "billing",
71 Resource::Repo => "repo",
72 Resource::Code => "code",
73 Resource::Security => "security",
74 Resource::Packages => "packages",
75 Resource::Issues => "issues",
76 Resource::PullRequests => "pull_requests",
77 Resource::Agents => "agents",
78 Resource::Workflows => "workflows",
79 Resource::Memory => "memory",
80 Resource::Access => "access",
81 Resource::Webhooks => "webhooks",
82 Resource::Secrets => "secrets",
83 Resource::Runners => "runners",
84 }
85 }
86
87 /// Its name, for people.
88 pub fn label(self) -> &'static str {
89 match self {
90 Resource::Account => "Your account",
91 Resource::Notifications => "Notifications",
92 Resource::Workspace => "Workspaces",
93 Resource::Billing => "Billing",
94 Resource::Repo => "Repositories",
95 Resource::Code => "Code",
96 Resource::Security => "Security",
97 Resource::Packages => "Packages",
98 Resource::Issues => "Issues",
99 Resource::PullRequests => "Pull requests",
100 Resource::Agents => "g1t agents",
101 Resource::Workflows => "Workflows",
102 Resource::Memory => "Memory and context",
103 Resource::Access => "Who has access",
104 Resource::Webhooks => "Webhooks",
105 Resource::Secrets => "Secrets and variables",
106 Resource::Runners => "Self-hosted runners",
107 }
108 }
109}
110
111/// How much of a resource.
112#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
113pub enum Level {
114 Read,
115 Write,
116 /// Starting g1t's agents, which spends the workspace's money.
117 Run,
118 /// Deleting what cannot be brought back, such as a package's versions.
119 Delete,
120 Admin,
121}
122
123impl Level {
124 pub fn as_str(self) -> &'static str {
125 match self {
126 Level::Read => "read",
127 Level::Write => "write",
128 Level::Run => "run",
129 Level::Delete => "delete",
130 Level::Admin => "admin",
131 }
132 }
133}
134
135/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
136/// clients ask for, and errors name.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
138pub enum Scope {
139 RepoRead,
140 RepoWrite,
141 RepoAdmin,
142 CodeRead,
143 CodeWrite,
144 SecurityRead,
145 SecurityWrite,
146 PackagesRead,
147 PackagesWrite,
148 PackagesDelete,
149 IssuesRead,
150 IssuesWrite,
151 PullRequestsRead,
152 PullRequestsWrite,
153 AgentsRun,
154 WorkflowsRead,
155 WorkflowsWrite,
156 MemoryRead,
157 MemoryWrite,
158 AccountRead,
159 AccountWrite,
160 NotificationsRead,
161 NotificationsWrite,
162 WorkspaceRead,
163 WorkspaceAdmin,
164 BillingRead,
165 BillingWrite,
166 AccessRead,
167 AccessAdmin,
168 WebhooksRead,
169 WebhooksAdmin,
170 SecretsRead,
171 SecretsAdmin,
172 RunnersRead,
173 RunnersAdmin,
174}
175
176impl Scope {
177 /// Every scope, grouped by resource, least first.
178 pub const ALL: [Scope; 35] = [
179 Scope::RepoRead,
180 Scope::RepoWrite,
181 Scope::RepoAdmin,
182 Scope::CodeRead,
183 Scope::CodeWrite,
184 Scope::SecurityRead,
185 Scope::SecurityWrite,
186 Scope::PackagesRead,
187 Scope::PackagesWrite,
188 Scope::PackagesDelete,
189 Scope::IssuesRead,
190 Scope::IssuesWrite,
191 Scope::PullRequestsRead,
192 Scope::PullRequestsWrite,
193 Scope::AgentsRun,
194 Scope::WorkflowsRead,
195 Scope::WorkflowsWrite,
196 Scope::MemoryRead,
197 Scope::MemoryWrite,
198 Scope::AccountRead,
199 Scope::AccountWrite,
200 Scope::NotificationsRead,
201 Scope::NotificationsWrite,
202 Scope::WorkspaceRead,
203 Scope::WorkspaceAdmin,
204 Scope::BillingRead,
205 Scope::BillingWrite,
206 Scope::AccessRead,
207 Scope::AccessAdmin,
208 Scope::WebhooksRead,
209 Scope::WebhooksAdmin,
210 Scope::SecretsRead,
211 Scope::SecretsAdmin,
212 Scope::RunnersRead,
213 Scope::RunnersAdmin,
214 ];
215
216 pub fn as_str(self) -> &'static str {
217 match self {
218 Scope::RepoRead => "repo:read",
219 Scope::RepoWrite => "repo:write",
220 Scope::RepoAdmin => "repo:admin",
221 Scope::CodeRead => "code:read",
222 Scope::CodeWrite => "code:write",
223 Scope::SecurityRead => "security:read",
224 Scope::SecurityWrite => "security:write",
225 Scope::PackagesRead => "packages:read",
226 Scope::PackagesWrite => "packages:write",
227 Scope::PackagesDelete => "packages:delete",
228 Scope::IssuesRead => "issues:read",
229 Scope::IssuesWrite => "issues:write",
230 Scope::PullRequestsRead => "pull_requests:read",
231 Scope::PullRequestsWrite => "pull_requests:write",
232 Scope::AgentsRun => "agents:run",
233 Scope::WorkflowsRead => "workflows:read",
234 Scope::WorkflowsWrite => "workflows:write",
235 Scope::MemoryRead => "memory:read",
236 Scope::MemoryWrite => "memory:write",
237 Scope::AccountRead => "account:read",
238 Scope::AccountWrite => "account:write",
239 Scope::NotificationsRead => "notifications:read",
240 Scope::NotificationsWrite => "notifications:write",
241 Scope::WorkspaceRead => "workspace:read",
242 Scope::WorkspaceAdmin => "workspace:admin",
243 Scope::BillingRead => "billing:read",
244 Scope::BillingWrite => "billing:write",
245 Scope::AccessRead => "access:read",
246 Scope::AccessAdmin => "access:admin",
247 Scope::WebhooksRead => "webhooks:read",
248 Scope::WebhooksAdmin => "webhooks:admin",
249 Scope::SecretsRead => "secrets:read",
250 Scope::SecretsAdmin => "secrets:admin",
251 Scope::RunnersRead => "runners:read",
252 Scope::RunnersAdmin => "runners:admin",
253 }
254 }
255
256 pub fn parse(text: &str) -> Option<Scope> {
257 let text = text.trim().to_ascii_lowercase();
258 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
259 }
260
261 pub fn resource(self) -> Resource {
262 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
263 Resource::ALL
264 .into_iter()
265 .find(|resource| resource.as_str() == name)
266 .unwrap_or(Resource::Account)
267 }
268
269 pub fn level(self) -> Level {
270 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
271 "write" => Level::Write,
272 "run" => Level::Run,
273 "delete" => Level::Delete,
274 "admin" => Level::Admin,
275 _ => Level::Read,
276 }
277 }
278
279 /// Whether holding `self` gives `other`: the same resource, at the same
280 /// level or a lower one.
281 pub fn includes(self, other: Scope) -> bool {
282 self.resource() == other.resource() && self.level() >= other.level()
283 }
284
285 /// Changes that are hard or impossible to undo, or that decide who can
286 /// reach what. Shown behind a warning wherever scopes are chosen.
287 pub fn dangerous(self) -> bool {
288 matches!(self.level(), Level::Admin | Level::Delete)
289 }
290
291 /// What it lets a token do, in plain words.
292 pub fn describe(self) -> &'static str {
293 match self {
294 Scope::RepoRead => "See repositories, their settings, labels, timelines and security alerts, and search",
295 Scope::RepoWrite => "Create repositories, rename branches and change how pull requests merge",
296 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts",
297 Scope::CodeRead => "Clone and fetch private repositories with git",
298 Scope::CodeWrite => "Push commits with git",
299 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
300 Scope::SecurityWrite => "Dismiss and reopen alerts, bypass push protection, review bypass requests, manage custom patterns, upload SARIF and change security settings",
301 Scope::PackagesRead => "Pull container images and install private packages",
302 Scope::PackagesWrite => "Push container images and publish packages",
303 Scope::PackagesDelete => "Delete packages and their versions",
304 Scope::IssuesRead => "Read issues, comments and plans",
305 Scope::IssuesWrite => "Open, edit, close and comment on issues",
306 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
307 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
308 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
309 Scope::WorkflowsRead => "Read workflows, runs and logs",
310 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
311 Scope::MemoryRead => "Recall memory and search the workspace's context",
312 Scope::MemoryWrite => "Save memory for the next agent",
313 Scope::AccountRead => "Read your email addresses, invites, invitations and pinned projects",
314 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations and pin projects",
315 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
316 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
317 Scope::WorkspaceRead => "Read workspace invites, integrations, model routes, teams and rulesets",
318 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets",
319 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
320 Scope::BillingWrite => "Change a workspace's budget and buy AI credit",
321 Scope::AccessRead => "See who has access to repositories",
322 Scope::AccessAdmin => "Give and take away access to repositories, a team's included",
323 Scope::WebhooksRead => "See webhooks and their deliveries",
324 Scope::WebhooksAdmin => "Create, change and delete webhooks",
325 Scope::SecretsRead => "List secrets (never their values) and read variables",
326 Scope::SecretsAdmin => "Set and delete secrets and variables",
327 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
328 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
329 }
330 }
331}
332
333impl Serialize for Scope {
334 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
335 serializer.serialize_str(self.as_str())
336 }
337}
338
339impl<'de> Deserialize<'de> for Scope {
340 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
341 let text = String::deserialize(deserializer)?;
342 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
343 }
344}
345
346/// Scopes as written in a token's row or an OAuth request: separated by
347/// spaces or commas. Unknown names are left out, so a client asking for a
348/// scope from a newer version gets the rest.
349pub fn parse_scopes(text: &str) -> Vec<Scope> {
350 let mut scopes: Vec<Scope> = text
351 .split(|c: char| c.is_whitespace() || c == ',')
352 .filter_map(Scope::parse)
353 .collect();
354 normalize(&mut scopes);
355 scopes
356}
357
358/// In table order, without repeats.
359pub fn normalize(scopes: &mut Vec<Scope>) {
360 let given = std::mem::take(scopes);
361 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
362}
363
364/// Space-separated, as stored and as OAuth writes them.
365pub fn scopes_text(scopes: &[Scope]) -> String {
366 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
367}
368
369/// What a token stores for full access, which is not a scope a client can
370/// ask for by name.
371pub const FULL_ACCESS: &str = "*";
372
373/// Starting points for choosing scopes.
374#[derive(Clone, Copy, Debug, PartialEq, Eq)]
375pub enum Preset {
376 ReadOnly,
377 Agent,
378 Ci,
379 Full,
380}
381
382impl Preset {
383 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
384
385 pub fn as_str(self) -> &'static str {
386 match self {
387 Preset::ReadOnly => "read_only",
388 Preset::Agent => "agent",
389 Preset::Ci => "ci",
390 Preset::Full => "full",
391 }
392 }
393
394 pub fn label(self) -> &'static str {
395 match self {
396 Preset::ReadOnly => "Read only",
397 Preset::Agent => "Agent",
398 Preset::Ci => "CI",
399 Preset::Full => "Full access",
400 }
401 }
402
403 /// Its scopes; `None` for full access.
404 pub fn scopes(self) -> Option<Vec<Scope>> {
405 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
406 match self {
407 Preset::ReadOnly => Some(reads().collect()),
408 Preset::Agent => {
409 // Not the machines work runs on: an agent has no business
410 // knowing a workspace's own runners.
411 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
412 // And answering what needs the person it works for: marking
413 // it done, subscribing, watching.
414 scopes.extend([
415 Scope::CodeWrite,
416 Scope::IssuesWrite,
417 Scope::PullRequestsWrite,
418 Scope::AgentsRun,
419 Scope::MemoryWrite,
420 Scope::NotificationsWrite,
421 ]);
422 normalize(&mut scopes);
423 Some(scopes)
424 }
425 Preset::Ci => Some(vec![
426 Scope::RepoRead,
427 Scope::CodeRead,
428 Scope::CodeWrite,
429 Scope::PackagesRead,
430 Scope::PackagesWrite,
431 Scope::WorkflowsRead,
432 Scope::WorkflowsWrite,
433 ]),
434 Preset::Full => None,
435 }
436 }
437}
438
439/// What an OAuth client gets when it asks for nothing in particular: the
440/// agent preset. Never an admin scope.
441pub fn oauth_default() -> Vec<Scope> {
442 Preset::Agent.scopes().unwrap_or_default()
443}
444
445/// Set on a [`crate::User`] resolved from an access token: what the token
446/// may do. Absent on a signed-in session, which may do whatever its person
447/// can.
448#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
449pub struct TokenAccess {
450 /// The token's id, as audit entries and errors name it.
451 #[serde(default)]
452 pub token_id: String,
453 /// Its scopes, as `resource:level`. Absent: full access, everything the
454 /// person (or workspace) can do.
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub scopes: Option<Vec<String>>,
457 /// Made before tokens had scopes: full access until someone narrows it.
458 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
459 pub legacy: bool,
460}
461
462impl TokenAccess {
463 /// Full access to everything: the access tokens made before scopes had.
464 pub fn full() -> Self {
465 TokenAccess::default()
466 }
467
468 pub fn is_full(&self) -> bool {
469 self.scopes.is_none()
470 }
471
472 /// The scopes it holds, or `None` for full access.
473 pub fn granted(&self) -> Option<Vec<Scope>> {
474 self.scopes
475 .as_ref()
476 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
477 }
478
479 pub fn allows(&self, needed: Scope) -> bool {
480 match self.granted() {
481 None => true,
482 Some(granted) => granted.iter().any(|held| held.includes(needed)),
483 }
484 }
485}
486
487/// Every operation of the API and MCP server, with the scope it needs. An
488/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
489/// its operations is in exactly one of the two.
490pub const OPERATIONS: &[(&str, Scope)] = &[
491 // Your account.
492 ("list_emails", Scope::AccountRead),
493 ("add_email", Scope::AccountWrite),
494 ("remove_email", Scope::AccountWrite),
495 ("update_email_settings", Scope::AccountWrite),
496 ("list_invites", Scope::AccountRead),
497 ("create_invite", Scope::AccountWrite),
498 ("revoke_invite", Scope::AccountWrite),
499 ("list_my_repo_invitations", Scope::AccountRead),
500 ("accept_repo_invitation", Scope::AccountWrite),
501 ("decline_repo_invitation", Scope::AccountWrite),
502 // Your pinned projects: a preference of your account.
503 ("list_pinned_projects", Scope::AccountRead),
504 ("pin_project", Scope::AccountWrite),
505 ("unpin_project", Scope::AccountWrite),
506 ("reorder_pinned_projects", Scope::AccountWrite),
507 // Your inbox: notifications, subscriptions and watching.
508 ("list_notifications", Scope::NotificationsRead),
509 ("get_notification_thread", Scope::NotificationsRead),
510 ("get_thread_subscription", Scope::NotificationsRead),
511 ("get_repo_subscription", Scope::NotificationsRead),
512 ("list_watched_repos", Scope::NotificationsRead),
513 ("mark_notifications_read", Scope::NotificationsWrite),
514 ("mark_thread_read", Scope::NotificationsWrite),
515 ("mark_thread_done", Scope::NotificationsWrite),
516 ("save_thread", Scope::NotificationsWrite),
517 ("snooze_thread", Scope::NotificationsWrite),
518 ("set_thread_subscription", Scope::NotificationsWrite),
519 ("delete_thread_subscription", Scope::NotificationsWrite),
520 ("set_repo_subscription", Scope::NotificationsWrite),
521 ("delete_repo_subscription", Scope::NotificationsWrite),
522 // Workspaces, their invites and integrations.
523 ("create_workspace", Scope::WorkspaceAdmin),
524 ("delete_workspace", Scope::WorkspaceAdmin),
525 ("update_workspace", Scope::WorkspaceAdmin),
526 ("list_workspace_invites", Scope::WorkspaceRead),
527 ("invite_member", Scope::WorkspaceAdmin),
528 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
529 ("list_integrations", Scope::WorkspaceRead),
530 ("connect_integration", Scope::WorkspaceAdmin),
531 ("disconnect_integration", Scope::WorkspaceAdmin),
532 ("test_integration", Scope::WorkspaceAdmin),
533 ("get_model_routes", Scope::WorkspaceRead),
534 ("set_model_routes", Scope::WorkspaceAdmin),
535 // Teams: reading them, and managing them. A team's role on a
536 // repository is who has access.
537 ("list_teams", Scope::WorkspaceRead),
538 ("get_team", Scope::WorkspaceRead),
539 ("list_team_members", Scope::WorkspaceRead),
540 ("list_child_teams", Scope::WorkspaceRead),
541 ("list_team_repos", Scope::WorkspaceRead),
542 ("list_user_teams", Scope::WorkspaceRead),
543 ("create_team", Scope::WorkspaceAdmin),
544 ("list_workspace_rulesets", Scope::WorkspaceRead),
545 ("get_workspace_ruleset", Scope::WorkspaceRead),
546 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
547 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
548 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
549 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
550 ("update_team", Scope::WorkspaceAdmin),
551 ("delete_team", Scope::WorkspaceAdmin),
552 ("set_team_member", Scope::WorkspaceAdmin),
553 ("remove_team_member", Scope::WorkspaceAdmin),
554 ("set_team_review_assignment", Scope::WorkspaceAdmin),
555 // A workspace's billing: usage, budget, AI credit and invoices.
556 ("get_usage", Scope::BillingRead),
557 ("get_budget", Scope::BillingRead),
558 ("get_ai_credit", Scope::BillingRead),
559 ("list_invoices", Scope::BillingRead),
560 ("get_billing_details", Scope::BillingRead),
561 ("set_budget", Scope::BillingWrite),
562 ("buy_ai_credit", Scope::BillingWrite),
563 // Repositories.
564 ("list_repos", Scope::RepoRead),
565 ("get_repo", Scope::RepoRead),
566 ("search", Scope::RepoRead),
567 ("list_events", Scope::RepoRead),
568 ("list_labels", Scope::RepoRead),
569 ("list_milestones", Scope::RepoRead),
570 ("get_milestone", Scope::RepoRead),
571 ("create_label", Scope::IssuesWrite),
572 ("update_label", Scope::IssuesWrite),
573 ("delete_label", Scope::IssuesWrite),
574 ("add_default_labels", Scope::IssuesWrite),
575 ("create_milestone", Scope::IssuesWrite),
576 ("update_milestone", Scope::IssuesWrite),
577 ("delete_milestone", Scope::IssuesWrite),
578 ("get_repo_settings", Scope::RepoRead),
579 ("list_check_names", Scope::RepoRead),
580 ("list_deleted_repos", Scope::RepoRead),
581 ("list_security_alerts", Scope::RepoRead),
582 ("get_codeowners_errors", Scope::RepoRead),
583 ("create_repo", Scope::RepoWrite),
584 ("update_repo", Scope::RepoWrite),
585 ("update_repo_settings", Scope::RepoWrite),
586 // Rulesets: reading them is reading the repository; changing them
587 // changes what everyone, agents included, may do, so it is admin.
588 ("list_repo_rulesets", Scope::RepoRead),
589 ("get_repo_ruleset", Scope::RepoRead),
590 ("get_branch_rules", Scope::RepoRead),
591 ("list_rule_evaluations", Scope::RepoRead),
592 ("create_repo_ruleset", Scope::RepoAdmin),
593 ("update_repo_ruleset", Scope::RepoAdmin),
594 ("delete_repo_ruleset", Scope::RepoAdmin),
595 ("rename_branch", Scope::RepoWrite),
596 ("rename_repo", Scope::RepoAdmin),
597 ("transfer_repo", Scope::RepoAdmin),
598 ("archive_repo", Scope::RepoAdmin),
599 ("unarchive_repo", Scope::RepoAdmin),
600 ("set_repo_visibility", Scope::RepoAdmin),
601 ("delete_repo", Scope::RepoAdmin),
602 ("restore_repo", Scope::RepoAdmin),
603 ("purge_repo", Scope::RepoAdmin),
604 // A dismissed secret is let through push protection.
605 ("dismiss_security_alert", Scope::RepoAdmin),
606 ("reopen_security_alert", Scope::RepoAdmin),
607 // The security suite: alerts, push protection, patterns, code
608 // scanning, the supply chain and settings.
609 ("list_secret_scanning_alerts", Scope::SecurityRead),
610 ("get_secret_scanning_alert", Scope::SecurityRead),
611 ("list_secret_scanning_locations", Scope::SecurityRead),
612 ("list_bypass_requests", Scope::SecurityRead),
613 ("list_custom_patterns", Scope::SecurityRead),
614 ("list_code_scanning_alerts", Scope::SecurityRead),
615 ("get_code_scanning_alert", Scope::SecurityRead),
616 ("list_code_scanning_analyses", Scope::SecurityRead),
617 ("get_sarif_upload", Scope::SecurityRead),
618 ("list_vulnerability_alerts", Scope::SecurityRead),
619 ("get_vulnerability_alert", Scope::SecurityRead),
620 ("get_dependency_graph", Scope::SecurityRead),
621 ("get_sbom", Scope::SecurityRead),
622 ("compare_dependencies", Scope::SecurityRead),
623 ("get_security_settings", Scope::SecurityRead),
624 ("get_workspace_security_settings", Scope::SecurityRead),
625 ("get_security_overview", Scope::SecurityRead),
626 ("update_secret_scanning_alert", Scope::SecurityWrite),
627 ("bypass_push_protection", Scope::SecurityWrite),
628 ("check_secret_validity", Scope::SecurityWrite),
629 ("review_bypass_request", Scope::SecurityWrite),
630 ("create_custom_pattern", Scope::SecurityWrite),
631 ("update_custom_pattern", Scope::SecurityWrite),
632 ("delete_custom_pattern", Scope::SecurityWrite),
633 ("dry_run_custom_pattern", Scope::SecurityWrite),
634 ("update_code_scanning_alert", Scope::SecurityWrite),
635 ("upload_sarif", Scope::SecurityWrite),
636 ("update_vulnerability_alert", Scope::SecurityWrite),
637 ("fix_security_alert", Scope::SecurityWrite),
638 ("update_security_settings", Scope::SecurityWrite),
639 ("update_workspace_security_settings", Scope::SecurityWrite),
640 // Issues and plans.
641 ("list_issues", Scope::IssuesRead),
642 ("get_issue", Scope::IssuesRead),
643 ("get_plan", Scope::IssuesRead),
644 ("create_issue", Scope::IssuesWrite),
645 ("update_issue", Scope::IssuesWrite),
646 ("list_issue_labels", Scope::IssuesRead),
647 ("add_issue_labels", Scope::IssuesWrite),
648 ("set_issue_labels", Scope::IssuesWrite),
649 ("remove_issue_labels", Scope::IssuesWrite),
650 ("close_issue", Scope::IssuesWrite),
651 ("reopen_issue", Scope::IssuesWrite),
652 ("add_comment", Scope::IssuesWrite),
653 ("import_issue", Scope::IssuesWrite),
654 ("apply_plan", Scope::IssuesWrite),
655 // Pull requests.
656 ("list_pull_requests", Scope::PullRequestsRead),
657 ("get_pull_request", Scope::PullRequestsRead),
658 ("get_pull_request_changes", Scope::PullRequestsRead),
659 ("read_session", Scope::PullRequestsRead),
660 ("get_merge_queue", Scope::PullRequestsRead),
661 ("create_pull_request", Scope::PullRequestsWrite),
662 ("update_pull_request", Scope::PullRequestsWrite),
663 ("record_session", Scope::PullRequestsWrite),
664 ("mark_pull_request_ready", Scope::PullRequestsWrite),
665 ("close_pull_request", Scope::PullRequestsWrite),
666 ("review_pull_request", Scope::PullRequestsWrite),
667 ("merge_pull_request", Scope::PullRequestsWrite),
668 ("request_reviewers", Scope::PullRequestsWrite),
669 ("remove_requested_reviewers", Scope::PullRequestsWrite),
670 // g1t's agents.
671 ("assign_issue", Scope::AgentsRun),
672 ("delegate", Scope::AgentsRun),
673 ("plan_work", Scope::AgentsRun),
674 ("message_agent", Scope::AgentsRun),
675 ("answer_message", Scope::AgentsRun),
676 ("take_messages", Scope::AgentsRun),
677 // Workflows.
678 ("list_workflows", Scope::WorkflowsRead),
679 ("list_workflow_runs", Scope::WorkflowsRead),
680 ("get_workflow_run", Scope::WorkflowsRead),
681 ("get_job_logs", Scope::WorkflowsRead),
682 ("dispatch_workflow", Scope::WorkflowsWrite),
683 ("cancel_workflow_run", Scope::WorkflowsWrite),
684 ("rerun_workflow_run", Scope::WorkflowsWrite),
685 ("update_workflow", Scope::WorkflowsWrite),
686 // Memory and the context hub.
687 ("recall", Scope::MemoryRead),
688 ("search_context", Scope::MemoryRead),
689 ("get_entity", Scope::MemoryRead),
690 ("get_context", Scope::MemoryRead),
691 ("remember", Scope::MemoryWrite),
692 // Who has access.
693 ("list_collaborators", Scope::AccessRead),
694 ("get_collaborator_permission", Scope::AccessRead),
695 ("list_repo_invitations", Scope::AccessRead),
696 ("list_outside_collaborators", Scope::AccessRead),
697 ("add_collaborator", Scope::AccessAdmin),
698 ("update_collaborator", Scope::AccessAdmin),
699 ("remove_collaborator", Scope::AccessAdmin),
700 ("revoke_repo_invitation", Scope::AccessAdmin),
701 ("set_base_permission", Scope::AccessAdmin),
702 ("set_team_repo", Scope::AccessAdmin),
703 ("remove_team_repo", Scope::AccessAdmin),
704 // Webhooks.
705 ("list_webhooks", Scope::WebhooksRead),
706 ("list_webhook_deliveries", Scope::WebhooksRead),
707 ("create_webhook", Scope::WebhooksAdmin),
708 ("update_webhook", Scope::WebhooksAdmin),
709 ("delete_webhook", Scope::WebhooksAdmin),
710 ("ping_webhook", Scope::WebhooksAdmin),
711 ("redeliver_webhook", Scope::WebhooksAdmin),
712 // Secrets and variables.
713 ("list_actions_secrets", Scope::SecretsRead),
714 ("list_actions_variables", Scope::SecretsRead),
715 ("set_actions_secret", Scope::SecretsAdmin),
716 ("delete_actions_secret", Scope::SecretsAdmin),
717 ("set_actions_variable", Scope::SecretsAdmin),
718 ("delete_actions_variable", Scope::SecretsAdmin),
719 // Self-hosted runners.
720 ("list_runners", Scope::RunnersRead),
721 ("list_runner_groups", Scope::RunnersRead),
722 ("get_runner_settings", Scope::RunnersRead),
723 ("create_runner_registration_token", Scope::RunnersAdmin),
724 ("remove_runner", Scope::RunnersAdmin),
725 ("create_runner_group", Scope::RunnersAdmin),
726 ("update_runner_group", Scope::RunnersAdmin),
727 ("delete_runner_group", Scope::RunnersAdmin),
728 ("update_runner_settings", Scope::RunnersAdmin),
729];
730
731/// Operations any token may use: saying who it is.
732pub const NO_SCOPE: &[&str] = &["whoami"];
733
734/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
735/// operation in neither list needs full access.
736pub fn scope_for(operation: &str) -> Option<Scope> {
737 OPERATIONS
738 .iter()
739 .find(|(name, _)| *name == operation)
740 .map(|(_, scope)| *scope)
741}
742
743/// What a token needs for `operation` with this input beyond its own
744/// scope: starting agents from an operation that can, and making a
745/// repository public or private.
746pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
747 let mut extra = Vec::new();
748 let assigns = input["assign"].as_bool() == Some(true)
749 || input["agent"].as_bool() == Some(true)
750 || input["assign_agent"].as_bool() == Some(true);
751 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
752 extra.push(Scope::AgentsRun);
753 }
754 // Fixing an alert opens an issue and puts g1t on it.
755 if operation == "fix_security_alert" {
756 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
757 }
758 // Opening the issue an agent is put on.
759 if operation == "delegate" {
760 extra.push(Scope::IssuesWrite);
761 }
762 // A workspace's base permission is who has access.
763 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
764 extra.push(Scope::AccessAdmin);
765 }
766 if operation == "update_repo" && (input.get("private").is_some_and(|v| !v.is_null()) || input.get("default_branch").is_some_and(|v| !v.is_null())) {
767 extra.push(Scope::RepoAdmin);
768 }
769 extra
770}
771
772/// The scopes a call needs, its own first.
773pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
774 scope_for(operation)
775 .into_iter()
776 .chain(extra_scopes(operation, input))
777 .collect()
778}
779
780/// Whether `access` may use `operation` with `input`. The person's (or
781/// workspace's) role is checked after this, by the service that owns what
782/// was asked about.
783pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
784 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
785 if access.scopes.is_some() {
786 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
787 if !known {
788 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
789 }
790 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
791 return Decision::deny(
792 "token:scope",
793 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
794 );
795 }
796 }
797 Decision::allow(rule)
798}
799
800/// Whether a token may clone or fetch (`write` false), or push to (`write`
801/// true), a repository with git. `public` is whether anyone may read it,
802/// which needs no scope.
803pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
804 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
805 if !access.allows(needed) && (write || !public) {
806 return Decision::deny(
807 "token:scope",
808 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
809 );
810 }
811 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
812}
813
814/// Whether a token may pull (`Level::Read`), push or publish
815/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
816/// whether anyone may pull the package, which needs no scope.
817pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
818 let (needed, doing) = match level {
819 Level::Read => (Scope::PackagesRead, "pull a private package"),
820 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
821 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
822 };
823 if !access.allows(needed) && !(level == Level::Read && public) {
824 return Decision::deny(
825 "token:scope",
826 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
827 );
828 }
829 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use serde_json::json;
836
837 fn token(scopes: &[Scope]) -> TokenAccess {
838 TokenAccess {
839 token_id: "tok_1".to_owned(),
840 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
841 legacy: false,
842 }
843 }
844
845 #[test]
846 fn every_scope_reads_back_and_belongs_to_a_resource() {
847 for scope in Scope::ALL {
848 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
849 assert!(scope.as_str().starts_with(scope.resource().as_str()));
850 assert!(scope.includes(scope));
851 }
852 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
853 assert_eq!(Scope::parse("issues"), None);
854 }
855
856 #[test]
857 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
858 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
859 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
860 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
861 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
862 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
863 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
864 }
865
866 #[test]
867 fn operations_are_listed_once_and_never_also_free() {
868 let mut seen = std::collections::HashSet::new();
869 for (name, _) in OPERATIONS {
870 assert!(seen.insert(*name), "{name} twice");
871 assert!(!NO_SCOPE.contains(name), "{name}");
872 }
873 }
874
875 #[test]
876 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
877 assert_eq!(
878 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
879 vec![Scope::RepoRead, Scope::IssuesWrite]
880 );
881 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
882 }
883
884 #[test]
885 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
886 let scopes = oauth_default();
887 assert!(scopes.contains(&Scope::IssuesWrite));
888 assert!(scopes.contains(&Scope::PullRequestsWrite));
889 assert!(scopes.contains(&Scope::AgentsRun));
890 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
891 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
892 // Every read but the machines work runs on.
893 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
894 }
895 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
896 assert_eq!(Preset::Full.scopes(), None);
897 }
898
899 #[test]
900 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
901 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
902 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
903 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
904 }
905 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
906 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
907 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
908 let reader = token(&[Scope::BillingRead]);
909 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
910 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
911 }
912
913 #[test]
914 fn a_legacy_token_can_do_everything() {
915 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
916 for (operation, _) in OPERATIONS {
917 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
918 }
919 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
920 }
921
922 #[test]
923 fn a_missing_scope_is_named() {
924 let read = token(&[Scope::IssuesRead]);
925 assert!(decide(&read, "get_issue", &json!({})).allowed);
926 assert!(decide(&read, "whoami", &json!({})).allowed);
927 let refused = decide(&read, "create_issue", &json!({}));
928 assert!(!refused.allowed);
929 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
930 // An operation the table does not know needs full access.
931 assert!(!decide(&read, "something_new", &json!({})).allowed);
932 }
933
934 #[test]
935 fn starting_agents_from_another_operation_needs_agents_run() {
936 let writer = token(&[Scope::IssuesWrite]);
937 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
938 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
939 assert!(refused.reason.unwrap().contains("agents:run"));
940 let maintainer = token(&[Scope::RepoWrite]);
941 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
942 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
943 }
944
945 #[test]
946 fn a_workspaces_base_permission_needs_access_admin_too() {
947 let admin = token(&[Scope::WorkspaceAdmin]);
948 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
949 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
950 assert!(refused.reason.unwrap().contains("access:admin"));
951 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
952 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
953 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
954 }
955
956 #[test]
957 fn delegating_needs_both_agents_and_issues() {
958 let agents = token(&[Scope::AgentsRun]);
959 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
960 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
961 assert!(decide(&both, "delegate", &json!({})).allowed);
962 }
963
964 #[test]
965 fn git_push_needs_code_write_and_private_reads_need_code_read() {
966 let reader = token(&[Scope::CodeRead]);
967 assert!(decide_git(&reader, false, false).allowed);
968 let refused = decide_git(&reader, true, false);
969 assert!(!refused.allowed);
970 assert!(refused.reason.unwrap().contains("code:write"));
971 let issues = token(&[Scope::IssuesWrite]);
972 assert!(!decide_git(&issues, false, false).allowed);
973 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
974 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
975 let writer = token(&[Scope::CodeWrite]);
976 assert!(decide_git(&writer, true, false).allowed);
977 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
978 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
979 }
980
981 #[test]
982 fn packages_need_their_own_scopes_and_public_pulls_none() {
983 let reader = token(&[Scope::PackagesRead]);
984 assert!(decide_packages(&reader, Level::Read, false).allowed);
985 assert!(!decide_packages(&reader, Level::Write, false).allowed);
986 let code = token(&[Scope::CodeWrite]);
987 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
988 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
989 let writer = token(&[Scope::PackagesWrite]);
990 assert!(decide_packages(&writer, Level::Write, false).allowed);
991 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
992 let refused = decide_packages(&writer, Level::Delete, false);
993 assert!(refused.reason.unwrap().contains("packages:delete"));
994 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
995 assert!(Scope::PackagesDelete.dangerous());
996 // Tokens made before these scopes, and full-access ones, keep working.
997 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
998 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
999 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1000 }
1001
1002 #[test]
1003 fn token_access_travels_as_json() {
1004 let access = token(&[Scope::IssuesRead]);
1005 let wire = serde_json::to_value(&access).unwrap();
1006 assert_eq!(wire["scopes"], json!(["issues:read"]));
1007 assert!(wire.get("resources").is_none());
1008 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1009 assert_eq!(back, access);
1010 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1011 assert!(full.is_full());
1012 // A reach written by an older version is ignored: a token reaches
1013 // whatever its owner can.
1014 let older: TokenAccess = serde_json::from_value(json!({
1015 "token_id": "tok_1",
1016 "scopes": ["issues:read"],
1017 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1018 }))
1019 .unwrap();
1020 assert_eq!(older, access);
1021 }
1022
1023 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1024 /// lists the same scopes in the same order, the same operations with
1025 /// the same scopes, and the same presets.
1026 #[test]
1027 fn the_typescript_mirror_has_the_same_table() {
1028 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1029 let section = |start: &str| {
1030 ts.split_once(start)
1031 .and_then(|(_, rest)| rest.split_once("] as const"))
1032 .map(|(table, _)| table)
1033 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1034 };
1035 let scopes: Vec<&str> = section("export const SCOPES = [")
1036 .lines()
1037 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
1038 .collect();
1039 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
1040 assert_eq!(scopes, expected);
1041 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1042 .lines()
1043 .filter_map(|line| {
1044 let mut quoted = line.split('"').skip(1).step_by(2);
1045 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1046 })
1047 .collect();
1048 let expected: Vec<(String, String)> = OPERATIONS
1049 .iter()
1050 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1051 .collect();
1052 assert_eq!(operations, expected);
1053 for preset in Preset::ALL {
1054 let list = section(&format!("{}: [", preset.as_str()));
1055 let mirrored: Vec<&str> = list
1056 .split(',')
1057 .map(|item| item.trim().trim_matches('"'))
1058 .filter(|item| !item.is_empty())
1059 .collect();
1060 let expected: Vec<&str> = preset
1061 .scopes()
1062 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1063 .unwrap_or_else(|| vec!["*"]);
1064 assert_eq!(mirrored, expected, "{}", preset.as_str());
1065 }
1066 }
1067}