Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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