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