Skip to content

Commit

Workflow files need workflow_files:write from a token; fine-grained permission table

syntaqxcommitted Parentb3f0d2cBrowse files
10 files+922−00/10 viewed
+61−0
856856 workspaces: vec![Membership::member("acme")],
857857 ..User::default()
858858 };
859+ // Acting as the workspace with no token: a service, which does what
860+ // an owner can.
859861 assert_eq!(permission(Some(&token), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
860862 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
861863 }
862864
863865 #[test]
866+ fn a_workspace_token_has_write_unless_an_owner_gave_it_admin() {
867+ let mut token = User {
868+ id: "wsp_1".into(),
869+ username: "acme".into(),
870+ kind: PrincipalKind::Workspace,
871+ workspaces: vec![Membership::member("acme")],
872+ token: Some(Box::new(crate::scopes::TokenAccess::full())),
873+ ..User::default()
874+ };
875+ let private = repo("rep_1", "acme", true);
876+ assert_eq!(permission(Some(&token), private), Some(RepoRole::Write));
877+ assert!(can(Some(&token), private, Capability::Push));
878+ assert!(can(Some(&token), private, Capability::Merge));
879+ assert!(!can(Some(&token), private, Capability::ManageIntegrations), "webhooks, secrets and deploy keys are an admin's");
880+ assert!(!can(Some(&token), private, Capability::ManageAccess));
881+ token.token.as_mut().unwrap().admin = true;
882+ assert_eq!(permission(Some(&token), private), Some(RepoRole::Admin));
883+ assert!(can(Some(&token), private, Capability::ManageIntegrations));
884+ // Never deleting: that needs an owner, as a person.
885+ assert!(!can(Some(&token), private, Capability::Delete));
886+ assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
887+ }
888+
889+ #[test]
890+ fn a_fine_grained_token_has_a_role_only_inside_its_reach() {
891+ use crate::scopes::{FineGrainedReach, RepositorySelection, TokenAccess};
892+ let mut person = user(&[("acme", Role::Owner, None), ("globex", Role::Member, None)], &[("rep_9", "initech", RepoRole::Write)]);
893+ let reach = |workspace: Option<&str>, repositories, ids: &[&str]| {
894+ Some(Box::new(TokenAccess {
895+ fine_grained: Some(FineGrainedReach { workspace: workspace.map(str::to_owned), repositories, repo_ids: ids.iter().map(|id| (*id).to_owned()).collect() }),
896+ ..TokenAccess::default()
897+ }))
898+ };
899+ let web = repo("rep_1", "acme", true);
900+ let api = repo("rep_2", "acme", true);
901+ let site = repo("rep_3", "acme", false);
902+ let elsewhere = repo("rep_4", "globex", true);
903+ person.token = reach(Some("acme"), RepositorySelection::All, &[]);
904+ assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin));
905+ assert_eq!(permission(Some(&person), elsewhere), None, "only its resource owner");
906+ assert_eq!(permission(Some(&person), repo("rep_9", "initech", true)), None, "nor where its owner collaborates");
907+ person.token = reach(Some("acme"), RepositorySelection::Selected, &["rep_1"]);
908+ assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin));
909+ assert_eq!(permission(Some(&person), api), None);
910+ // A public repository it does not reach still reads, and nothing more.
911+ assert_eq!(permission(Some(&person), site), Some(RepoRole::Read));
912+ assert!(can(Some(&person), site, Capability::Read));
913+ assert!(!can(Some(&person), site, Capability::Participate));
914+ person.token = reach(Some("acme"), RepositorySelection::Public, &[]);
915+ assert_eq!(permission(Some(&person), web), None);
916+ assert_eq!(permission(Some(&person), site), Some(RepoRole::Read));
917+ person.token = reach(None, RepositorySelection::All, &[]);
918+ assert_eq!(permission(Some(&person), web), None, "your own account reaches no workspace's repositories");
919+ // A classic token reaches whatever its owner can.
920+ person.token = Some(Box::new(TokenAccess::full()));
921+ assert_eq!(permission(Some(&person), elsewhere), Some(RepoRole::Write));
922+ }
923+
924+ #[test]
864925 fn roles_and_base_permissions_read_and_write_as_words() {
865926 for role in RepoRole::ALL {
866927 assert_eq!(RepoRole::parse(role.as_str()), Some(role));
+301−0
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+}
+1−0
1717 pub mod codeowners;
1818 pub mod credentials;
1919 pub mod events;
20+pub mod fine_grained;
2021 pub mod github;
2122 pub mod guardrails;
2223 pub mod identity;
+49−0
13121312 }
13131313
13141314 #[test]
1315+ fn workflow_files_need_their_own_scope() {
1316+ for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1317+ assert!(is_workflow_file(path), "{path}");
1318+ }
1319+ for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1320+ assert!(!is_workflow_file(path), "{path}");
1321+ }
1322+ let code = token(&[Scope::CodeWrite]);
1323+ let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1324+ assert_eq!(refused.rule, "token:workflows");
1325+ assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1326+ assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1327+ assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1328+ assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1329+ assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1330+ assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1331+ // A job's token never may, as GITHUB_TOKEN never may.
1332+ let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1333+ assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1334+ // Nothing in a preset changes workflow files but full access.
1335+ for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1336+ assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1337+ }
1338+ assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1339+ }
1340+
1341+ #[test]
1342+ fn a_fine_grained_token_only_reads_outside_its_resource_owner() {
1343+ let reach = FineGrainedReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1344+ let fine = TokenAccess { fine_grained: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) };
1345+ assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1346+ assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1347+ let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1348+ assert_eq!(elsewhere.rule, "token:resource-owner");
1349+ assert!(elsewhere.reason.unwrap().contains("acme"));
1350+ assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1351+ assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold");
1352+ let mine = TokenAccess { fine_grained: Some(FineGrainedReach::default()), ..token(&[Scope::IssuesWrite]) };
1353+ assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1354+ assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex"));
1355+ let selected = FineGrainedReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] };
1356+ assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme"));
1357+ let public = FineGrainedReach { repositories: RepositorySelection::Public, ..selected.clone() };
1358+ assert!(!public.covers("rep_1", "acme") && public.owned_by("acme"));
1359+ assert!(token(&[]).covers_repo("rep_1", "anything"), "a classic token's reach is its owner's");
1360+ assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1361+ }
1362+
1363+ #[test]
13151364 fn a_legacy_token_can_do_everything() {
13161365 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
13171366 for (operation, _) in OPERATIONS {
+104−0
1+/**
2+ * Fine-grained personal access tokens: each permission, as the form and the
3+ * API name it, and the g1t scopes each level gives. Mirrors
4+ * `crates/contracts/src/fine_grained.rs`, which is the source of truth; a
5+ * Rust test keeps the table here the same.
6+ *
7+ * A fine-grained token has one resource owner (your own account, or one
8+ * workspace), reaches all, selected or only public repositories of it,
9+ * and has a level for each permission. Its scopes are stored and checked
10+ * as a classic token's are.
11+ */
12+
13+import type { Scope } from "./scopes";
14+
15+export type PermissionGroup = "repository" | "workspace" | "account";
16+
17+export type PermissionAccess = "none" | "read" | "write" | "admin";
18+
19+export type RepositorySelection = "all" | "selected" | "public";
20+
21+export type FineGrainedPermission = {
22+ name: string;
23+ label: string;
24+ group: PermissionGroup;
25+ about: string;
26+ /** The scopes reading gives; empty when it cannot be read only. */
27+ read: readonly Scope[];
28+ /** The scopes writing gives, besides reading's. */
29+ write: readonly Scope[];
30+ /** The scopes admin gives, besides writing's; empty when it has none. */
31+ admin: readonly Scope[];
32+};
33+
34+/** Every permission, in the order the form shows them. */
35+export const PERMISSIONS = [
36+ { name: "actions", label: "Actions", group: "repository", about: "Workflow runs, jobs, logs and artifacts: reading them, and running, cancelling and rerunning workflows", read: ["workflows:read"], write: ["workflows:write"], admin: [] },
37+ { name: "administration", label: "Administration", group: "repository", about: "Repository settings, rulesets, who has access and deploy keys; renaming, archiving, transferring and deleting", read: ["repo:read", "access:read"], write: ["repo:admin", "access:admin"], admin: [] },
38+ { name: "agents", label: "g1t agents", group: "repository", about: "Putting g1t's agents to work and messaging them, which uses the workspace's money", read: [], write: ["agents:run"], admin: [] },
39+ { name: "checks", label: "Checks", group: "repository", about: "Check runs and check suites on commits. Shares its scopes with Commit statuses", read: ["checks:read"], write: ["checks:write"], admin: [] },
40+ { name: "contents", label: "Contents", group: "repository", about: "Code, branches, commits and releases: cloning and fetching, pushing, and publishing releases", read: ["code:read"], write: ["code:write", "repo:write"], admin: [] },
41+ { name: "deployments", label: "Deployments", group: "repository", about: "Deployments and their statuses", read: ["deployments:read"], write: ["deployments:write"], admin: [] },
42+ { name: "environments", label: "Environments", group: "repository", about: "Environments, and their secrets and variables", read: ["deployments:read", "secrets:read"], write: ["secrets:admin"], admin: [] },
43+ { name: "issues", label: "Issues", group: "repository", about: "Issues, their comments, labels and milestones, and plans", read: ["issues:read"], write: ["issues:write"], admin: [] },
44+ { name: "memory", label: "Memory and context", group: "repository", about: "Recalling memory and searching the workspace's context, and saving memory for the next agent", read: ["memory:read"], write: ["memory:write"], admin: [] },
45+ { name: "metadata", label: "Metadata", group: "repository", about: "Seeing repositories and searching them. Always read", read: ["repo:read"], write: [], admin: [] },
46+ { name: "packages", label: "Packages", group: "repository", about: "Pulling private packages, publishing them, and (admin) deleting packages and versions", read: ["packages:read"], write: ["packages:write"], admin: ["packages:delete"] },
47+ { name: "pages", label: "Pages", group: "repository", about: "Deployments on g1t.page. Shares its scopes with Deployments", read: ["deployments:read"], write: ["deployments:write"], admin: [] },
48+ { name: "pull_requests", label: "Pull requests", group: "repository", about: "Pull requests, their reviews, changes, sessions and merge queues", read: ["pull_requests:read"], write: ["pull_requests:write"], admin: [] },
49+ { name: "secrets", label: "Secrets", group: "repository", about: "Actions secrets: listing them (never their values), setting and deleting them. Shares its scopes with Variables", read: ["secrets:read"], write: ["secrets:admin"], admin: [] },
50+ { name: "security_events", label: "Security events and alerts", group: "repository", about: "Code scanning, secret scanning and vulnerability alerts, SARIF uploads and security settings", read: ["security:read"], write: ["security:write"], admin: [] },
51+ { name: "statuses", label: "Commit statuses", group: "repository", about: "Statuses on commits. Shares its scopes with Checks", read: ["checks:read"], write: ["checks:write"], admin: [] },
52+ { name: "variables", label: "Variables", group: "repository", about: "Actions variables: reading, setting and deleting them. Shares its scopes with Secrets", read: ["secrets:read"], write: ["secrets:admin"], admin: [] },
53+ { name: "webhooks", label: "Webhooks", group: "repository", about: "Webhooks and their deliveries", read: ["webhooks:read"], write: ["webhooks:admin"], admin: [] },
54+ { name: "workflows", label: "Workflows", group: "repository", about: "Adding, changing and deleting workflow files under .g1t/workflows and .github/workflows. Write only", read: [], write: ["workflow_files:write"], admin: [] },
55+ { name: "members", label: "Members", group: "workspace", about: "The workspace's people, invitations and teams", read: ["workspace:read"], write: ["workspace:admin"], admin: [] },
56+ { name: "workspace_administration", label: "Administration", group: "workspace", about: "The workspace's settings, integrations, rulesets and base permission", read: ["workspace:read", "access:read"], write: ["workspace:admin", "access:admin"], admin: [] },
57+ { name: "workspace_billing", label: "Billing", group: "workspace", about: "Usage, budget, AI credit and invoices, and (write) changing the budget and buying credit", read: ["billing:read"], write: ["billing:write"], admin: [] },
58+ { name: "models", label: "AI Gateway", group: "workspace", about: "AI Gateway requests: seeing them, and sending requests, which uses the workspace's AI credit", read: ["models:read"], write: ["models:write"], admin: [] },
59+ { name: "self_hosted_runners", label: "Self-hosted runners", group: "workspace", about: "Runners, their groups and settings", read: ["runners:read"], write: ["runners:admin"], admin: [] },
60+ { name: "workspace_secrets", label: "Secrets", group: "workspace", about: "The workspace's Actions secrets. Shares its scopes with the repository Secrets permission", read: ["secrets:read"], write: ["secrets:admin"], admin: [] },
61+ { name: "workspace_webhooks", label: "Webhooks", group: "workspace", about: "The workspace's webhooks. Shares its scopes with the repository Webhooks permission", read: ["webhooks:read"], write: ["webhooks:admin"], admin: [] },
62+ { name: "email_addresses", label: "Email addresses", group: "account", about: "Your email addresses and email settings, invites and invitations", read: ["account:read"], write: ["account:write"], admin: [] },
63+ { name: "starring", label: "Starring", group: "account", about: "Stars and pinned projects. Shares its scopes with Email addresses", read: ["account:read"], write: ["account:write"], admin: [] },
64+ { name: "notifications", label: "Notifications", group: "account", about: "Your inbox, subscriptions and watched repositories", read: ["notifications:read"], write: ["notifications:write"], admin: [] },
65+] as const satisfies readonly FineGrainedPermission[];
66+
67+export type PermissionName = (typeof PERMISSIONS)[number]["name"];
68+
69+export const PERMISSION_GROUPS: { group: PermissionGroup; label: string; about: string }[] = [
70+ { group: "repository", label: "Repository permissions", about: "What it may do in the repositories it reaches." },
71+ { group: "workspace", label: "Workspace permissions", about: "What it may do with the workspace itself." },
72+ { group: "account", label: "Account permissions", about: "What it may do with your own account." },
73+];
74+
75+/** The longest a fine-grained token may last, whatever a workspace allows. */
76+export const FINE_GRAINED_MAX_LIFETIME_DAYS = 366;
77+
78+/** The levels a permission can be set to, least first, none excluded. */
79+export function permissionLevels(permission: FineGrainedPermission): PermissionAccess[] {
80+ const levels: PermissionAccess[] = [];
81+ if (permission.read.length > 0) levels.push("read");
82+ if (permission.write.length > 0) levels.push("write");
83+ if (permission.admin.length > 0) levels.push("admin");
84+ return levels;
85+}
86+
87+const ORDER: Record<PermissionAccess, number> = { none: 0, read: 1, write: 2, admin: 3 };
88+
89+/** The scopes a level of a permission gives, lower levels' included. */
90+export function permissionScopes(permission: FineGrainedPermission, access: PermissionAccess): Scope[] {
91+ const scopes: Scope[] = [];
92+ if (ORDER[access] >= ORDER.read) scopes.push(...permission.read);
93+ if (ORDER[access] >= ORDER.write) scopes.push(...permission.write);
94+ if (ORDER[access] >= ORDER.admin) scopes.push(...permission.admin);
95+ return scopes;
96+}
97+
98+/** A token's permissions: each name's level; names left out are none. */
99+export type TokenPermissions = Partial<Record<string, PermissionAccess>>;
100+
101+/** The permission named `name`. */
102+export function findPermission(name: string): FineGrainedPermission | undefined {
103+ return PERMISSIONS.find((permission) => permission.name === name);
104+}
+1−0
1313 export * from "./d1";
1414 export * from "./deployments";
1515 export * from "./events";
16+export * from "./fine-grained";
1617 export * from "./github";
1718 export * from "./guardrails";
1819 export * from "./identity";
+4−0
6666 if !valid_path(&a.path) {
6767 return Ok(Outcome::fail(FailureCode::Invalid, format!("{} is not a path a file can be written to.", a.path)));
6868 }
69+ // A workflow file, written for a token without the scope for it.
70+ if let Some(refused) = g1t_contracts::scopes::decide_workflow_files(a.actor.token.as_deref(), [a.path.as_str()]) {
71+ return Ok(Outcome::fail(FailureCode::Forbidden, refused.reason.unwrap_or_default()));
72+ }
6973 if a.content.len() > MAX_CONTENT_BYTES {
7074 return Ok(Outcome::fail(FailureCode::Invalid, "The file is too large to write this way."));
7175 }
+1−0
4444 mod stats;
4545 mod store;
4646 mod transfer;
47+mod workflow_gate;
4748
4849 use g1t_contracts::events::{
4950 Event, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted,
+5−0
167167 /// on. `body` is as much of the push as was read; `whole` says whether
168168 /// that is all of it, so that its commits can be read.
169169 pub(crate) async fn check_push(&self, repo: &Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> {
170+ // Workflow files need their own scope from a token, on a pull
171+ // request's working copy too (workflow_gate.rs).
172+ if let Some(response) = self.workflow_gate(repo, pusher, body, whole).await? {
173+ return Ok(Some(response));
174+ }
170175 if repo.fork_of.is_some() {
171176 return Ok(None);
172177 }
+395−0
1+//! Workflow files: a token adds, changes or deletes files under
2+//! `.g1t/workflows/` or `.github/workflows/` only with the
3+//! `workflow_files:write` scope (a fine-grained token's Workflows
4+//! permission). A workflow job's token never may. Without the gate, a token
5+//! that can push code could write a workflow that runs with the
6+//! repository's secrets and a stronger token than its own.
7+//!
8+//! A push is checked commit by commit: every commit it adds is compared
9+//! with its first parent, looking only at the two workflow directories, so
10+//! the check is complete however many other files a commit changes. A push
11+//! too large to be read whole is refused for such a token, since what it
12+//! holds cannot be checked. A signed-in person (no token) is never refused
13+//! here, and neither is a token that has the scope: their roles and the
14+//! repository's rules decide.
15+
16+use std::cell::Cell;
17+
18+use g1t_contracts::User;
19+use g1t_contracts::scopes::{TokenAccess, WORKFLOW_DIRS, decide_workflow_files};
20+use g1t_scan::pack::{ObjectKind, Pack, TreeItem, pack_start};
21+use worker::{Response, Result};
22+
23+use crate::registry::store_key;
24+use crate::rule_facts::{self, MAX_COMMITS};
25+use crate::secret_scan::Objects;
26+use crate::store::{GitRepo, GitStore};
27+use crate::Repos;
28+
29+/// The token behind a push or an edit, when it is one this gate checks:
30+/// any token without `workflow_files:write`, and every job's token.
31+pub(crate) fn gated(actor: Option<&User>) -> Option<&TokenAccess> {
32+ let token = actor?.token.as_deref()?;
33+ decide_workflow_files(Some(token), [WORKFLOW_DIRS[0]]).map(|_| token)
34+}
35+
36+/// The id of the tree at `dir` (such as `.github/workflows/`) under the
37+/// tree `root`, if there is one.
38+async fn subtree<R: GitRepo>(objects: &Objects<'_, R>, root: &str, dir: &str) -> Result<Option<String>> {
39+ let mut id = root.to_owned();
40+ for part in dir.trim_end_matches('/').split('/') {
41+ let items = objects.tree(&id).await?;
42+ match items.into_iter().find(|item| item.name == part && item.is_tree()) {
43+ Some(item) => id = item.id,
44+ None => return Ok(None),
45+ }
46+ }
47+ Ok(Some(id))
48+}
49+
50+/// The first entry that differs between two listings of one directory.
51+fn first_difference(old: &[TreeItem], new: &[TreeItem]) -> Option<String> {
52+ new.iter()
53+ .find(|item| !old.iter().any(|before| before.name == item.name && before.id == item.id && before.mode == item.mode))
54+ .or_else(|| old.iter().find(|item| !new.iter().any(|after| after.name == item.name)))
55+ .map(|item| item.name.clone())
56+}
57+
58+/// The first workflow file that differs between two root trees (`None`
59+/// for a commit with no parent), as a path.
60+pub(crate) async fn changed_between<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<&str>, new_root: &str) -> Result<Option<String>> {
61+ for dir in WORKFLOW_DIRS {
62+ let old = match old_root {
63+ Some(root) => subtree(objects, root, dir).await?,
64+ None => None,
65+ };
66+ let new = subtree(objects, new_root, dir).await?;
67+ if old == new {
68+ continue;
69+ }
70+ let old_items = match &old {
71+ Some(id) => objects.tree(id).await?,
72+ None => Vec::new(),
73+ };
74+ let new_items = match &new {
75+ Some(id) => objects.tree(id).await?,
76+ None => Vec::new(),
77+ };
78+ let name = first_difference(&old_items, &new_items).unwrap_or_default();
79+ return Ok(Some(format!("{dir}{name}")));
80+ }
81+ Ok(None)
82+}
83+
84+/// The first workflow file one of `ids` (commits the pack holds) changes
85+/// against its first parent.
86+pub(crate) async fn changed_in_commits<R: GitRepo>(pack: &Pack, repo: &R, ids: &[String]) -> Result<Option<String>> {
87+ let objects = Objects { pack, repo, reads: Cell::new(0) };
88+ for id in ids {
89+ let Some((ObjectKind::Commit, data)) = pack.get(id) else {
90+ continue;
91+ };
92+ let commit = rule_facts::read_commit(data);
93+ let old_root = match commit.parents.first() {
94+ Some(parent) => objects.commit_tree(parent).await?,
95+ None => None,
96+ };
97+ if let Some(path) = changed_between(&objects, old_root.as_deref(), &commit.tree).await? {
98+ return Ok(Some(path));
99+ }
100+ }
101+ Ok(None)
102+}
103+
104+/// Why a push is refused, as the reason git shows beside each ref and the
105+/// lines it prints, when `token` may not change workflow files and the
106+/// push (`body`, read `whole` or not) does or cannot be checked.
107+pub(crate) async fn judge_push<R: GitRepo>(token: &TokenAccess, body: &[u8], whole: bool, git: &R) -> Result<Option<(&'static str, Vec<String>)>> {
108+ let updates = crate::git_http::ref_updates(body);
109+ if updates.iter().all(|(_, _, new)| new.is_none()) {
110+ return Ok(None);
111+ }
112+ let too_large = |line: String| Ok(Some(("push too large to check for workflow files", vec![line, "Push in smaller parts, or with a token that has the workflow_files:write scope.".to_owned()])));
113+ if !whole {
114+ return too_large("This push is too large for g1t to check whether it changes workflow files, and this token may not change them.".to_owned());
115+ }
116+ let mut pack = match pack_start(body).map(|start| Pack::parse(&body[start..])) {
117+ Some(Ok(pack)) => pack,
118+ // Nothing but ref moves to commits the repository has.
119+ None => return Ok(None),
120+ Some(Err(problem)) => {
121+ worker::console_error!("a push's pack could not be read for workflow files: {problem}");
122+ return Ok(Some(("push could not be checked for workflow files", vec!["g1t could not read this push to check it for workflow files. Push again.".to_owned()])));
123+ }
124+ };
125+ crate::secret_scan::supply_bases(&mut pack, git).await?;
126+ for (_, _, new) in updates {
127+ let Some(new) = new else { continue };
128+ let Some(ids) = rule_facts::added(&pack, &new, MAX_COMMITS) else {
129+ return too_large(format!("This push adds more than {MAX_COMMITS} commits to one ref, too many for g1t to check for workflow files, and this token may not change them."));
130+ };
131+ if let Some(path) = changed_in_commits(&pack, git, &ids).await? {
132+ let reason = decide_workflow_files(Some(token), [path.as_str()])
133+ .and_then(|decision| decision.reason)
134+ .unwrap_or_else(|| format!("This access token cannot change the workflow file {path}."));
135+ return Ok(Some((
136+ "workflow files need the workflow_files:write scope",
137+ vec![reason, "Push with a token that has the workflow_files:write scope, or make the change signed in on g1t.sh.".to_owned()],
138+ )));
139+ }
140+ }
141+ Ok(None)
142+}
143+
144+impl<S: GitStore> Repos<S> {
145+ /// For a push by a token this gate checks: the response declining it
146+ /// when it changes a workflow file, or when it cannot be checked.
147+ pub(crate) async fn workflow_gate(&self, repo: &g1t_contracts::repos::Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> {
148+ let Some(token) = gated(pusher) else {
149+ return Ok(None);
150+ };
151+ let git = self.store.open(&store_key(repo)).await?;
152+ match judge_push(token, body, whole, &git).await? {
153+ Some((reason, lines)) => Ok(Some(crate::git_http::declined(body, reason, &lines)?)),
154+ None => Ok(None),
155+ }
156+ }
157+}
158+
159+#[cfg(test)]
160+mod tests {
161+ use std::collections::HashMap;
162+ use std::future::Future;
163+ use std::pin::pin;
164+ use std::task::{Context, Poll, Waker};
165+
166+ use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry};
167+ use g1t_contracts::scopes::{JobToken, Scope};
168+ use g1t_scan::pack::{encode_tree, object_id, write_pack};
169+
170+ use super::*;
171+ use crate::store::Scope as StoreScope;
172+
173+ fn run<F: Future>(future: F) -> F::Output {
174+ match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
175+ Poll::Ready(output) => output,
176+ Poll::Pending => panic!("the fake store never waits"),
177+ }
178+ }
179+
180+ /// The repository before the push: one commit, with its trees.
181+ #[derive(Default)]
182+ struct Fake {
183+ trees: HashMap<String, Vec<TreeEntry>>,
184+ commits: HashMap<String, Commit>,
185+ }
186+
187+ impl GitRepo for Fake {
188+ async fn access(&self, _scope: StoreScope) -> Result<GitAccess> {
189+ unimplemented!()
190+ }
191+ async fn branches(&self) -> Result<Vec<Branch>> {
192+ Ok(Vec::new())
193+ }
194+ async fn log(&self, git_ref: &str, _limit: u32) -> Result<Vec<Commit>> {
195+ Ok(self.commits.get(git_ref).cloned().into_iter().collect())
196+ }
197+ async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> {
198+ Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone()))
199+ }
200+ async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
201+ Ok(self.trees.get(tree_hash).cloned())
202+ }
203+ async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> {
204+ Ok(None)
205+ }
206+ async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
207+ Ok(None)
208+ }
209+ async fn fork(&self, _target_key: &str) -> Result<()> {
210+ Ok(())
211+ }
212+ }
213+
214+ fn item(mode: &str, name: &str, id: &str) -> TreeItem {
215+ TreeItem { mode: mode.into(), name: name.into(), id: id.into() }
216+ }
217+
218+ /// Objects for one tree layout: a root with `dir/workflows/<file>`
219+ /// holding `content`, and `README.md`.
220+ struct Layout {
221+ objects: Vec<(ObjectKind, Vec<u8>)>,
222+ root: String,
223+ }
224+
225+ fn layout(dir: &str, file: &str, content: &str, readme: &str) -> Layout {
226+ let mut objects = Vec::new();
227+ let mut add = |kind: ObjectKind, data: Vec<u8>| {
228+ let id = object_id(kind, &data);
229+ objects.push((kind, data));
230+ id
231+ };
232+ let workflow = add(ObjectKind::Blob, content.as_bytes().to_vec());
233+ let readme = add(ObjectKind::Blob, readme.as_bytes().to_vec());
234+ let workflows = add(ObjectKind::Tree, encode_tree(&[item("100644", file, &workflow)]));
235+ let parent = add(ObjectKind::Tree, encode_tree(&[item("40000", "workflows", &workflows)]));
236+ let root = add(ObjectKind::Tree, encode_tree(&[item("40000", dir, &parent), item("100644", "README.md", &readme)]));
237+ Layout { objects, root }
238+ }
239+
240+ fn commit(tree: &str, parent: Option<&str>) -> (String, Vec<u8>) {
241+ let parent = parent.map(|parent| format!("parent {parent}\n")).unwrap_or_default();
242+ let data = format!("tree {tree}\n{parent}author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nchange\n").into_bytes();
243+ (object_id(ObjectKind::Commit, &data), data)
244+ }
245+
246+ /// The first workflow file a push of `after` on top of `before` changes.
247+ fn check(before: &Layout, after: &Layout) -> Option<String> {
248+ // The repository holds `before` and its commit; the pack, `after`.
249+ let mut repo = Fake::default();
250+ let base = Pack::parse(&write_pack(&before.objects)).unwrap();
251+ for (kind, data) in &before.objects {
252+ if *kind == ObjectKind::Tree {
253+ let id = object_id(*kind, data);
254+ let entries = base
255+ .tree(&id)
256+ .unwrap()
257+ .into_iter()
258+ .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } })
259+ .collect();
260+ repo.trees.insert(id, entries);
261+ }
262+ }
263+ let (base_id, _) = commit(&before.root, None);
264+ repo.commits.insert(
265+ base_id.clone(),
266+ Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() },
267+ );
268+ let (tip, data) = commit(&after.root, Some(&base_id));
269+ let mut objects = after.objects.clone();
270+ objects.push((ObjectKind::Commit, data));
271+ let pack = Pack::parse(&write_pack(&objects)).unwrap();
272+ run(changed_in_commits(&pack, &repo, &[tip])).unwrap()
273+ }
274+
275+ #[test]
276+ fn a_push_that_changes_a_workflow_is_named_by_its_file() {
277+ let before = layout(".github", "ci.yml", "on: push", "hello");
278+ let changed = layout(".github", "ci.yml", "on: [push, pull_request]", "hello");
279+ assert_eq!(check(&before, &changed).as_deref(), Some(".github/workflows/ci.yml"));
280+ let added = layout(".github", "deploy.yml", "on: push", "hello");
281+ assert!(check(&before, &added).unwrap().starts_with(".github/workflows/"));
282+ // The g1t directory too, added where there was none.
283+ let g1t = layout(".g1t", "ci.yml", "on: push", "hello");
284+ assert_eq!(check(&before, &g1t).as_deref(), Some(".g1t/workflows/ci.yml"));
285+ }
286+
287+ /// A receive-pack request moving `main` from `old` to `new`, with the pack.
288+ fn receive_pack(old: &str, new: &str, pack: &[u8]) -> Vec<u8> {
289+ let command = format!("{old} {new} refs/heads/main\0report-status side-band-64k\n");
290+ let mut body = format!("{:04x}", command.len() + 4).into_bytes();
291+ body.extend_from_slice(command.as_bytes());
292+ body.extend_from_slice(b"0000");
293+ body.extend_from_slice(pack);
294+ body
295+ }
296+
297+ /// The repository holding `before` at its commit, and a push of `after`
298+ /// on top of it: the refusal, if any, for `pusher`'s token.
299+ fn push(before: &Layout, after: &Layout, pusher: &User, whole: bool) -> Option<(&'static str, Vec<String>)> {
300+ let mut repo = Fake::default();
301+ let base = Pack::parse(&write_pack(&before.objects)).unwrap();
302+ for (kind, data) in &before.objects {
303+ if *kind == ObjectKind::Tree {
304+ let id = object_id(*kind, data);
305+ let entries = base
306+ .tree(&id)
307+ .unwrap()
308+ .into_iter()
309+ .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } })
310+ .collect();
311+ repo.trees.insert(id, entries);
312+ }
313+ }
314+ let (base_id, _) = commit(&before.root, None);
315+ repo.commits.insert(
316+ base_id.clone(),
317+ Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() },
318+ );
319+ let (tip, data) = commit(&after.root, Some(&base_id));
320+ let mut objects = after.objects.clone();
321+ objects.push((ObjectKind::Commit, data));
322+ let body = receive_pack(&base_id, &tip, &write_pack(&objects));
323+ let token = gated(Some(pusher))?;
324+ run(judge_push(token, &body, whole, &repo)).unwrap()
325+ }
326+
327+ #[test]
328+ fn a_git_push_of_a_workflow_change_is_declined_for_a_token_without_the_scope() {
329+ let before = layout(".github", "ci.yml", "on: push", "hello");
330+ let changed = layout(".github", "ci.yml", "on: [push, pull_request]\njobs: {}", "hello");
331+ let (reason, lines) = push(&before, &changed, &token(&[Scope::CodeWrite]), true).expect("declined");
332+ assert_eq!(reason, "workflow files need the workflow_files:write scope");
333+ assert!(lines[0].contains(".github/workflows/ci.yml"), "{lines:?}");
334+ assert!(lines[0].contains("workflow_files:write"), "{lines:?}");
335+ // With the scope, or full access, it goes through.
336+ assert!(push(&before, &changed, &token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]), true).is_none());
337+ // A push that changes other files goes through without it.
338+ let readme = layout(".github", "ci.yml", "on: push", "hello, world");
339+ assert!(push(&before, &readme, &token(&[Scope::CodeWrite]), true).is_none());
340+ // One too large to read whole cannot be checked, so it is declined.
341+ let (reason, _) = push(&before, &readme, &token(&[Scope::CodeWrite]), false).expect("declined");
342+ assert_eq!(reason, "push too large to check for workflow files");
343+ }
344+
345+ #[test]
346+ fn a_job_token_never_pushes_workflow_changes() {
347+ let before = layout(".g1t", "ci.yml", "on: push", "hello");
348+ let changed = layout(".g1t", "ci.yml", "on: workflow_dispatch", "hello");
349+ let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]);
350+ job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false });
351+ let (_, lines) = push(&before, &changed, &job, true).expect("declined");
352+ assert!(lines[0].contains("workflow job's token"), "{lines:?}");
353+ }
354+
355+ #[test]
356+ fn a_push_that_leaves_workflows_alone_passes() {
357+ let before = layout(".github", "ci.yml", "on: push", "hello");
358+ let readme = layout(".github", "ci.yml", "on: push", "hello, world");
359+ assert_eq!(check(&before, &readme), None);
360+ }
361+
362+ fn token(scopes: &[Scope]) -> User {
363+ User {
364+ token: Some(Box::new(TokenAccess {
365+ token_id: "tok_1".into(),
366+ scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
367+ ..TokenAccess::default()
368+ })),
369+ ..User::default()
370+ }
371+ }
372+
373+ #[test]
374+ fn who_the_gate_checks() {
375+ assert!(gated(None).is_none(), "nobody");
376+ assert!(gated(Some(&User::default())).is_none(), "a signed-in person");
377+ assert!(gated(Some(&token(&[Scope::CodeWrite]))).is_some(), "a token that may push code only");
378+ assert!(gated(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]))).is_none());
379+ let full = User { token: Some(Box::new(TokenAccess::full())), ..User::default() };
380+ assert!(gated(Some(&full)).is_none(), "full access includes workflow files");
381+ let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]);
382+ job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false });
383+ assert!(gated(Some(&job)).is_some(), "a job's token, whatever it holds");
384+ }
385+
386+ #[test]
387+ fn the_first_difference_names_added_changed_and_removed_entries() {
388+ let a = item("100644", "a.yml", "1");
389+ let b = item("100644", "b.yml", "2");
390+ assert_eq!(first_difference(&[a.clone()], &[a.clone(), b.clone()]).as_deref(), Some("b.yml"));
391+ assert_eq!(first_difference(&[a.clone(), b.clone()], &[a.clone()]).as_deref(), Some("b.yml"));
392+ assert_eq!(first_difference(&[a.clone()], &[item("100644", "a.yml", "3")]).as_deref(), Some("a.yml"));
393+ assert_eq!(first_difference(&[a.clone()], &[a]), None);
394+ }
395+}