g1t/crates/contracts/src/scopes.rs

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