Skip to content
1,838 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 /// Set on a person's token whose owner let it use the website (g1t.sh)
685 /// as them, sent as `Authorization: Bearer`. Not a scope: no preset,
686 /// full access or OAuth grant includes it, and git, the API and MCP
687 /// ignore it.
688 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
689 pub website: bool,
690}
691
692/// Which repositories a token reaches in the workspace it is made for.
693#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "snake_case")]
695pub enum RepositorySelection {
696 /// Every repository of the workspace, ones made later included.
697 #[default]
698 All,
699 /// The repositories chosen, by id.
700 Selected,
701 /// None of the workspace's private repositories: public repositories,
702 /// read-only, and the workspace's own settings its permissions allow.
703 /// With no workspace: the owner's account and public repositories only.
704 Public,
705}
706
707impl RepositorySelection {
708 pub fn as_str(self) -> &'static str {
709 match self {
710 RepositorySelection::All => "all",
711 RepositorySelection::Selected => "selected",
712 RepositorySelection::Public => "public",
713 }
714 }
715
716 pub fn parse(text: &str) -> Option<RepositorySelection> {
717 match text.trim().to_ascii_lowercase().as_str() {
718 "all" => Some(RepositorySelection::All),
719 "selected" => Some(RepositorySelection::Selected),
720 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
721 _ => None,
722 }
723 }
724}
725
726/// What a narrowed token reaches, as identity resolves it on each use.
727#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
728pub struct TokenReach {
729 /// The workspace whose repositories and settings it reaches, by slug as
730 /// it is now. Absent: its owner's account only, with public
731 /// repositories read-only.
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub workspace: Option<String>,
734 #[serde(default)]
735 pub repositories: RepositorySelection,
736 /// With [`RepositorySelection::Selected`]: the repositories' ids.
737 #[serde(default, skip_serializing_if = "Vec::is_empty")]
738 pub repo_ids: Vec<String>,
739}
740
741impl TokenReach {
742 /// Whether it reaches the repository with this id in the workspace
743 /// `namespace` for more than what anyone may do with a public one.
744 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
745 let Some(workspace) = self.workspace.as_deref() else {
746 return false;
747 };
748 if !workspace.eq_ignore_ascii_case(namespace) {
749 return false;
750 }
751 match self.repositories {
752 RepositorySelection::All => true,
753 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
754 RepositorySelection::Public => false,
755 }
756 }
757
758 /// Whether it is made for the workspace `slug`.
759 pub fn owned_by(&self, slug: &str) -> bool {
760 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
761 }
762}
763
764/// Where workflow files live. Adding, changing or deleting a file under
765/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
766/// token: what GitHub's `workflow` scope and `workflows` permission do.
767pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
768
769/// Whether `path` is a workflow file, or a file in one's directory.
770pub fn is_workflow_file(path: &str) -> bool {
771 let path = path.trim_start_matches('/');
772 WORKFLOW_DIRS.iter().any(|dir| {
773 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
774 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
775}
776
777/// Whether a token may add, change or delete the files at `paths`: a
778/// refusal naming the first workflow file it may not touch, else `None`.
779/// A signed-in person (no token) is never refused here; their role decides.
780pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
781 let access = access?;
782 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
783 return None;
784 }
785 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
786 let why = if access.job.is_some() {
787 "a workflow job's token can never add or change workflow files".to_owned()
788 } else {
789 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
790 };
791 Some(Decision::deny(
792 "token:workflows",
793 format!("This access token cannot change the workflow file {path}: {why}."),
794 ))
795}
796
797/// The workflow job a token was made for.
798#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
799pub struct JobToken {
800 /// The run, `run_…`.
801 pub run_id: String,
802 /// The job, `job_…`.
803 pub job_id: String,
804 /// Whether it may open pull requests and approve them, by its
805 /// repository's and workspace's choice ("Allow g1t Actions to create and
806 /// approve pull requests"). Off unless chosen.
807 #[serde(default)]
808 pub pull_requests: bool,
809}
810
811impl TokenAccess {
812 /// Full access to everything: the access tokens made before scopes had.
813 pub fn full() -> Self {
814 TokenAccess::default()
815 }
816
817 /// Whether it may reach the repository `owner/name`: every token but a
818 /// workflow job's, which reaches its own repository only.
819 pub fn reaches(&self, repo: &str) -> bool {
820 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
821 }
822
823 pub fn is_full(&self) -> bool {
824 self.scopes.is_none()
825 }
826
827 /// The scopes it holds, or `None` for full access.
828 pub fn granted(&self) -> Option<Vec<Scope>> {
829 self.scopes
830 .as_ref()
831 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
832 }
833
834 pub fn allows(&self, needed: Scope) -> bool {
835 match self.granted() {
836 None => true,
837 Some(granted) => granted.iter().any(|held| held.includes(needed)),
838 }
839 }
840
841 /// Whether it reaches the repository with this id in `namespace` for
842 /// more than reading a public one: every token but a narrowed one
843 /// outside its workspace or repository selection. Its owner's role
844 /// still decides; see [`crate::access`].
845 pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool {
846 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
847 }
848}
849
850/// Every operation of the API and MCP server, with the scope it needs. An
851/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
852/// its operations is in exactly one of the two.
853pub const OPERATIONS: &[(&str, Scope)] = &[
854 // Your account.
855 ("list_emails", Scope::AccountRead),
856 ("add_email", Scope::AccountWrite),
857 ("confirm_email", Scope::AccountWrite),
858 ("remove_email", Scope::AccountWrite),
859 ("update_email_settings", Scope::AccountWrite),
860 ("list_invites", Scope::AccountRead),
861 ("create_invite", Scope::AccountWrite),
862 ("revoke_invite", Scope::AccountWrite),
863 ("list_invitations", Scope::AccountRead),
864 ("accept_invitation", Scope::AccountWrite),
865 ("decline_invitation", Scope::AccountWrite),
866 ("list_my_repo_invitations", Scope::AccountRead),
867 ("accept_repo_invitation", Scope::AccountWrite),
868 ("decline_repo_invitation", Scope::AccountWrite),
869 // Your pinned projects: a preference of your account.
870 ("list_pinned_projects", Scope::AccountRead),
871 ("pin_project", Scope::AccountWrite),
872 // Your stars: a preference of your account.
873 ("list_starred", Scope::AccountRead),
874 ("check_starred", Scope::AccountRead),
875 ("star_repo", Scope::AccountWrite),
876 ("unstar_repo", Scope::AccountWrite),
877 ("unpin_project", Scope::AccountWrite),
878 ("reorder_pinned_projects", Scope::AccountWrite),
879 // Your inbox: notifications, subscriptions and watching.
880 ("list_notifications", Scope::NotificationsRead),
881 ("get_notification_thread", Scope::NotificationsRead),
882 ("get_thread_subscription", Scope::NotificationsRead),
883 ("get_repo_subscription", Scope::NotificationsRead),
884 ("list_watched_repos", Scope::NotificationsRead),
885 ("mark_notifications_read", Scope::NotificationsWrite),
886 ("mark_thread_read", Scope::NotificationsWrite),
887 ("mark_thread_done", Scope::NotificationsWrite),
888 ("save_thread", Scope::NotificationsWrite),
889 ("snooze_thread", Scope::NotificationsWrite),
890 ("set_thread_subscription", Scope::NotificationsWrite),
891 ("delete_thread_subscription", Scope::NotificationsWrite),
892 ("set_repo_subscription", Scope::NotificationsWrite),
893 ("delete_repo_subscription", Scope::NotificationsWrite),
894 // Workspaces, their invites and integrations.
895 ("create_workspace", Scope::WorkspaceAdmin),
896 ("delete_workspace", Scope::WorkspaceAdmin),
897 ("get_workspace", Scope::WorkspaceRead),
898 ("update_workspace", Scope::WorkspaceAdmin),
899 // Its members, and who owns it.
900 ("list_members", Scope::WorkspaceRead),
901 ("update_member", Scope::WorkspaceAdmin),
902 ("remove_member", Scope::WorkspaceAdmin),
903 ("transfer_ownership", Scope::WorkspaceAdmin),
904 ("leave_workspace", Scope::AccountWrite),
905 ("list_workspace_invites", Scope::WorkspaceRead),
906 ("invite_member", Scope::WorkspaceAdmin),
907 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
908 ("list_integrations", Scope::WorkspaceRead),
909 ("connect_integration", Scope::WorkspaceAdmin),
910 ("update_integration", Scope::WorkspaceAdmin),
911 ("disconnect_integration", Scope::WorkspaceAdmin),
912 ("test_integration", Scope::WorkspaceAdmin),
913 ("get_model_routes", Scope::WorkspaceRead),
914 ("set_model_routes", Scope::WorkspaceAdmin),
915 // Teams: reading them, and managing them. A team's role on a
916 // repository is who has access.
917 ("list_teams", Scope::WorkspaceRead),
918 ("get_team", Scope::WorkspaceRead),
919 ("list_team_members", Scope::WorkspaceRead),
920 ("list_child_teams", Scope::WorkspaceRead),
921 ("list_team_repos", Scope::WorkspaceRead),
922 ("list_user_teams", Scope::WorkspaceRead),
923 ("create_team", Scope::WorkspaceAdmin),
924 ("list_workspace_rulesets", Scope::WorkspaceRead),
925 ("get_workspace_ruleset", Scope::WorkspaceRead),
926 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
927 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
928 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
929 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
930 ("update_team", Scope::WorkspaceAdmin),
931 ("delete_team", Scope::WorkspaceAdmin),
932 ("set_team_member", Scope::WorkspaceAdmin),
933 ("remove_team_member", Scope::WorkspaceAdmin),
934 ("set_team_review_assignment", Scope::WorkspaceAdmin),
935 // A workspace's billing: usage, budget, AI credit and invoices.
936 ("get_usage", Scope::BillingRead),
937 ("get_budget", Scope::BillingRead),
938 ("get_ai_credit", Scope::BillingRead),
939 ("list_invoices", Scope::BillingRead),
940 ("get_billing_details", Scope::BillingRead),
941 ("set_budget", Scope::BillingWrite),
942 ("buy_ai_credit", Scope::BillingWrite),
943 // Repositories.
944 ("list_repos", Scope::RepoRead),
945 ("get_repo", Scope::RepoRead),
946 // Projects follow their repositories.
947 ("list_projects", Scope::RepoRead),
948 ("get_project", Scope::RepoRead),
949 ("search", Scope::RepoRead),
950 ("list_events", Scope::RepoRead),
951 // What the default branch says about a repository, who starred it, and
952 // its releases.
953 ("get_languages", Scope::RepoRead),
954 ("list_contributors", Scope::RepoRead),
955 ("get_license", Scope::RepoRead),
956 ("list_stargazers", Scope::RepoRead),
957 ("list_releases", Scope::RepoRead),
958 ("get_latest_release", Scope::RepoRead),
959 ("get_release_by_tag", Scope::RepoRead),
960 ("get_release", Scope::RepoRead),
961 ("create_release", Scope::RepoWrite),
962 ("update_release", Scope::RepoWrite),
963 ("delete_release", Scope::RepoWrite),
964 ("list_labels", Scope::RepoRead),
965 ("list_milestones", Scope::RepoRead),
966 ("get_milestone", Scope::RepoRead),
967 ("create_label", Scope::IssuesWrite),
968 ("update_label", Scope::IssuesWrite),
969 ("delete_label", Scope::IssuesWrite),
970 ("add_default_labels", Scope::IssuesWrite),
971 ("create_milestone", Scope::IssuesWrite),
972 ("update_milestone", Scope::IssuesWrite),
973 ("delete_milestone", Scope::IssuesWrite),
974 ("get_repo_settings", Scope::RepoRead),
975 ("list_check_names", Scope::RepoRead),
976 ("list_deleted_repos", Scope::RepoRead),
977 ("list_security_alerts", Scope::RepoRead),
978 ("get_codeowners_errors", Scope::RepoRead),
979 ("create_repo", Scope::RepoWrite),
980 ("update_repo", Scope::RepoWrite),
981 ("update_project", Scope::RepoWrite),
982 ("update_repo_settings", Scope::RepoWrite),
983 // Rulesets: reading them is reading the repository; changing them
984 // changes what everyone, agents included, may do, so it is admin.
985 ("list_repo_rulesets", Scope::RepoRead),
986 ("get_repo_ruleset", Scope::RepoRead),
987 ("get_branch_rules", Scope::RepoRead),
988 ("list_rule_evaluations", Scope::RepoRead),
989 ("create_repo_ruleset", Scope::RepoAdmin),
990 ("update_repo_ruleset", Scope::RepoAdmin),
991 ("delete_repo_ruleset", Scope::RepoAdmin),
992 ("rename_branch", Scope::RepoWrite),
993 ("rename_repo", Scope::RepoAdmin),
994 ("transfer_repo", Scope::RepoAdmin),
995 ("archive_repo", Scope::RepoAdmin),
996 ("unarchive_repo", Scope::RepoAdmin),
997 ("set_repo_visibility", Scope::RepoAdmin),
998 ("delete_repo", Scope::RepoAdmin),
999 ("restore_repo", Scope::RepoAdmin),
1000 ("purge_repo", Scope::RepoAdmin),
1001 // A dismissed secret is let through push protection.
1002 ("dismiss_security_alert", Scope::RepoAdmin),
1003 ("reopen_security_alert", Scope::RepoAdmin),
1004 // The security suite: alerts, push protection, patterns, code
1005 // scanning, the supply chain and settings.
1006 ("list_secret_scanning_alerts", Scope::SecurityRead),
1007 ("get_secret_scanning_alert", Scope::SecurityRead),
1008 ("list_secret_scanning_locations", Scope::SecurityRead),
1009 ("list_bypass_requests", Scope::SecurityRead),
1010 ("list_custom_patterns", Scope::SecurityRead),
1011 ("list_code_scanning_alerts", Scope::SecurityRead),
1012 ("get_code_scanning_alert", Scope::SecurityRead),
1013 ("list_code_scanning_analyses", Scope::SecurityRead),
1014 ("get_sarif_upload", Scope::SecurityRead),
1015 ("list_vulnerability_alerts", Scope::SecurityRead),
1016 ("get_vulnerability_alert", Scope::SecurityRead),
1017 ("get_dependency_graph", Scope::SecurityRead),
1018 ("get_sbom", Scope::SecurityRead),
1019 ("compare_dependencies", Scope::SecurityRead),
1020 ("get_security_settings", Scope::SecurityRead),
1021 ("get_workspace_security_settings", Scope::SecurityRead),
1022 ("get_security_overview", Scope::SecurityRead),
1023 ("update_secret_scanning_alert", Scope::SecurityWrite),
1024 ("bypass_push_protection", Scope::SecurityWrite),
1025 ("check_secret_validity", Scope::SecurityWrite),
1026 ("review_bypass_request", Scope::SecurityWrite),
1027 ("create_custom_pattern", Scope::SecurityWrite),
1028 ("update_custom_pattern", Scope::SecurityWrite),
1029 ("delete_custom_pattern", Scope::SecurityWrite),
1030 ("dry_run_custom_pattern", Scope::SecurityWrite),
1031 ("update_code_scanning_alert", Scope::SecurityWrite),
1032 ("upload_sarif", Scope::SecurityWrite),
1033 ("update_vulnerability_alert", Scope::SecurityWrite),
1034 ("fix_security_alert", Scope::SecurityWrite),
1035 ("update_security_settings", Scope::SecurityWrite),
1036 ("update_workspace_security_settings", Scope::SecurityWrite),
1037 // Issues and plans.
1038 ("list_issues", Scope::IssuesRead),
1039 ("get_issue", Scope::IssuesRead),
1040 ("get_plan", Scope::IssuesRead),
1041 ("create_issue", Scope::IssuesWrite),
1042 ("update_issue", Scope::IssuesWrite),
1043 ("list_issue_labels", Scope::IssuesRead),
1044 ("add_issue_labels", Scope::IssuesWrite),
1045 ("set_issue_labels", Scope::IssuesWrite),
1046 ("remove_issue_labels", Scope::IssuesWrite),
1047 ("close_issue", Scope::IssuesWrite),
1048 ("reopen_issue", Scope::IssuesWrite),
1049 ("add_comment", Scope::IssuesWrite),
1050 ("edit_comment", Scope::IssuesWrite),
1051 ("delete_comment", Scope::IssuesWrite),
1052 ("import_issue", Scope::IssuesWrite),
1053 ("apply_plan", Scope::IssuesWrite),
1054 // Pull requests.
1055 ("list_pull_requests", Scope::PullRequestsRead),
1056 ("get_pull_request", Scope::PullRequestsRead),
1057 ("get_pull_request_changes", Scope::PullRequestsRead),
1058 ("read_session", Scope::PullRequestsRead),
1059 ("get_merge_queue", Scope::PullRequestsRead),
1060 ("create_pull_request", Scope::PullRequestsWrite),
1061 ("update_pull_request", Scope::PullRequestsWrite),
1062 ("record_session", Scope::PullRequestsWrite),
1063 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1064 ("close_pull_request", Scope::PullRequestsWrite),
1065 ("reopen_pull_request", Scope::PullRequestsWrite),
1066 ("convert_pull_request_to_draft", Scope::PullRequestsWrite),
1067 ("review_pull_request", Scope::PullRequestsWrite),
1068 ("merge_pull_request", Scope::PullRequestsWrite),
1069 ("request_reviewers", Scope::PullRequestsWrite),
1070 ("remove_requested_reviewers", Scope::PullRequestsWrite),
1071 // g1t's agents.
1072 ("assign_issue", Scope::AgentsRun),
1073 ("delegate", Scope::AgentsRun),
1074 ("plan_work", Scope::AgentsRun),
1075 ("message_agent", Scope::AgentsRun),
1076 ("answer_message", Scope::AgentsRun),
1077 ("take_messages", Scope::AgentsRun),
1078 // Workflows.
1079 ("list_workflows", Scope::WorkflowsRead),
1080 ("list_workflow_runs", Scope::WorkflowsRead),
1081 ("get_workflow_run", Scope::WorkflowsRead),
1082 ("get_job_logs", Scope::WorkflowsRead),
1083 ("dispatch_workflow", Scope::WorkflowsWrite),
1084 ("cancel_workflow_run", Scope::WorkflowsWrite),
1085 ("rerun_workflow_run", Scope::WorkflowsWrite),
1086 ("update_workflow", Scope::WorkflowsWrite),
1087 ("list_artifacts", Scope::WorkflowsRead),
1088 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1089 ("get_artifact", Scope::WorkflowsRead),
1090 ("download_artifact", Scope::WorkflowsRead),
1091 ("get_artifact_retention", Scope::WorkflowsRead),
1092 ("delete_artifact", Scope::WorkflowsWrite),
1093 ("set_artifact_retention", Scope::WorkflowsWrite),
1094 // Checks: statuses, check runs and check suites on commits.
1095 ("list_commit_statuses", Scope::ChecksRead),
1096 ("get_combined_status", Scope::ChecksRead),
1097 ("list_check_runs_for_ref", Scope::ChecksRead),
1098 ("get_check_run", Scope::ChecksRead),
1099 ("list_check_run_annotations", Scope::ChecksRead),
1100 ("list_check_suites_for_ref", Scope::ChecksRead),
1101 ("get_check_suite", Scope::ChecksRead),
1102 ("create_commit_status", Scope::ChecksWrite),
1103 ("create_check_run", Scope::ChecksWrite),
1104 ("update_check_run", Scope::ChecksWrite),
1105 ("rerequest_check_run", Scope::ChecksWrite),
1106 ("rerequest_check_suite", Scope::ChecksWrite),
1107 // Deployments, wherever they run: reading them, and reporting them.
1108 ("list_deployments", Scope::DeploymentsRead),
1109 ("get_deployment", Scope::DeploymentsRead),
1110 ("list_deployment_statuses", Scope::DeploymentsRead),
1111 ("list_environments", Scope::DeploymentsRead),
1112 ("get_environment", Scope::DeploymentsRead),
1113 ("create_deployment", Scope::DeploymentsWrite),
1114 ("create_deployment_status", Scope::DeploymentsWrite),
1115 // What keeps runs safe: the runs environments hold and reviewing them,
1116 // approving a pull request's run, and a repository's own rules for
1117 // its environments and tokens, which are an admin's.
1118 ("get_pending_deployments", Scope::WorkflowsRead),
1119 ("review_pending_deployments", Scope::WorkflowsWrite),
1120 ("approve_workflow_run", Scope::WorkflowsWrite),
1121 ("get_workflow_permissions", Scope::RepoRead),
1122 ("get_fork_pr_approval", Scope::RepoRead),
1123 ("get_actions_access", Scope::RepoRead),
1124 ("update_environment", Scope::RepoAdmin),
1125 ("delete_environment", Scope::RepoAdmin),
1126 ("set_workflow_permissions", Scope::RepoAdmin),
1127 ("set_fork_pr_approval", Scope::RepoAdmin),
1128 ("set_actions_access", Scope::RepoAdmin),
1129 // Starting workflows from outside, as a push would.
1130 ("create_repository_dispatch", Scope::CodeWrite),
1131 // A workspace's policy for its repositories' tokens.
1132 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1133 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
1134 // A workspace's rules for personal access tokens, and the members'
1135 // tokens that reach it: who has access.
1136 ("get_token_policy", Scope::WorkspaceRead),
1137 ("set_token_policy", Scope::WorkspaceAdmin),
1138 ("list_member_tokens", Scope::AccessRead),
1139 ("list_token_requests", Scope::AccessRead),
1140 ("review_token_request", Scope::AccessAdmin),
1141 ("revoke_member_token", Scope::AccessAdmin),
1142 // Memory and the context hub.
1143 ("recall", Scope::MemoryRead),
1144 ("search_context", Scope::MemoryRead),
1145 ("get_entity", Scope::MemoryRead),
1146 ("get_context", Scope::MemoryRead),
1147 ("remember", Scope::MemoryWrite),
1148 // Who has access.
1149 ("list_collaborators", Scope::AccessRead),
1150 ("get_collaborator_permission", Scope::AccessRead),
1151 ("list_repo_invitations", Scope::AccessRead),
1152 ("list_outside_collaborators", Scope::AccessRead),
1153 ("add_collaborator", Scope::AccessAdmin),
1154 ("update_collaborator", Scope::AccessAdmin),
1155 ("remove_collaborator", Scope::AccessAdmin),
1156 ("revoke_repo_invitation", Scope::AccessAdmin),
1157 ("set_base_permission", Scope::AccessAdmin),
1158 ("set_team_repo", Scope::AccessAdmin),
1159 ("remove_team_repo", Scope::AccessAdmin),
1160 // Deploy keys: each lets a machine reach one repository, so they
1161 // are part of who has access.
1162 ("list_deploy_keys", Scope::AccessRead),
1163 ("get_deploy_key", Scope::AccessRead),
1164 ("create_deploy_key", Scope::AccessAdmin),
1165 ("delete_deploy_key", Scope::AccessAdmin),
1166 // Mirroring: a repository's links to other hosts. Reading them is
1167 // reading the repository; syncing writes code; the rest is an admin's.
1168 ("get_mirror", Scope::RepoRead),
1169 ("sync_mirror", Scope::CodeWrite),
1170 ("get_hand_back_plan", Scope::RepoAdmin),
1171 ("take_over_mirror", Scope::RepoAdmin),
1172 ("set_ci_failover", Scope::RepoAdmin),
1173 ("hand_back_mirror", Scope::RepoAdmin),
1174 ("move_mirror_to_g1t", Scope::RepoAdmin),
1175 ("add_mirror_remote", Scope::RepoAdmin),
1176 ("update_mirror_remote", Scope::RepoAdmin),
1177 ("remove_mirror_remote", Scope::RepoAdmin),
1178 // Webhooks.
1179 ("list_webhooks", Scope::WebhooksRead),
1180 ("list_webhook_deliveries", Scope::WebhooksRead),
1181 ("create_webhook", Scope::WebhooksAdmin),
1182 ("update_webhook", Scope::WebhooksAdmin),
1183 ("delete_webhook", Scope::WebhooksAdmin),
1184 ("ping_webhook", Scope::WebhooksAdmin),
1185 ("redeliver_webhook", Scope::WebhooksAdmin),
1186 // Secrets and variables.
1187 ("list_actions_secrets", Scope::SecretsRead),
1188 ("list_actions_variables", Scope::SecretsRead),
1189 ("set_actions_secret", Scope::SecretsAdmin),
1190 ("delete_actions_secret", Scope::SecretsAdmin),
1191 ("set_actions_variable", Scope::SecretsAdmin),
1192 ("delete_actions_variable", Scope::SecretsAdmin),
1193 // Self-hosted runners.
1194 ("list_runners", Scope::RunnersRead),
1195 ("list_runner_groups", Scope::RunnersRead),
1196 ("get_runner_settings", Scope::RunnersRead),
1197 ("create_runner_registration_token", Scope::RunnersAdmin),
1198 ("remove_runner", Scope::RunnersAdmin),
1199 ("create_runner_group", Scope::RunnersAdmin),
1200 ("update_runner_group", Scope::RunnersAdmin),
1201 ("delete_runner_group", Scope::RunnersAdmin),
1202 ("update_runner_settings", Scope::RunnersAdmin),
1203 // Packages: reading them, their versions and who may use them needs
1204 // `packages:read`; changing their settings, access and Manage Actions
1205 // access `packages:write` (and the Admin role on the package, which the
1206 // packages service checks); deleting and restoring packages and
1207 // versions `packages:delete`, as the registries' own deletes do.
1208 ("list_packages", Scope::PackagesRead),
1209 ("get_package", Scope::PackagesRead),
1210 ("list_package_versions", Scope::PackagesRead),
1211 ("get_package_version", Scope::PackagesRead),
1212 ("list_package_access", Scope::PackagesRead),
1213 ("list_package_actions_access", Scope::PackagesRead),
1214 ("update_package", Scope::PackagesWrite),
1215 ("link_package", Scope::PackagesWrite),
1216 ("unlink_package", Scope::PackagesWrite),
1217 ("set_package_access", Scope::PackagesWrite),
1218 ("remove_package_access", Scope::PackagesWrite),
1219 ("set_package_actions_access", Scope::PackagesWrite),
1220 ("remove_package_actions_access", Scope::PackagesWrite),
1221 ("delete_package", Scope::PackagesDelete),
1222 ("restore_package", Scope::PackagesDelete),
1223 ("delete_package_version", Scope::PackagesDelete),
1224 ("restore_package_version", Scope::PackagesDelete),
1225 // The AI Gateway. Sending a request to a model needs `models:write`,
1226 // checked by the model proxy at models.g1t.sh, not here.
1227 ("list_gateway_requests", Scope::ModelsRead),
1228];
1229
1230/// Operations any token may use: saying who it is.
1231pub const NO_SCOPE: &[&str] = &["whoami"];
1232
1233/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1234/// operation in neither list needs full access.
1235pub fn scope_for(operation: &str) -> Option<Scope> {
1236 OPERATIONS
1237 .iter()
1238 .find(|(name, _)| *name == operation)
1239 .map(|(_, scope)| *scope)
1240}
1241
1242/// What a token needs for `operation` with this input beyond its own
1243/// scope: starting agents from an operation that can, and making a
1244/// repository public or private.
1245pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1246 let mut extra = Vec::new();
1247 let assigns = input["assign"].as_bool() == Some(true)
1248 || input["agent"].as_bool() == Some(true)
1249 || input["assign_agent"].as_bool() == Some(true);
1250 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1251 extra.push(Scope::AgentsRun);
1252 }
1253 // Fixing an alert opens an issue and puts g1t on it.
1254 if operation == "fix_security_alert" {
1255 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1256 }
1257 // Opening the issue an agent is put on.
1258 if operation == "delegate" {
1259 extra.push(Scope::IssuesWrite);
1260 }
1261 // A workspace's base permission is who has access.
1262 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1263 extra.push(Scope::AccessAdmin);
1264 }
1265 // Asking a g1t Actions job or run to run again reruns its workflow.
1266 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1267 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1268 {
1269 extra.push(Scope::WorkflowsWrite);
1270 }
1271 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())) {
1272 extra.push(Scope::RepoAdmin);
1273 }
1274 extra
1275}
1276
1277/// The scopes a call needs, its own first.
1278pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1279 scope_for(operation)
1280 .into_iter()
1281 .chain(extra_scopes(operation, input))
1282 .collect()
1283}
1284
1285/// Whether `access` may use `operation` with `input`. The person's (or
1286/// workspace's) role is checked after this, by the service that owns what
1287/// was asked about.
1288pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1289 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
1290 // A workflow job may open or approve pull requests only where its
1291 // repository and workspace let it, as on GitHub.
1292 if let Some(job) = &access.job
1293 && !job.pull_requests
1294 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1295 {
1296 return Decision::deny(
1297 "token:pull-requests",
1298 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1299 );
1300 }
1301 if let Some(only) = access.repo.as_deref()
1302 && !NO_SCOPE.contains(&operation)
1303 {
1304 match input["repo"].as_str() {
1305 Some(repo) if access.reaches(repo) => {}
1306 Some(repo) => {
1307 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1308 }
1309 None => {
1310 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1311 }
1312 }
1313 }
1314 // A token made for one workspace (or none) only reads outside it:
1315 // public repositories, as anyone may. Inside it, its repository
1316 // selection is checked with its owner's role (`access::granted`).
1317 if let Some(reach) = &access.reach
1318 && let Some(repo) = input["repo"].as_str()
1319 && !NO_SCOPE.contains(&operation)
1320 {
1321 let namespace = repo.split('/').next().unwrap_or_default();
1322 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1323 if changes && !reach.owned_by(namespace) {
1324 let made_for = reach.workspace.as_deref().map_or_else(|| "your account only".to_owned(), |workspace| format!("the workspace {workspace}"));
1325 return Decision::deny(
1326 "token:resource-owner",
1327 format!("This access token is made for {made_for}: elsewhere it can only read public repositories, and {repo} is not in its reach."),
1328 );
1329 }
1330 }
1331 if access.scopes.is_some() {
1332 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1333 if !known {
1334 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1335 }
1336 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1337 return Decision::deny(
1338 "token:scope",
1339 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1340 );
1341 }
1342 }
1343 Decision::allow(rule)
1344}
1345
1346/// Whether a token may use the repository `owner/name` at all: a refusal
1347/// for a workflow job's token or a deploy key in another repository,
1348/// else `None`. Git and
1349/// the package registries ask this before [`decide_git`] and
1350/// [`decide_packages`].
1351pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1352 let only = access.repo.as_deref()?;
1353 let why = if access.deploy_key.is_some() {
1354 format!("This deploy key is for {only}: it cannot reach {repo}.")
1355 } else {
1356 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1357 };
1358 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
1359}
1360
1361/// Whether a token may clone or fetch (`write` false), or push to (`write`
1362/// true), a repository with git. `public` is whether anyone may read it,
1363/// which needs no scope.
1364pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1365 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1366 if !access.allows(needed) && (write || !public) {
1367 if access.deploy_key.is_some() {
1368 return Decision::deny(
1369 "token:scope",
1370 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1371 );
1372 }
1373 return Decision::deny(
1374 "token:scope",
1375 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1376 );
1377 }
1378 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1379}
1380
1381/// Whether a token may pull (`Level::Read`), push or publish
1382/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1383/// whether anyone may pull the package, which needs no scope.
1384pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1385 let (needed, doing) = match level {
1386 Level::Read => (Scope::PackagesRead, "pull a private package"),
1387 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1388 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1389 };
1390 if !access.allows(needed) && !(level == Level::Read && public) {
1391 return Decision::deny(
1392 "token:scope",
1393 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1394 );
1395 }
1396 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402 use serde_json::json;
1403
1404 fn token(scopes: &[Scope]) -> TokenAccess {
1405 TokenAccess {
1406 token_id: "tok_1".to_owned(),
1407 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1408 legacy: false,
1409 name: None,
1410 ..TokenAccess::default()
1411 }
1412 }
1413
1414 #[test]
1415 fn every_scope_reads_back_and_belongs_to_a_resource() {
1416 for scope in Scope::ALL {
1417 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1418 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1419 assert!(scope.includes(scope));
1420 }
1421 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1422 assert_eq!(Scope::parse("issues"), None);
1423 }
1424
1425 #[test]
1426 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1427 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1428 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1429 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1430 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1431 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1432 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1433 }
1434
1435 #[test]
1436 fn operations_are_listed_once_and_never_also_free() {
1437 let mut seen = std::collections::HashSet::new();
1438 for (name, _) in OPERATIONS {
1439 assert!(seen.insert(*name), "{name} twice");
1440 assert!(!NO_SCOPE.contains(name), "{name}");
1441 }
1442 }
1443
1444 #[test]
1445 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1446 assert_eq!(
1447 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1448 vec![Scope::RepoRead, Scope::IssuesWrite]
1449 );
1450 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1451 }
1452
1453 #[test]
1454 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1455 let scopes = oauth_default();
1456 assert!(scopes.contains(&Scope::IssuesWrite));
1457 assert!(scopes.contains(&Scope::PullRequestsWrite));
1458 assert!(scopes.contains(&Scope::AgentsRun));
1459 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
1460 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1461 // Every read offered but the machines work runs on.
1462 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
1463 }
1464 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1465 assert_eq!(Preset::Full.scopes(), None);
1466 }
1467
1468 #[test]
1469 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1470 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1471 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1472 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1473 }
1474 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1475 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1476 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1477 let reader = token(&[Scope::BillingRead]);
1478 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1479 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1480 }
1481
1482 #[test]
1483 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1484 // Reading the log is a read like any other.
1485 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1486 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1487 // Sending requests spends the workspace's AI credit: chosen on purpose.
1488 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1489 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1490 }
1491 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1492 assert!(!Scope::ModelsWrite.dangerous());
1493 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1494 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1495 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1496 }
1497
1498 #[test]
1499 fn artifacts_scopes_exist_but_are_not_offered_yet() {
1500 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1501 assert_eq!(scope.resource(), Resource::Artifacts);
1502 assert!(!scope.offered());
1503 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1504 // Nothing hands it out: not presets, full access, OAuth or the form.
1505 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1506 assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str());
1507 }
1508 assert!(!everything().contains(&scope));
1509 assert!(!offered_scopes().contains(&scope));
1510 assert!(!oauth_default().contains(&scope));
1511 assert!(parse_scopes(scope.as_str()).is_empty());
1512 // And no operation needs it yet.
1513 assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope));
1514 }
1515 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1516 assert!(Scope::ArtifactsAdmin.dangerous());
1517 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1518 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]);
1519 assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned()));
1520 assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3);
1521 }
1522
1523 #[test]
1524 fn checks_are_reported_with_checks_write_which_ci_gets() {
1525 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1526 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1527 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1528 let ci = Preset::Ci.scopes().unwrap();
1529 assert!(ci.contains(&Scope::ChecksWrite));
1530 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1531 let reporter = token(&[Scope::ChecksWrite]);
1532 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1533 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1534 // A g1t Actions job runs again as its workflow does.
1535 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1536 assert!(refused.reason.unwrap().contains("workflows:write"));
1537 }
1538
1539 #[test]
1540 fn a_job_token_reaches_its_repository_only() {
1541 let job = TokenAccess {
1542 repo: Some("acme/web".into()),
1543 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1544 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1545 };
1546 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1547 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1548 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1549 assert!(!elsewhere.allowed);
1550 assert_eq!(elsewhere.rule, "token:repository");
1551 // Nothing beyond the one repository, a workspace's listing included.
1552 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1553 assert!(decide(&job, "whoami", &json!({})).allowed);
1554 // Its scopes still hold inside it.
1555 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1556 assert!(decide_repo(&job, "acme/web").is_none());
1557 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1558 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1559 // Opening and approving pull requests is off unless allowed.
1560 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1561 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1562 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1563 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1564 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1565 }
1566
1567 #[test]
1568 fn workflow_files_need_their_own_scope() {
1569 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1570 assert!(is_workflow_file(path), "{path}");
1571 }
1572 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1573 assert!(!is_workflow_file(path), "{path}");
1574 }
1575 let code = token(&[Scope::CodeWrite]);
1576 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1577 assert_eq!(refused.rule, "token:workflows");
1578 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1579 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1580 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1581 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1582 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1583 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1584 // A job's token never may, as GITHUB_TOKEN never may.
1585 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1586 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1587 // Nothing in a preset changes workflow files but full access.
1588 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1589 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1590 }
1591 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1592 }
1593
1594 #[test]
1595 fn a_narrowed_token_only_reads_outside_its_workspace() {
1596 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1597 let fine = TokenAccess { reach: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) };
1598 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1599 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1600 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1601 assert_eq!(elsewhere.rule, "token:resource-owner");
1602 assert!(elsewhere.reason.unwrap().contains("acme"));
1603 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1604 assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold");
1605 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
1606 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1607 assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex"));
1608 let selected = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] };
1609 assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme"));
1610 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
1611 assert!(!public.covers("rep_1", "acme") && public.owned_by("acme"));
1612 assert!(token(&[]).covers_repo("rep_1", "anything"), "a token for every workspace reaches what its owner can");
1613 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1614 }
1615
1616 #[test]
1617 fn permissions_are_scopes_read_per_resource() {
1618 let asked: std::collections::BTreeMap<String, String> =
1619 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1620 let scopes = resolve_permissions(&asked, true).unwrap();
1621 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1622 let back = permissions_of(&scopes);
1623 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1624 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1625 assert!(!back.contains_key("code"));
1626 // Lower levels held beside a higher one say nothing more.
1627 assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]);
1628 // Every offered resource's top, and nothing a level can lose.
1629 let all = everything();
1630 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1631 for scope in offered_scopes() {
1632 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1633 }
1634 }
1635
1636 #[test]
1637 fn permissions_are_checked_by_name_level_and_owner() {
1638 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1639 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1640 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1641 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1642 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1643 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1644 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1645 for resource in Resource::ALL {
1646 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1647 assert!(!resource.scopes().is_empty());
1648 }
1649 }
1650
1651 #[test]
1652 fn a_legacy_token_can_do_everything() {
1653 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1654 for (operation, _) in OPERATIONS {
1655 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1656 }
1657 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1658 }
1659
1660 #[test]
1661 fn a_missing_scope_is_named() {
1662 let read = token(&[Scope::IssuesRead]);
1663 assert!(decide(&read, "get_issue", &json!({})).allowed);
1664 assert!(decide(&read, "whoami", &json!({})).allowed);
1665 let refused = decide(&read, "create_issue", &json!({}));
1666 assert!(!refused.allowed);
1667 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1668 // An operation the table does not know needs full access.
1669 assert!(!decide(&read, "something_new", &json!({})).allowed);
1670 }
1671
1672 #[test]
1673 fn starting_agents_from_another_operation_needs_agents_run() {
1674 let writer = token(&[Scope::IssuesWrite]);
1675 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1676 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1677 assert!(refused.reason.unwrap().contains("agents:run"));
1678 let maintainer = token(&[Scope::RepoWrite]);
1679 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1680 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1681 }
1682
1683 #[test]
1684 fn a_workspaces_base_permission_needs_access_admin_too() {
1685 let admin = token(&[Scope::WorkspaceAdmin]);
1686 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1687 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1688 assert!(refused.reason.unwrap().contains("access:admin"));
1689 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1690 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1691 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1692 }
1693
1694 #[test]
1695 fn delegating_needs_both_agents_and_issues() {
1696 let agents = token(&[Scope::AgentsRun]);
1697 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1698 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1699 assert!(decide(&both, "delegate", &json!({})).allowed);
1700 }
1701
1702 #[test]
1703 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1704 let reader = token(&[Scope::CodeRead]);
1705 assert!(decide_git(&reader, false, false).allowed);
1706 let refused = decide_git(&reader, true, false);
1707 assert!(!refused.allowed);
1708 assert!(refused.reason.unwrap().contains("code:write"));
1709 let issues = token(&[Scope::IssuesWrite]);
1710 assert!(!decide_git(&issues, false, false).allowed);
1711 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1712 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1713 let writer = token(&[Scope::CodeWrite]);
1714 assert!(decide_git(&writer, true, false).allowed);
1715 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1716 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1717 }
1718
1719 #[test]
1720 fn packages_need_their_own_scopes_and_public_pulls_none() {
1721 let reader = token(&[Scope::PackagesRead]);
1722 assert!(decide_packages(&reader, Level::Read, false).allowed);
1723 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1724 let code = token(&[Scope::CodeWrite]);
1725 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1726 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1727 let writer = token(&[Scope::PackagesWrite]);
1728 assert!(decide_packages(&writer, Level::Write, false).allowed);
1729 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1730 let refused = decide_packages(&writer, Level::Delete, false);
1731 assert!(refused.reason.unwrap().contains("packages:delete"));
1732 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1733 assert!(Scope::PackagesDelete.dangerous());
1734 // Tokens made before these scopes, and full-access ones, keep working.
1735 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1736 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1737 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1738 }
1739
1740 #[test]
1741 fn token_access_travels_as_json() {
1742 let access = token(&[Scope::IssuesRead]);
1743 let wire = serde_json::to_value(&access).unwrap();
1744 assert_eq!(wire["scopes"], json!(["issues:read"]));
1745 assert!(wire.get("resources").is_none());
1746 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1747 assert_eq!(back, access);
1748 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1749 assert!(full.is_full());
1750 // A reach written by an older version is ignored: a token reaches
1751 // whatever its owner can.
1752 let older: TokenAccess = serde_json::from_value(json!({
1753 "token_id": "tok_1",
1754 "scopes": ["issues:read"],
1755 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1756 }))
1757 .unwrap();
1758 assert_eq!(older, access);
1759 // Using the website is off unless set, and said only when on.
1760 assert!(!access.website);
1761 assert!(wire_of(&access).get("website").is_none());
1762 let website = TokenAccess { website: true, ..access };
1763 assert_eq!(wire_of(&website)["website"], json!(true));
1764 // Never part of full access.
1765 assert!(!TokenAccess::full().website);
1766 }
1767
1768 fn wire_of(access: &TokenAccess) -> serde_json::Value {
1769 serde_json::to_value(access).unwrap()
1770 }
1771
1772 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1773 /// lists the same scopes in the same order, the same operations with
1774 /// the same scopes, and the same presets.
1775 #[test]
1776 fn the_typescript_mirror_has_the_same_table() {
1777 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1778 let section = |start: &str| {
1779 ts.split_once(start)
1780 .and_then(|(_, rest)| rest.split_once("] as const"))
1781 .map(|(table, _)| table)
1782 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1783 };
1784 let names = |table: &str| -> Vec<String> {
1785 section(table)
1786 .lines()
1787 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1788 .collect()
1789 };
1790 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1791 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1792 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1793 assert_eq!(names("export const SCOPES = ["), offered);
1794 assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming);
1795 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1796 .lines()
1797 .filter_map(|line| {
1798 let mut quoted = line.split('"').skip(1).step_by(2);
1799 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1800 })
1801 .collect();
1802 let expected: Vec<(String, String)> = OPERATIONS
1803 .iter()
1804 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1805 .collect();
1806 assert_eq!(operations, expected);
1807 for preset in Preset::ALL {
1808 let list = section(&format!("{}: [", preset.as_str()));
1809 let mirrored: Vec<&str> = list
1810 .split(',')
1811 .map(|item| item.trim().trim_matches('"'))
1812 .filter(|item| !item.is_empty())
1813 .collect();
1814 let expected: Vec<&str> = preset
1815 .scopes()
1816 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1817 .unwrap_or_else(|| vec!["*"]);
1818 assert_eq!(mirrored, expected, "{}", preset.as_str());
1819 }
1820 // Each resource with its group, in the same order: offered ones in
1821 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1822 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1823 let resources = ts
1824 .split_once(table)
1825 .and_then(|(_, rest)| rest.split_once("
1826];"))
1827 .map(|(table, _)| table)
1828 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1829 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1830 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1831 assert_eq!(rows.len(), expected.len(), "{table}");
1832 for (row, resource) in rows.iter().zip(expected) {
1833 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1834 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1835 }
1836 }
1837 }
1838}