Skip to content
1,859 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, until Phase 3 of docs/ARTIFACTS_MODE.md.
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}
687
688/// Which repositories a token reaches in the workspace it is made for.
689#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
690#[serde(rename_all = "snake_case")]
691pub enum RepositorySelection {
692 /// Every repository of the workspace, ones made later included.
693 #[default]
694 All,
695 /// The repositories chosen, by id.
696 Selected,
697 /// None of the workspace's private repositories: public repositories,
698 /// read-only, and the workspace's own settings its permissions allow.
699 /// With no workspace: the owner's account and public repositories only.
700 Public,
701}
702
703impl RepositorySelection {
704 pub fn as_str(self) -> &'static str {
705 match self {
706 RepositorySelection::All => "all",
707 RepositorySelection::Selected => "selected",
708 RepositorySelection::Public => "public",
709 }
710 }
711
712 pub fn parse(text: &str) -> Option<RepositorySelection> {
713 match text.trim().to_ascii_lowercase().as_str() {
714 "all" => Some(RepositorySelection::All),
715 "selected" => Some(RepositorySelection::Selected),
716 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
717 _ => None,
718 }
719 }
720}
721
722/// What a narrowed token reaches, as identity resolves it on each use.
723#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
724pub struct TokenReach {
725 /// The workspace whose repositories and settings it reaches, by slug as
726 /// it is now. Absent: its owner's account only, with public
727 /// repositories read-only.
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub workspace: Option<String>,
730 #[serde(default)]
731 pub repositories: RepositorySelection,
732 /// With [`RepositorySelection::Selected`]: the repositories' ids.
733 #[serde(default, skip_serializing_if = "Vec::is_empty")]
734 pub repo_ids: Vec<String>,
735}
736
737impl TokenReach {
738 /// Whether it reaches the repository with this id in the workspace
739 /// `namespace` for more than what anyone may do with a public one.
740 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
741 let Some(workspace) = self.workspace.as_deref() else {
742 return false;
743 };
744 if !workspace.eq_ignore_ascii_case(namespace) {
745 return false;
746 }
747 match self.repositories {
748 RepositorySelection::All => true,
749 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
750 RepositorySelection::Public => false,
751 }
752 }
753
754 /// Whether it is made for the workspace `slug`.
755 pub fn owned_by(&self, slug: &str) -> bool {
756 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
757 }
758}
759
760/// Where workflow files live. Adding, changing or deleting a file under
761/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
762/// token: what GitHub's `workflow` scope and `workflows` permission do.
763pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
764
765/// Whether `path` is a workflow file, or a file in one's directory.
766pub fn is_workflow_file(path: &str) -> bool {
767 let path = path.trim_start_matches('/');
768 WORKFLOW_DIRS.iter().any(|dir| {
769 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
770 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
771}
772
773/// Whether a token may add, change or delete the files at `paths`: a
774/// refusal naming the first workflow file it may not touch, else `None`.
775/// A signed-in person (no token) is never refused here; their role decides.
776pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
777 let access = access?;
778 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
779 return None;
780 }
781 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
782 let why = if access.job.is_some() {
783 "a workflow job's token can never add or change workflow files".to_owned()
784 } else {
785 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
786 };
787 Some(Decision::deny(
788 "token:workflows",
789 format!("This access token cannot change the workflow file {path}: {why}."),
790 ))
791}
792
793/// The workflow job a token was made for.
794#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
795pub struct JobToken {
796 /// The run, `run_…`.
797 pub run_id: String,
798 /// The job, `job_…`.
799 pub job_id: String,
800 /// Whether it may open pull requests and approve them, by its
801 /// repository's and workspace's choice ("Allow g1t Actions to create and
802 /// approve pull requests"). Off unless chosen.
803 #[serde(default)]
804 pub pull_requests: bool,
805}
806
807impl TokenAccess {
808 /// Full access to everything: the access tokens made before scopes had.
809 pub fn full() -> Self {
810 TokenAccess::default()
811 }
812
813 /// Whether it may reach the repository `owner/name`: every token but a
814 /// workflow job's, which reaches its own repository only.
815 pub fn reaches(&self, repo: &str) -> bool {
816 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
817 }
818
819 pub fn is_full(&self) -> bool {
820 self.scopes.is_none()
821 }
822
823 /// The scopes it holds, or `None` for full access.
824 pub fn granted(&self) -> Option<Vec<Scope>> {
825 self.scopes
826 .as_ref()
827 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
828 }
829
830 pub fn allows(&self, needed: Scope) -> bool {
831 match self.granted() {
832 None => true,
833 Some(granted) => granted.iter().any(|held| held.includes(needed)),
834 }
835 }
836
837 /// Whether it reaches the repository with this id in `namespace` for
838 /// more than reading a public one: every token but a narrowed one
839 /// outside its workspace or repository selection. Its owner's role
840 /// still decides; see [`crate::access`].
841 pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool {
842 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
843 }
844}
845
846/// Every operation of the API and MCP server, with the scope it needs. An
847/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
848/// its operations is in exactly one of the two.
849pub const OPERATIONS: &[(&str, Scope)] = &[
850 // Your account.
851 ("list_emails", Scope::AccountRead),
852 ("add_email", Scope::AccountWrite),
853 ("confirm_email", Scope::AccountWrite),
854 ("remove_email", Scope::AccountWrite),
855 ("update_email_settings", Scope::AccountWrite),
856 ("list_invites", Scope::AccountRead),
857 ("create_invite", Scope::AccountWrite),
858 ("revoke_invite", Scope::AccountWrite),
859 ("list_invitations", Scope::AccountRead),
860 ("accept_invitation", Scope::AccountWrite),
861 ("decline_invitation", Scope::AccountWrite),
862 ("list_my_repo_invitations", Scope::AccountRead),
863 ("accept_repo_invitation", Scope::AccountWrite),
864 ("decline_repo_invitation", Scope::AccountWrite),
865 // Your pinned projects: a preference of your account.
866 ("list_pinned_projects", Scope::AccountRead),
867 ("pin_project", Scope::AccountWrite),
868 // Your stars: a preference of your account.
869 ("list_starred", Scope::AccountRead),
870 ("check_starred", Scope::AccountRead),
871 ("star_repo", Scope::AccountWrite),
872 ("unstar_repo", Scope::AccountWrite),
873 ("unpin_project", Scope::AccountWrite),
874 ("reorder_pinned_projects", Scope::AccountWrite),
875 // Your inbox: notifications, subscriptions and watching.
876 ("list_notifications", Scope::NotificationsRead),
877 ("get_notification_thread", Scope::NotificationsRead),
878 ("get_thread_subscription", Scope::NotificationsRead),
879 ("get_repo_subscription", Scope::NotificationsRead),
880 ("list_watched_repos", Scope::NotificationsRead),
881 ("mark_notifications_read", Scope::NotificationsWrite),
882 ("mark_thread_read", Scope::NotificationsWrite),
883 ("mark_thread_done", Scope::NotificationsWrite),
884 ("save_thread", Scope::NotificationsWrite),
885 ("snooze_thread", Scope::NotificationsWrite),
886 ("set_thread_subscription", Scope::NotificationsWrite),
887 ("delete_thread_subscription", Scope::NotificationsWrite),
888 ("set_repo_subscription", Scope::NotificationsWrite),
889 ("delete_repo_subscription", Scope::NotificationsWrite),
890 // Workspaces, their invites and integrations.
891 ("create_workspace", Scope::WorkspaceAdmin),
892 ("delete_workspace", Scope::WorkspaceAdmin),
893 ("get_workspace", Scope::WorkspaceRead),
894 ("update_workspace", Scope::WorkspaceAdmin),
895 // Its members, and who owns it.
896 ("list_members", Scope::WorkspaceRead),
897 ("update_member", Scope::WorkspaceAdmin),
898 ("remove_member", Scope::WorkspaceAdmin),
899 ("transfer_ownership", Scope::WorkspaceAdmin),
900 ("leave_workspace", Scope::AccountWrite),
901 ("list_workspace_invites", Scope::WorkspaceRead),
902 ("invite_member", Scope::WorkspaceAdmin),
903 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
904 ("list_integrations", Scope::WorkspaceRead),
905 ("connect_integration", Scope::WorkspaceAdmin),
906 ("update_integration", Scope::WorkspaceAdmin),
907 ("disconnect_integration", Scope::WorkspaceAdmin),
908 ("test_integration", Scope::WorkspaceAdmin),
909 ("get_model_routes", Scope::WorkspaceRead),
910 ("set_model_routes", Scope::WorkspaceAdmin),
911 // Teams: reading them, and managing them. A team's role on a
912 // repository is who has access.
913 ("list_teams", Scope::WorkspaceRead),
914 ("get_team", Scope::WorkspaceRead),
915 ("list_team_members", Scope::WorkspaceRead),
916 ("list_child_teams", Scope::WorkspaceRead),
917 ("list_team_repos", Scope::WorkspaceRead),
918 ("list_user_teams", Scope::WorkspaceRead),
919 ("create_team", Scope::WorkspaceAdmin),
920 ("list_workspace_rulesets", Scope::WorkspaceRead),
921 ("get_workspace_ruleset", Scope::WorkspaceRead),
922 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
923 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
924 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
925 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
926 ("update_team", Scope::WorkspaceAdmin),
927 ("delete_team", Scope::WorkspaceAdmin),
928 ("set_team_member", Scope::WorkspaceAdmin),
929 ("remove_team_member", Scope::WorkspaceAdmin),
930 ("set_team_review_assignment", Scope::WorkspaceAdmin),
931 // A workspace's billing: usage, budget, AI credit and invoices.
932 ("get_usage", Scope::BillingRead),
933 ("get_budget", Scope::BillingRead),
934 ("get_ai_credit", Scope::BillingRead),
935 ("list_invoices", Scope::BillingRead),
936 ("get_billing_details", Scope::BillingRead),
937 ("set_budget", Scope::BillingWrite),
938 ("buy_ai_credit", Scope::BillingWrite),
939 // Repositories.
940 ("list_repos", Scope::RepoRead),
941 ("get_repo", Scope::RepoRead),
942 // Projects follow their repositories.
943 ("list_projects", Scope::RepoRead),
944 ("get_project", Scope::RepoRead),
945 ("search", Scope::RepoRead),
946 ("list_events", Scope::RepoRead),
947 // What the default branch says about a repository, who starred it, and
948 // its releases.
949 ("get_languages", Scope::RepoRead),
950 ("list_contributors", Scope::RepoRead),
951 ("get_license", Scope::RepoRead),
952 ("list_stargazers", Scope::RepoRead),
953 ("list_releases", Scope::RepoRead),
954 ("get_latest_release", Scope::RepoRead),
955 ("get_release_by_tag", Scope::RepoRead),
956 ("get_release", Scope::RepoRead),
957 ("create_release", Scope::RepoWrite),
958 ("update_release", Scope::RepoWrite),
959 ("delete_release", Scope::RepoWrite),
960 ("list_labels", Scope::RepoRead),
961 ("list_milestones", Scope::RepoRead),
962 ("get_milestone", Scope::RepoRead),
963 ("create_label", Scope::IssuesWrite),
964 ("update_label", Scope::IssuesWrite),
965 ("delete_label", Scope::IssuesWrite),
966 ("add_default_labels", Scope::IssuesWrite),
967 ("create_milestone", Scope::IssuesWrite),
968 ("update_milestone", Scope::IssuesWrite),
969 ("delete_milestone", Scope::IssuesWrite),
970 ("get_repo_settings", Scope::RepoRead),
971 ("list_check_names", Scope::RepoRead),
972 ("list_deleted_repos", Scope::RepoRead),
973 ("list_security_alerts", Scope::RepoRead),
974 ("get_codeowners_errors", Scope::RepoRead),
975 ("create_repo", Scope::RepoWrite),
976 ("update_repo", Scope::RepoWrite),
977 ("update_project", Scope::RepoWrite),
978 ("update_repo_settings", Scope::RepoWrite),
979 // Rulesets: reading them is reading the repository; changing them
980 // changes what everyone, agents included, may do, so it is admin.
981 ("list_repo_rulesets", Scope::RepoRead),
982 ("get_repo_ruleset", Scope::RepoRead),
983 ("get_branch_rules", Scope::RepoRead),
984 ("list_rule_evaluations", Scope::RepoRead),
985 ("create_repo_ruleset", Scope::RepoAdmin),
986 ("update_repo_ruleset", Scope::RepoAdmin),
987 ("delete_repo_ruleset", Scope::RepoAdmin),
988 ("rename_branch", Scope::RepoWrite),
989 ("rename_repo", Scope::RepoAdmin),
990 ("transfer_repo", Scope::RepoAdmin),
991 ("archive_repo", Scope::RepoAdmin),
992 ("unarchive_repo", Scope::RepoAdmin),
993 ("set_repo_visibility", Scope::RepoAdmin),
994 ("delete_repo", Scope::RepoAdmin),
995 ("restore_repo", Scope::RepoAdmin),
996 ("purge_repo", Scope::RepoAdmin),
997 // A dismissed secret is let through push protection.
998 ("dismiss_security_alert", Scope::RepoAdmin),
999 ("reopen_security_alert", Scope::RepoAdmin),
1000 // The security suite: alerts, push protection, patterns, code
1001 // scanning, the supply chain and settings.
1002 ("list_secret_scanning_alerts", Scope::SecurityRead),
1003 ("get_secret_scanning_alert", Scope::SecurityRead),
1004 ("list_secret_scanning_locations", Scope::SecurityRead),
1005 ("list_bypass_requests", Scope::SecurityRead),
1006 ("list_custom_patterns", Scope::SecurityRead),
1007 ("list_code_scanning_alerts", Scope::SecurityRead),
1008 ("get_code_scanning_alert", Scope::SecurityRead),
1009 ("list_code_scanning_analyses", Scope::SecurityRead),
1010 ("get_sarif_upload", Scope::SecurityRead),
1011 ("list_vulnerability_alerts", Scope::SecurityRead),
1012 ("get_vulnerability_alert", Scope::SecurityRead),
1013 ("get_dependency_graph", Scope::SecurityRead),
1014 ("get_sbom", Scope::SecurityRead),
1015 ("compare_dependencies", Scope::SecurityRead),
1016 ("get_security_settings", Scope::SecurityRead),
1017 ("get_workspace_security_settings", Scope::SecurityRead),
1018 ("get_security_overview", Scope::SecurityRead),
1019 ("update_secret_scanning_alert", Scope::SecurityWrite),
1020 ("bypass_push_protection", Scope::SecurityWrite),
1021 ("check_secret_validity", Scope::SecurityWrite),
1022 ("review_bypass_request", Scope::SecurityWrite),
1023 ("create_custom_pattern", Scope::SecurityWrite),
1024 ("update_custom_pattern", Scope::SecurityWrite),
1025 ("delete_custom_pattern", Scope::SecurityWrite),
1026 ("dry_run_custom_pattern", Scope::SecurityWrite),
1027 ("update_code_scanning_alert", Scope::SecurityWrite),
1028 ("upload_sarif", Scope::SecurityWrite),
1029 ("update_vulnerability_alert", Scope::SecurityWrite),
1030 ("fix_security_alert", Scope::SecurityWrite),
1031 ("update_security_settings", Scope::SecurityWrite),
1032 ("update_workspace_security_settings", Scope::SecurityWrite),
1033 // Issues and plans.
1034 ("list_issues", Scope::IssuesRead),
1035 ("get_issue", Scope::IssuesRead),
1036 ("get_plan", Scope::IssuesRead),
1037 ("create_issue", Scope::IssuesWrite),
1038 ("update_issue", Scope::IssuesWrite),
1039 ("list_issue_labels", Scope::IssuesRead),
1040 ("add_issue_labels", Scope::IssuesWrite),
1041 ("set_issue_labels", Scope::IssuesWrite),
1042 ("remove_issue_labels", Scope::IssuesWrite),
1043 ("close_issue", Scope::IssuesWrite),
1044 ("reopen_issue", Scope::IssuesWrite),
1045 ("add_comment", Scope::IssuesWrite),
1046 ("edit_comment", Scope::IssuesWrite),
1047 ("delete_comment", Scope::IssuesWrite),
1048 ("import_issue", Scope::IssuesWrite),
1049 ("apply_plan", Scope::IssuesWrite),
1050 // Pull requests.
1051 ("list_pull_requests", Scope::PullRequestsRead),
1052 ("get_pull_request", Scope::PullRequestsRead),
1053 ("get_pull_request_changes", Scope::PullRequestsRead),
1054 ("read_session", Scope::PullRequestsRead),
1055 ("get_merge_queue", Scope::PullRequestsRead),
1056 ("create_pull_request", Scope::PullRequestsWrite),
1057 ("update_pull_request", Scope::PullRequestsWrite),
1058 ("record_session", Scope::PullRequestsWrite),
1059 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1060 ("close_pull_request", Scope::PullRequestsWrite),
1061 ("reopen_pull_request", Scope::PullRequestsWrite),
1062 ("convert_pull_request_to_draft", Scope::PullRequestsWrite),
1063 ("review_pull_request", Scope::PullRequestsWrite),
1064 ("merge_pull_request", Scope::PullRequestsWrite),
1065 ("request_reviewers", Scope::PullRequestsWrite),
1066 ("remove_requested_reviewers", Scope::PullRequestsWrite),
1067 // g1t's agents.
1068 ("assign_issue", Scope::AgentsRun),
1069 ("delegate", Scope::AgentsRun),
1070 ("plan_work", Scope::AgentsRun),
1071 ("message_agent", Scope::AgentsRun),
1072 ("answer_message", Scope::AgentsRun),
1073 ("take_messages", Scope::AgentsRun),
1074 // Workflows.
1075 ("list_workflows", Scope::WorkflowsRead),
1076 ("list_workflow_runs", Scope::WorkflowsRead),
1077 ("get_workflow_run", Scope::WorkflowsRead),
1078 ("get_job_logs", Scope::WorkflowsRead),
1079 ("dispatch_workflow", Scope::WorkflowsWrite),
1080 ("cancel_workflow_run", Scope::WorkflowsWrite),
1081 ("rerun_workflow_run", Scope::WorkflowsWrite),
1082 ("update_workflow", Scope::WorkflowsWrite),
1083 ("list_artifacts", Scope::WorkflowsRead),
1084 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1085 ("get_artifact", Scope::WorkflowsRead),
1086 ("download_artifact", Scope::WorkflowsRead),
1087 ("get_artifact_retention", Scope::WorkflowsRead),
1088 ("delete_artifact", Scope::WorkflowsWrite),
1089 ("set_artifact_retention", Scope::WorkflowsWrite),
1090 // Checks: statuses, check runs and check suites on commits.
1091 ("list_commit_statuses", Scope::ChecksRead),
1092 ("get_combined_status", Scope::ChecksRead),
1093 ("list_check_runs_for_ref", Scope::ChecksRead),
1094 ("get_check_run", Scope::ChecksRead),
1095 ("list_check_run_annotations", Scope::ChecksRead),
1096 ("list_check_suites_for_ref", Scope::ChecksRead),
1097 ("get_check_suite", Scope::ChecksRead),
1098 ("create_commit_status", Scope::ChecksWrite),
1099 ("create_check_run", Scope::ChecksWrite),
1100 ("update_check_run", Scope::ChecksWrite),
1101 ("rerequest_check_run", Scope::ChecksWrite),
1102 ("rerequest_check_suite", Scope::ChecksWrite),
1103 // Deployments, wherever they run: reading them, and reporting them.
1104 ("list_deployments", Scope::DeploymentsRead),
1105 ("get_deployment", Scope::DeploymentsRead),
1106 ("list_deployment_statuses", Scope::DeploymentsRead),
1107 ("list_environments", Scope::DeploymentsRead),
1108 ("get_environment", Scope::DeploymentsRead),
1109 ("create_deployment", Scope::DeploymentsWrite),
1110 ("create_deployment_status", Scope::DeploymentsWrite),
1111 // What keeps runs safe: the runs environments hold and reviewing them,
1112 // approving a pull request's run, and a repository's own rules for
1113 // its environments and tokens, which are an admin's.
1114 ("get_pending_deployments", Scope::WorkflowsRead),
1115 ("review_pending_deployments", Scope::WorkflowsWrite),
1116 ("approve_workflow_run", Scope::WorkflowsWrite),
1117 ("get_workflow_permissions", Scope::RepoRead),
1118 ("get_fork_pr_approval", Scope::RepoRead),
1119 ("get_actions_access", Scope::RepoRead),
1120 ("update_environment", Scope::RepoAdmin),
1121 ("delete_environment", Scope::RepoAdmin),
1122 ("set_workflow_permissions", Scope::RepoAdmin),
1123 ("set_fork_pr_approval", Scope::RepoAdmin),
1124 ("set_actions_access", Scope::RepoAdmin),
1125 // Starting workflows from outside, as a push would.
1126 ("create_repository_dispatch", Scope::CodeWrite),
1127 // A workspace's policy for its repositories' tokens.
1128 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1129 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
1130 // A workspace's rules for personal access tokens, and the members'
1131 // tokens that reach it: who has access.
1132 ("get_token_policy", Scope::WorkspaceRead),
1133 ("set_token_policy", Scope::WorkspaceAdmin),
1134 ("list_member_tokens", Scope::AccessRead),
1135 ("list_token_requests", Scope::AccessRead),
1136 ("review_token_request", Scope::AccessAdmin),
1137 ("revoke_member_token", Scope::AccessAdmin),
1138 // Memory and the context hub.
1139 ("recall", Scope::MemoryRead),
1140 ("search_context", Scope::MemoryRead),
1141 ("get_entity", Scope::MemoryRead),
1142 ("get_context", Scope::MemoryRead),
1143 ("remember", Scope::MemoryWrite),
1144 // Who has access.
1145 ("list_collaborators", Scope::AccessRead),
1146 ("get_collaborator_permission", Scope::AccessRead),
1147 ("list_repo_invitations", Scope::AccessRead),
1148 ("list_outside_collaborators", Scope::AccessRead),
1149 ("add_collaborator", Scope::AccessAdmin),
1150 ("update_collaborator", Scope::AccessAdmin),
1151 ("remove_collaborator", Scope::AccessAdmin),
1152 ("revoke_repo_invitation", Scope::AccessAdmin),
1153 ("set_base_permission", Scope::AccessAdmin),
1154 ("set_team_repo", Scope::AccessAdmin),
1155 ("remove_team_repo", Scope::AccessAdmin),
1156 // Deploy keys: each lets a machine reach one repository, so they
1157 // are part of who has access.
1158 ("list_deploy_keys", Scope::AccessRead),
1159 ("get_deploy_key", Scope::AccessRead),
1160 ("create_deploy_key", Scope::AccessAdmin),
1161 ("delete_deploy_key", Scope::AccessAdmin),
1162 // Mirroring: a repository's links to other hosts. Reading them is
1163 // reading the repository; syncing writes code; the rest is an admin's.
1164 ("get_mirror", Scope::RepoRead),
1165 ("sync_mirror", Scope::CodeWrite),
1166 ("get_hand_back_plan", Scope::RepoAdmin),
1167 ("take_over_mirror", Scope::RepoAdmin),
1168 ("set_ci_failover", Scope::RepoAdmin),
1169 ("hand_back_mirror", Scope::RepoAdmin),
1170 ("move_mirror_to_g1t", Scope::RepoAdmin),
1171 ("add_mirror_remote", Scope::RepoAdmin),
1172 ("update_mirror_remote", Scope::RepoAdmin),
1173 ("remove_mirror_remote", Scope::RepoAdmin),
1174 // Webhooks.
1175 ("list_webhooks", Scope::WebhooksRead),
1176 ("list_webhook_deliveries", Scope::WebhooksRead),
1177 ("create_webhook", Scope::WebhooksAdmin),
1178 ("update_webhook", Scope::WebhooksAdmin),
1179 ("delete_webhook", Scope::WebhooksAdmin),
1180 ("ping_webhook", Scope::WebhooksAdmin),
1181 ("redeliver_webhook", Scope::WebhooksAdmin),
1182 // Secrets and variables.
1183 ("list_actions_secrets", Scope::SecretsRead),
1184 ("list_actions_variables", Scope::SecretsRead),
1185 ("set_actions_secret", Scope::SecretsAdmin),
1186 ("delete_actions_secret", Scope::SecretsAdmin),
1187 ("set_actions_variable", Scope::SecretsAdmin),
1188 ("delete_actions_variable", Scope::SecretsAdmin),
1189 // Self-hosted runners.
1190 ("list_runners", Scope::RunnersRead),
1191 ("list_runner_groups", Scope::RunnersRead),
1192 ("get_runner_settings", Scope::RunnersRead),
1193 ("create_runner_registration_token", Scope::RunnersAdmin),
1194 ("remove_runner", Scope::RunnersAdmin),
1195 ("create_runner_group", Scope::RunnersAdmin),
1196 ("update_runner_group", Scope::RunnersAdmin),
1197 ("delete_runner_group", Scope::RunnersAdmin),
1198 ("update_runner_settings", Scope::RunnersAdmin),
1199 // Packages: reading them, their versions and who may use them needs
1200 // `packages:read`; changing their settings, access and Manage Actions
1201 // access `packages:write` (and the Admin role on the package, which the
1202 // packages service checks); deleting and restoring packages and
1203 // versions `packages:delete`, as the registries' own deletes do.
1204 ("list_packages", Scope::PackagesRead),
1205 ("get_package", Scope::PackagesRead),
1206 ("list_package_versions", Scope::PackagesRead),
1207 ("get_package_version", Scope::PackagesRead),
1208 ("list_package_access", Scope::PackagesRead),
1209 ("list_package_actions_access", Scope::PackagesRead),
1210 ("update_package", Scope::PackagesWrite),
1211 ("link_package", Scope::PackagesWrite),
1212 ("unlink_package", Scope::PackagesWrite),
1213 ("set_package_access", Scope::PackagesWrite),
1214 ("remove_package_access", Scope::PackagesWrite),
1215 ("set_package_actions_access", Scope::PackagesWrite),
1216 ("remove_package_actions_access", Scope::PackagesWrite),
1217 ("delete_package", Scope::PackagesDelete),
1218 ("restore_package", Scope::PackagesDelete),
1219 ("delete_package_version", Scope::PackagesDelete),
1220 ("restore_package_version", Scope::PackagesDelete),
1221 // The AI Gateway. Sending a request to a model needs `models:write`,
1222 // checked by the model proxy at models.g1t.sh, not here.
1223 ("list_gateway_requests", Scope::ModelsRead),
1224 // Artifacts mode's docs, slides, designs and dashboards (the `artifact`
1225 // MCP tool). Reading takes artifacts:read, making and changing them
1226 // artifacts:write, and sharing them or deleting them for good
1227 // artifacts:admin. The docs service then checks the person's own role
1228 // on each one.
1229 ("list_workspace_artifacts", Scope::ArtifactsRead),
1230 ("search_workspace_artifacts", Scope::ArtifactsRead),
1231 ("get_workspace_artifact", Scope::ArtifactsRead),
1232 ("get_workspace_artifact_content", Scope::ArtifactsRead),
1233 ("list_workspace_artifact_versions", Scope::ArtifactsRead),
1234 ("get_workspace_artifact_access", Scope::ArtifactsRead),
1235 ("list_workspace_artifact_templates", Scope::ArtifactsRead),
1236 ("list_workspace_artifact_spaces", Scope::ArtifactsRead),
1237 ("query_workspace_dataset", Scope::ArtifactsRead),
1238 ("create_workspace_artifact", Scope::ArtifactsWrite),
1239 ("update_workspace_artifact", Scope::ArtifactsWrite),
1240 ("edit_workspace_artifact", Scope::ArtifactsWrite),
1241 ("trash_workspace_artifact", Scope::ArtifactsWrite),
1242 ("restore_workspace_artifact", Scope::ArtifactsWrite),
1243 ("restore_workspace_artifact_version", Scope::ArtifactsWrite),
1244 ("set_workspace_artifact_access", Scope::ArtifactsAdmin),
1245 ("purge_workspace_artifact", Scope::ArtifactsAdmin),
1246];
1247
1248/// Operations any token may use: saying who it is.
1249pub const NO_SCOPE: &[&str] = &["whoami"];
1250
1251/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1252/// operation in neither list needs full access.
1253pub fn scope_for(operation: &str) -> Option<Scope> {
1254 OPERATIONS
1255 .iter()
1256 .find(|(name, _)| *name == operation)
1257 .map(|(_, scope)| *scope)
1258}
1259
1260/// What a token needs for `operation` with this input beyond its own
1261/// scope: starting agents from an operation that can, and making a
1262/// repository public or private.
1263pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1264 let mut extra = Vec::new();
1265 let assigns = input["assign"].as_bool() == Some(true)
1266 || input["agent"].as_bool() == Some(true)
1267 || input["assign_agent"].as_bool() == Some(true);
1268 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1269 extra.push(Scope::AgentsRun);
1270 }
1271 // Fixing an alert opens an issue and puts g1t on it.
1272 if operation == "fix_security_alert" {
1273 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1274 }
1275 // Opening the issue an agent is put on.
1276 if operation == "delegate" {
1277 extra.push(Scope::IssuesWrite);
1278 }
1279 // A workspace's base permission is who has access.
1280 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1281 extra.push(Scope::AccessAdmin);
1282 }
1283 // Asking a g1t Actions job or run to run again reruns its workflow.
1284 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1285 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1286 {
1287 extra.push(Scope::WorkflowsWrite);
1288 }
1289 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())) {
1290 extra.push(Scope::RepoAdmin);
1291 }
1292 extra
1293}
1294
1295/// The scopes a call needs, its own first.
1296pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1297 scope_for(operation)
1298 .into_iter()
1299 .chain(extra_scopes(operation, input))
1300 .collect()
1301}
1302
1303/// Whether `access` may use `operation` with `input`. The person's (or
1304/// workspace's) role is checked after this, by the service that owns what
1305/// was asked about.
1306pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1307 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
1308 // A workflow job may open or approve pull requests only where its
1309 // repository and workspace let it, as on GitHub.
1310 if let Some(job) = &access.job
1311 && !job.pull_requests
1312 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1313 {
1314 return Decision::deny(
1315 "token:pull-requests",
1316 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1317 );
1318 }
1319 if let Some(only) = access.repo.as_deref()
1320 && !NO_SCOPE.contains(&operation)
1321 {
1322 match input["repo"].as_str() {
1323 Some(repo) if access.reaches(repo) => {}
1324 Some(repo) => {
1325 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1326 }
1327 None => {
1328 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1329 }
1330 }
1331 }
1332 // A token made for one workspace (or none) only reads outside it:
1333 // public repositories, as anyone may. Inside it, its repository
1334 // selection is checked with its owner's role (`access::granted`).
1335 if let Some(reach) = &access.reach
1336 && let Some(repo) = input["repo"].as_str()
1337 && !NO_SCOPE.contains(&operation)
1338 {
1339 let namespace = repo.split('/').next().unwrap_or_default();
1340 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1341 if changes && !reach.owned_by(namespace) {
1342 let made_for = reach.workspace.as_deref().map_or_else(|| "your account only".to_owned(), |workspace| format!("the workspace {workspace}"));
1343 return Decision::deny(
1344 "token:resource-owner",
1345 format!("This access token is made for {made_for}: elsewhere it can only read public repositories, and {repo} is not in its reach."),
1346 );
1347 }
1348 }
1349 if access.scopes.is_some() {
1350 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1351 if !known {
1352 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1353 }
1354 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1355 return Decision::deny(
1356 "token:scope",
1357 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1358 );
1359 }
1360 }
1361 Decision::allow(rule)
1362}
1363
1364/// Whether a token may use the repository `owner/name` at all: a refusal
1365/// for a workflow job's token or a deploy key in another repository,
1366/// else `None`. Git and
1367/// the package registries ask this before [`decide_git`] and
1368/// [`decide_packages`].
1369pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1370 let only = access.repo.as_deref()?;
1371 let why = if access.deploy_key.is_some() {
1372 format!("This deploy key is for {only}: it cannot reach {repo}.")
1373 } else {
1374 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1375 };
1376 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
1377}
1378
1379/// Whether a token may clone or fetch (`write` false), or push to (`write`
1380/// true), a repository with git. `public` is whether anyone may read it,
1381/// which needs no scope.
1382pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1383 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1384 if !access.allows(needed) && (write || !public) {
1385 if access.deploy_key.is_some() {
1386 return Decision::deny(
1387 "token:scope",
1388 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1389 );
1390 }
1391 return Decision::deny(
1392 "token:scope",
1393 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1394 );
1395 }
1396 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1397}
1398
1399/// Whether a token may pull (`Level::Read`), push or publish
1400/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1401/// whether anyone may pull the package, which needs no scope.
1402pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1403 let (needed, doing) = match level {
1404 Level::Read => (Scope::PackagesRead, "pull a private package"),
1405 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1406 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1407 };
1408 if !access.allows(needed) && !(level == Level::Read && public) {
1409 return Decision::deny(
1410 "token:scope",
1411 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1412 );
1413 }
1414 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420 use serde_json::json;
1421
1422 fn token(scopes: &[Scope]) -> TokenAccess {
1423 TokenAccess {
1424 token_id: "tok_1".to_owned(),
1425 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1426 legacy: false,
1427 name: None,
1428 ..TokenAccess::default()
1429 }
1430 }
1431
1432 #[test]
1433 fn every_scope_reads_back_and_belongs_to_a_resource() {
1434 for scope in Scope::ALL {
1435 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1436 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1437 assert!(scope.includes(scope));
1438 }
1439 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1440 assert_eq!(Scope::parse("issues"), None);
1441 }
1442
1443 #[test]
1444 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1445 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1446 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1447 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1448 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1449 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1450 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1451 }
1452
1453 #[test]
1454 fn operations_are_listed_once_and_never_also_free() {
1455 let mut seen = std::collections::HashSet::new();
1456 for (name, _) in OPERATIONS {
1457 assert!(seen.insert(*name), "{name} twice");
1458 assert!(!NO_SCOPE.contains(name), "{name}");
1459 }
1460 }
1461
1462 #[test]
1463 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1464 assert_eq!(
1465 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1466 vec![Scope::RepoRead, Scope::IssuesWrite]
1467 );
1468 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1469 }
1470
1471 #[test]
1472 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1473 let scopes = oauth_default();
1474 assert!(scopes.contains(&Scope::IssuesWrite));
1475 assert!(scopes.contains(&Scope::PullRequestsWrite));
1476 assert!(scopes.contains(&Scope::AgentsRun));
1477 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
1478 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1479 // Every read offered but the machines work runs on.
1480 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
1481 }
1482 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1483 assert_eq!(Preset::Full.scopes(), None);
1484 }
1485
1486 #[test]
1487 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1488 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1489 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1490 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1491 }
1492 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1493 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1494 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1495 let reader = token(&[Scope::BillingRead]);
1496 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1497 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1498 }
1499
1500 #[test]
1501 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1502 // Reading the log is a read like any other.
1503 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1504 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1505 // Sending requests spends the workspace's AI credit: chosen on purpose.
1506 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1507 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1508 }
1509 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1510 assert!(!Scope::ModelsWrite.dangerous());
1511 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1512 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1513 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1514 }
1515
1516 #[test]
1517 fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() {
1518 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1519 assert_eq!(scope.resource(), Resource::Artifacts);
1520 assert!(scope.offered());
1521 assert!(offered_scopes().contains(&scope));
1522 assert_eq!(parse_scopes(scope.as_str()), vec![scope]);
1523 assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing");
1524 }
1525 // Reading them is a read like any other; changing them is chosen.
1526 for preset in [Preset::ReadOnly, Preset::Agent] {
1527 let scopes = preset.scopes().unwrap();
1528 assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str());
1529 assert!(!scopes.contains(&Scope::ArtifactsWrite) && !scopes.contains(&Scope::ArtifactsAdmin), "{}", preset.as_str());
1530 }
1531 assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead));
1532 assert!(everything().contains(&Scope::ArtifactsAdmin));
1533 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1534 assert!(Scope::ArtifactsAdmin.dangerous());
1535 assert!(!Scope::ArtifactsWrite.dangerous());
1536 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1537 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]);
1538 assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite]));
1539 assert_eq!(offered_scopes().len(), Scope::ALL.len());
1540 // Sharing and deleting for good need admin; editing needs write.
1541 assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin));
1542 assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin));
1543 assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite));
1544 assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead));
1545 let writer = token(&[Scope::ArtifactsWrite]);
1546 assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed);
1547 assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read");
1548 assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin"));
1549 // Workflow runs' artifacts are another thing, with their own scope.
1550 let reader = token(&[Scope::ArtifactsRead]);
1551 assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed);
1552 assert!(!decide(&token(&[Scope::WorkflowsRead]), "list_workspace_artifacts", &json!({})).allowed);
1553 }
1554
1555 #[test]
1556 fn checks_are_reported_with_checks_write_which_ci_gets() {
1557 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1558 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1559 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1560 let ci = Preset::Ci.scopes().unwrap();
1561 assert!(ci.contains(&Scope::ChecksWrite));
1562 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1563 let reporter = token(&[Scope::ChecksWrite]);
1564 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1565 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1566 // A g1t Actions job runs again as its workflow does.
1567 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1568 assert!(refused.reason.unwrap().contains("workflows:write"));
1569 }
1570
1571 #[test]
1572 fn a_job_token_reaches_its_repository_only() {
1573 let job = TokenAccess {
1574 repo: Some("acme/web".into()),
1575 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1576 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1577 };
1578 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1579 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1580 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1581 assert!(!elsewhere.allowed);
1582 assert_eq!(elsewhere.rule, "token:repository");
1583 // Nothing beyond the one repository, a workspace's listing included.
1584 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1585 assert!(decide(&job, "whoami", &json!({})).allowed);
1586 // Its scopes still hold inside it.
1587 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1588 assert!(decide_repo(&job, "acme/web").is_none());
1589 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1590 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1591 // Opening and approving pull requests is off unless allowed.
1592 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1593 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1594 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1595 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1596 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1597 }
1598
1599 #[test]
1600 fn workflow_files_need_their_own_scope() {
1601 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1602 assert!(is_workflow_file(path), "{path}");
1603 }
1604 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1605 assert!(!is_workflow_file(path), "{path}");
1606 }
1607 let code = token(&[Scope::CodeWrite]);
1608 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1609 assert_eq!(refused.rule, "token:workflows");
1610 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1611 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1612 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1613 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1614 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1615 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1616 // A job's token never may, as GITHUB_TOKEN never may.
1617 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1618 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1619 // Nothing in a preset changes workflow files but full access.
1620 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1621 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1622 }
1623 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1624 }
1625
1626 #[test]
1627 fn a_narrowed_token_only_reads_outside_its_workspace() {
1628 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1629 let fine = TokenAccess { reach: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) };
1630 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1631 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1632 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1633 assert_eq!(elsewhere.rule, "token:resource-owner");
1634 assert!(elsewhere.reason.unwrap().contains("acme"));
1635 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1636 assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold");
1637 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
1638 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1639 assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex"));
1640 let selected = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] };
1641 assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme"));
1642 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
1643 assert!(!public.covers("rep_1", "acme") && public.owned_by("acme"));
1644 assert!(token(&[]).covers_repo("rep_1", "anything"), "a token for every workspace reaches what its owner can");
1645 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1646 }
1647
1648 #[test]
1649 fn permissions_are_scopes_read_per_resource() {
1650 let asked: std::collections::BTreeMap<String, String> =
1651 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1652 let scopes = resolve_permissions(&asked, true).unwrap();
1653 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1654 let back = permissions_of(&scopes);
1655 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1656 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1657 assert!(!back.contains_key("code"));
1658 // Lower levels held beside a higher one say nothing more.
1659 assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]);
1660 // Every offered resource's top, and nothing a level can lose.
1661 let all = everything();
1662 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1663 for scope in offered_scopes() {
1664 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1665 }
1666 }
1667
1668 #[test]
1669 fn permissions_are_checked_by_name_level_and_owner() {
1670 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1671 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1672 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1673 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1674 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1675 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1676 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1677 for resource in Resource::ALL {
1678 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1679 assert!(!resource.scopes().is_empty());
1680 }
1681 }
1682
1683 #[test]
1684 fn a_legacy_token_can_do_everything() {
1685 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1686 for (operation, _) in OPERATIONS {
1687 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1688 }
1689 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1690 }
1691
1692 #[test]
1693 fn a_missing_scope_is_named() {
1694 let read = token(&[Scope::IssuesRead]);
1695 assert!(decide(&read, "get_issue", &json!({})).allowed);
1696 assert!(decide(&read, "whoami", &json!({})).allowed);
1697 let refused = decide(&read, "create_issue", &json!({}));
1698 assert!(!refused.allowed);
1699 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1700 // An operation the table does not know needs full access.
1701 assert!(!decide(&read, "something_new", &json!({})).allowed);
1702 }
1703
1704 #[test]
1705 fn starting_agents_from_another_operation_needs_agents_run() {
1706 let writer = token(&[Scope::IssuesWrite]);
1707 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1708 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1709 assert!(refused.reason.unwrap().contains("agents:run"));
1710 let maintainer = token(&[Scope::RepoWrite]);
1711 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1712 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1713 }
1714
1715 #[test]
1716 fn a_workspaces_base_permission_needs_access_admin_too() {
1717 let admin = token(&[Scope::WorkspaceAdmin]);
1718 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1719 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1720 assert!(refused.reason.unwrap().contains("access:admin"));
1721 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1722 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1723 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1724 }
1725
1726 #[test]
1727 fn delegating_needs_both_agents_and_issues() {
1728 let agents = token(&[Scope::AgentsRun]);
1729 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1730 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1731 assert!(decide(&both, "delegate", &json!({})).allowed);
1732 }
1733
1734 #[test]
1735 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1736 let reader = token(&[Scope::CodeRead]);
1737 assert!(decide_git(&reader, false, false).allowed);
1738 let refused = decide_git(&reader, true, false);
1739 assert!(!refused.allowed);
1740 assert!(refused.reason.unwrap().contains("code:write"));
1741 let issues = token(&[Scope::IssuesWrite]);
1742 assert!(!decide_git(&issues, false, false).allowed);
1743 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1744 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1745 let writer = token(&[Scope::CodeWrite]);
1746 assert!(decide_git(&writer, true, false).allowed);
1747 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1748 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1749 }
1750
1751 #[test]
1752 fn packages_need_their_own_scopes_and_public_pulls_none() {
1753 let reader = token(&[Scope::PackagesRead]);
1754 assert!(decide_packages(&reader, Level::Read, false).allowed);
1755 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1756 let code = token(&[Scope::CodeWrite]);
1757 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1758 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1759 let writer = token(&[Scope::PackagesWrite]);
1760 assert!(decide_packages(&writer, Level::Write, false).allowed);
1761 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1762 let refused = decide_packages(&writer, Level::Delete, false);
1763 assert!(refused.reason.unwrap().contains("packages:delete"));
1764 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1765 assert!(Scope::PackagesDelete.dangerous());
1766 // Tokens made before these scopes, and full-access ones, keep working.
1767 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1768 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1769 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1770 }
1771
1772 #[test]
1773 fn token_access_travels_as_json() {
1774 let access = token(&[Scope::IssuesRead]);
1775 let wire = serde_json::to_value(&access).unwrap();
1776 assert_eq!(wire["scopes"], json!(["issues:read"]));
1777 assert!(wire.get("resources").is_none());
1778 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1779 assert_eq!(back, access);
1780 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1781 assert!(full.is_full());
1782 // A reach written by an older version is ignored: a token reaches
1783 // whatever its owner can.
1784 let older: TokenAccess = serde_json::from_value(json!({
1785 "token_id": "tok_1",
1786 "scopes": ["issues:read"],
1787 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1788 }))
1789 .unwrap();
1790 assert_eq!(older, access);
1791 }
1792
1793 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1794 /// lists the same scopes in the same order, the same operations with
1795 /// the same scopes, and the same presets.
1796 #[test]
1797 fn the_typescript_mirror_has_the_same_table() {
1798 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1799 let section = |start: &str| {
1800 ts.split_once(start)
1801 .and_then(|(_, rest)| rest.split_once("] as const"))
1802 .map(|(table, _)| table)
1803 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1804 };
1805 let names = |table: &str| -> Vec<String> {
1806 section(table)
1807 .lines()
1808 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1809 .collect()
1810 };
1811 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1812 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1813 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1814 assert_eq!(names("export const SCOPES = ["), offered);
1815 assert_eq!(names("export const UPCOMING_SCOPES"), upcoming);
1816 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1817 .lines()
1818 .filter_map(|line| {
1819 let mut quoted = line.split('"').skip(1).step_by(2);
1820 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1821 })
1822 .collect();
1823 let expected: Vec<(String, String)> = OPERATIONS
1824 .iter()
1825 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1826 .collect();
1827 assert_eq!(operations, expected);
1828 for preset in Preset::ALL {
1829 let list = section(&format!("{}: [", preset.as_str()));
1830 let mirrored: Vec<&str> = list
1831 .split(',')
1832 .map(|item| item.trim().trim_matches('"'))
1833 .filter(|item| !item.is_empty())
1834 .collect();
1835 let expected: Vec<&str> = preset
1836 .scopes()
1837 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1838 .unwrap_or_else(|| vec!["*"]);
1839 assert_eq!(mirrored, expected, "{}", preset.as_str());
1840 }
1841 // Each resource with its group, in the same order: offered ones in
1842 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1843 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1844 let resources = ts
1845 .split_once(table)
1846 .and_then(|(_, rest)| rest.split_once("
1847];"))
1848 .map(|(table, _)| table)
1849 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1850 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1851 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1852 assert_eq!(rows.len(), expected.len(), "{table}");
1853 for (row, resource) in rows.iter().zip(expected) {
1854 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1855 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1856 }
1857 }
1858 }
1859}