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