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