Skip to content
1,883 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 WorkflowFiles,
38 Checks,
39 Deployments,
40 Memory,
41 Access,
42 Webhooks,
43 Secrets,
44 Runners,
45 Models,
46 /// Artifacts mode's docs, slides, designs and dashboards (folios in
47 /// code): the `artifact` MCP tool and `/workspaces/{ws}/artifacts`.
48 /// Not workflow runs' artifacts, which are `workflows:*`.
49 Artifacts,
50}
51
52impl Resource {
53 pub const ALL: [Resource; 22] = [
54 Resource::Repo,
55 Resource::Code,
56 Resource::Security,
57 Resource::Packages,
58 Resource::Issues,
59 Resource::PullRequests,
60 Resource::Agents,
61 Resource::Workflows,
62 Resource::WorkflowFiles,
63 Resource::Checks,
64 Resource::Deployments,
65 Resource::Memory,
66 Resource::Account,
67 Resource::Notifications,
68 Resource::Workspace,
69 Resource::Billing,
70 Resource::Access,
71 Resource::Webhooks,
72 Resource::Secrets,
73 Resource::Runners,
74 Resource::Models,
75 Resource::Artifacts,
76 ];
77
78 pub fn as_str(self) -> &'static str {
79 match self {
80 Resource::Account => "account",
81 Resource::Notifications => "notifications",
82 Resource::Workspace => "workspace",
83 Resource::Billing => "billing",
84 Resource::Repo => "repo",
85 Resource::Code => "code",
86 Resource::Security => "security",
87 Resource::Packages => "packages",
88 Resource::Issues => "issues",
89 Resource::PullRequests => "pull_requests",
90 Resource::Agents => "agents",
91 Resource::Workflows => "workflows",
92 Resource::WorkflowFiles => "workflow_files",
93 Resource::Checks => "checks",
94 Resource::Deployments => "deployments",
95 Resource::Memory => "memory",
96 Resource::Access => "access",
97 Resource::Webhooks => "webhooks",
98 Resource::Secrets => "secrets",
99 Resource::Runners => "runners",
100 Resource::Models => "models",
101 Resource::Artifacts => "artifacts",
102 }
103 }
104
105 /// Its name, for people.
106 pub fn label(self) -> &'static str {
107 match self {
108 Resource::Account => "Your account",
109 Resource::Notifications => "Notifications",
110 Resource::Workspace => "Workspaces",
111 Resource::Billing => "Billing",
112 Resource::Repo => "Repositories",
113 Resource::Code => "Code",
114 Resource::Security => "Security",
115 Resource::Packages => "Packages",
116 Resource::Issues => "Issues",
117 Resource::PullRequests => "Pull requests",
118 Resource::Agents => "g1t agents",
119 Resource::Workflows => "Workflows",
120 Resource::WorkflowFiles => "Workflow files",
121 Resource::Checks => "Checks and statuses",
122 Resource::Deployments => "Deployments",
123 Resource::Memory => "Memory and context",
124 Resource::Access => "Who has access",
125 Resource::Webhooks => "Webhooks",
126 Resource::Secrets => "Secrets and variables",
127 Resource::Runners => "Self-hosted runners",
128 Resource::Models => "AI Gateway",
129 Resource::Artifacts => "Artifacts",
130 }
131 }
132
133 /// Whether tokens are offered it yet. A resource being built can be in
134 /// the table before its API ships (so its scopes parse, and the
135 /// TypeScript mirror lists it under `UPCOMING_RESOURCES`) while nothing
136 /// hands it out: presets, full access, OAuth and the token form leave
137 /// it out, and no operation needs it. Every resource is offered now;
138 /// Artifacts was the last.
139 pub fn offered(self) -> bool {
140 let _ = self;
141 true
142 }
143}
144
145/// How much of a resource.
146#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
147pub enum Level {
148 Read,
149 Write,
150 /// Starting g1t's agents, which spends the workspace's money.
151 Run,
152 /// Deleting what cannot be brought back, such as a package's versions.
153 Delete,
154 Admin,
155}
156
157impl Level {
158 pub fn as_str(self) -> &'static str {
159 match self {
160 Level::Read => "read",
161 Level::Write => "write",
162 Level::Run => "run",
163 Level::Delete => "delete",
164 Level::Admin => "admin",
165 }
166 }
167}
168
169/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
170/// clients ask for, and errors name.
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
172pub enum Scope {
173 RepoRead,
174 RepoWrite,
175 RepoAdmin,
176 CodeRead,
177 CodeWrite,
178 SecurityRead,
179 SecurityWrite,
180 PackagesRead,
181 PackagesWrite,
182 PackagesDelete,
183 IssuesRead,
184 IssuesWrite,
185 PullRequestsRead,
186 PullRequestsWrite,
187 AgentsRun,
188 WorkflowsRead,
189 WorkflowsWrite,
190 WorkflowFilesWrite,
191 ChecksRead,
192 ChecksWrite,
193 DeploymentsRead,
194 DeploymentsWrite,
195 MemoryRead,
196 MemoryWrite,
197 AccountRead,
198 AccountWrite,
199 NotificationsRead,
200 NotificationsWrite,
201 WorkspaceRead,
202 WorkspaceAdmin,
203 BillingRead,
204 BillingWrite,
205 AccessRead,
206 AccessAdmin,
207 WebhooksRead,
208 WebhooksAdmin,
209 SecretsRead,
210 SecretsAdmin,
211 RunnersRead,
212 RunnersAdmin,
213 ModelsRead,
214 ModelsWrite,
215 ArtifactsRead,
216 ArtifactsWrite,
217 ArtifactsAdmin,
218}
219
220impl Scope {
221 /// Every scope, grouped by resource, least first.
222 pub const ALL: [Scope; 45] = [
223 Scope::RepoRead,
224 Scope::RepoWrite,
225 Scope::RepoAdmin,
226 Scope::CodeRead,
227 Scope::CodeWrite,
228 Scope::SecurityRead,
229 Scope::SecurityWrite,
230 Scope::PackagesRead,
231 Scope::PackagesWrite,
232 Scope::PackagesDelete,
233 Scope::IssuesRead,
234 Scope::IssuesWrite,
235 Scope::PullRequestsRead,
236 Scope::PullRequestsWrite,
237 Scope::AgentsRun,
238 Scope::WorkflowsRead,
239 Scope::WorkflowsWrite,
240 Scope::WorkflowFilesWrite,
241 Scope::ChecksRead,
242 Scope::ChecksWrite,
243 Scope::DeploymentsRead,
244 Scope::DeploymentsWrite,
245 Scope::MemoryRead,
246 Scope::MemoryWrite,
247 Scope::AccountRead,
248 Scope::AccountWrite,
249 Scope::NotificationsRead,
250 Scope::NotificationsWrite,
251 Scope::WorkspaceRead,
252 Scope::WorkspaceAdmin,
253 Scope::BillingRead,
254 Scope::BillingWrite,
255 Scope::AccessRead,
256 Scope::AccessAdmin,
257 Scope::WebhooksRead,
258 Scope::WebhooksAdmin,
259 Scope::SecretsRead,
260 Scope::SecretsAdmin,
261 Scope::RunnersRead,
262 Scope::RunnersAdmin,
263 Scope::ModelsRead,
264 Scope::ModelsWrite,
265 Scope::ArtifactsRead,
266 Scope::ArtifactsWrite,
267 Scope::ArtifactsAdmin,
268 ];
269
270 pub fn as_str(self) -> &'static str {
271 match self {
272 Scope::RepoRead => "repo:read",
273 Scope::RepoWrite => "repo:write",
274 Scope::RepoAdmin => "repo:admin",
275 Scope::CodeRead => "code:read",
276 Scope::CodeWrite => "code:write",
277 Scope::SecurityRead => "security:read",
278 Scope::SecurityWrite => "security:write",
279 Scope::PackagesRead => "packages:read",
280 Scope::PackagesWrite => "packages:write",
281 Scope::PackagesDelete => "packages:delete",
282 Scope::IssuesRead => "issues:read",
283 Scope::IssuesWrite => "issues:write",
284 Scope::PullRequestsRead => "pull_requests:read",
285 Scope::PullRequestsWrite => "pull_requests:write",
286 Scope::AgentsRun => "agents:run",
287 Scope::WorkflowsRead => "workflows:read",
288 Scope::WorkflowsWrite => "workflows:write",
289 Scope::WorkflowFilesWrite => "workflow_files:write",
290 Scope::ChecksRead => "checks:read",
291 Scope::ChecksWrite => "checks:write",
292 Scope::DeploymentsRead => "deployments:read",
293 Scope::DeploymentsWrite => "deployments:write",
294 Scope::MemoryRead => "memory:read",
295 Scope::MemoryWrite => "memory:write",
296 Scope::AccountRead => "account:read",
297 Scope::AccountWrite => "account:write",
298 Scope::NotificationsRead => "notifications:read",
299 Scope::NotificationsWrite => "notifications:write",
300 Scope::WorkspaceRead => "workspace:read",
301 Scope::WorkspaceAdmin => "workspace:admin",
302 Scope::BillingRead => "billing:read",
303 Scope::BillingWrite => "billing:write",
304 Scope::AccessRead => "access:read",
305 Scope::AccessAdmin => "access:admin",
306 Scope::WebhooksRead => "webhooks:read",
307 Scope::WebhooksAdmin => "webhooks:admin",
308 Scope::SecretsRead => "secrets:read",
309 Scope::SecretsAdmin => "secrets:admin",
310 Scope::RunnersRead => "runners:read",
311 Scope::RunnersAdmin => "runners:admin",
312 Scope::ModelsRead => "models:read",
313 Scope::ModelsWrite => "models:write",
314 Scope::ArtifactsRead => "artifacts:read",
315 Scope::ArtifactsWrite => "artifacts:write",
316 Scope::ArtifactsAdmin => "artifacts:admin",
317 }
318 }
319
320 pub fn parse(text: &str) -> Option<Scope> {
321 let text = text.trim().to_ascii_lowercase();
322 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
323 }
324
325 pub fn resource(self) -> Resource {
326 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
327 Resource::ALL
328 .into_iter()
329 .find(|resource| resource.as_str() == name)
330 .unwrap_or(Resource::Account)
331 }
332
333 pub fn level(self) -> Level {
334 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
335 "write" => Level::Write,
336 "run" => Level::Run,
337 "delete" => Level::Delete,
338 "admin" => Level::Admin,
339 _ => Level::Read,
340 }
341 }
342
343 /// Whether holding `self` gives `other`: the same resource, at the same
344 /// level or a lower one.
345 pub fn includes(self, other: Scope) -> bool {
346 self.resource() == other.resource() && self.level() >= other.level()
347 }
348
349 /// Changes that are hard or impossible to undo, or that decide who can
350 /// reach what. Shown behind a warning wherever scopes are chosen.
351 pub fn dangerous(self) -> bool {
352 matches!(self.level(), Level::Admin | Level::Delete)
353 }
354
355 /// What it lets a token do, in plain words.
356 pub fn describe(self) -> &'static str {
357 match self {
358 Scope::RepoRead => "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search",
359 Scope::RepoWrite => "Create repositories, rename branches, change how pull requests merge and publish releases",
360 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts",
361 Scope::CodeRead => "Clone and fetch private repositories with git",
362 Scope::CodeWrite => "Push commits with git",
363 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
364 Scope::SecurityWrite => "Dismiss and reopen alerts, bypass push protection, review bypass requests, manage custom patterns, upload SARIF and change security settings",
365 Scope::PackagesRead => "Pull container images and install private packages",
366 Scope::PackagesWrite => "Push container images and publish packages",
367 Scope::PackagesDelete => "Delete and restore packages and their versions",
368 Scope::IssuesRead => "Read issues, comments and plans",
369 Scope::IssuesWrite => "Open, edit, close and comment on issues",
370 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
371 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
372 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
373 Scope::WorkflowsRead => "Read workflows, runs and logs",
374 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
375 Scope::WorkflowFilesWrite => "Add, change and delete workflow files under .g1t/workflows and .github/workflows, with git or the API",
376 Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations",
377 Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again",
378 Scope::DeploymentsRead => "See deployments, their statuses and environments",
379 Scope::DeploymentsWrite => "Report deployments and their statuses, from any CI",
380 Scope::MemoryRead => "Recall memory and search the workspace's context",
381 Scope::MemoryWrite => "Save memory for the next agent",
382 Scope::AccountRead => "Read your email addresses, invites, invitations, pinned projects and stars",
383 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories",
384 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
385 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
386 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
387 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets",
388 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
389 Scope::BillingWrite => "Change a workspace's budget and buy AI credit",
390 Scope::AccessRead => "See who has access to repositories",
391 Scope::AccessAdmin => "Give and take away access to repositories, a team's included",
392 Scope::WebhooksRead => "See webhooks and their deliveries",
393 Scope::WebhooksAdmin => "Create, change and delete webhooks",
394 Scope::SecretsRead => "List secrets (never their values) and read variables",
395 Scope::SecretsAdmin => "Set and delete secrets and variables",
396 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
397 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
398 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
399 Scope::ModelsWrite => "Send model requests through the AI Gateway, which uses the workspace's AI credit",
400 Scope::ArtifactsRead => "List, read and search artifacts you can see, their versions, and the numbers their dashboards show",
401 Scope::ArtifactsWrite => "Create, rename, move, edit, trash and restore artifacts, and propose changes to them",
402 Scope::ArtifactsAdmin => "Share artifacts, change who can open them, and delete them for good",
403 }
404 }
405
406 /// Whether tokens are offered it yet: its resource's [`Resource::offered`].
407 pub fn offered(self) -> bool {
408 self.resource().offered()
409 }
410}
411
412/// Every scope tokens are offered, in table order: what OAuth advertises.
413pub fn offered_scopes() -> Vec<Scope> {
414 Scope::ALL.into_iter().filter(|scope| scope.offered()).collect()
415}
416
417impl Serialize for Scope {
418 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
419 serializer.serialize_str(self.as_str())
420 }
421}
422
423impl<'de> Deserialize<'de> for Scope {
424 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
425 let text = String::deserialize(deserializer)?;
426 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
427 }
428}
429
430/// Scopes as written in a token's row or an OAuth request: separated by
431/// spaces or commas. Unknown names are left out, so a client asking for a
432/// scope from a newer version gets the rest.
433pub fn parse_scopes(text: &str) -> Vec<Scope> {
434 let mut scopes: Vec<Scope> = text
435 .split(|c: char| c.is_whitespace() || c == ',')
436 .filter_map(Scope::parse)
437 .filter(|scope| scope.offered())
438 .collect();
439 normalize(&mut scopes);
440 scopes
441}
442
443/// In table order, without repeats.
444pub fn normalize(scopes: &mut Vec<Scope>) {
445 let given = std::mem::take(scopes);
446 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
447}
448
449/// Space-separated, as stored and as OAuth writes them.
450pub fn scopes_text(scopes: &[Scope]) -> String {
451 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
452}
453
454/// Where a resource sits on the token form, and which tokens may hold it.
455#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "snake_case")]
457pub enum ResourceGroup {
458 /// About repositories: what they hold and how they are run.
459 Repository,
460 /// About a workspace itself.
461 Workspace,
462 /// About the person: only a personal token may hold these.
463 Account,
464}
465
466impl ResourceGroup {
467 pub const ALL: [ResourceGroup; 3] = [ResourceGroup::Repository, ResourceGroup::Workspace, ResourceGroup::Account];
468
469 pub fn as_str(self) -> &'static str {
470 match self {
471 ResourceGroup::Repository => "repository",
472 ResourceGroup::Workspace => "workspace",
473 ResourceGroup::Account => "account",
474 }
475 }
476}
477
478impl Resource {
479 pub fn group(self) -> ResourceGroup {
480 match self {
481 Resource::Account | Resource::Notifications => ResourceGroup::Account,
482 Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models | Resource::Artifacts => ResourceGroup::Workspace,
483 _ => ResourceGroup::Repository,
484 }
485 }
486
487 pub fn parse(text: &str) -> Option<Resource> {
488 let text = text.trim().to_ascii_lowercase();
489 Resource::ALL.into_iter().find(|resource| resource.as_str() == text)
490 }
491
492 /// Its scopes, least first.
493 pub fn scopes(self) -> Vec<Scope> {
494 Scope::ALL.into_iter().filter(|scope| scope.resource() == self).collect()
495 }
496}
497
498// --- Permissions --------------------------------------------------------------
499//
500// A token's permissions are its scopes read per resource: each resource
501// at none or one level (`{"issues": "write", "repo": "read"}`). A level
502// includes the ones below it, so the highest scope held of each resource
503// says everything; that is what a token stores. Personal tokens and a
504// workspace's own tokens are made, shown and checked this way alike.
505
506/// The highest scope of each resource held, in table order: the fewest
507/// scopes that give the same access, as tokens store them.
508pub fn top_scopes(scopes: &[Scope]) -> Vec<Scope> {
509 let mut top: Vec<Scope> = Vec::new();
510 for resource in Resource::ALL {
511 if let Some(best) = scopes.iter().filter(|scope| scope.resource() == resource).max_by_key(|scope| scope.level()) {
512 top.push(*best);
513 }
514 }
515 normalize(&mut top);
516 top
517}
518
519/// Every resource at its highest level: all a token can be given.
520pub fn everything() -> Vec<Scope> {
521 top_scopes(&offered_scopes())
522}
523
524/// Scopes as permissions: each resource held, by name, at its highest
525/// level held.
526pub fn permissions_of(scopes: &[Scope]) -> std::collections::BTreeMap<String, String> {
527 top_scopes(scopes)
528 .into_iter()
529 .map(|scope| (scope.resource().as_str().to_owned(), scope.level().as_str().to_owned()))
530 .collect()
531}
532
533/// Permissions as asked for (`{"issues": "write"}`, `none` or empty left
534/// out) into the scopes a token stores, or why they cannot be. `personal`
535/// is whether the token is a person's: only theirs may hold account ones.
536pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> {
537 let mut scopes = Vec::new();
538 for (name, level) in asked {
539 let Some(resource) = Resource::parse(name).filter(|resource| resource.offered()) else {
540 return Err(format!("There is no permission called {name}."));
541 };
542 let level = level.trim().to_ascii_lowercase();
543 if level.is_empty() || level == "none" {
544 continue;
545 }
546 let Some(scope) = Scope::parse(&format!("{}:{level}", resource.as_str())) else {
547 let levels: Vec<&str> = resource.scopes().iter().map(|scope| scope.level().as_str()).collect();
548 return Err(format!("{} is none or {}, not {level}.", resource.as_str(), levels.join(", ")));
549 };
550 if resource.group() == ResourceGroup::Account && !personal {
551 return Err(format!("{} is about a person's account: a workspace's token cannot hold it.", resource.as_str()));
552 }
553 scopes.push(scope);
554 }
555 Ok(top_scopes(&scopes))
556}
557
558/// What a token stores for full access, which is not a scope a client can
559/// ask for by name.
560pub const FULL_ACCESS: &str = "*";
561
562/// Starting points for choosing scopes.
563#[derive(Clone, Copy, Debug, PartialEq, Eq)]
564pub enum Preset {
565 ReadOnly,
566 Agent,
567 Ci,
568 Full,
569}
570
571impl Preset {
572 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
573
574 pub fn as_str(self) -> &'static str {
575 match self {
576 Preset::ReadOnly => "read_only",
577 Preset::Agent => "agent",
578 Preset::Ci => "ci",
579 Preset::Full => "full",
580 }
581 }
582
583 pub fn label(self) -> &'static str {
584 match self {
585 Preset::ReadOnly => "Read only",
586 Preset::Agent => "Agent",
587 Preset::Ci => "CI",
588 Preset::Full => "Full access",
589 }
590 }
591
592 /// Its scopes; `None` for full access.
593 pub fn scopes(self) -> Option<Vec<Scope>> {
594 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered());
595 match self {
596 Preset::ReadOnly => Some(reads().collect()),
597 Preset::Agent => {
598 // Not the machines work runs on: an agent has no business
599 // knowing a workspace's own runners.
600 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
601 // And answering what needs the person it works for: marking
602 // it done, subscribing, watching.
603 scopes.extend([
604 Scope::CodeWrite,
605 Scope::IssuesWrite,
606 Scope::PullRequestsWrite,
607 Scope::AgentsRun,
608 Scope::MemoryWrite,
609 Scope::NotificationsWrite,
610 ]);
611 normalize(&mut scopes);
612 Some(scopes)
613 }
614 Preset::Ci => Some(vec![
615 Scope::RepoRead,
616 Scope::CodeRead,
617 Scope::CodeWrite,
618 Scope::PackagesRead,
619 Scope::PackagesWrite,
620 Scope::WorkflowsRead,
621 Scope::WorkflowsWrite,
622 Scope::ChecksRead,
623 Scope::ChecksWrite,
624 Scope::DeploymentsRead,
625 Scope::DeploymentsWrite,
626 ]),
627 Preset::Full => None,
628 }
629 }
630}
631
632/// What an OAuth client gets when it asks for nothing in particular: the
633/// agent preset. Never an admin scope.
634pub fn oauth_default() -> Vec<Scope> {
635 Preset::Agent.scopes().unwrap_or_default()
636}
637
638/// Set on a [`crate::User`] resolved from an access token: what the token
639/// may do. Absent on a signed-in session, which may do whatever its person
640/// can.
641#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
642pub struct TokenAccess {
643 /// The token's id, as audit entries and errors name it.
644 #[serde(default)]
645 pub token_id: String,
646 /// Its scopes, as `resource:level`. Absent: full access, everything the
647 /// person (or workspace) can do.
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub scopes: Option<Vec<String>>,
650 /// Made before tokens had scopes: full access until someone narrows it.
651 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
652 pub legacy: bool,
653 /// Set on a workflow job's token (`G1T_TOKEN`): the one repository it
654 /// reaches, as `owner/name`. Every other is refused, whatever its owner
655 /// could reach.
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub repo: Option<String>,
658 /// Set on a workflow job's token: the run and job it was made for. The
659 /// audit log records its changes as that job's, and what it changes
660 /// starts no workflows (only `workflow_dispatch` and
661 /// `repository_dispatch` do), so a workflow cannot set itself off.
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub job: Option<JobToken>,
664 /// The token's name, as its owner gave it, so a log can say which
665 /// token made a request. Absent where whoever resolved it did not say.
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub name: Option<String>,
668 /// Set on a token narrowed to less than its owner can reach: one
669 /// workspace (all, selected or none of its private repositories), or
670 /// none at all (its owner's account and public repositories). Absent
671 /// on a token that reaches every workspace its owner can.
672 ///
673 /// The wire key is `fine_grained`, kept from before tokens were one
674 /// kind, so services deployed at different moments agree on it.
675 #[serde(rename = "fine_grained", default, skip_serializing_if = "Option::is_none")]
676 pub reach: Option<TokenReach>,
677 /// Set on a workspace's own token that an owner gave Admin when making
678 /// it. Without it a workspace's token has Write on the workspace's
679 /// repositories, as a member would (see [`crate::access`]).
680 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
681 pub admin: bool,
682 /// Set on what a repository's deploy key resolves to: the key's id. Its
683 /// `repo` is the one repository it reaches.
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub deploy_key: Option<String>,
686 /// Set on a person's token whose owner let it use the website (g1t.sh)
687 /// as them, sent as `Authorization: Bearer`. Not a scope: no preset,
688 /// full access or OAuth grant includes it, and git, the API and MCP
689 /// ignore it.
690 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
691 pub website: bool,
692}
693
694/// Which repositories a token reaches in the workspace it is made for.
695#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "snake_case")]
697pub enum RepositorySelection {
698 /// Every repository of the workspace, ones made later included.
699 #[default]
700 All,
701 /// The repositories chosen, by id.
702 Selected,
703 /// None of the workspace's private repositories: public repositories,
704 /// read-only, and the workspace's own settings its permissions allow.
705 /// With no workspace: the owner's account and public repositories only.
706 Public,
707}
708
709impl RepositorySelection {
710 pub fn as_str(self) -> &'static str {
711 match self {
712 RepositorySelection::All => "all",
713 RepositorySelection::Selected => "selected",
714 RepositorySelection::Public => "public",
715 }
716 }
717
718 pub fn parse(text: &str) -> Option<RepositorySelection> {
719 match text.trim().to_ascii_lowercase().as_str() {
720 "all" => Some(RepositorySelection::All),
721 "selected" => Some(RepositorySelection::Selected),
722 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
723 _ => None,
724 }
725 }
726}
727
728/// What a narrowed token reaches, as identity resolves it on each use.
729#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
730pub struct TokenReach {
731 /// The workspace whose repositories and settings it reaches, by slug as
732 /// it is now. Absent: its owner's account only, with public
733 /// repositories read-only.
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub workspace: Option<String>,
736 #[serde(default)]
737 pub repositories: RepositorySelection,
738 /// With [`RepositorySelection::Selected`]: the repositories' ids.
739 #[serde(default, skip_serializing_if = "Vec::is_empty")]
740 pub repo_ids: Vec<String>,
741}
742
743impl TokenReach {
744 /// Whether it reaches the repository with this id in the workspace
745 /// `namespace` for more than what anyone may do with a public one.
746 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
747 let Some(workspace) = self.workspace.as_deref() else {
748 return false;
749 };
750 if !workspace.eq_ignore_ascii_case(namespace) {
751 return false;
752 }
753 match self.repositories {
754 RepositorySelection::All => true,
755 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
756 RepositorySelection::Public => false,
757 }
758 }
759
760 /// Whether it is made for the workspace `slug`.
761 pub fn owned_by(&self, slug: &str) -> bool {
762 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
763 }
764}
765
766/// Where workflow files live. Adding, changing or deleting a file under
767/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
768/// token: what GitHub's `workflow` scope and `workflows` permission do.
769pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
770
771/// Whether `path` is a workflow file, or a file in one's directory.
772pub fn is_workflow_file(path: &str) -> bool {
773 let path = path.trim_start_matches('/');
774 WORKFLOW_DIRS.iter().any(|dir| {
775 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
776 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
777}
778
779/// Whether a token may add, change or delete the files at `paths`: a
780/// refusal naming the first workflow file it may not touch, else `None`.
781/// A signed-in person (no token) is never refused here; their role decides.
782pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
783 let access = access?;
784 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
785 return None;
786 }
787 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
788 let why = if access.job.is_some() {
789 "a workflow job's token can never add or change workflow files".to_owned()
790 } else {
791 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
792 };
793 Some(Decision::deny(
794 "token:workflows",
795 format!("This access token cannot change the workflow file {path}: {why}."),
796 ))
797}
798
799/// The workflow job a token was made for.
800#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
801pub struct JobToken {
802 /// The run, `run_…`.
803 pub run_id: String,
804 /// The job, `job_…`.
805 pub job_id: String,
806 /// Whether it may open pull requests and approve them, by its
807 /// repository's and workspace's choice ("Allow g1t Actions to create and
808 /// approve pull requests"). Off unless chosen.
809 #[serde(default)]
810 pub pull_requests: bool,
811}
812
813impl TokenAccess {
814 /// Full access to everything: the access tokens made before scopes had.
815 pub fn full() -> Self {
816 TokenAccess::default()
817 }
818
819 /// Whether it may reach the repository `owner/name`: every token but a
820 /// workflow job's, which reaches its own repository only.
821 pub fn reaches(&self, repo: &str) -> bool {
822 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
823 }
824
825 pub fn is_full(&self) -> bool {
826 self.scopes.is_none()
827 }
828
829 /// The scopes it holds, or `None` for full access.
830 pub fn granted(&self) -> Option<Vec<Scope>> {
831 self.scopes
832 .as_ref()
833 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
834 }
835
836 pub fn allows(&self, needed: Scope) -> bool {
837 match self.granted() {
838 None => true,
839 Some(granted) => granted.iter().any(|held| held.includes(needed)),
840 }
841 }
842
843 /// Whether it reaches the repository with this id in `namespace` for
844 /// more than reading a public one: every token but a narrowed one
845 /// outside its workspace or repository selection. Its owner's role
846 /// still decides; see [`crate::access`].
847 pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool {
848 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
849 }
850}
851
852/// Every operation of the API and MCP server, with the scope it needs. An
853/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
854/// its operations is in exactly one of the two.
855pub const OPERATIONS: &[(&str, Scope)] = &[
856 // Your account.
857 ("list_emails", Scope::AccountRead),
858 ("add_email", Scope::AccountWrite),
859 ("confirm_email", Scope::AccountWrite),
860 ("remove_email", Scope::AccountWrite),
861 ("update_email_settings", Scope::AccountWrite),
862 ("list_invites", Scope::AccountRead),
863 ("create_invite", Scope::AccountWrite),
864 ("revoke_invite", Scope::AccountWrite),
865 ("list_invitations", Scope::AccountRead),
866 ("accept_invitation", Scope::AccountWrite),
867 ("decline_invitation", Scope::AccountWrite),
868 ("list_my_repo_invitations", Scope::AccountRead),
869 ("accept_repo_invitation", Scope::AccountWrite),
870 ("decline_repo_invitation", Scope::AccountWrite),
871 // Your pinned projects: a preference of your account.
872 ("list_pinned_projects", Scope::AccountRead),
873 ("pin_project", Scope::AccountWrite),
874 // Your stars: a preference of your account.
875 ("list_starred", Scope::AccountRead),
876 ("check_starred", Scope::AccountRead),
877 ("star_repo", Scope::AccountWrite),
878 ("unstar_repo", Scope::AccountWrite),
879 ("unpin_project", Scope::AccountWrite),
880 ("reorder_pinned_projects", Scope::AccountWrite),
881 // Your inbox: notifications, subscriptions and watching.
882 ("list_notifications", Scope::NotificationsRead),
883 ("get_notification_thread", Scope::NotificationsRead),
884 ("get_thread_subscription", Scope::NotificationsRead),
885 ("get_repo_subscription", Scope::NotificationsRead),
886 ("list_watched_repos", Scope::NotificationsRead),
887 ("mark_notifications_read", Scope::NotificationsWrite),
888 ("mark_thread_read", Scope::NotificationsWrite),
889 ("mark_thread_done", Scope::NotificationsWrite),
890 ("save_thread", Scope::NotificationsWrite),
891 ("snooze_thread", Scope::NotificationsWrite),
892 ("set_thread_subscription", Scope::NotificationsWrite),
893 ("delete_thread_subscription", Scope::NotificationsWrite),
894 ("set_repo_subscription", Scope::NotificationsWrite),
895 ("delete_repo_subscription", Scope::NotificationsWrite),
896 // Workspaces, their invites and integrations.
897 ("create_workspace", Scope::WorkspaceAdmin),
898 ("delete_workspace", Scope::WorkspaceAdmin),
899 ("get_workspace", Scope::WorkspaceRead),
900 ("update_workspace", Scope::WorkspaceAdmin),
901 // Its members, and who owns it.
902 ("list_members", Scope::WorkspaceRead),
903 ("update_member", Scope::WorkspaceAdmin),
904 ("remove_member", Scope::WorkspaceAdmin),
905 ("transfer_ownership", Scope::WorkspaceAdmin),
906 ("leave_workspace", Scope::AccountWrite),
907 ("list_workspace_invites", Scope::WorkspaceRead),
908 ("invite_member", Scope::WorkspaceAdmin),
909 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
910 ("list_integrations", Scope::WorkspaceRead),
911 ("connect_integration", Scope::WorkspaceAdmin),
912 ("update_integration", Scope::WorkspaceAdmin),
913 ("disconnect_integration", Scope::WorkspaceAdmin),
914 ("test_integration", Scope::WorkspaceAdmin),
915 ("get_model_routes", Scope::WorkspaceRead),
916 ("set_model_routes", Scope::WorkspaceAdmin),
917 // Teams: reading them, and managing them. A team's role on a
918 // repository is who has access.
919 ("list_teams", Scope::WorkspaceRead),
920 ("get_team", Scope::WorkspaceRead),
921 ("list_team_members", Scope::WorkspaceRead),
922 ("list_child_teams", Scope::WorkspaceRead),
923 ("list_team_repos", Scope::WorkspaceRead),
924 ("list_user_teams", Scope::WorkspaceRead),
925 ("create_team", Scope::WorkspaceAdmin),
926 ("list_workspace_rulesets", Scope::WorkspaceRead),
927 ("get_workspace_ruleset", Scope::WorkspaceRead),
928 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
929 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
930 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
931 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
932 ("update_team", Scope::WorkspaceAdmin),
933 ("delete_team", Scope::WorkspaceAdmin),
934 ("set_team_member", Scope::WorkspaceAdmin),
935 ("remove_team_member", Scope::WorkspaceAdmin),
936 ("set_team_review_assignment", Scope::WorkspaceAdmin),
937 // A workspace's billing: usage, budget, AI credit and invoices.
938 ("get_usage", Scope::BillingRead),
939 ("get_budget", Scope::BillingRead),
940 ("get_ai_credit", Scope::BillingRead),
941 ("list_invoices", Scope::BillingRead),
942 ("get_billing_details", Scope::BillingRead),
943 ("set_budget", Scope::BillingWrite),
944 ("buy_ai_credit", Scope::BillingWrite),
945 // Repositories.
946 ("list_repos", Scope::RepoRead),
947 ("get_repo", Scope::RepoRead),
948 // Projects follow their repositories.
949 ("list_projects", Scope::RepoRead),
950 ("get_project", Scope::RepoRead),
951 ("search", Scope::RepoRead),
952 ("list_events", Scope::RepoRead),
953 // What the default branch says about a repository, who starred it, and
954 // its releases.
955 ("get_languages", Scope::RepoRead),
956 ("list_contributors", Scope::RepoRead),
957 ("get_license", Scope::RepoRead),
958 ("list_stargazers", Scope::RepoRead),
959 ("list_releases", Scope::RepoRead),
960 ("get_latest_release", Scope::RepoRead),
961 ("get_release_by_tag", Scope::RepoRead),
962 ("get_release", Scope::RepoRead),
963 ("create_release", Scope::RepoWrite),
964 ("update_release", Scope::RepoWrite),
965 ("delete_release", Scope::RepoWrite),
966 ("list_labels", Scope::RepoRead),
967 ("list_milestones", Scope::RepoRead),
968 ("get_milestone", Scope::RepoRead),
969 ("create_label", Scope::IssuesWrite),
970 ("update_label", Scope::IssuesWrite),
971 ("delete_label", Scope::IssuesWrite),
972 ("add_default_labels", Scope::IssuesWrite),
973 ("create_milestone", Scope::IssuesWrite),
974 ("update_milestone", Scope::IssuesWrite),
975 ("delete_milestone", Scope::IssuesWrite),
976 ("get_repo_settings", Scope::RepoRead),
977 ("list_check_names", Scope::RepoRead),
978 ("list_deleted_repos", Scope::RepoRead),
979 ("list_security_alerts", Scope::RepoRead),
980 ("get_codeowners_errors", Scope::RepoRead),
981 ("create_repo", Scope::RepoWrite),
982 ("update_repo", Scope::RepoWrite),
983 ("update_project", Scope::RepoWrite),
984 ("update_repo_settings", Scope::RepoWrite),
985 // Rulesets: reading them is reading the repository; changing them
986 // changes what everyone, agents included, may do, so it is admin.
987 ("list_repo_rulesets", Scope::RepoRead),
988 ("get_repo_ruleset", Scope::RepoRead),
989 ("get_branch_rules", Scope::RepoRead),
990 ("list_rule_evaluations", Scope::RepoRead),
991 ("create_repo_ruleset", Scope::RepoAdmin),
992 ("update_repo_ruleset", Scope::RepoAdmin),
993 ("delete_repo_ruleset", Scope::RepoAdmin),
994 ("rename_branch", Scope::RepoWrite),
995 ("rename_repo", Scope::RepoAdmin),
996 ("transfer_repo", Scope::RepoAdmin),
997 ("archive_repo", Scope::RepoAdmin),
998 ("unarchive_repo", Scope::RepoAdmin),
999 ("set_repo_visibility", Scope::RepoAdmin),
1000 ("delete_repo", Scope::RepoAdmin),
1001 ("restore_repo", Scope::RepoAdmin),
1002 ("purge_repo", Scope::RepoAdmin),
1003 // A dismissed secret is let through push protection.
1004 ("dismiss_security_alert", Scope::RepoAdmin),
1005 ("reopen_security_alert", Scope::RepoAdmin),
1006 // The security suite: alerts, push protection, patterns, code
1007 // scanning, the supply chain and settings.
1008 ("list_secret_scanning_alerts", Scope::SecurityRead),
1009 ("get_secret_scanning_alert", Scope::SecurityRead),
1010 ("list_secret_scanning_locations", Scope::SecurityRead),
1011 ("list_bypass_requests", Scope::SecurityRead),
1012 ("list_custom_patterns", Scope::SecurityRead),
1013 ("list_code_scanning_alerts", Scope::SecurityRead),
1014 ("get_code_scanning_alert", Scope::SecurityRead),
1015 ("list_code_scanning_analyses", Scope::SecurityRead),
1016 ("get_sarif_upload", Scope::SecurityRead),
1017 ("list_vulnerability_alerts", Scope::SecurityRead),
1018 ("get_vulnerability_alert", Scope::SecurityRead),
1019 ("get_dependency_graph", Scope::SecurityRead),
1020 ("get_sbom", Scope::SecurityRead),
1021 ("compare_dependencies", Scope::SecurityRead),
1022 ("get_security_settings", Scope::SecurityRead),
1023 ("get_workspace_security_settings", Scope::SecurityRead),
1024 ("get_security_overview", Scope::SecurityRead),
1025 ("update_secret_scanning_alert", Scope::SecurityWrite),
1026 ("bypass_push_protection", Scope::SecurityWrite),
1027 ("check_secret_validity", Scope::SecurityWrite),
1028 ("review_bypass_request", Scope::SecurityWrite),
1029 ("create_custom_pattern", Scope::SecurityWrite),
1030 ("update_custom_pattern", Scope::SecurityWrite),
1031 ("delete_custom_pattern", Scope::SecurityWrite),
1032 ("dry_run_custom_pattern", Scope::SecurityWrite),
1033 ("update_code_scanning_alert", Scope::SecurityWrite),
1034 ("upload_sarif", Scope::SecurityWrite),
1035 ("update_vulnerability_alert", Scope::SecurityWrite),
1036 ("fix_security_alert", Scope::SecurityWrite),
1037 ("update_security_settings", Scope::SecurityWrite),
1038 ("update_workspace_security_settings", Scope::SecurityWrite),
1039 // Issues and plans.
1040 ("list_issues", Scope::IssuesRead),
1041 ("get_issue", Scope::IssuesRead),
1042 ("get_plan", Scope::IssuesRead),
1043 ("create_issue", Scope::IssuesWrite),
1044 ("update_issue", Scope::IssuesWrite),
1045 ("list_issue_labels", Scope::IssuesRead),
1046 ("add_issue_labels", Scope::IssuesWrite),
1047 ("set_issue_labels", Scope::IssuesWrite),
1048 ("remove_issue_labels", Scope::IssuesWrite),
1049 ("close_issue", Scope::IssuesWrite),
1050 ("reopen_issue", Scope::IssuesWrite),
1051 ("add_comment", Scope::IssuesWrite),
1052 ("edit_comment", Scope::IssuesWrite),
1053 ("delete_comment", Scope::IssuesWrite),
1054 ("import_issue", Scope::IssuesWrite),
1055 ("apply_plan", Scope::IssuesWrite),
1056 // Pull requests.
1057 ("list_pull_requests", Scope::PullRequestsRead),
1058 ("get_pull_request", Scope::PullRequestsRead),
1059 ("get_pull_request_changes", Scope::PullRequestsRead),
1060 ("read_session", Scope::PullRequestsRead),
1061 ("get_merge_queue", Scope::PullRequestsRead),
1062 ("create_pull_request", Scope::PullRequestsWrite),
1063 ("update_pull_request", Scope::PullRequestsWrite),
1064 ("record_session", Scope::PullRequestsWrite),
1065 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1066 ("close_pull_request", Scope::PullRequestsWrite),
1067 ("reopen_pull_request", Scope::PullRequestsWrite),
1068 ("convert_pull_request_to_draft", Scope::PullRequestsWrite),
1069 ("review_pull_request", Scope::PullRequestsWrite),
1070 ("merge_pull_request", Scope::PullRequestsWrite),
1071 ("request_reviewers", Scope::PullRequestsWrite),
1072 ("remove_requested_reviewers", Scope::PullRequestsWrite),
1073 // g1t's agents.
1074 ("assign_issue", Scope::AgentsRun),
1075 ("delegate", Scope::AgentsRun),
1076 ("plan_work", Scope::AgentsRun),
1077 ("message_agent", Scope::AgentsRun),
1078 ("answer_message", Scope::AgentsRun),
1079 // A workspace agent's own computer: waking it spends the workspace's
1080 // money, so the same scope covers reading and managing it.
1081 ("get_agent_computer", Scope::AgentsRun),
1082 ("wake_agent_computer", Scope::AgentsRun),
1083 ("sleep_agent_computer", Scope::AgentsRun),
1084 ("reset_agent_computer", Scope::AgentsRun),
1085 ("list_agent_computer_commands", Scope::AgentsRun),
1086 ("take_messages", Scope::AgentsRun),
1087 // Workflows.
1088 ("list_workflows", Scope::WorkflowsRead),
1089 ("list_workflow_runs", Scope::WorkflowsRead),
1090 ("get_workflow_run", Scope::WorkflowsRead),
1091 ("get_job_logs", Scope::WorkflowsRead),
1092 ("dispatch_workflow", Scope::WorkflowsWrite),
1093 ("cancel_workflow_run", Scope::WorkflowsWrite),
1094 ("rerun_workflow_run", Scope::WorkflowsWrite),
1095 ("update_workflow", Scope::WorkflowsWrite),
1096 ("list_artifacts", Scope::WorkflowsRead),
1097 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1098 ("get_artifact", Scope::WorkflowsRead),
1099 ("download_artifact", Scope::WorkflowsRead),
1100 ("get_artifact_retention", Scope::WorkflowsRead),
1101 ("delete_artifact", Scope::WorkflowsWrite),
1102 ("set_artifact_retention", Scope::WorkflowsWrite),
1103 // Checks: statuses, check runs and check suites on commits.
1104 ("list_commit_statuses", Scope::ChecksRead),
1105 ("get_combined_status", Scope::ChecksRead),
1106 ("list_check_runs_for_ref", Scope::ChecksRead),
1107 ("get_check_run", Scope::ChecksRead),
1108 ("list_check_run_annotations", Scope::ChecksRead),
1109 ("list_check_suites_for_ref", Scope::ChecksRead),
1110 ("get_check_suite", Scope::ChecksRead),
1111 ("create_commit_status", Scope::ChecksWrite),
1112 ("create_check_run", Scope::ChecksWrite),
1113 ("update_check_run", Scope::ChecksWrite),
1114 ("rerequest_check_run", Scope::ChecksWrite),
1115 ("rerequest_check_suite", Scope::ChecksWrite),
1116 // Deployments, wherever they run: reading them, and reporting them.
1117 ("list_deployments", Scope::DeploymentsRead),
1118 ("get_deployment", Scope::DeploymentsRead),
1119 ("list_deployment_statuses", Scope::DeploymentsRead),
1120 ("list_environments", Scope::DeploymentsRead),
1121 ("get_environment", Scope::DeploymentsRead),
1122 ("create_deployment", Scope::DeploymentsWrite),
1123 ("create_deployment_status", Scope::DeploymentsWrite),
1124 // What keeps runs safe: the runs environments hold and reviewing them,
1125 // approving a pull request's run, and a repository's own rules for
1126 // its environments and tokens, which are an admin's.
1127 ("get_pending_deployments", Scope::WorkflowsRead),
1128 ("review_pending_deployments", Scope::WorkflowsWrite),
1129 ("approve_workflow_run", Scope::WorkflowsWrite),
1130 ("get_workflow_permissions", Scope::RepoRead),
1131 ("get_fork_pr_approval", Scope::RepoRead),
1132 ("get_actions_access", Scope::RepoRead),
1133 ("update_environment", Scope::RepoAdmin),
1134 ("delete_environment", Scope::RepoAdmin),
1135 ("set_workflow_permissions", Scope::RepoAdmin),
1136 ("set_fork_pr_approval", Scope::RepoAdmin),
1137 ("set_actions_access", Scope::RepoAdmin),
1138 // Starting workflows from outside, as a push would.
1139 ("create_repository_dispatch", Scope::CodeWrite),
1140 // A workspace's policy for its repositories' tokens.
1141 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1142 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
1143 // A workspace's rules for personal access tokens, and the members'
1144 // tokens that reach it: who has access.
1145 ("get_token_policy", Scope::WorkspaceRead),
1146 ("set_token_policy", Scope::WorkspaceAdmin),
1147 ("list_member_tokens", Scope::AccessRead),
1148 ("list_token_requests", Scope::AccessRead),
1149 ("review_token_request", Scope::AccessAdmin),
1150 ("revoke_member_token", Scope::AccessAdmin),
1151 // Memory and the context hub.
1152 ("recall", Scope::MemoryRead),
1153 ("search_context", Scope::MemoryRead),
1154 ("get_entity", Scope::MemoryRead),
1155 ("get_context", Scope::MemoryRead),
1156 ("remember", Scope::MemoryWrite),
1157 // Who has access.
1158 ("list_collaborators", Scope::AccessRead),
1159 ("get_collaborator_permission", Scope::AccessRead),
1160 ("list_repo_invitations", Scope::AccessRead),
1161 ("list_outside_collaborators", Scope::AccessRead),
1162 ("add_collaborator", Scope::AccessAdmin),
1163 ("update_collaborator", Scope::AccessAdmin),
1164 ("remove_collaborator", Scope::AccessAdmin),
1165 ("revoke_repo_invitation", Scope::AccessAdmin),
1166 ("set_base_permission", Scope::AccessAdmin),
1167 ("set_team_repo", Scope::AccessAdmin),
1168 ("remove_team_repo", Scope::AccessAdmin),
1169 // Deploy keys: each lets a machine reach one repository, so they
1170 // are part of who has access.
1171 ("list_deploy_keys", Scope::AccessRead),
1172 ("get_deploy_key", Scope::AccessRead),
1173 ("create_deploy_key", Scope::AccessAdmin),
1174 ("delete_deploy_key", Scope::AccessAdmin),
1175 // Mirroring: a repository's links to other hosts. Reading them is
1176 // reading the repository; syncing writes code; the rest is an admin's.
1177 ("get_mirror", Scope::RepoRead),
1178 ("sync_mirror", Scope::CodeWrite),
1179 ("get_hand_back_plan", Scope::RepoAdmin),
1180 ("take_over_mirror", Scope::RepoAdmin),
1181 ("set_ci_failover", Scope::RepoAdmin),
1182 ("hand_back_mirror", Scope::RepoAdmin),
1183 ("move_mirror_to_g1t", Scope::RepoAdmin),
1184 ("add_mirror_remote", Scope::RepoAdmin),
1185 ("update_mirror_remote", Scope::RepoAdmin),
1186 ("remove_mirror_remote", Scope::RepoAdmin),
1187 // Webhooks.
1188 ("list_webhooks", Scope::WebhooksRead),
1189 ("list_webhook_deliveries", Scope::WebhooksRead),
1190 ("create_webhook", Scope::WebhooksAdmin),
1191 ("update_webhook", Scope::WebhooksAdmin),
1192 ("delete_webhook", Scope::WebhooksAdmin),
1193 ("ping_webhook", Scope::WebhooksAdmin),
1194 ("redeliver_webhook", Scope::WebhooksAdmin),
1195 // Secrets and variables.
1196 ("list_actions_secrets", Scope::SecretsRead),
1197 ("list_actions_variables", Scope::SecretsRead),
1198 ("set_actions_secret", Scope::SecretsAdmin),
1199 ("delete_actions_secret", Scope::SecretsAdmin),
1200 ("set_actions_variable", Scope::SecretsAdmin),
1201 ("delete_actions_variable", Scope::SecretsAdmin),
1202 // Self-hosted runners.
1203 ("list_runners", Scope::RunnersRead),
1204 ("list_runner_groups", Scope::RunnersRead),
1205 ("get_runner_settings", Scope::RunnersRead),
1206 ("create_runner_registration_token", Scope::RunnersAdmin),
1207 ("remove_runner", Scope::RunnersAdmin),
1208 ("create_runner_group", Scope::RunnersAdmin),
1209 ("update_runner_group", Scope::RunnersAdmin),
1210 ("delete_runner_group", Scope::RunnersAdmin),
1211 ("update_runner_settings", Scope::RunnersAdmin),
1212 // Packages: reading them, their versions and who may use them needs
1213 // `packages:read`; changing their settings, access and Manage Actions
1214 // access `packages:write` (and the Admin role on the package, which the
1215 // packages service checks); deleting and restoring packages and
1216 // versions `packages:delete`, as the registries' own deletes do.
1217 ("list_packages", Scope::PackagesRead),
1218 ("get_package", Scope::PackagesRead),
1219 ("list_package_versions", Scope::PackagesRead),
1220 ("get_package_version", Scope::PackagesRead),
1221 ("list_package_access", Scope::PackagesRead),
1222 ("list_package_actions_access", Scope::PackagesRead),
1223 ("update_package", Scope::PackagesWrite),
1224 ("link_package", Scope::PackagesWrite),
1225 ("unlink_package", Scope::PackagesWrite),
1226 ("set_package_access", Scope::PackagesWrite),
1227 ("remove_package_access", Scope::PackagesWrite),
1228 ("set_package_actions_access", Scope::PackagesWrite),
1229 ("remove_package_actions_access", Scope::PackagesWrite),
1230 ("delete_package", Scope::PackagesDelete),
1231 ("restore_package", Scope::PackagesDelete),
1232 ("delete_package_version", Scope::PackagesDelete),
1233 ("restore_package_version", Scope::PackagesDelete),
1234 // The AI Gateway. Sending a request to a model needs `models:write`,
1235 // checked by the model proxy at models.g1t.sh, not here.
1236 ("list_gateway_requests", Scope::ModelsRead),
1237 // Artifacts mode's docs, slides, designs and dashboards (the `artifact`
1238 // MCP tool). Reading takes artifacts:read, making and changing them
1239 // artifacts:write, and sharing them or deleting them for good
1240 // artifacts:admin. The artifacts service then checks the person's own role
1241 // on each one.
1242 ("list_workspace_artifacts", Scope::ArtifactsRead),
1243 ("search_workspace_artifacts", Scope::ArtifactsRead),
1244 ("get_workspace_artifact", Scope::ArtifactsRead),
1245 ("get_workspace_artifact_content", Scope::ArtifactsRead),
1246 ("list_workspace_artifact_versions", Scope::ArtifactsRead),
1247 ("get_workspace_artifact_access", Scope::ArtifactsRead),
1248 ("list_workspace_artifact_templates", Scope::ArtifactsRead),
1249 ("list_workspace_artifact_spaces", Scope::ArtifactsRead),
1250 ("query_workspace_dataset", Scope::ArtifactsRead),
1251 ("create_workspace_artifact", Scope::ArtifactsWrite),
1252 ("update_workspace_artifact", Scope::ArtifactsWrite),
1253 ("edit_workspace_artifact", Scope::ArtifactsWrite),
1254 ("trash_workspace_artifact", Scope::ArtifactsWrite),
1255 ("restore_workspace_artifact", Scope::ArtifactsWrite),
1256 ("restore_workspace_artifact_version", Scope::ArtifactsWrite),
1257 ("set_workspace_artifact_access", Scope::ArtifactsAdmin),
1258 ("purge_workspace_artifact", Scope::ArtifactsAdmin),
1259];
1260
1261/// Operations any token may use: saying who it is.
1262pub const NO_SCOPE: &[&str] = &["whoami"];
1263
1264/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1265/// operation in neither list needs full access.
1266pub fn scope_for(operation: &str) -> Option<Scope> {
1267 OPERATIONS
1268 .iter()
1269 .find(|(name, _)| *name == operation)
1270 .map(|(_, scope)| *scope)
1271}
1272
1273/// What a token needs for `operation` with this input beyond its own
1274/// scope: starting agents from an operation that can, and making a
1275/// repository public or private.
1276pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1277 let mut extra = Vec::new();
1278 let assigns = input["assign"].as_bool() == Some(true)
1279 || input["agent"].as_bool() == Some(true)
1280 || input["assign_agent"].as_bool() == Some(true);
1281 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1282 extra.push(Scope::AgentsRun);
1283 }
1284 // Fixing an alert opens an issue and puts g1t on it.
1285 if operation == "fix_security_alert" {
1286 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1287 }
1288 // Opening the issue an agent is put on.
1289 if operation == "delegate" {
1290 extra.push(Scope::IssuesWrite);
1291 }
1292 // A workspace's base permission is who has access.
1293 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1294 extra.push(Scope::AccessAdmin);
1295 }
1296 // Asking a g1t Actions job or run to run again reruns its workflow.
1297 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1298 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1299 {
1300 extra.push(Scope::WorkflowsWrite);
1301 }
1302 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())) {
1303 extra.push(Scope::RepoAdmin);
1304 }
1305 extra
1306}
1307
1308/// The scopes a call needs, its own first.
1309pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1310 scope_for(operation)
1311 .into_iter()
1312 .chain(extra_scopes(operation, input))
1313 .collect()
1314}
1315
1316/// Whether `access` may use `operation` with `input`. The person's (or
1317/// workspace's) role is checked after this, by the service that owns what
1318/// was asked about.
1319pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1320 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
1321 // A workflow job may open or approve pull requests only where its
1322 // repository and workspace let it, as on GitHub.
1323 if let Some(job) = &access.job
1324 && !job.pull_requests
1325 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1326 {
1327 return Decision::deny(
1328 "token:pull-requests",
1329 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1330 );
1331 }
1332 if let Some(only) = access.repo.as_deref()
1333 && !NO_SCOPE.contains(&operation)
1334 {
1335 match input["repo"].as_str() {
1336 Some(repo) if access.reaches(repo) => {}
1337 Some(repo) => {
1338 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1339 }
1340 None => {
1341 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1342 }
1343 }
1344 }
1345 // A token made for one workspace (or none) only reads outside it:
1346 // public repositories, as anyone may. Inside it, its repository
1347 // selection is checked with its owner's role (`access::granted`).
1348 if let Some(reach) = &access.reach
1349 && let Some(repo) = input["repo"].as_str()
1350 && !NO_SCOPE.contains(&operation)
1351 {
1352 let namespace = repo.split('/').next().unwrap_or_default();
1353 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1354 if changes && !reach.owned_by(namespace) {
1355 let made_for = reach.workspace.as_deref().map_or_else(|| "your account only".to_owned(), |workspace| format!("the workspace {workspace}"));
1356 return Decision::deny(
1357 "token:resource-owner",
1358 format!("This access token is made for {made_for}: elsewhere it can only read public repositories, and {repo} is not in its reach."),
1359 );
1360 }
1361 }
1362 if access.scopes.is_some() {
1363 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1364 if !known {
1365 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1366 }
1367 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1368 return Decision::deny(
1369 "token:scope",
1370 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1371 );
1372 }
1373 }
1374 Decision::allow(rule)
1375}
1376
1377/// Whether a token may use the repository `owner/name` at all: a refusal
1378/// for a workflow job's token or a deploy key in another repository,
1379/// else `None`. Git and
1380/// the package registries ask this before [`decide_git`] and
1381/// [`decide_packages`].
1382pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1383 let only = access.repo.as_deref()?;
1384 let why = if access.deploy_key.is_some() {
1385 format!("This deploy key is for {only}: it cannot reach {repo}.")
1386 } else {
1387 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1388 };
1389 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
1390}
1391
1392/// Whether a token may clone or fetch (`write` false), or push to (`write`
1393/// true), a repository with git. `public` is whether anyone may read it,
1394/// which needs no scope.
1395pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1396 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1397 if !access.allows(needed) && (write || !public) {
1398 if access.deploy_key.is_some() {
1399 return Decision::deny(
1400 "token:scope",
1401 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1402 );
1403 }
1404 return Decision::deny(
1405 "token:scope",
1406 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1407 );
1408 }
1409 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1410}
1411
1412/// Whether a token may pull (`Level::Read`), push or publish
1413/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1414/// whether anyone may pull the package, which needs no scope.
1415pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1416 let (needed, doing) = match level {
1417 Level::Read => (Scope::PackagesRead, "pull a private package"),
1418 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1419 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1420 };
1421 if !access.allows(needed) && !(level == Level::Read && public) {
1422 return Decision::deny(
1423 "token:scope",
1424 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1425 );
1426 }
1427 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use super::*;
1433 use serde_json::json;
1434
1435 fn token(scopes: &[Scope]) -> TokenAccess {
1436 TokenAccess {
1437 token_id: "tok_1".to_owned(),
1438 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1439 legacy: false,
1440 name: None,
1441 ..TokenAccess::default()
1442 }
1443 }
1444
1445 #[test]
1446 fn every_scope_reads_back_and_belongs_to_a_resource() {
1447 for scope in Scope::ALL {
1448 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1449 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1450 assert!(scope.includes(scope));
1451 }
1452 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1453 assert_eq!(Scope::parse("issues"), None);
1454 }
1455
1456 #[test]
1457 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1458 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1459 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1460 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1461 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1462 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1463 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1464 }
1465
1466 #[test]
1467 fn operations_are_listed_once_and_never_also_free() {
1468 let mut seen = std::collections::HashSet::new();
1469 for (name, _) in OPERATIONS {
1470 assert!(seen.insert(*name), "{name} twice");
1471 assert!(!NO_SCOPE.contains(name), "{name}");
1472 }
1473 }
1474
1475 #[test]
1476 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1477 assert_eq!(
1478 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1479 vec![Scope::RepoRead, Scope::IssuesWrite]
1480 );
1481 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1482 }
1483
1484 #[test]
1485 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1486 let scopes = oauth_default();
1487 assert!(scopes.contains(&Scope::IssuesWrite));
1488 assert!(scopes.contains(&Scope::PullRequestsWrite));
1489 assert!(scopes.contains(&Scope::AgentsRun));
1490 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
1491 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1492 // Every read offered but the machines work runs on.
1493 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
1494 }
1495 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1496 assert_eq!(Preset::Full.scopes(), None);
1497 }
1498
1499 #[test]
1500 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1501 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1502 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1503 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1504 }
1505 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1506 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1507 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1508 let reader = token(&[Scope::BillingRead]);
1509 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1510 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1511 }
1512
1513 #[test]
1514 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1515 // Reading the log is a read like any other.
1516 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1517 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1518 // Sending requests spends the workspace's AI credit: chosen on purpose.
1519 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1520 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1521 }
1522 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1523 assert!(!Scope::ModelsWrite.dangerous());
1524 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1525 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1526 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1527 }
1528
1529 #[test]
1530 fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() {
1531 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1532 assert_eq!(scope.resource(), Resource::Artifacts);
1533 assert!(scope.offered());
1534 assert!(offered_scopes().contains(&scope));
1535 assert_eq!(parse_scopes(scope.as_str()), vec![scope]);
1536 assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing");
1537 }
1538 // Reading them is a read like any other; changing them is chosen.
1539 for preset in [Preset::ReadOnly, Preset::Agent] {
1540 let scopes = preset.scopes().unwrap();
1541 assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str());
1542 assert!(!scopes.contains(&Scope::ArtifactsWrite) && !scopes.contains(&Scope::ArtifactsAdmin), "{}", preset.as_str());
1543 }
1544 assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead));
1545 assert!(everything().contains(&Scope::ArtifactsAdmin));
1546 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1547 assert!(Scope::ArtifactsAdmin.dangerous());
1548 assert!(!Scope::ArtifactsWrite.dangerous());
1549 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1550 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]);
1551 assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite]));
1552 assert_eq!(offered_scopes().len(), Scope::ALL.len());
1553 // Sharing and deleting for good need admin; editing needs write.
1554 assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin));
1555 assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin));
1556 assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite));
1557 assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead));
1558 let writer = token(&[Scope::ArtifactsWrite]);
1559 assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed);
1560 assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read");
1561 assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin"));
1562 // Workflow runs' artifacts are another thing, with their own scope.
1563 let reader = token(&[Scope::ArtifactsRead]);
1564 assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed);
1565 assert!(!decide(&token(&[Scope::WorkflowsRead]), "list_workspace_artifacts", &json!({})).allowed);
1566 }
1567
1568 #[test]
1569 fn checks_are_reported_with_checks_write_which_ci_gets() {
1570 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1571 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1572 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1573 let ci = Preset::Ci.scopes().unwrap();
1574 assert!(ci.contains(&Scope::ChecksWrite));
1575 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1576 let reporter = token(&[Scope::ChecksWrite]);
1577 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1578 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1579 // A g1t Actions job runs again as its workflow does.
1580 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1581 assert!(refused.reason.unwrap().contains("workflows:write"));
1582 }
1583
1584 #[test]
1585 fn a_job_token_reaches_its_repository_only() {
1586 let job = TokenAccess {
1587 repo: Some("acme/web".into()),
1588 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1589 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1590 };
1591 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1592 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1593 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1594 assert!(!elsewhere.allowed);
1595 assert_eq!(elsewhere.rule, "token:repository");
1596 // Nothing beyond the one repository, a workspace's listing included.
1597 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1598 assert!(decide(&job, "whoami", &json!({})).allowed);
1599 // Its scopes still hold inside it.
1600 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1601 assert!(decide_repo(&job, "acme/web").is_none());
1602 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1603 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1604 // Opening and approving pull requests is off unless allowed.
1605 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1606 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1607 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1608 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1609 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1610 }
1611
1612 #[test]
1613 fn workflow_files_need_their_own_scope() {
1614 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1615 assert!(is_workflow_file(path), "{path}");
1616 }
1617 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1618 assert!(!is_workflow_file(path), "{path}");
1619 }
1620 let code = token(&[Scope::CodeWrite]);
1621 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1622 assert_eq!(refused.rule, "token:workflows");
1623 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1624 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1625 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1626 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1627 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1628 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1629 // A job's token never may, as GITHUB_TOKEN never may.
1630 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1631 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1632 // Nothing in a preset changes workflow files but full access.
1633 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1634 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1635 }
1636 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1637 }
1638
1639 #[test]
1640 fn a_narrowed_token_only_reads_outside_its_workspace() {
1641 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1642 let fine = TokenAccess { reach: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) };
1643 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1644 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1645 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1646 assert_eq!(elsewhere.rule, "token:resource-owner");
1647 assert!(elsewhere.reason.unwrap().contains("acme"));
1648 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1649 assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold");
1650 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
1651 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1652 assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex"));
1653 let selected = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] };
1654 assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme"));
1655 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
1656 assert!(!public.covers("rep_1", "acme") && public.owned_by("acme"));
1657 assert!(token(&[]).covers_repo("rep_1", "anything"), "a token for every workspace reaches what its owner can");
1658 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1659 }
1660
1661 #[test]
1662 fn permissions_are_scopes_read_per_resource() {
1663 let asked: std::collections::BTreeMap<String, String> =
1664 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1665 let scopes = resolve_permissions(&asked, true).unwrap();
1666 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1667 let back = permissions_of(&scopes);
1668 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1669 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1670 assert!(!back.contains_key("code"));
1671 // Lower levels held beside a higher one say nothing more.
1672 assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]);
1673 // Every offered resource's top, and nothing a level can lose.
1674 let all = everything();
1675 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1676 for scope in offered_scopes() {
1677 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1678 }
1679 }
1680
1681 #[test]
1682 fn permissions_are_checked_by_name_level_and_owner() {
1683 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1684 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1685 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1686 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1687 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1688 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1689 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1690 for resource in Resource::ALL {
1691 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1692 assert!(!resource.scopes().is_empty());
1693 }
1694 }
1695
1696 #[test]
1697 fn a_legacy_token_can_do_everything() {
1698 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1699 for (operation, _) in OPERATIONS {
1700 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1701 }
1702 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1703 }
1704
1705 #[test]
1706 fn a_missing_scope_is_named() {
1707 let read = token(&[Scope::IssuesRead]);
1708 assert!(decide(&read, "get_issue", &json!({})).allowed);
1709 assert!(decide(&read, "whoami", &json!({})).allowed);
1710 let refused = decide(&read, "create_issue", &json!({}));
1711 assert!(!refused.allowed);
1712 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1713 // An operation the table does not know needs full access.
1714 assert!(!decide(&read, "something_new", &json!({})).allowed);
1715 }
1716
1717 #[test]
1718 fn starting_agents_from_another_operation_needs_agents_run() {
1719 let writer = token(&[Scope::IssuesWrite]);
1720 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1721 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1722 assert!(refused.reason.unwrap().contains("agents:run"));
1723 let maintainer = token(&[Scope::RepoWrite]);
1724 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1725 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1726 }
1727
1728 #[test]
1729 fn a_workspaces_base_permission_needs_access_admin_too() {
1730 let admin = token(&[Scope::WorkspaceAdmin]);
1731 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1732 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1733 assert!(refused.reason.unwrap().contains("access:admin"));
1734 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1735 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1736 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1737 }
1738
1739 #[test]
1740 fn delegating_needs_both_agents_and_issues() {
1741 let agents = token(&[Scope::AgentsRun]);
1742 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1743 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1744 assert!(decide(&both, "delegate", &json!({})).allowed);
1745 }
1746
1747 #[test]
1748 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1749 let reader = token(&[Scope::CodeRead]);
1750 assert!(decide_git(&reader, false, false).allowed);
1751 let refused = decide_git(&reader, true, false);
1752 assert!(!refused.allowed);
1753 assert!(refused.reason.unwrap().contains("code:write"));
1754 let issues = token(&[Scope::IssuesWrite]);
1755 assert!(!decide_git(&issues, false, false).allowed);
1756 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1757 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1758 let writer = token(&[Scope::CodeWrite]);
1759 assert!(decide_git(&writer, true, false).allowed);
1760 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1761 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1762 }
1763
1764 #[test]
1765 fn packages_need_their_own_scopes_and_public_pulls_none() {
1766 let reader = token(&[Scope::PackagesRead]);
1767 assert!(decide_packages(&reader, Level::Read, false).allowed);
1768 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1769 let code = token(&[Scope::CodeWrite]);
1770 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1771 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1772 let writer = token(&[Scope::PackagesWrite]);
1773 assert!(decide_packages(&writer, Level::Write, false).allowed);
1774 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1775 let refused = decide_packages(&writer, Level::Delete, false);
1776 assert!(refused.reason.unwrap().contains("packages:delete"));
1777 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1778 assert!(Scope::PackagesDelete.dangerous());
1779 // Tokens made before these scopes, and full-access ones, keep working.
1780 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1781 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1782 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1783 }
1784
1785 #[test]
1786 fn token_access_travels_as_json() {
1787 let access = token(&[Scope::IssuesRead]);
1788 let wire = serde_json::to_value(&access).unwrap();
1789 assert_eq!(wire["scopes"], json!(["issues:read"]));
1790 assert!(wire.get("resources").is_none());
1791 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1792 assert_eq!(back, access);
1793 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1794 assert!(full.is_full());
1795 // A reach written by an older version is ignored: a token reaches
1796 // whatever its owner can.
1797 let older: TokenAccess = serde_json::from_value(json!({
1798 "token_id": "tok_1",
1799 "scopes": ["issues:read"],
1800 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1801 }))
1802 .unwrap();
1803 assert_eq!(older, access);
1804 // Using the website is off unless set, and said only when on.
1805 assert!(!access.website);
1806 assert!(wire_of(&access).get("website").is_none());
1807 let website = TokenAccess { website: true, ..access };
1808 assert_eq!(wire_of(&website)["website"], json!(true));
1809 // Never part of full access.
1810 assert!(!TokenAccess::full().website);
1811 }
1812
1813 fn wire_of(access: &TokenAccess) -> serde_json::Value {
1814 serde_json::to_value(access).unwrap()
1815 }
1816
1817 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1818 /// lists the same scopes in the same order, the same operations with
1819 /// the same scopes, and the same presets.
1820 #[test]
1821 fn the_typescript_mirror_has_the_same_table() {
1822 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1823 let section = |start: &str| {
1824 ts.split_once(start)
1825 .and_then(|(_, rest)| rest.split_once("] as const"))
1826 .map(|(table, _)| table)
1827 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1828 };
1829 let names = |table: &str| -> Vec<String> {
1830 section(table)
1831 .lines()
1832 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1833 .collect()
1834 };
1835 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1836 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1837 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1838 assert_eq!(names("export const SCOPES = ["), offered);
1839 assert_eq!(names("export const UPCOMING_SCOPES"), upcoming);
1840 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1841 .lines()
1842 .filter_map(|line| {
1843 let mut quoted = line.split('"').skip(1).step_by(2);
1844 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1845 })
1846 .collect();
1847 let expected: Vec<(String, String)> = OPERATIONS
1848 .iter()
1849 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1850 .collect();
1851 assert_eq!(operations, expected);
1852 for preset in Preset::ALL {
1853 let list = section(&format!("{}: [", preset.as_str()));
1854 let mirrored: Vec<&str> = list
1855 .split(',')
1856 .map(|item| item.trim().trim_matches('"'))
1857 .filter(|item| !item.is_empty())
1858 .collect();
1859 let expected: Vec<&str> = preset
1860 .scopes()
1861 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1862 .unwrap_or_else(|| vec!["*"]);
1863 assert_eq!(mirrored, expected, "{}", preset.as_str());
1864 }
1865 // Each resource with its group, in the same order: offered ones in
1866 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1867 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1868 let resources = ts
1869 .split_once(table)
1870 .and_then(|(_, rest)| rest.split_once("
1871];"))
1872 .map(|(table, _)| table)
1873 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1874 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1875 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1876 assert_eq!(rows.len(), expected.len(), "{table}");
1877 for (row, resource) in rows.iter().zip(expected) {
1878 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1879 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1880 }
1881 }
1882 }
1883}