g1t/crates/contracts/src/scopes.rs

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