Skip to content

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

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