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