Skip to content

g1t/crates/contracts/src/scopes.rs

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