flagon-io/g1t

public

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

g1t/crates/contracts/src/scopes.rs

840 lines33,593 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, timelines and security alerts, 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, and dismiss security alerts",
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 ("update_workspace", Scope::WorkspaceAdmin),
445 ("list_workspace_invites", Scope::WorkspaceRead),
446 ("invite_member", Scope::WorkspaceAdmin),
447 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
448 ("list_integrations", Scope::WorkspaceRead),
449 ("connect_integration", Scope::WorkspaceAdmin),
450 ("disconnect_integration", Scope::WorkspaceAdmin),
451 ("test_integration", Scope::WorkspaceAdmin),
452 ("get_model_routes", Scope::WorkspaceRead),
453 ("set_model_routes", Scope::WorkspaceAdmin),
454 // Repositories.
455 ("list_repos", Scope::RepoRead),
456 ("get_repo", Scope::RepoRead),
457 ("search", Scope::RepoRead),
458 ("list_events", Scope::RepoRead),
459 ("list_labels", Scope::RepoRead),
460 ("get_repo_settings", Scope::RepoRead),
461 ("list_check_names", Scope::RepoRead),
462 ("list_deleted_repos", Scope::RepoRead),
463 ("list_security_alerts", Scope::RepoRead),
464 ("create_repo", Scope::RepoWrite),
465 ("update_repo", Scope::RepoWrite),
466 ("update_repo_settings", Scope::RepoWrite),
467 ("rename_branch", Scope::RepoWrite),
468 ("rename_repo", Scope::RepoAdmin),
469 ("transfer_repo", Scope::RepoAdmin),
470 ("archive_repo", Scope::RepoAdmin),
471 ("unarchive_repo", Scope::RepoAdmin),
472 ("set_repo_visibility", Scope::RepoAdmin),
473 ("delete_repo", Scope::RepoAdmin),
474 ("restore_repo", Scope::RepoAdmin),
475 ("purge_repo", Scope::RepoAdmin),
476 // A dismissed secret is let through push protection.
477 ("dismiss_security_alert", Scope::RepoAdmin),
478 ("reopen_security_alert", Scope::RepoAdmin),
479 // Issues and plans.
480 ("list_issues", Scope::IssuesRead),
481 ("get_issue", Scope::IssuesRead),
482 ("get_plan", Scope::IssuesRead),
483 ("create_issue", Scope::IssuesWrite),
484 ("update_issue", Scope::IssuesWrite),
485 ("close_issue", Scope::IssuesWrite),
486 ("reopen_issue", Scope::IssuesWrite),
487 ("add_comment", Scope::IssuesWrite),
488 ("import_issue", Scope::IssuesWrite),
489 ("apply_plan", Scope::IssuesWrite),
490 // Pull requests.
491 ("list_pull_requests", Scope::PullRequestsRead),
492 ("get_pull_request", Scope::PullRequestsRead),
493 ("get_pull_request_changes", Scope::PullRequestsRead),
494 ("read_session", Scope::PullRequestsRead),
495 ("get_merge_queue", Scope::PullRequestsRead),
496 ("create_pull_request", Scope::PullRequestsWrite),
497 ("record_session", Scope::PullRequestsWrite),
498 ("mark_pull_request_ready", Scope::PullRequestsWrite),
499 ("close_pull_request", Scope::PullRequestsWrite),
500 ("review_pull_request", Scope::PullRequestsWrite),
501 ("merge_pull_request", Scope::PullRequestsWrite),
502 // g1t's agents.
503 ("assign_issue", Scope::AgentsRun),
504 ("delegate", Scope::AgentsRun),
505 ("plan_work", Scope::AgentsRun),
506 ("message_agent", Scope::AgentsRun),
507 ("answer_message", Scope::AgentsRun),
508 ("take_messages", Scope::AgentsRun),
509 // Workflows.
510 ("list_workflows", Scope::WorkflowsRead),
511 ("list_workflow_runs", Scope::WorkflowsRead),
512 ("get_workflow_run", Scope::WorkflowsRead),
513 ("get_job_logs", Scope::WorkflowsRead),
514 ("dispatch_workflow", Scope::WorkflowsWrite),
515 ("cancel_workflow_run", Scope::WorkflowsWrite),
516 ("rerun_workflow_run", Scope::WorkflowsWrite),
517 ("update_workflow", Scope::WorkflowsWrite),
518 // Memory and the context hub.
519 ("recall", Scope::MemoryRead),
520 ("search_context", Scope::MemoryRead),
521 ("get_entity", Scope::MemoryRead),
522 ("get_context", Scope::MemoryRead),
523 ("remember", Scope::MemoryWrite),
524 // Who has access.
525 ("list_collaborators", Scope::AccessRead),
526 ("get_collaborator_permission", Scope::AccessRead),
527 ("list_repo_invitations", Scope::AccessRead),
528 ("list_outside_collaborators", Scope::AccessRead),
529 ("add_collaborator", Scope::AccessAdmin),
530 ("update_collaborator", Scope::AccessAdmin),
531 ("remove_collaborator", Scope::AccessAdmin),
532 ("revoke_repo_invitation", Scope::AccessAdmin),
533 ("set_base_permission", Scope::AccessAdmin),
534 // Webhooks.
535 ("list_webhooks", Scope::WebhooksRead),
536 ("list_webhook_deliveries", Scope::WebhooksRead),
537 ("create_webhook", Scope::WebhooksAdmin),
538 ("update_webhook", Scope::WebhooksAdmin),
539 ("delete_webhook", Scope::WebhooksAdmin),
540 ("ping_webhook", Scope::WebhooksAdmin),
541 ("redeliver_webhook", Scope::WebhooksAdmin),
542 // Secrets and variables.
543 ("list_actions_secrets", Scope::SecretsRead),
544 ("list_actions_variables", Scope::SecretsRead),
545 ("set_actions_secret", Scope::SecretsAdmin),
546 ("delete_actions_secret", Scope::SecretsAdmin),
547 ("set_actions_variable", Scope::SecretsAdmin),
548 ("delete_actions_variable", Scope::SecretsAdmin),
549 // Self-hosted runners.
550 ("list_runners", Scope::RunnersRead),
551 ("list_runner_groups", Scope::RunnersRead),
552 ("get_runner_settings", Scope::RunnersRead),
553 ("create_runner_registration_token", Scope::RunnersAdmin),
554 ("remove_runner", Scope::RunnersAdmin),
555 ("create_runner_group", Scope::RunnersAdmin),
556 ("update_runner_group", Scope::RunnersAdmin),
557 ("delete_runner_group", Scope::RunnersAdmin),
558 ("update_runner_settings", Scope::RunnersAdmin),
559];
560
561/// Operations any token may use: saying who it is.
562pub const NO_SCOPE: &[&str] = &["whoami"];
563
564/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
565/// operation in neither list needs full access.
566pub fn scope_for(operation: &str) -> Option<Scope> {
567 OPERATIONS
568 .iter()
569 .find(|(name, _)| *name == operation)
570 .map(|(_, scope)| *scope)
571}
572
573/// What a token needs for `operation` with this input beyond its own
574/// scope: starting agents from an operation that can, and making a
575/// repository public or private.
576pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
577 let mut extra = Vec::new();
578 let assigns = input["assign"].as_bool() == Some(true)
579 || input["agent"].as_bool() == Some(true)
580 || input["assign_agent"].as_bool() == Some(true);
581 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
582 extra.push(Scope::AgentsRun);
583 }
584 // Opening the issue an agent is put on.
585 if operation == "delegate" {
586 extra.push(Scope::IssuesWrite);
587 }
588 // A workspace's base permission is who has access.
589 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
590 extra.push(Scope::AccessAdmin);
591 }
592 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())) {
593 extra.push(Scope::RepoAdmin);
594 }
595 extra
596}
597
598/// The scopes a call needs, its own first.
599pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
600 scope_for(operation)
601 .into_iter()
602 .chain(extra_scopes(operation, input))
603 .collect()
604}
605
606/// Whether `access` may use `operation` with `input`. The person's (or
607/// workspace's) role is checked after this, by the service that owns what
608/// was asked about.
609pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
610 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
611 if access.scopes.is_some() {
612 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
613 if !known {
614 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
615 }
616 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
617 return Decision::deny(
618 "token:scope",
619 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
620 );
621 }
622 }
623 Decision::allow(rule)
624}
625
626/// Whether a token may clone or fetch (`write` false), or push to (`write`
627/// true), a repository with git. `public` is whether anyone may read it,
628/// which needs no scope.
629pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
630 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
631 if !access.allows(needed) && (write || !public) {
632 return Decision::deny(
633 "token:scope",
634 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
635 );
636 }
637 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use serde_json::json;
644
645 fn token(scopes: &[Scope]) -> TokenAccess {
646 TokenAccess {
647 token_id: "tok_1".to_owned(),
648 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
649 legacy: false,
650 }
651 }
652
653 #[test]
654 fn every_scope_reads_back_and_belongs_to_a_resource() {
655 for scope in Scope::ALL {
656 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
657 assert!(scope.as_str().starts_with(scope.resource().as_str()));
658 assert!(scope.includes(scope));
659 }
660 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
661 assert_eq!(Scope::parse("issues"), None);
662 }
663
664 #[test]
665 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
666 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
667 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
668 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
669 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
670 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
671 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
672 }
673
674 #[test]
675 fn operations_are_listed_once_and_never_also_free() {
676 let mut seen = std::collections::HashSet::new();
677 for (name, _) in OPERATIONS {
678 assert!(seen.insert(*name), "{name} twice");
679 assert!(!NO_SCOPE.contains(name), "{name}");
680 }
681 }
682
683 #[test]
684 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
685 assert_eq!(
686 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
687 vec![Scope::RepoRead, Scope::IssuesWrite]
688 );
689 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
690 }
691
692 #[test]
693 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
694 let scopes = oauth_default();
695 assert!(scopes.contains(&Scope::IssuesWrite));
696 assert!(scopes.contains(&Scope::PullRequestsWrite));
697 assert!(scopes.contains(&Scope::AgentsRun));
698 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
699 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
700 // Every read but the machines work runs on.
701 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
702 }
703 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
704 assert_eq!(Preset::Full.scopes(), None);
705 }
706
707 #[test]
708 fn a_legacy_token_can_do_everything() {
709 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
710 for (operation, _) in OPERATIONS {
711 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
712 }
713 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
714 }
715
716 #[test]
717 fn a_missing_scope_is_named() {
718 let read = token(&[Scope::IssuesRead]);
719 assert!(decide(&read, "get_issue", &json!({})).allowed);
720 assert!(decide(&read, "whoami", &json!({})).allowed);
721 let refused = decide(&read, "create_issue", &json!({}));
722 assert!(!refused.allowed);
723 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
724 // An operation the table does not know needs full access.
725 assert!(!decide(&read, "something_new", &json!({})).allowed);
726 }
727
728 #[test]
729 fn starting_agents_from_another_operation_needs_agents_run() {
730 let writer = token(&[Scope::IssuesWrite]);
731 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
732 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
733 assert!(refused.reason.unwrap().contains("agents:run"));
734 let maintainer = token(&[Scope::RepoWrite]);
735 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
736 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
737 }
738
739 #[test]
740 fn a_workspaces_base_permission_needs_access_admin_too() {
741 let admin = token(&[Scope::WorkspaceAdmin]);
742 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
743 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
744 assert!(refused.reason.unwrap().contains("access:admin"));
745 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
746 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
747 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
748 }
749
750 #[test]
751 fn delegating_needs_both_agents_and_issues() {
752 let agents = token(&[Scope::AgentsRun]);
753 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
754 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
755 assert!(decide(&both, "delegate", &json!({})).allowed);
756 }
757
758 #[test]
759 fn git_push_needs_code_write_and_private_reads_need_code_read() {
760 let reader = token(&[Scope::CodeRead]);
761 assert!(decide_git(&reader, false, false).allowed);
762 let refused = decide_git(&reader, true, false);
763 assert!(!refused.allowed);
764 assert!(refused.reason.unwrap().contains("code:write"));
765 let issues = token(&[Scope::IssuesWrite]);
766 assert!(!decide_git(&issues, false, false).allowed);
767 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
768 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
769 let writer = token(&[Scope::CodeWrite]);
770 assert!(decide_git(&writer, true, false).allowed);
771 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
772 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
773 }
774
775 #[test]
776 fn token_access_travels_as_json() {
777 let access = token(&[Scope::IssuesRead]);
778 let wire = serde_json::to_value(&access).unwrap();
779 assert_eq!(wire["scopes"], json!(["issues:read"]));
780 assert!(wire.get("resources").is_none());
781 let back: TokenAccess = serde_json::from_value(wire).unwrap();
782 assert_eq!(back, access);
783 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
784 assert!(full.is_full());
785 // A reach written by an older version is ignored: a token reaches
786 // whatever its owner can.
787 let older: TokenAccess = serde_json::from_value(json!({
788 "token_id": "tok_1",
789 "scopes": ["issues:read"],
790 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
791 }))
792 .unwrap();
793 assert_eq!(older, access);
794 }
795
796 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
797 /// lists the same scopes in the same order, the same operations with
798 /// the same scopes, and the same presets.
799 #[test]
800 fn the_typescript_mirror_has_the_same_table() {
801 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
802 let section = |start: &str| {
803 ts.split_once(start)
804 .and_then(|(_, rest)| rest.split_once("] as const"))
805 .map(|(table, _)| table)
806 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
807 };
808 let scopes: Vec<&str> = section("export const SCOPES = [")
809 .lines()
810 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
811 .collect();
812 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
813 assert_eq!(scopes, expected);
814 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
815 .lines()
816 .filter_map(|line| {
817 let mut quoted = line.split('"').skip(1).step_by(2);
818 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
819 })
820 .collect();
821 let expected: Vec<(String, String)> = OPERATIONS
822 .iter()
823 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
824 .collect();
825 assert_eq!(operations, expected);
826 for preset in Preset::ALL {
827 let list = section(&format!("{}: [", preset.as_str()));
828 let mirrored: Vec<&str> = list
829 .split(',')
830 .map(|item| item.trim().trim_matches('"'))
831 .filter(|item| !item.is_empty())
832 .collect();
833 let expected: Vec<&str> = preset
834 .scopes()
835 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
836 .unwrap_or_else(|| vec!["*"]);
837 assert_eq!(mirrored, expected, "{}", preset.as_str());
838 }
839 }
840}