Skip to content
1,126 linesCodeBlameRaw
1//! Scopes: what an access token may do on its owner's behalf.
2//!
3//! A personal access token, a workspace's token and an application signed
4//! in with OAuth each carry a set of scopes. A token reaches whatever the
5//! one it acts as can reach: a person's token, that person's workspaces and
6//! repositories; a workspace's token, that workspace. What a request may do
7//! is the intersection of two things: the role of whoever the token acts as
8//! (see [`crate::access`]) and the token's scopes.
9//!
10//! Each scope is a resource and a level, written `resource:level`, such as
11//! `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 Notifications,
27 Workspace,
28 Billing,
29 Repo,
30 Code,
31 Security,
32 Packages,
33 Issues,
34 PullRequests,
35 Agents,
36 Workflows,
37 Deployments,
38 Memory,
39 Access,
40 Webhooks,
41 Secrets,
42 Runners,
43 Models,
44}
45
46impl Resource {
47 pub const ALL: [Resource; 19] = [
48 Resource::Repo,
49 Resource::Code,
50 Resource::Security,
51 Resource::Packages,
52 Resource::Issues,
53 Resource::PullRequests,
54 Resource::Agents,
55 Resource::Workflows,
56 Resource::Deployments,
57 Resource::Memory,
58 Resource::Account,
59 Resource::Notifications,
60 Resource::Workspace,
61 Resource::Billing,
62 Resource::Access,
63 Resource::Webhooks,
64 Resource::Secrets,
65 Resource::Runners,
66 Resource::Models,
67 ];
68
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Resource::Account => "account",
72 Resource::Notifications => "notifications",
73 Resource::Workspace => "workspace",
74 Resource::Billing => "billing",
75 Resource::Repo => "repo",
76 Resource::Code => "code",
77 Resource::Security => "security",
78 Resource::Packages => "packages",
79 Resource::Issues => "issues",
80 Resource::PullRequests => "pull_requests",
81 Resource::Agents => "agents",
82 Resource::Workflows => "workflows",
83 Resource::Deployments => "deployments",
84 Resource::Memory => "memory",
85 Resource::Access => "access",
86 Resource::Webhooks => "webhooks",
87 Resource::Secrets => "secrets",
88 Resource::Runners => "runners",
89 Resource::Models => "models",
90 }
91 }
92
93 /// Its name, for people.
94 pub fn label(self) -> &'static str {
95 match self {
96 Resource::Account => "Your account",
97 Resource::Notifications => "Notifications",
98 Resource::Workspace => "Workspaces",
99 Resource::Billing => "Billing",
100 Resource::Repo => "Repositories",
101 Resource::Code => "Code",
102 Resource::Security => "Security",
103 Resource::Packages => "Packages",
104 Resource::Issues => "Issues",
105 Resource::PullRequests => "Pull requests",
106 Resource::Agents => "g1t agents",
107 Resource::Workflows => "Workflows",
108 Resource::Deployments => "Deployments",
109 Resource::Memory => "Memory and context",
110 Resource::Access => "Who has access",
111 Resource::Webhooks => "Webhooks",
112 Resource::Secrets => "Secrets and variables",
113 Resource::Runners => "Self-hosted runners",
114 Resource::Models => "AI Gateway",
115 }
116 }
117}
118
119/// How much of a resource.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
121pub enum Level {
122 Read,
123 Write,
124 /// Starting g1t's agents, which spends the workspace's money.
125 Run,
126 /// Deleting what cannot be brought back, such as a package's versions.
127 Delete,
128 Admin,
129}
130
131impl Level {
132 pub fn as_str(self) -> &'static str {
133 match self {
134 Level::Read => "read",
135 Level::Write => "write",
136 Level::Run => "run",
137 Level::Delete => "delete",
138 Level::Admin => "admin",
139 }
140 }
141}
142
143/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
144/// clients ask for, and errors name.
145#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
146pub enum Scope {
147 RepoRead,
148 RepoWrite,
149 RepoAdmin,
150 CodeRead,
151 CodeWrite,
152 SecurityRead,
153 SecurityWrite,
154 PackagesRead,
155 PackagesWrite,
156 PackagesDelete,
157 IssuesRead,
158 IssuesWrite,
159 PullRequestsRead,
160 PullRequestsWrite,
161 AgentsRun,
162 WorkflowsRead,
163 WorkflowsWrite,
164 DeploymentsRead,
165 DeploymentsWrite,
166 MemoryRead,
167 MemoryWrite,
168 AccountRead,
169 AccountWrite,
170 NotificationsRead,
171 NotificationsWrite,
172 WorkspaceRead,
173 WorkspaceAdmin,
174 BillingRead,
175 BillingWrite,
176 AccessRead,
177 AccessAdmin,
178 WebhooksRead,
179 WebhooksAdmin,
180 SecretsRead,
181 SecretsAdmin,
182 RunnersRead,
183 RunnersAdmin,
184 ModelsRead,
185 ModelsWrite,
186}
187
188impl Scope {
189 /// Every scope, grouped by resource, least first.
190 pub const ALL: [Scope; 39] = [
191 Scope::RepoRead,
192 Scope::RepoWrite,
193 Scope::RepoAdmin,
194 Scope::CodeRead,
195 Scope::CodeWrite,
196 Scope::SecurityRead,
197 Scope::SecurityWrite,
198 Scope::PackagesRead,
199 Scope::PackagesWrite,
200 Scope::PackagesDelete,
201 Scope::IssuesRead,
202 Scope::IssuesWrite,
203 Scope::PullRequestsRead,
204 Scope::PullRequestsWrite,
205 Scope::AgentsRun,
206 Scope::WorkflowsRead,
207 Scope::WorkflowsWrite,
208 Scope::DeploymentsRead,
209 Scope::DeploymentsWrite,
210 Scope::MemoryRead,
211 Scope::MemoryWrite,
212 Scope::AccountRead,
213 Scope::AccountWrite,
214 Scope::NotificationsRead,
215 Scope::NotificationsWrite,
216 Scope::WorkspaceRead,
217 Scope::WorkspaceAdmin,
218 Scope::BillingRead,
219 Scope::BillingWrite,
220 Scope::AccessRead,
221 Scope::AccessAdmin,
222 Scope::WebhooksRead,
223 Scope::WebhooksAdmin,
224 Scope::SecretsRead,
225 Scope::SecretsAdmin,
226 Scope::RunnersRead,
227 Scope::RunnersAdmin,
228 Scope::ModelsRead,
229 Scope::ModelsWrite,
230 ];
231
232 pub fn as_str(self) -> &'static str {
233 match self {
234 Scope::RepoRead => "repo:read",
235 Scope::RepoWrite => "repo:write",
236 Scope::RepoAdmin => "repo:admin",
237 Scope::CodeRead => "code:read",
238 Scope::CodeWrite => "code:write",
239 Scope::SecurityRead => "security:read",
240 Scope::SecurityWrite => "security:write",
241 Scope::PackagesRead => "packages:read",
242 Scope::PackagesWrite => "packages:write",
243 Scope::PackagesDelete => "packages:delete",
244 Scope::IssuesRead => "issues:read",
245 Scope::IssuesWrite => "issues:write",
246 Scope::PullRequestsRead => "pull_requests:read",
247 Scope::PullRequestsWrite => "pull_requests:write",
248 Scope::AgentsRun => "agents:run",
249 Scope::WorkflowsRead => "workflows:read",
250 Scope::WorkflowsWrite => "workflows:write",
251 Scope::DeploymentsRead => "deployments:read",
252 Scope::DeploymentsWrite => "deployments:write",
253 Scope::MemoryRead => "memory:read",
254 Scope::MemoryWrite => "memory:write",
255 Scope::AccountRead => "account:read",
256 Scope::AccountWrite => "account:write",
257 Scope::NotificationsRead => "notifications:read",
258 Scope::NotificationsWrite => "notifications:write",
259 Scope::WorkspaceRead => "workspace:read",
260 Scope::WorkspaceAdmin => "workspace:admin",
261 Scope::BillingRead => "billing:read",
262 Scope::BillingWrite => "billing:write",
263 Scope::AccessRead => "access:read",
264 Scope::AccessAdmin => "access:admin",
265 Scope::WebhooksRead => "webhooks:read",
266 Scope::WebhooksAdmin => "webhooks:admin",
267 Scope::SecretsRead => "secrets:read",
268 Scope::SecretsAdmin => "secrets:admin",
269 Scope::RunnersRead => "runners:read",
270 Scope::RunnersAdmin => "runners:admin",
271 Scope::ModelsRead => "models:read",
272 Scope::ModelsWrite => "models:write",
273 }
274 }
275
276 pub fn parse(text: &str) -> Option<Scope> {
277 let text = text.trim().to_ascii_lowercase();
278 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
279 }
280
281 pub fn resource(self) -> Resource {
282 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
283 Resource::ALL
284 .into_iter()
285 .find(|resource| resource.as_str() == name)
286 .unwrap_or(Resource::Account)
287 }
288
289 pub fn level(self) -> Level {
290 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
291 "write" => Level::Write,
292 "run" => Level::Run,
293 "delete" => Level::Delete,
294 "admin" => Level::Admin,
295 _ => Level::Read,
296 }
297 }
298
299 /// Whether holding `self` gives `other`: the same resource, at the same
300 /// level or a lower one.
301 pub fn includes(self, other: Scope) -> bool {
302 self.resource() == other.resource() && self.level() >= other.level()
303 }
304
305 /// Changes that are hard or impossible to undo, or that decide who can
306 /// reach what. Shown behind a warning wherever scopes are chosen.
307 pub fn dangerous(self) -> bool {
308 matches!(self.level(), Level::Admin | Level::Delete)
309 }
310
311 /// What it lets a token do, in plain words.
312 pub fn describe(self) -> &'static str {
313 match self {
314 Scope::RepoRead => "See repositories, their settings, labels, timelines and security alerts, and search",
315 Scope::RepoWrite => "Create repositories, rename branches and change how pull requests merge",
316 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts",
317 Scope::CodeRead => "Clone and fetch private repositories with git",
318 Scope::CodeWrite => "Push commits with git",
319 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
320 Scope::SecurityWrite => "Dismiss and reopen alerts, bypass push protection, review bypass requests, manage custom patterns, upload SARIF and change security settings",
321 Scope::PackagesRead => "Pull container images and install private packages",
322 Scope::PackagesWrite => "Push container images and publish packages",
323 Scope::PackagesDelete => "Delete packages and their versions",
324 Scope::IssuesRead => "Read issues, comments and plans",
325 Scope::IssuesWrite => "Open, edit, close and comment on issues",
326 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
327 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
328 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
329 Scope::WorkflowsRead => "Read workflows, runs and logs",
330 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
331 Scope::DeploymentsRead => "See deployments, their statuses and environments",
332 Scope::DeploymentsWrite => "Report deployments and their statuses, from any CI",
333 Scope::MemoryRead => "Recall memory and search the workspace's context",
334 Scope::MemoryWrite => "Save memory for the next agent",
335 Scope::AccountRead => "Read your email addresses, invites, invitations and pinned projects",
336 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations and pin projects",
337 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
338 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
339 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
340 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets",
341 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
342 Scope::BillingWrite => "Change a workspace's budget and buy AI credit",
343 Scope::AccessRead => "See who has access to repositories",
344 Scope::AccessAdmin => "Give and take away access to repositories, a team's included",
345 Scope::WebhooksRead => "See webhooks and their deliveries",
346 Scope::WebhooksAdmin => "Create, change and delete webhooks",
347 Scope::SecretsRead => "List secrets (never their values) and read variables",
348 Scope::SecretsAdmin => "Set and delete secrets and variables",
349 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
350 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
351 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
352 Scope::ModelsWrite => "Send model requests through the AI Gateway, which uses the workspace's AI credit",
353 }
354 }
355}
356
357impl Serialize for Scope {
358 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
359 serializer.serialize_str(self.as_str())
360 }
361}
362
363impl<'de> Deserialize<'de> for Scope {
364 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
365 let text = String::deserialize(deserializer)?;
366 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
367 }
368}
369
370/// Scopes as written in a token's row or an OAuth request: separated by
371/// spaces or commas. Unknown names are left out, so a client asking for a
372/// scope from a newer version gets the rest.
373pub fn parse_scopes(text: &str) -> Vec<Scope> {
374 let mut scopes: Vec<Scope> = text
375 .split(|c: char| c.is_whitespace() || c == ',')
376 .filter_map(Scope::parse)
377 .collect();
378 normalize(&mut scopes);
379 scopes
380}
381
382/// In table order, without repeats.
383pub fn normalize(scopes: &mut Vec<Scope>) {
384 let given = std::mem::take(scopes);
385 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
386}
387
388/// Space-separated, as stored and as OAuth writes them.
389pub fn scopes_text(scopes: &[Scope]) -> String {
390 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
391}
392
393/// What a token stores for full access, which is not a scope a client can
394/// ask for by name.
395pub const FULL_ACCESS: &str = "*";
396
397/// Starting points for choosing scopes.
398#[derive(Clone, Copy, Debug, PartialEq, Eq)]
399pub enum Preset {
400 ReadOnly,
401 Agent,
402 Ci,
403 Full,
404}
405
406impl Preset {
407 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
408
409 pub fn as_str(self) -> &'static str {
410 match self {
411 Preset::ReadOnly => "read_only",
412 Preset::Agent => "agent",
413 Preset::Ci => "ci",
414 Preset::Full => "full",
415 }
416 }
417
418 pub fn label(self) -> &'static str {
419 match self {
420 Preset::ReadOnly => "Read only",
421 Preset::Agent => "Agent",
422 Preset::Ci => "CI",
423 Preset::Full => "Full access",
424 }
425 }
426
427 /// Its scopes; `None` for full access.
428 pub fn scopes(self) -> Option<Vec<Scope>> {
429 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
430 match self {
431 Preset::ReadOnly => Some(reads().collect()),
432 Preset::Agent => {
433 // Not the machines work runs on: an agent has no business
434 // knowing a workspace's own runners.
435 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
436 // And answering what needs the person it works for: marking
437 // it done, subscribing, watching.
438 scopes.extend([
439 Scope::CodeWrite,
440 Scope::IssuesWrite,
441 Scope::PullRequestsWrite,
442 Scope::AgentsRun,
443 Scope::MemoryWrite,
444 Scope::NotificationsWrite,
445 ]);
446 normalize(&mut scopes);
447 Some(scopes)
448 }
449 Preset::Ci => Some(vec![
450 Scope::RepoRead,
451 Scope::CodeRead,
452 Scope::CodeWrite,
453 Scope::PackagesRead,
454 Scope::PackagesWrite,
455 Scope::WorkflowsRead,
456 Scope::WorkflowsWrite,
457 Scope::DeploymentsRead,
458 Scope::DeploymentsWrite,
459 ]),
460 Preset::Full => None,
461 }
462 }
463}
464
465/// What an OAuth client gets when it asks for nothing in particular: the
466/// agent preset. Never an admin scope.
467pub fn oauth_default() -> Vec<Scope> {
468 Preset::Agent.scopes().unwrap_or_default()
469}
470
471/// Set on a [`crate::User`] resolved from an access token: what the token
472/// may do. Absent on a signed-in session, which may do whatever its person
473/// can.
474#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
475pub struct TokenAccess {
476 /// The token's id, as audit entries and errors name it.
477 #[serde(default)]
478 pub token_id: String,
479 /// Its scopes, as `resource:level`. Absent: full access, everything the
480 /// person (or workspace) can do.
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub scopes: Option<Vec<String>>,
483 /// Made before tokens had scopes: full access until someone narrows it.
484 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
485 pub legacy: bool,
486 /// The token's name, as its owner gave it, so a log can say which
487 /// token made a request. Absent where whoever resolved it did not say.
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub name: Option<String>,
490}
491
492impl TokenAccess {
493 /// Full access to everything: the access tokens made before scopes had.
494 pub fn full() -> Self {
495 TokenAccess::default()
496 }
497
498 pub fn is_full(&self) -> bool {
499 self.scopes.is_none()
500 }
501
502 /// The scopes it holds, or `None` for full access.
503 pub fn granted(&self) -> Option<Vec<Scope>> {
504 self.scopes
505 .as_ref()
506 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
507 }
508
509 pub fn allows(&self, needed: Scope) -> bool {
510 match self.granted() {
511 None => true,
512 Some(granted) => granted.iter().any(|held| held.includes(needed)),
513 }
514 }
515}
516
517/// Every operation of the API and MCP server, with the scope it needs. An
518/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
519/// its operations is in exactly one of the two.
520pub const OPERATIONS: &[(&str, Scope)] = &[
521 // Your account.
522 ("list_emails", Scope::AccountRead),
523 ("add_email", Scope::AccountWrite),
524 ("remove_email", Scope::AccountWrite),
525 ("update_email_settings", Scope::AccountWrite),
526 ("list_invites", Scope::AccountRead),
527 ("create_invite", Scope::AccountWrite),
528 ("revoke_invite", Scope::AccountWrite),
529 ("list_my_repo_invitations", Scope::AccountRead),
530 ("accept_repo_invitation", Scope::AccountWrite),
531 ("decline_repo_invitation", Scope::AccountWrite),
532 // Your pinned projects: a preference of your account.
533 ("list_pinned_projects", Scope::AccountRead),
534 ("pin_project", Scope::AccountWrite),
535 ("unpin_project", Scope::AccountWrite),
536 ("reorder_pinned_projects", Scope::AccountWrite),
537 // Your inbox: notifications, subscriptions and watching.
538 ("list_notifications", Scope::NotificationsRead),
539 ("get_notification_thread", Scope::NotificationsRead),
540 ("get_thread_subscription", Scope::NotificationsRead),
541 ("get_repo_subscription", Scope::NotificationsRead),
542 ("list_watched_repos", Scope::NotificationsRead),
543 ("mark_notifications_read", Scope::NotificationsWrite),
544 ("mark_thread_read", Scope::NotificationsWrite),
545 ("mark_thread_done", Scope::NotificationsWrite),
546 ("save_thread", Scope::NotificationsWrite),
547 ("snooze_thread", Scope::NotificationsWrite),
548 ("set_thread_subscription", Scope::NotificationsWrite),
549 ("delete_thread_subscription", Scope::NotificationsWrite),
550 ("set_repo_subscription", Scope::NotificationsWrite),
551 ("delete_repo_subscription", Scope::NotificationsWrite),
552 // Workspaces, their invites and integrations.
553 ("create_workspace", Scope::WorkspaceAdmin),
554 ("delete_workspace", Scope::WorkspaceAdmin),
555 ("get_workspace", Scope::WorkspaceRead),
556 ("update_workspace", Scope::WorkspaceAdmin),
557 ("list_workspace_invites", Scope::WorkspaceRead),
558 ("invite_member", Scope::WorkspaceAdmin),
559 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
560 ("list_integrations", Scope::WorkspaceRead),
561 ("connect_integration", Scope::WorkspaceAdmin),
562 ("disconnect_integration", Scope::WorkspaceAdmin),
563 ("test_integration", Scope::WorkspaceAdmin),
564 ("get_model_routes", Scope::WorkspaceRead),
565 ("set_model_routes", Scope::WorkspaceAdmin),
566 // Teams: reading them, and managing them. A team's role on a
567 // repository is who has access.
568 ("list_teams", Scope::WorkspaceRead),
569 ("get_team", Scope::WorkspaceRead),
570 ("list_team_members", Scope::WorkspaceRead),
571 ("list_child_teams", Scope::WorkspaceRead),
572 ("list_team_repos", Scope::WorkspaceRead),
573 ("list_user_teams", Scope::WorkspaceRead),
574 ("create_team", Scope::WorkspaceAdmin),
575 ("list_workspace_rulesets", Scope::WorkspaceRead),
576 ("get_workspace_ruleset", Scope::WorkspaceRead),
577 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
578 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
579 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
580 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
581 ("update_team", Scope::WorkspaceAdmin),
582 ("delete_team", Scope::WorkspaceAdmin),
583 ("set_team_member", Scope::WorkspaceAdmin),
584 ("remove_team_member", Scope::WorkspaceAdmin),
585 ("set_team_review_assignment", Scope::WorkspaceAdmin),
586 // A workspace's billing: usage, budget, AI credit and invoices.
587 ("get_usage", Scope::BillingRead),
588 ("get_budget", Scope::BillingRead),
589 ("get_ai_credit", Scope::BillingRead),
590 ("list_invoices", Scope::BillingRead),
591 ("get_billing_details", Scope::BillingRead),
592 ("set_budget", Scope::BillingWrite),
593 ("buy_ai_credit", Scope::BillingWrite),
594 // Repositories.
595 ("list_repos", Scope::RepoRead),
596 ("get_repo", Scope::RepoRead),
597 ("search", Scope::RepoRead),
598 ("list_events", Scope::RepoRead),
599 ("list_labels", Scope::RepoRead),
600 ("list_milestones", Scope::RepoRead),
601 ("get_milestone", Scope::RepoRead),
602 ("create_label", Scope::IssuesWrite),
603 ("update_label", Scope::IssuesWrite),
604 ("delete_label", Scope::IssuesWrite),
605 ("add_default_labels", Scope::IssuesWrite),
606 ("create_milestone", Scope::IssuesWrite),
607 ("update_milestone", Scope::IssuesWrite),
608 ("delete_milestone", Scope::IssuesWrite),
609 ("get_repo_settings", Scope::RepoRead),
610 ("list_check_names", Scope::RepoRead),
611 ("list_deleted_repos", Scope::RepoRead),
612 ("list_security_alerts", Scope::RepoRead),
613 ("get_codeowners_errors", Scope::RepoRead),
614 ("create_repo", Scope::RepoWrite),
615 ("update_repo", Scope::RepoWrite),
616 ("update_repo_settings", Scope::RepoWrite),
617 // Rulesets: reading them is reading the repository; changing them
618 // changes what everyone, agents included, may do, so it is admin.
619 ("list_repo_rulesets", Scope::RepoRead),
620 ("get_repo_ruleset", Scope::RepoRead),
621 ("get_branch_rules", Scope::RepoRead),
622 ("list_rule_evaluations", Scope::RepoRead),
623 ("create_repo_ruleset", Scope::RepoAdmin),
624 ("update_repo_ruleset", Scope::RepoAdmin),
625 ("delete_repo_ruleset", Scope::RepoAdmin),
626 ("rename_branch", Scope::RepoWrite),
627 ("rename_repo", Scope::RepoAdmin),
628 ("transfer_repo", Scope::RepoAdmin),
629 ("archive_repo", Scope::RepoAdmin),
630 ("unarchive_repo", Scope::RepoAdmin),
631 ("set_repo_visibility", Scope::RepoAdmin),
632 ("delete_repo", Scope::RepoAdmin),
633 ("restore_repo", Scope::RepoAdmin),
634 ("purge_repo", Scope::RepoAdmin),
635 // A dismissed secret is let through push protection.
636 ("dismiss_security_alert", Scope::RepoAdmin),
637 ("reopen_security_alert", Scope::RepoAdmin),
638 // The security suite: alerts, push protection, patterns, code
639 // scanning, the supply chain and settings.
640 ("list_secret_scanning_alerts", Scope::SecurityRead),
641 ("get_secret_scanning_alert", Scope::SecurityRead),
642 ("list_secret_scanning_locations", Scope::SecurityRead),
643 ("list_bypass_requests", Scope::SecurityRead),
644 ("list_custom_patterns", Scope::SecurityRead),
645 ("list_code_scanning_alerts", Scope::SecurityRead),
646 ("get_code_scanning_alert", Scope::SecurityRead),
647 ("list_code_scanning_analyses", Scope::SecurityRead),
648 ("get_sarif_upload", Scope::SecurityRead),
649 ("list_vulnerability_alerts", Scope::SecurityRead),
650 ("get_vulnerability_alert", Scope::SecurityRead),
651 ("get_dependency_graph", Scope::SecurityRead),
652 ("get_sbom", Scope::SecurityRead),
653 ("compare_dependencies", Scope::SecurityRead),
654 ("get_security_settings", Scope::SecurityRead),
655 ("get_workspace_security_settings", Scope::SecurityRead),
656 ("get_security_overview", Scope::SecurityRead),
657 ("update_secret_scanning_alert", Scope::SecurityWrite),
658 ("bypass_push_protection", Scope::SecurityWrite),
659 ("check_secret_validity", Scope::SecurityWrite),
660 ("review_bypass_request", Scope::SecurityWrite),
661 ("create_custom_pattern", Scope::SecurityWrite),
662 ("update_custom_pattern", Scope::SecurityWrite),
663 ("delete_custom_pattern", Scope::SecurityWrite),
664 ("dry_run_custom_pattern", Scope::SecurityWrite),
665 ("update_code_scanning_alert", Scope::SecurityWrite),
666 ("upload_sarif", Scope::SecurityWrite),
667 ("update_vulnerability_alert", Scope::SecurityWrite),
668 ("fix_security_alert", Scope::SecurityWrite),
669 ("update_security_settings", Scope::SecurityWrite),
670 ("update_workspace_security_settings", Scope::SecurityWrite),
671 // Issues and plans.
672 ("list_issues", Scope::IssuesRead),
673 ("get_issue", Scope::IssuesRead),
674 ("get_plan", Scope::IssuesRead),
675 ("create_issue", Scope::IssuesWrite),
676 ("update_issue", Scope::IssuesWrite),
677 ("list_issue_labels", Scope::IssuesRead),
678 ("add_issue_labels", Scope::IssuesWrite),
679 ("set_issue_labels", Scope::IssuesWrite),
680 ("remove_issue_labels", Scope::IssuesWrite),
681 ("close_issue", Scope::IssuesWrite),
682 ("reopen_issue", Scope::IssuesWrite),
683 ("add_comment", Scope::IssuesWrite),
684 ("import_issue", Scope::IssuesWrite),
685 ("apply_plan", Scope::IssuesWrite),
686 // Pull requests.
687 ("list_pull_requests", Scope::PullRequestsRead),
688 ("get_pull_request", Scope::PullRequestsRead),
689 ("get_pull_request_changes", Scope::PullRequestsRead),
690 ("read_session", Scope::PullRequestsRead),
691 ("get_merge_queue", Scope::PullRequestsRead),
692 ("create_pull_request", Scope::PullRequestsWrite),
693 ("update_pull_request", Scope::PullRequestsWrite),
694 ("record_session", Scope::PullRequestsWrite),
695 ("mark_pull_request_ready", Scope::PullRequestsWrite),
696 ("close_pull_request", Scope::PullRequestsWrite),
697 ("review_pull_request", Scope::PullRequestsWrite),
698 ("merge_pull_request", Scope::PullRequestsWrite),
699 ("request_reviewers", Scope::PullRequestsWrite),
700 ("remove_requested_reviewers", Scope::PullRequestsWrite),
701 // g1t's agents.
702 ("assign_issue", Scope::AgentsRun),
703 ("delegate", Scope::AgentsRun),
704 ("plan_work", Scope::AgentsRun),
705 ("message_agent", Scope::AgentsRun),
706 ("answer_message", Scope::AgentsRun),
707 ("take_messages", Scope::AgentsRun),
708 // Workflows.
709 ("list_workflows", Scope::WorkflowsRead),
710 ("list_workflow_runs", Scope::WorkflowsRead),
711 ("get_workflow_run", Scope::WorkflowsRead),
712 ("get_job_logs", Scope::WorkflowsRead),
713 ("dispatch_workflow", Scope::WorkflowsWrite),
714 ("cancel_workflow_run", Scope::WorkflowsWrite),
715 ("rerun_workflow_run", Scope::WorkflowsWrite),
716 ("update_workflow", Scope::WorkflowsWrite),
717 // Deployments, wherever they run: reading them, and reporting them.
718 ("list_deployments", Scope::DeploymentsRead),
719 ("get_deployment", Scope::DeploymentsRead),
720 ("list_deployment_statuses", Scope::DeploymentsRead),
721 ("list_environments", Scope::DeploymentsRead),
722 ("get_environment", Scope::DeploymentsRead),
723 ("create_deployment", Scope::DeploymentsWrite),
724 ("create_deployment_status", Scope::DeploymentsWrite),
725 // Memory and the context hub.
726 ("recall", Scope::MemoryRead),
727 ("search_context", Scope::MemoryRead),
728 ("get_entity", Scope::MemoryRead),
729 ("get_context", Scope::MemoryRead),
730 ("remember", Scope::MemoryWrite),
731 // Who has access.
732 ("list_collaborators", Scope::AccessRead),
733 ("get_collaborator_permission", Scope::AccessRead),
734 ("list_repo_invitations", Scope::AccessRead),
735 ("list_outside_collaborators", Scope::AccessRead),
736 ("add_collaborator", Scope::AccessAdmin),
737 ("update_collaborator", Scope::AccessAdmin),
738 ("remove_collaborator", Scope::AccessAdmin),
739 ("revoke_repo_invitation", Scope::AccessAdmin),
740 ("set_base_permission", Scope::AccessAdmin),
741 ("set_team_repo", Scope::AccessAdmin),
742 ("remove_team_repo", Scope::AccessAdmin),
743 // Webhooks.
744 ("list_webhooks", Scope::WebhooksRead),
745 ("list_webhook_deliveries", Scope::WebhooksRead),
746 ("create_webhook", Scope::WebhooksAdmin),
747 ("update_webhook", Scope::WebhooksAdmin),
748 ("delete_webhook", Scope::WebhooksAdmin),
749 ("ping_webhook", Scope::WebhooksAdmin),
750 ("redeliver_webhook", Scope::WebhooksAdmin),
751 // Secrets and variables.
752 ("list_actions_secrets", Scope::SecretsRead),
753 ("list_actions_variables", Scope::SecretsRead),
754 ("set_actions_secret", Scope::SecretsAdmin),
755 ("delete_actions_secret", Scope::SecretsAdmin),
756 ("set_actions_variable", Scope::SecretsAdmin),
757 ("delete_actions_variable", Scope::SecretsAdmin),
758 // Self-hosted runners.
759 ("list_runners", Scope::RunnersRead),
760 ("list_runner_groups", Scope::RunnersRead),
761 ("get_runner_settings", Scope::RunnersRead),
762 ("create_runner_registration_token", Scope::RunnersAdmin),
763 ("remove_runner", Scope::RunnersAdmin),
764 ("create_runner_group", Scope::RunnersAdmin),
765 ("update_runner_group", Scope::RunnersAdmin),
766 ("delete_runner_group", Scope::RunnersAdmin),
767 ("update_runner_settings", Scope::RunnersAdmin),
768 // The AI Gateway. Sending a request to a model needs `models:write`,
769 // checked by the model proxy at models.g1t.sh, not here.
770 ("list_gateway_requests", Scope::ModelsRead),
771];
772
773/// Operations any token may use: saying who it is.
774pub const NO_SCOPE: &[&str] = &["whoami"];
775
776/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
777/// operation in neither list needs full access.
778pub fn scope_for(operation: &str) -> Option<Scope> {
779 OPERATIONS
780 .iter()
781 .find(|(name, _)| *name == operation)
782 .map(|(_, scope)| *scope)
783}
784
785/// What a token needs for `operation` with this input beyond its own
786/// scope: starting agents from an operation that can, and making a
787/// repository public or private.
788pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
789 let mut extra = Vec::new();
790 let assigns = input["assign"].as_bool() == Some(true)
791 || input["agent"].as_bool() == Some(true)
792 || input["assign_agent"].as_bool() == Some(true);
793 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
794 extra.push(Scope::AgentsRun);
795 }
796 // Fixing an alert opens an issue and puts g1t on it.
797 if operation == "fix_security_alert" {
798 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
799 }
800 // Opening the issue an agent is put on.
801 if operation == "delegate" {
802 extra.push(Scope::IssuesWrite);
803 }
804 // A workspace's base permission is who has access.
805 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
806 extra.push(Scope::AccessAdmin);
807 }
808 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())) {
809 extra.push(Scope::RepoAdmin);
810 }
811 extra
812}
813
814/// The scopes a call needs, its own first.
815pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
816 scope_for(operation)
817 .into_iter()
818 .chain(extra_scopes(operation, input))
819 .collect()
820}
821
822/// Whether `access` may use `operation` with `input`. The person's (or
823/// workspace's) role is checked after this, by the service that owns what
824/// was asked about.
825pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
826 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
827 if access.scopes.is_some() {
828 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
829 if !known {
830 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
831 }
832 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
833 return Decision::deny(
834 "token:scope",
835 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
836 );
837 }
838 }
839 Decision::allow(rule)
840}
841
842/// Whether a token may clone or fetch (`write` false), or push to (`write`
843/// true), a repository with git. `public` is whether anyone may read it,
844/// which needs no scope.
845pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
846 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
847 if !access.allows(needed) && (write || !public) {
848 return Decision::deny(
849 "token:scope",
850 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
851 );
852 }
853 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
854}
855
856/// Whether a token may pull (`Level::Read`), push or publish
857/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
858/// whether anyone may pull the package, which needs no scope.
859pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
860 let (needed, doing) = match level {
861 Level::Read => (Scope::PackagesRead, "pull a private package"),
862 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
863 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
864 };
865 if !access.allows(needed) && !(level == Level::Read && public) {
866 return Decision::deny(
867 "token:scope",
868 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
869 );
870 }
871 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use serde_json::json;
878
879 fn token(scopes: &[Scope]) -> TokenAccess {
880 TokenAccess {
881 token_id: "tok_1".to_owned(),
882 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
883 legacy: false,
884 name: None,
885 }
886 }
887
888 #[test]
889 fn every_scope_reads_back_and_belongs_to_a_resource() {
890 for scope in Scope::ALL {
891 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
892 assert!(scope.as_str().starts_with(scope.resource().as_str()));
893 assert!(scope.includes(scope));
894 }
895 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
896 assert_eq!(Scope::parse("issues"), None);
897 }
898
899 #[test]
900 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
901 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
902 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
903 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
904 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
905 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
906 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
907 }
908
909 #[test]
910 fn operations_are_listed_once_and_never_also_free() {
911 let mut seen = std::collections::HashSet::new();
912 for (name, _) in OPERATIONS {
913 assert!(seen.insert(*name), "{name} twice");
914 assert!(!NO_SCOPE.contains(name), "{name}");
915 }
916 }
917
918 #[test]
919 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
920 assert_eq!(
921 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
922 vec![Scope::RepoRead, Scope::IssuesWrite]
923 );
924 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
925 }
926
927 #[test]
928 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
929 let scopes = oauth_default();
930 assert!(scopes.contains(&Scope::IssuesWrite));
931 assert!(scopes.contains(&Scope::PullRequestsWrite));
932 assert!(scopes.contains(&Scope::AgentsRun));
933 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
934 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
935 // Every read but the machines work runs on.
936 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
937 }
938 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
939 assert_eq!(Preset::Full.scopes(), None);
940 }
941
942 #[test]
943 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
944 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
945 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
946 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
947 }
948 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
949 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
950 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
951 let reader = token(&[Scope::BillingRead]);
952 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
953 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
954 }
955
956 #[test]
957 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
958 // Reading the log is a read like any other.
959 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
960 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
961 // Sending requests spends the workspace's AI credit: chosen on purpose.
962 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
963 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
964 }
965 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
966 assert!(!Scope::ModelsWrite.dangerous());
967 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
968 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
969 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
970 }
971
972 #[test]
973 fn a_legacy_token_can_do_everything() {
974 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
975 for (operation, _) in OPERATIONS {
976 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
977 }
978 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
979 }
980
981 #[test]
982 fn a_missing_scope_is_named() {
983 let read = token(&[Scope::IssuesRead]);
984 assert!(decide(&read, "get_issue", &json!({})).allowed);
985 assert!(decide(&read, "whoami", &json!({})).allowed);
986 let refused = decide(&read, "create_issue", &json!({}));
987 assert!(!refused.allowed);
988 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
989 // An operation the table does not know needs full access.
990 assert!(!decide(&read, "something_new", &json!({})).allowed);
991 }
992
993 #[test]
994 fn starting_agents_from_another_operation_needs_agents_run() {
995 let writer = token(&[Scope::IssuesWrite]);
996 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
997 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
998 assert!(refused.reason.unwrap().contains("agents:run"));
999 let maintainer = token(&[Scope::RepoWrite]);
1000 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1001 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1002 }
1003
1004 #[test]
1005 fn a_workspaces_base_permission_needs_access_admin_too() {
1006 let admin = token(&[Scope::WorkspaceAdmin]);
1007 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1008 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1009 assert!(refused.reason.unwrap().contains("access:admin"));
1010 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1011 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1012 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1013 }
1014
1015 #[test]
1016 fn delegating_needs_both_agents_and_issues() {
1017 let agents = token(&[Scope::AgentsRun]);
1018 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1019 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1020 assert!(decide(&both, "delegate", &json!({})).allowed);
1021 }
1022
1023 #[test]
1024 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1025 let reader = token(&[Scope::CodeRead]);
1026 assert!(decide_git(&reader, false, false).allowed);
1027 let refused = decide_git(&reader, true, false);
1028 assert!(!refused.allowed);
1029 assert!(refused.reason.unwrap().contains("code:write"));
1030 let issues = token(&[Scope::IssuesWrite]);
1031 assert!(!decide_git(&issues, false, false).allowed);
1032 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1033 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1034 let writer = token(&[Scope::CodeWrite]);
1035 assert!(decide_git(&writer, true, false).allowed);
1036 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1037 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1038 }
1039
1040 #[test]
1041 fn packages_need_their_own_scopes_and_public_pulls_none() {
1042 let reader = token(&[Scope::PackagesRead]);
1043 assert!(decide_packages(&reader, Level::Read, false).allowed);
1044 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1045 let code = token(&[Scope::CodeWrite]);
1046 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1047 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1048 let writer = token(&[Scope::PackagesWrite]);
1049 assert!(decide_packages(&writer, Level::Write, false).allowed);
1050 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1051 let refused = decide_packages(&writer, Level::Delete, false);
1052 assert!(refused.reason.unwrap().contains("packages:delete"));
1053 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1054 assert!(Scope::PackagesDelete.dangerous());
1055 // Tokens made before these scopes, and full-access ones, keep working.
1056 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1057 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1058 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1059 }
1060
1061 #[test]
1062 fn token_access_travels_as_json() {
1063 let access = token(&[Scope::IssuesRead]);
1064 let wire = serde_json::to_value(&access).unwrap();
1065 assert_eq!(wire["scopes"], json!(["issues:read"]));
1066 assert!(wire.get("resources").is_none());
1067 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1068 assert_eq!(back, access);
1069 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1070 assert!(full.is_full());
1071 // A reach written by an older version is ignored: a token reaches
1072 // whatever its owner can.
1073 let older: TokenAccess = serde_json::from_value(json!({
1074 "token_id": "tok_1",
1075 "scopes": ["issues:read"],
1076 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1077 }))
1078 .unwrap();
1079 assert_eq!(older, access);
1080 }
1081
1082 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1083 /// lists the same scopes in the same order, the same operations with
1084 /// the same scopes, and the same presets.
1085 #[test]
1086 fn the_typescript_mirror_has_the_same_table() {
1087 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1088 let section = |start: &str| {
1089 ts.split_once(start)
1090 .and_then(|(_, rest)| rest.split_once("] as const"))
1091 .map(|(table, _)| table)
1092 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1093 };
1094 let scopes: Vec<&str> = section("export const SCOPES = [")
1095 .lines()
1096 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
1097 .collect();
1098 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
1099 assert_eq!(scopes, expected);
1100 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1101 .lines()
1102 .filter_map(|line| {
1103 let mut quoted = line.split('"').skip(1).step_by(2);
1104 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1105 })
1106 .collect();
1107 let expected: Vec<(String, String)> = OPERATIONS
1108 .iter()
1109 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1110 .collect();
1111 assert_eq!(operations, expected);
1112 for preset in Preset::ALL {
1113 let list = section(&format!("{}: [", preset.as_str()));
1114 let mirrored: Vec<&str> = list
1115 .split(',')
1116 .map(|item| item.trim().trim_matches('"'))
1117 .filter(|item| !item.is_empty())
1118 .collect();
1119 let expected: Vec<&str> = preset
1120 .scopes()
1121 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1122 .unwrap_or_else(|| vec!["*"]);
1123 assert_eq!(mirrored, expected, "{}", preset.as_str());
1124 }
1125 }
1126}