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.

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