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