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