g1t/crates/contracts/src/scopes.rs

901 lines36,819 bytesCodeBlame
1//! Scopes: what an access token may do on its owner's behalf.
2//!
3//! A personal access token, a workspace's token and an application signed
4//! in with OAuth each carry a set of scopes. A token reaches whatever the
5//! one it acts as can reach: a person's token, that person's workspaces and
6//! repositories; a workspace's token, that workspace. What a request may do
7//! is the intersection of two things: the role of whoever the token acts as
8//! (see [`crate::access`]) and the token's scopes.
9//!
10//! Each scope is a resource and a level, written `resource:level`, such as
11//! `issues:write`. A higher level of a resource includes the lower ones:
12//! `repo:admin` includes `repo:write`, which includes `repo:read`.
13//!
14//! This module is the one source of truth: the API (REST and MCP) and git
15//! enforce it, and identity stores it. `packages/contracts/src/scopes.ts`
16//! mirrors the table for the site; a test keeps the two the same.
17
18use serde::{Deserialize, Serialize};
19
20use crate::credentials::Decision;
21
22/// Something a token can be given access to.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24pub enum Resource {
25 Account,
26 Workspace,
27 Repo,
28 Code,
29 Packages,
30 Issues,
31 PullRequests,
32 Agents,
33 Workflows,
34 Memory,
35 Access,
36 Webhooks,
37 Secrets,
38 Runners,
39}
40
41impl Resource {
42 pub const ALL: [Resource; 14] = [
43 Resource::Repo,
44 Resource::Code,
45 Resource::Packages,
46 Resource::Issues,
47 Resource::PullRequests,
48 Resource::Agents,
49 Resource::Workflows,
50 Resource::Memory,
51 Resource::Account,
52 Resource::Workspace,
53 Resource::Access,
54 Resource::Webhooks,
55 Resource::Secrets,
56 Resource::Runners,
57 ];
58
59 pub fn as_str(self) -> &'static str {
60 match self {
61 Resource::Account => "account",
62 Resource::Workspace => "workspace",
63 Resource::Repo => "repo",
64 Resource::Code => "code",
65 Resource::Packages => "packages",
66 Resource::Issues => "issues",
67 Resource::PullRequests => "pull_requests",
68 Resource::Agents => "agents",
69 Resource::Workflows => "workflows",
70 Resource::Memory => "memory",
71 Resource::Access => "access",
72 Resource::Webhooks => "webhooks",
73 Resource::Secrets => "secrets",
74 Resource::Runners => "runners",
75 }
76 }
77
78 /// Its name, for people.
79 pub fn label(self) -> &'static str {
80 match self {
81 Resource::Account => "Your account",
82 Resource::Workspace => "Workspaces",
83 Resource::Repo => "Repositories",
84 Resource::Code => "Code",
85 Resource::Packages => "Packages",
86 Resource::Issues => "Issues",
87 Resource::PullRequests => "Pull requests",
88 Resource::Agents => "g1t agents",
89 Resource::Workflows => "Workflows",
90 Resource::Memory => "Memory and context",
91 Resource::Access => "Who has access",
92 Resource::Webhooks => "Webhooks",
93 Resource::Secrets => "Secrets and variables",
94 Resource::Runners => "Self-hosted runners",
95 }
96 }
97}
98
99/// How much of a resource.
100#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
101pub enum Level {
102 Read,
103 Write,
104 /// Starting g1t's agents, which spends the workspace's money.
105 Run,
106 /// Deleting what cannot be brought back, such as a package's versions.
107 Delete,
108 Admin,
109}
110
111impl Level {
112 pub fn as_str(self) -> &'static str {
113 match self {
114 Level::Read => "read",
115 Level::Write => "write",
116 Level::Run => "run",
117 Level::Delete => "delete",
118 Level::Admin => "admin",
119 }
120 }
121}
122
123/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
124/// clients ask for, and errors name.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
126pub enum Scope {
127 RepoRead,
128 RepoWrite,
129 RepoAdmin,
130 CodeRead,
131 CodeWrite,
132 PackagesRead,
133 PackagesWrite,
134 PackagesDelete,
135 IssuesRead,
136 IssuesWrite,
137 PullRequestsRead,
138 PullRequestsWrite,
139 AgentsRun,
140 WorkflowsRead,
141 WorkflowsWrite,
142 MemoryRead,
143 MemoryWrite,
144 AccountRead,
145 AccountWrite,
146 WorkspaceRead,
147 WorkspaceAdmin,
148 AccessRead,
149 AccessAdmin,
150 WebhooksRead,
151 WebhooksAdmin,
152 SecretsRead,
153 SecretsAdmin,
154 RunnersRead,
155 RunnersAdmin,
156}
157
158impl Scope {
159 /// Every scope, grouped by resource, least first.
160 pub const ALL: [Scope; 29] = [
161 Scope::RepoRead,
162 Scope::RepoWrite,
163 Scope::RepoAdmin,
164 Scope::CodeRead,
165 Scope::CodeWrite,
166 Scope::PackagesRead,
167 Scope::PackagesWrite,
168 Scope::PackagesDelete,
169 Scope::IssuesRead,
170 Scope::IssuesWrite,
171 Scope::PullRequestsRead,
172 Scope::PullRequestsWrite,
173 Scope::AgentsRun,
174 Scope::WorkflowsRead,
175 Scope::WorkflowsWrite,
176 Scope::MemoryRead,
177 Scope::MemoryWrite,
178 Scope::AccountRead,
179 Scope::AccountWrite,
180 Scope::WorkspaceRead,
181 Scope::WorkspaceAdmin,
182 Scope::AccessRead,
183 Scope::AccessAdmin,
184 Scope::WebhooksRead,
185 Scope::WebhooksAdmin,
186 Scope::SecretsRead,
187 Scope::SecretsAdmin,
188 Scope::RunnersRead,
189 Scope::RunnersAdmin,
190 ];
191
192 pub fn as_str(self) -> &'static str {
193 match self {
194 Scope::RepoRead => "repo:read",
195 Scope::RepoWrite => "repo:write",
196 Scope::RepoAdmin => "repo:admin",
197 Scope::CodeRead => "code:read",
198 Scope::CodeWrite => "code:write",
199 Scope::PackagesRead => "packages:read",
200 Scope::PackagesWrite => "packages:write",
201 Scope::PackagesDelete => "packages:delete",
202 Scope::IssuesRead => "issues:read",
203 Scope::IssuesWrite => "issues:write",
204 Scope::PullRequestsRead => "pull_requests:read",
205 Scope::PullRequestsWrite => "pull_requests:write",
206 Scope::AgentsRun => "agents:run",
207 Scope::WorkflowsRead => "workflows:read",
208 Scope::WorkflowsWrite => "workflows:write",
209 Scope::MemoryRead => "memory:read",
210 Scope::MemoryWrite => "memory:write",
211 Scope::AccountRead => "account:read",
212 Scope::AccountWrite => "account:write",
213 Scope::WorkspaceRead => "workspace:read",
214 Scope::WorkspaceAdmin => "workspace:admin",
215 Scope::AccessRead => "access:read",
216 Scope::AccessAdmin => "access:admin",
217 Scope::WebhooksRead => "webhooks:read",
218 Scope::WebhooksAdmin => "webhooks:admin",
219 Scope::SecretsRead => "secrets:read",
220 Scope::SecretsAdmin => "secrets:admin",
221 Scope::RunnersRead => "runners:read",
222 Scope::RunnersAdmin => "runners:admin",
223 }
224 }
225
226 pub fn parse(text: &str) -> Option<Scope> {
227 let text = text.trim().to_ascii_lowercase();
228 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
229 }
230
231 pub fn resource(self) -> Resource {
232 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
233 Resource::ALL
234 .into_iter()
235 .find(|resource| resource.as_str() == name)
236 .unwrap_or(Resource::Account)
237 }
238
239 pub fn level(self) -> Level {
240 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
241 "write" => Level::Write,
242 "run" => Level::Run,
243 "delete" => Level::Delete,
244 "admin" => Level::Admin,
245 _ => Level::Read,
246 }
247 }
248
249 /// Whether holding `self` gives `other`: the same resource, at the same
250 /// level or a lower one.
251 pub fn includes(self, other: Scope) -> bool {
252 self.resource() == other.resource() && self.level() >= other.level()
253 }
254
255 /// Changes that are hard or impossible to undo, or that decide who can
256 /// reach what. Shown behind a warning wherever scopes are chosen.
257 pub fn dangerous(self) -> bool {
258 matches!(self.level(), Level::Admin | Level::Delete)
259 }
260
261 /// What it lets a token do, in plain words.
262 pub fn describe(self) -> &'static str {
263 match self {
264 Scope::RepoRead => "See repositories, their settings, labels, timelines and security alerts, and search",
265 Scope::RepoWrite => "Create repositories, rename branches and change how pull requests merge",
266 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts",
267 Scope::CodeRead => "Clone and fetch private repositories with git",
268 Scope::CodeWrite => "Push commits with git",
269 Scope::PackagesRead => "Pull container images and install private packages",
270 Scope::PackagesWrite => "Push container images and publish packages",
271 Scope::PackagesDelete => "Delete packages and their versions",
272 Scope::IssuesRead => "Read issues, comments and plans",
273 Scope::IssuesWrite => "Open, edit, close and comment on issues",
274 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
275 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
276 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
277 Scope::WorkflowsRead => "Read workflows, runs and logs",
278 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
279 Scope::MemoryRead => "Recall memory and search the workspace's context",
280 Scope::MemoryWrite => "Save memory for the next agent",
281 Scope::AccountRead => "Read your email addresses, invites and invitations",
282 Scope::AccountWrite => "Change your email addresses, make invites and answer invitations",
283 Scope::WorkspaceRead => "Read workspace invites, integrations and model routes",
284 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations",
285 Scope::AccessRead => "See who has access to repositories",
286 Scope::AccessAdmin => "Give and take away access to repositories",
287 Scope::WebhooksRead => "See webhooks and their deliveries",
288 Scope::WebhooksAdmin => "Create, change and delete webhooks",
289 Scope::SecretsRead => "List secrets (never their values) and read variables",
290 Scope::SecretsAdmin => "Set and delete secrets and variables",
291 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
292 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
293 }
294 }
295}
296
297impl Serialize for Scope {
298 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
299 serializer.serialize_str(self.as_str())
300 }
301}
302
303impl<'de> Deserialize<'de> for Scope {
304 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
305 let text = String::deserialize(deserializer)?;
306 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
307 }
308}
309
310/// Scopes as written in a token's row or an OAuth request: separated by
311/// spaces or commas. Unknown names are left out, so a client asking for a
312/// scope from a newer version gets the rest.
313pub fn parse_scopes(text: &str) -> Vec<Scope> {
314 let mut scopes: Vec<Scope> = text
315 .split(|c: char| c.is_whitespace() || c == ',')
316 .filter_map(Scope::parse)
317 .collect();
318 normalize(&mut scopes);
319 scopes
320}
321
322/// In table order, without repeats.
323pub fn normalize(scopes: &mut Vec<Scope>) {
324 let given = std::mem::take(scopes);
325 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
326}
327
328/// Space-separated, as stored and as OAuth writes them.
329pub fn scopes_text(scopes: &[Scope]) -> String {
330 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
331}
332
333/// What a token stores for full access, which is not a scope a client can
334/// ask for by name.
335pub const FULL_ACCESS: &str = "*";
336
337/// Starting points for choosing scopes.
338#[derive(Clone, Copy, Debug, PartialEq, Eq)]
339pub enum Preset {
340 ReadOnly,
341 Agent,
342 Ci,
343 Full,
344}
345
346impl Preset {
347 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
348
349 pub fn as_str(self) -> &'static str {
350 match self {
351 Preset::ReadOnly => "read_only",
352 Preset::Agent => "agent",
353 Preset::Ci => "ci",
354 Preset::Full => "full",
355 }
356 }
357
358 pub fn label(self) -> &'static str {
359 match self {
360 Preset::ReadOnly => "Read only",
361 Preset::Agent => "Agent",
362 Preset::Ci => "CI",
363 Preset::Full => "Full access",
364 }
365 }
366
367 /// Its scopes; `None` for full access.
368 pub fn scopes(self) -> Option<Vec<Scope>> {
369 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
370 match self {
371 Preset::ReadOnly => Some(reads().collect()),
372 Preset::Agent => {
373 // Not the machines work runs on: an agent has no business
374 // knowing a workspace's own runners.
375 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
376 scopes.extend([
377 Scope::CodeWrite,
378 Scope::IssuesWrite,
379 Scope::PullRequestsWrite,
380 Scope::AgentsRun,
381 Scope::MemoryWrite,
382 ]);
383 normalize(&mut scopes);
384 Some(scopes)
385 }
386 Preset::Ci => Some(vec![
387 Scope::RepoRead,
388 Scope::CodeRead,
389 Scope::CodeWrite,
390 Scope::PackagesRead,
391 Scope::PackagesWrite,
392 Scope::WorkflowsRead,
393 Scope::WorkflowsWrite,
394 ]),
395 Preset::Full => None,
396 }
397 }
398}
399
400/// What an OAuth client gets when it asks for nothing in particular: the
401/// agent preset. Never an admin scope.
402pub fn oauth_default() -> Vec<Scope> {
403 Preset::Agent.scopes().unwrap_or_default()
404}
405
406/// Set on a [`crate::User`] resolved from an access token: what the token
407/// may do. Absent on a signed-in session, which may do whatever its person
408/// can.
409#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410pub struct TokenAccess {
411 /// The token's id, as audit entries and errors name it.
412 #[serde(default)]
413 pub token_id: String,
414 /// Its scopes, as `resource:level`. Absent: full access, everything the
415 /// person (or workspace) can do.
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub scopes: Option<Vec<String>>,
418 /// Made before tokens had scopes: full access until someone narrows it.
419 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
420 pub legacy: bool,
421}
422
423impl TokenAccess {
424 /// Full access to everything: the access tokens made before scopes had.
425 pub fn full() -> Self {
426 TokenAccess::default()
427 }
428
429 pub fn is_full(&self) -> bool {
430 self.scopes.is_none()
431 }
432
433 /// The scopes it holds, or `None` for full access.
434 pub fn granted(&self) -> Option<Vec<Scope>> {
435 self.scopes
436 .as_ref()
437 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
438 }
439
440 pub fn allows(&self, needed: Scope) -> bool {
441 match self.granted() {
442 None => true,
443 Some(granted) => granted.iter().any(|held| held.includes(needed)),
444 }
445 }
446}
447
448/// Every operation of the API and MCP server, with the scope it needs. An
449/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
450/// its operations is in exactly one of the two.
451pub const OPERATIONS: &[(&str, Scope)] = &[
452 // Your account.
453 ("list_emails", Scope::AccountRead),
454 ("add_email", Scope::AccountWrite),
455 ("remove_email", Scope::AccountWrite),
456 ("update_email_settings", Scope::AccountWrite),
457 ("list_invites", Scope::AccountRead),
458 ("create_invite", Scope::AccountWrite),
459 ("revoke_invite", Scope::AccountWrite),
460 ("list_my_repo_invitations", Scope::AccountRead),
461 ("accept_repo_invitation", Scope::AccountWrite),
462 ("decline_repo_invitation", Scope::AccountWrite),
463 // Workspaces, their invites and integrations.
464 ("create_workspace", Scope::WorkspaceAdmin),
465 ("delete_workspace", Scope::WorkspaceAdmin),
466 ("update_workspace", Scope::WorkspaceAdmin),
467 ("list_workspace_invites", Scope::WorkspaceRead),
468 ("invite_member", Scope::WorkspaceAdmin),
469 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
470 ("list_integrations", Scope::WorkspaceRead),
471 ("connect_integration", Scope::WorkspaceAdmin),
472 ("disconnect_integration", Scope::WorkspaceAdmin),
473 ("test_integration", Scope::WorkspaceAdmin),
474 ("get_model_routes", Scope::WorkspaceRead),
475 ("set_model_routes", Scope::WorkspaceAdmin),
476 // Repositories.
477 ("list_repos", Scope::RepoRead),
478 ("get_repo", Scope::RepoRead),
479 ("search", Scope::RepoRead),
480 ("list_events", Scope::RepoRead),
481 ("list_labels", Scope::RepoRead),
482 ("get_repo_settings", Scope::RepoRead),
483 ("list_check_names", Scope::RepoRead),
484 ("list_deleted_repos", Scope::RepoRead),
485 ("list_security_alerts", Scope::RepoRead),
486 ("create_repo", Scope::RepoWrite),
487 ("update_repo", Scope::RepoWrite),
488 ("update_repo_settings", Scope::RepoWrite),
489 ("rename_branch", Scope::RepoWrite),
490 ("rename_repo", Scope::RepoAdmin),
491 ("transfer_repo", Scope::RepoAdmin),
492 ("archive_repo", Scope::RepoAdmin),
493 ("unarchive_repo", Scope::RepoAdmin),
494 ("set_repo_visibility", Scope::RepoAdmin),
495 ("delete_repo", Scope::RepoAdmin),
496 ("restore_repo", Scope::RepoAdmin),
497 ("purge_repo", Scope::RepoAdmin),
498 // A dismissed secret is let through push protection.
499 ("dismiss_security_alert", Scope::RepoAdmin),
500 ("reopen_security_alert", Scope::RepoAdmin),
501 // Issues and plans.
502 ("list_issues", Scope::IssuesRead),
503 ("get_issue", Scope::IssuesRead),
504 ("get_plan", Scope::IssuesRead),
505 ("create_issue", Scope::IssuesWrite),
506 ("update_issue", Scope::IssuesWrite),
507 ("close_issue", Scope::IssuesWrite),
508 ("reopen_issue", Scope::IssuesWrite),
509 ("add_comment", Scope::IssuesWrite),
510 ("import_issue", Scope::IssuesWrite),
511 ("apply_plan", Scope::IssuesWrite),
512 // Pull requests.
513 ("list_pull_requests", Scope::PullRequestsRead),
514 ("get_pull_request", Scope::PullRequestsRead),
515 ("get_pull_request_changes", Scope::PullRequestsRead),
516 ("read_session", Scope::PullRequestsRead),
517 ("get_merge_queue", Scope::PullRequestsRead),
518 ("create_pull_request", Scope::PullRequestsWrite),
519 ("record_session", Scope::PullRequestsWrite),
520 ("mark_pull_request_ready", Scope::PullRequestsWrite),
521 ("close_pull_request", Scope::PullRequestsWrite),
522 ("review_pull_request", Scope::PullRequestsWrite),
523 ("merge_pull_request", Scope::PullRequestsWrite),
524 // g1t's agents.
525 ("assign_issue", Scope::AgentsRun),
526 ("delegate", Scope::AgentsRun),
527 ("plan_work", Scope::AgentsRun),
528 ("message_agent", Scope::AgentsRun),
529 ("answer_message", Scope::AgentsRun),
530 ("take_messages", Scope::AgentsRun),
531 // Workflows.
532 ("list_workflows", Scope::WorkflowsRead),
533 ("list_workflow_runs", Scope::WorkflowsRead),
534 ("get_workflow_run", Scope::WorkflowsRead),
535 ("get_job_logs", Scope::WorkflowsRead),
536 ("dispatch_workflow", Scope::WorkflowsWrite),
537 ("cancel_workflow_run", Scope::WorkflowsWrite),
538 ("rerun_workflow_run", Scope::WorkflowsWrite),
539 ("update_workflow", Scope::WorkflowsWrite),
540 // Memory and the context hub.
541 ("recall", Scope::MemoryRead),
542 ("search_context", Scope::MemoryRead),
543 ("get_entity", Scope::MemoryRead),
544 ("get_context", Scope::MemoryRead),
545 ("remember", Scope::MemoryWrite),
546 // Who has access.
547 ("list_collaborators", Scope::AccessRead),
548 ("get_collaborator_permission", Scope::AccessRead),
549 ("list_repo_invitations", Scope::AccessRead),
550 ("list_outside_collaborators", Scope::AccessRead),
551 ("add_collaborator", Scope::AccessAdmin),
552 ("update_collaborator", Scope::AccessAdmin),
553 ("remove_collaborator", Scope::AccessAdmin),
554 ("revoke_repo_invitation", Scope::AccessAdmin),
555 ("set_base_permission", Scope::AccessAdmin),
556 // Webhooks.
557 ("list_webhooks", Scope::WebhooksRead),
558 ("list_webhook_deliveries", Scope::WebhooksRead),
559 ("create_webhook", Scope::WebhooksAdmin),
560 ("update_webhook", Scope::WebhooksAdmin),
561 ("delete_webhook", Scope::WebhooksAdmin),
562 ("ping_webhook", Scope::WebhooksAdmin),
563 ("redeliver_webhook", Scope::WebhooksAdmin),
564 // Secrets and variables.
565 ("list_actions_secrets", Scope::SecretsRead),
566 ("list_actions_variables", Scope::SecretsRead),
567 ("set_actions_secret", Scope::SecretsAdmin),
568 ("delete_actions_secret", Scope::SecretsAdmin),
569 ("set_actions_variable", Scope::SecretsAdmin),
570 ("delete_actions_variable", Scope::SecretsAdmin),
571 // Self-hosted runners.
572 ("list_runners", Scope::RunnersRead),
573 ("list_runner_groups", Scope::RunnersRead),
574 ("get_runner_settings", Scope::RunnersRead),
575 ("create_runner_registration_token", Scope::RunnersAdmin),
576 ("remove_runner", Scope::RunnersAdmin),
577 ("create_runner_group", Scope::RunnersAdmin),
578 ("update_runner_group", Scope::RunnersAdmin),
579 ("delete_runner_group", Scope::RunnersAdmin),
580 ("update_runner_settings", Scope::RunnersAdmin),
581];
582
583/// Operations any token may use: saying who it is.
584pub const NO_SCOPE: &[&str] = &["whoami"];
585
586/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
587/// operation in neither list needs full access.
588pub fn scope_for(operation: &str) -> Option<Scope> {
589 OPERATIONS
590 .iter()
591 .find(|(name, _)| *name == operation)
592 .map(|(_, scope)| *scope)
593}
594
595/// What a token needs for `operation` with this input beyond its own
596/// scope: starting agents from an operation that can, and making a
597/// repository public or private.
598pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
599 let mut extra = Vec::new();
600 let assigns = input["assign"].as_bool() == Some(true)
601 || input["agent"].as_bool() == Some(true)
602 || input["assign_agent"].as_bool() == Some(true);
603 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
604 extra.push(Scope::AgentsRun);
605 }
606 // Opening the issue an agent is put on.
607 if operation == "delegate" {
608 extra.push(Scope::IssuesWrite);
609 }
610 // A workspace's base permission is who has access.
611 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
612 extra.push(Scope::AccessAdmin);
613 }
614 if operation == "update_repo" && (input.get("private").is_some_and(|v| !v.is_null()) || input.get("default_branch").is_some_and(|v| !v.is_null())) {
615 extra.push(Scope::RepoAdmin);
616 }
617 extra
618}
619
620/// The scopes a call needs, its own first.
621pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
622 scope_for(operation)
623 .into_iter()
624 .chain(extra_scopes(operation, input))
625 .collect()
626}
627
628/// Whether `access` may use `operation` with `input`. The person's (or
629/// workspace's) role is checked after this, by the service that owns what
630/// was asked about.
631pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
632 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
633 if access.scopes.is_some() {
634 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
635 if !known {
636 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
637 }
638 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
639 return Decision::deny(
640 "token:scope",
641 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
642 );
643 }
644 }
645 Decision::allow(rule)
646}
647
648/// Whether a token may clone or fetch (`write` false), or push to (`write`
649/// true), a repository with git. `public` is whether anyone may read it,
650/// which needs no scope.
651pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
652 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
653 if !access.allows(needed) && (write || !public) {
654 return Decision::deny(
655 "token:scope",
656 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
657 );
658 }
659 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
660}
661
662/// Whether a token may pull (`Level::Read`), push or publish
663/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
664/// whether anyone may pull the package, which needs no scope.
665pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
666 let (needed, doing) = match level {
667 Level::Read => (Scope::PackagesRead, "pull a private package"),
668 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
669 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
670 };
671 if !access.allows(needed) && !(level == Level::Read && public) {
672 return Decision::deny(
673 "token:scope",
674 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
675 );
676 }
677 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use serde_json::json;
684
685 fn token(scopes: &[Scope]) -> TokenAccess {
686 TokenAccess {
687 token_id: "tok_1".to_owned(),
688 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
689 legacy: false,
690 }
691 }
692
693 #[test]
694 fn every_scope_reads_back_and_belongs_to_a_resource() {
695 for scope in Scope::ALL {
696 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
697 assert!(scope.as_str().starts_with(scope.resource().as_str()));
698 assert!(scope.includes(scope));
699 }
700 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
701 assert_eq!(Scope::parse("issues"), None);
702 }
703
704 #[test]
705 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
706 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
707 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
708 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
709 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
710 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
711 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
712 }
713
714 #[test]
715 fn operations_are_listed_once_and_never_also_free() {
716 let mut seen = std::collections::HashSet::new();
717 for (name, _) in OPERATIONS {
718 assert!(seen.insert(*name), "{name} twice");
719 assert!(!NO_SCOPE.contains(name), "{name}");
720 }
721 }
722
723 #[test]
724 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
725 assert_eq!(
726 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
727 vec![Scope::RepoRead, Scope::IssuesWrite]
728 );
729 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
730 }
731
732 #[test]
733 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
734 let scopes = oauth_default();
735 assert!(scopes.contains(&Scope::IssuesWrite));
736 assert!(scopes.contains(&Scope::PullRequestsWrite));
737 assert!(scopes.contains(&Scope::AgentsRun));
738 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
739 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
740 // Every read but the machines work runs on.
741 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
742 }
743 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
744 assert_eq!(Preset::Full.scopes(), None);
745 }
746
747 #[test]
748 fn a_legacy_token_can_do_everything() {
749 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
750 for (operation, _) in OPERATIONS {
751 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
752 }
753 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
754 }
755
756 #[test]
757 fn a_missing_scope_is_named() {
758 let read = token(&[Scope::IssuesRead]);
759 assert!(decide(&read, "get_issue", &json!({})).allowed);
760 assert!(decide(&read, "whoami", &json!({})).allowed);
761 let refused = decide(&read, "create_issue", &json!({}));
762 assert!(!refused.allowed);
763 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
764 // An operation the table does not know needs full access.
765 assert!(!decide(&read, "something_new", &json!({})).allowed);
766 }
767
768 #[test]
769 fn starting_agents_from_another_operation_needs_agents_run() {
770 let writer = token(&[Scope::IssuesWrite]);
771 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
772 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
773 assert!(refused.reason.unwrap().contains("agents:run"));
774 let maintainer = token(&[Scope::RepoWrite]);
775 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
776 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
777 }
778
779 #[test]
780 fn a_workspaces_base_permission_needs_access_admin_too() {
781 let admin = token(&[Scope::WorkspaceAdmin]);
782 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
783 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
784 assert!(refused.reason.unwrap().contains("access:admin"));
785 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
786 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
787 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
788 }
789
790 #[test]
791 fn delegating_needs_both_agents_and_issues() {
792 let agents = token(&[Scope::AgentsRun]);
793 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
794 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
795 assert!(decide(&both, "delegate", &json!({})).allowed);
796 }
797
798 #[test]
799 fn git_push_needs_code_write_and_private_reads_need_code_read() {
800 let reader = token(&[Scope::CodeRead]);
801 assert!(decide_git(&reader, false, false).allowed);
802 let refused = decide_git(&reader, true, false);
803 assert!(!refused.allowed);
804 assert!(refused.reason.unwrap().contains("code:write"));
805 let issues = token(&[Scope::IssuesWrite]);
806 assert!(!decide_git(&issues, false, false).allowed);
807 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
808 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
809 let writer = token(&[Scope::CodeWrite]);
810 assert!(decide_git(&writer, true, false).allowed);
811 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
812 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
813 }
814
815 #[test]
816 fn packages_need_their_own_scopes_and_public_pulls_none() {
817 let reader = token(&[Scope::PackagesRead]);
818 assert!(decide_packages(&reader, Level::Read, false).allowed);
819 assert!(!decide_packages(&reader, Level::Write, false).allowed);
820 let code = token(&[Scope::CodeWrite]);
821 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
822 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
823 let writer = token(&[Scope::PackagesWrite]);
824 assert!(decide_packages(&writer, Level::Write, false).allowed);
825 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
826 let refused = decide_packages(&writer, Level::Delete, false);
827 assert!(refused.reason.unwrap().contains("packages:delete"));
828 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
829 assert!(Scope::PackagesDelete.dangerous());
830 // Tokens made before these scopes, and full-access ones, keep working.
831 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
832 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
833 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
834 }
835
836 #[test]
837 fn token_access_travels_as_json() {
838 let access = token(&[Scope::IssuesRead]);
839 let wire = serde_json::to_value(&access).unwrap();
840 assert_eq!(wire["scopes"], json!(["issues:read"]));
841 assert!(wire.get("resources").is_none());
842 let back: TokenAccess = serde_json::from_value(wire).unwrap();
843 assert_eq!(back, access);
844 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
845 assert!(full.is_full());
846 // A reach written by an older version is ignored: a token reaches
847 // whatever its owner can.
848 let older: TokenAccess = serde_json::from_value(json!({
849 "token_id": "tok_1",
850 "scopes": ["issues:read"],
851 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
852 }))
853 .unwrap();
854 assert_eq!(older, access);
855 }
856
857 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
858 /// lists the same scopes in the same order, the same operations with
859 /// the same scopes, and the same presets.
860 #[test]
861 fn the_typescript_mirror_has_the_same_table() {
862 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
863 let section = |start: &str| {
864 ts.split_once(start)
865 .and_then(|(_, rest)| rest.split_once("] as const"))
866 .map(|(table, _)| table)
867 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
868 };
869 let scopes: Vec<&str> = section("export const SCOPES = [")
870 .lines()
871 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
872 .collect();
873 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
874 assert_eq!(scopes, expected);
875 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
876 .lines()
877 .filter_map(|line| {
878 let mut quoted = line.split('"').skip(1).step_by(2);
879 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
880 })
881 .collect();
882 let expected: Vec<(String, String)> = OPERATIONS
883 .iter()
884 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
885 .collect();
886 assert_eq!(operations, expected);
887 for preset in Preset::ALL {
888 let list = section(&format!("{}: [", preset.as_str()));
889 let mirrored: Vec<&str> = list
890 .split(',')
891 .map(|item| item.trim().trim_matches('"'))
892 .filter(|item| !item.is_empty())
893 .collect();
894 let expected: Vec<&str> = preset
895 .scopes()
896 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
897 .unwrap_or_else(|| vec!["*"]);
898 assert_eq!(mirrored, expected, "{}", preset.as_str());
899 }
900 }
901}