| 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 | |
| 21 | use std::collections::BTreeMap; |
| 22 | |
| 23 | use serde::{Deserialize, Serialize}; |
| 24 | |
| 25 | use 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")] |
| 30 | pub 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 | |
| 39 | impl 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")] |
| 52 | pub enum Access { |
| 53 | #[default] |
| 54 | None, |
| 55 | Read, |
| 56 | Write, |
| 57 | Admin, |
| 58 | } |
| 59 | |
| 60 | impl 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)] |
| 83 | pub 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 | |
| 99 | impl 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 | |
| 131 | use PermissionGroup::{Account as A, Repository as R, Workspace as W}; |
| 132 | |
| 133 | /// Every permission, in the order the form shows them. |
| 134 | pub 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`. |
| 170 | pub 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. |
| 176 | pub const MAX_LIFETIME_DAYS: u32 = 366; |
| 177 | |
| 178 | /// A token's permissions: each name's level, the ones left out none. |
| 179 | pub 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. |
| 185 | pub 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)] |
| 228 | mod 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 | } |