Skip to content
301 linesCodeBlameRaw
1//! Fine-grained personal access tokens: what each permission is, and the
2//! scopes it gives.
3//!
4//! A fine-grained token names one resource owner (the person's own
5//! account, or one workspace), which of that workspace's repositories it
6//! reaches (all, selected, or public ones only), and a level for each
7//! permission: none, read or write (admin for the few that have it). The
8//! permission names are the ones GitHub's fine-grained tokens use, so a
9//! token's settings read the same there and here; g1t-only ones (agents,
10//! memory) sit beside them.
11//!
12//! Each level maps onto g1t's own scopes ([`crate::scopes`]), and the token
13//! stores them: every check that reads a classic token's scopes reads a
14//! fine-grained token's the same way. Where g1t has one scope for what
15//! GitHub splits in two (checks and statuses, secrets and variables), both
16//! permissions give the same scopes, and each says so.
17//!
18//! `packages/contracts/src/fine-grained.ts` mirrors the table; a test here
19//! keeps the two the same.
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::scopes::{Scope, normalize};
26
27/// Where a permission is shown, and which resource owner it needs.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PermissionGroup {
31 /// About repositories: needs a workspace as the resource owner.
32 Repository,
33 /// About the workspace itself: needs a workspace as the resource owner.
34 Workspace,
35 /// About the person: needs their own account as the resource owner.
36 Account,
37}
38
39impl PermissionGroup {
40 pub fn as_str(self) -> &'static str {
41 match self {
42 PermissionGroup::Repository => "repository",
43 PermissionGroup::Workspace => "workspace",
44 PermissionGroup::Account => "account",
45 }
46 }
47}
48
49/// How much of one permission.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Access {
53 #[default]
54 None,
55 Read,
56 Write,
57 Admin,
58}
59
60impl Access {
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Access::None => "none",
64 Access::Read => "read",
65 Access::Write => "write",
66 Access::Admin => "admin",
67 }
68 }
69
70 pub fn parse(text: &str) -> Option<Access> {
71 match text.trim().to_ascii_lowercase().as_str() {
72 "none" | "no_access" | "" => Some(Access::None),
73 "read" | "read_only" => Some(Access::Read),
74 "write" | "read_write" | "read_and_write" => Some(Access::Write),
75 "admin" => Some(Access::Admin),
76 _ => None,
77 }
78 }
79}
80
81/// One permission a fine-grained token can be given.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub struct Permission {
84 /// As the API and the form name it, such as `pull_requests`.
85 pub name: &'static str,
86 pub label: &'static str,
87 pub group: PermissionGroup,
88 /// What it covers, in plain words.
89 pub about: &'static str,
90 /// The scopes reading gives; empty when it has no read level (written
91 /// to only, such as workflows).
92 pub read: &'static [Scope],
93 /// The scopes writing gives, besides reading's.
94 pub write: &'static [Scope],
95 /// The scopes admin gives, besides writing's; empty when it has none.
96 pub admin: &'static [Scope],
97}
98
99impl Permission {
100 /// The levels it can be set to, least first, none excluded.
101 pub fn levels(&self) -> Vec<Access> {
102 let mut levels = Vec::new();
103 if !self.read.is_empty() {
104 levels.push(Access::Read);
105 }
106 if !self.write.is_empty() {
107 levels.push(Access::Write);
108 }
109 if !self.admin.is_empty() {
110 levels.push(Access::Admin);
111 }
112 levels
113 }
114
115 /// The scopes `access` gives, lower levels' included.
116 pub fn scopes(&self, access: Access) -> Vec<Scope> {
117 let mut scopes = Vec::new();
118 if access >= Access::Read {
119 scopes.extend_from_slice(self.read);
120 }
121 if access >= Access::Write {
122 scopes.extend_from_slice(self.write);
123 }
124 if access >= Access::Admin {
125 scopes.extend_from_slice(self.admin);
126 }
127 scopes
128 }
129}
130
131use PermissionGroup::{Account as A, Repository as R, Workspace as W};
132
133/// Every permission, in the order the form shows them.
134pub const PERMISSIONS: [Permission; 29] = [
135 // Repository permissions.
136 Permission { name: "actions", label: "Actions", group: R, about: "Workflow runs, jobs, logs and artifacts: reading them, and running, cancelling and rerunning workflows", read: &[Scope::WorkflowsRead], write: &[Scope::WorkflowsWrite], admin: &[] },
137 Permission { name: "administration", label: "Administration", group: R, about: "Repository settings, rulesets, who has access and deploy keys; renaming, archiving, transferring and deleting", read: &[Scope::RepoRead, Scope::AccessRead], write: &[Scope::RepoAdmin, Scope::AccessAdmin], admin: &[] },
138 Permission { name: "agents", label: "g1t agents", group: R, about: "Putting g1t's agents to work and messaging them, which uses the workspace's money", read: &[], write: &[Scope::AgentsRun], admin: &[] },
139 Permission { name: "checks", label: "Checks", group: R, about: "Check runs and check suites on commits. Shares its scopes with Commit statuses", read: &[Scope::ChecksRead], write: &[Scope::ChecksWrite], admin: &[] },
140 Permission { name: "contents", label: "Contents", group: R, about: "Code, branches, commits and releases: cloning and fetching, pushing, and publishing releases", read: &[Scope::CodeRead], write: &[Scope::CodeWrite, Scope::RepoWrite], admin: &[] },
141 Permission { name: "deployments", label: "Deployments", group: R, about: "Deployments and their statuses", read: &[Scope::DeploymentsRead], write: &[Scope::DeploymentsWrite], admin: &[] },
142 Permission { name: "environments", label: "Environments", group: R, about: "Environments, and their secrets and variables", read: &[Scope::DeploymentsRead, Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] },
143 Permission { name: "issues", label: "Issues", group: R, about: "Issues, their comments, labels and milestones, and plans", read: &[Scope::IssuesRead], write: &[Scope::IssuesWrite], admin: &[] },
144 Permission { name: "memory", label: "Memory and context", group: R, about: "Recalling memory and searching the workspace's context, and saving memory for the next agent", read: &[Scope::MemoryRead], write: &[Scope::MemoryWrite], admin: &[] },
145 Permission { name: "metadata", label: "Metadata", group: R, about: "Seeing repositories and searching them. Always read", read: &[Scope::RepoRead], write: &[], admin: &[] },
146 Permission { name: "packages", label: "Packages", group: R, about: "Pulling private packages, publishing them, and (admin) deleting packages and versions", read: &[Scope::PackagesRead], write: &[Scope::PackagesWrite], admin: &[Scope::PackagesDelete] },
147 Permission { name: "pages", label: "Pages", group: R, about: "Deployments on g1t.page. Shares its scopes with Deployments", read: &[Scope::DeploymentsRead], write: &[Scope::DeploymentsWrite], admin: &[] },
148 Permission { name: "pull_requests", label: "Pull requests", group: R, about: "Pull requests, their reviews, changes, sessions and merge queues", read: &[Scope::PullRequestsRead], write: &[Scope::PullRequestsWrite], admin: &[] },
149 Permission { name: "secrets", label: "Secrets", group: R, about: "Actions secrets: listing them (never their values), setting and deleting them. Shares its scopes with Variables", read: &[Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] },
150 Permission { name: "security_events", label: "Security events and alerts", group: R, about: "Code scanning, secret scanning and vulnerability alerts, SARIF uploads and security settings", read: &[Scope::SecurityRead], write: &[Scope::SecurityWrite], admin: &[] },
151 Permission { name: "statuses", label: "Commit statuses", group: R, about: "Statuses on commits. Shares its scopes with Checks", read: &[Scope::ChecksRead], write: &[Scope::ChecksWrite], admin: &[] },
152 Permission { name: "variables", label: "Variables", group: R, about: "Actions variables: reading, setting and deleting them. Shares its scopes with Secrets", read: &[Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] },
153 Permission { name: "webhooks", label: "Webhooks", group: R, about: "Webhooks and their deliveries", read: &[Scope::WebhooksRead], write: &[Scope::WebhooksAdmin], admin: &[] },
154 Permission { name: "workflows", label: "Workflows", group: R, about: "Adding, changing and deleting workflow files under .g1t/workflows and .github/workflows. Write only", read: &[], write: &[Scope::WorkflowFilesWrite], admin: &[] },
155 // Workspace permissions.
156 Permission { name: "members", label: "Members", group: W, about: "The workspace's people, invitations and teams", read: &[Scope::WorkspaceRead], write: &[Scope::WorkspaceAdmin], admin: &[] },
157 Permission { name: "workspace_administration", label: "Administration", group: W, about: "The workspace's settings, integrations, rulesets and base permission", read: &[Scope::WorkspaceRead, Scope::AccessRead], write: &[Scope::WorkspaceAdmin, Scope::AccessAdmin], admin: &[] },
158 Permission { name: "workspace_billing", label: "Billing", group: W, about: "Usage, budget, AI credit and invoices, and (write) changing the budget and buying credit", read: &[Scope::BillingRead], write: &[Scope::BillingWrite], admin: &[] },
159 Permission { name: "models", label: "AI Gateway", group: W, about: "AI Gateway requests: seeing them, and sending requests, which uses the workspace's AI credit", read: &[Scope::ModelsRead], write: &[Scope::ModelsWrite], admin: &[] },
160 Permission { name: "self_hosted_runners", label: "Self-hosted runners", group: W, about: "Runners, their groups and settings", read: &[Scope::RunnersRead], write: &[Scope::RunnersAdmin], admin: &[] },
161 Permission { name: "workspace_secrets", label: "Secrets", group: W, about: "The workspace's Actions secrets. Shares its scopes with the repository Secrets permission", read: &[Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] },
162 Permission { name: "workspace_webhooks", label: "Webhooks", group: W, about: "The workspace's webhooks. Shares its scopes with the repository Webhooks permission", read: &[Scope::WebhooksRead], write: &[Scope::WebhooksAdmin], admin: &[] },
163 // Account permissions.
164 Permission { name: "email_addresses", label: "Email addresses", group: A, about: "Your email addresses and email settings, invites and invitations", read: &[Scope::AccountRead], write: &[Scope::AccountWrite], admin: &[] },
165 Permission { name: "starring", label: "Starring", group: A, about: "Stars and pinned projects. Shares its scopes with Email addresses", read: &[Scope::AccountRead], write: &[Scope::AccountWrite], admin: &[] },
166 Permission { name: "notifications", label: "Notifications", group: A, about: "Your inbox, subscriptions and watched repositories", read: &[Scope::NotificationsRead], write: &[Scope::NotificationsWrite], admin: &[] },
167];
168
169/// The permission named `name`.
170pub fn permission(name: &str) -> Option<&'static Permission> {
171 PERMISSIONS.iter().find(|permission| permission.name == name)
172}
173
174/// The longest a fine-grained token may last, in days, whatever a
175/// workspace allows.
176pub const MAX_LIFETIME_DAYS: u32 = 366;
177
178/// A token's permissions: each name's level, the ones left out none.
179pub type Permissions = BTreeMap<String, Access>;
180
181/// Checks permissions as asked for, for a token whose resource owner is a
182/// workspace (`workspace` true) or the person's own account: the tidied
183/// permissions (`metadata` always read, nothing at none) and the scopes
184/// they give, or why they cannot be.
185pub fn resolve(asked: &BTreeMap<String, String>, workspace: bool) -> Result<(Permissions, Vec<Scope>), String> {
186 let mut permissions = Permissions::new();
187 for (name, level) in asked {
188 let Some(found) = permission(name) else {
189 return Err(format!("There is no permission called {name}."));
190 };
191 let Some(access) = Access::parse(level) else {
192 return Err(format!("{name} is none, read, write or admin."));
193 };
194 if access == Access::None {
195 continue;
196 }
197 if !found.levels().contains(&access) {
198 let levels: Vec<&str> = found.levels().iter().map(|level| level.as_str()).collect();
199 return Err(format!("{name} can be {}, not {}.", levels.join(" or "), access.as_str()));
200 }
201 let fits = match found.group {
202 PermissionGroup::Account => !workspace,
203 PermissionGroup::Repository | PermissionGroup::Workspace => workspace,
204 };
205 if !fits {
206 return Err(if workspace {
207 format!("{name} is about your own account: choose yourself as the resource owner to give it.")
208 } else {
209 format!("{name} is about a workspace: choose a workspace as the resource owner to give it.")
210 });
211 }
212 permissions.insert(found.name.to_owned(), access);
213 }
214 if workspace {
215 let metadata = permissions.entry("metadata".to_owned()).or_insert(Access::Read);
216 *metadata = (*metadata).max(Access::Read);
217 }
218 let mut scopes: Vec<Scope> = permissions
219 .iter()
220 .filter_map(|(name, access)| permission(name).map(|found| found.scopes(*access)))
221 .flatten()
222 .collect();
223 normalize(&mut scopes);
224 Ok((permissions, scopes))
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn asked(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
232 pairs.iter().map(|(name, level)| ((*name).to_owned(), (*level).to_owned())).collect()
233 }
234
235 #[test]
236 fn names_are_unique_and_every_permission_has_a_level() {
237 let mut seen = std::collections::HashSet::new();
238 for permission in PERMISSIONS {
239 assert!(seen.insert(permission.name), "{} twice", permission.name);
240 assert!(!permission.levels().is_empty(), "{}", permission.name);
241 assert!(permission.name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{}", permission.name);
242 }
243 }
244
245 #[test]
246 fn github_permissions_map_onto_g1t_scopes() {
247 let (permissions, scopes) = resolve(&asked(&[("contents", "write"), ("pull_requests", "read")]), true).unwrap();
248 assert_eq!(permissions.get("metadata"), Some(&Access::Read), "metadata is always read");
249 assert_eq!(scopes, vec![Scope::RepoRead, Scope::RepoWrite, Scope::CodeRead, Scope::CodeWrite, Scope::PullRequestsRead]);
250 // Actions is runs; Workflows is the files, write only.
251 let (_, actions) = resolve(&asked(&[("actions", "write")]), true).unwrap();
252 assert!(actions.contains(&Scope::WorkflowsWrite) && !actions.contains(&Scope::WorkflowFilesWrite));
253 let (_, files) = resolve(&asked(&[("workflows", "write")]), true).unwrap();
254 assert!(files.contains(&Scope::WorkflowFilesWrite) && !files.contains(&Scope::WorkflowsWrite));
255 assert!(resolve(&asked(&[("workflows", "read")]), true).unwrap_err().contains("write"));
256 // Pages are deployments; statuses are checks.
257 let (_, pages) = resolve(&asked(&[("pages", "write")]), true).unwrap();
258 assert!(pages.contains(&Scope::DeploymentsWrite));
259 let (_, statuses) = resolve(&asked(&[("statuses", "write")]), true).unwrap();
260 assert!(statuses.contains(&Scope::ChecksWrite));
261 // Packages have admin, which deletes.
262 let (_, packages) = resolve(&asked(&[("packages", "admin")]), true).unwrap();
263 assert!(packages.contains(&Scope::PackagesDelete) && packages.contains(&Scope::PackagesWrite));
264 assert!(resolve(&asked(&[("issues", "admin")]), true).is_err());
265 }
266
267 #[test]
268 fn permissions_fit_their_resource_owner() {
269 assert!(resolve(&asked(&[("email_addresses", "read")]), true).unwrap_err().contains("your own account"));
270 assert!(resolve(&asked(&[("contents", "read")]), false).unwrap_err().contains("workspace"));
271 let (permissions, scopes) = resolve(&asked(&[("notifications", "write")]), false).unwrap();
272 assert!(!permissions.contains_key("metadata"), "no repositories, no metadata");
273 assert_eq!(scopes, vec![Scope::NotificationsRead, Scope::NotificationsWrite]);
274 assert!(resolve(&asked(&[("wiki", "read")]), true).unwrap_err().contains("wiki"));
275 // None is left out.
276 let (permissions, _) = resolve(&asked(&[("issues", "none")]), true).unwrap();
277 assert!(!permissions.contains_key("issues"));
278 }
279
280 /// `packages/contracts/src/fine-grained.ts` lists the same permissions,
281 /// in the same order, with the same scopes.
282 #[test]
283 fn the_typescript_mirror_has_the_same_table() {
284 let ts = include_str!("../../../packages/contracts/src/fine-grained.ts");
285 let table = ts
286 .split_once("export const PERMISSIONS = [")
287 .and_then(|(_, rest)| rest.split_once("] as const"))
288 .map(|(table, _)| table)
289 .expect("PERMISSIONS in fine-grained.ts");
290 let rows: Vec<&str> = table.lines().filter(|line| line.trim_start().starts_with("{ name:")).collect();
291 assert_eq!(rows.len(), PERMISSIONS.len());
292 for (row, permission) in rows.iter().zip(PERMISSIONS) {
293 assert!(row.contains(&format!("name: \"{}\"", permission.name)), "{row}");
294 assert!(row.contains(&format!("group: \"{}\"", permission.group.as_str())), "{row}");
295 let list = |scopes: &[Scope]| format!("[{}]", scopes.iter().map(|scope| format!("\"{}\"", scope.as_str())).collect::<Vec<_>>().join(", "));
296 assert!(row.contains(&format!("read: {}", list(permission.read))), "{row}");
297 assert!(row.contains(&format!("write: {}", list(permission.write))), "{row}");
298 assert!(row.contains(&format!("admin: {}", list(permission.admin))), "{row}");
299 }
300 }
301}