Skip to content

Commit

Token reach: workflow_files scope, fine-grained reach, workspace token cap

syntaqxcommitted Parent4edb70bBrowse files
3 files+189−80/3 viewed
+30−5
2828 //! resolves them from credentials, so asking costs nothing: no call, and
2929 //! the answer is as fresh as the request.
3030 //!
31−//! **Tokens.** A workspace's own token has Admin on its workspace's
32−//! repositories, as it could do everything a member could before roles;
33−//! what is for people only stays refused by the checks that say so. An
31+//! **Tokens.** A workspace's own token has Write on its workspace's
32+//! repositories, as a member would, and Admin only when an owner gave it
33+//! Admin when making it ([`crate::scopes::TokenAccess::admin`]); what is
34+//! for people only stays refused by the checks that say so. A
35+//! fine-grained personal token has no role outside its resource owner and
36+//! repository selection ([`crate::scopes::FineGrainedReach`]): there it
37+//! reads public repositories, as anyone may, and nothing more. An
3438 //! agent's token carries the memberships and grants of the person it acts
3539 //! for, cut down to its repository's workspace
3640 //! (`credentials::intersect`), so it never has more than that person on
274278
275279 /// What a membership gives on each of the workspace's repositories.
276280 fn membership_role(user: &User, membership: &Membership) -> Option<RepoRole> {
277− // A workspace's own token, and g1t acting in the workspace, do what an
278− // owner can on its repositories.
281+ // A workspace's own token has Write on its repositories, as a member
282+ // would, unless an owner gave it Admin when making it. A workflow job's
283+ // token and a deploy key resolve the same way, capped further by their
284+ // scopes. g1t acting in the workspace, and a service acting as the
285+ // workspace (no token), do what an owner can.
286+ if user.kind == PrincipalKind::Workspace
287+ && let Some(token) = user.token.as_deref()
288+ {
289+ return Some(if token.admin { RepoRole::Admin } else { RepoRole::Write });
290+ }
279291 if matches!(user.kind, PrincipalKind::Workspace | PrincipalKind::System) {
280292 return Some(RepoRole::Admin);
281293 }
287299
288300 /// `user`'s role on the repository, not counting that it may be public.
289301 pub fn granted(user: &User, repo: RepoRef<'_>) -> Option<RepoRole> {
302+ // A fine-grained token outside its resource owner, or its repository
303+ // selection, has no role there: a public repository still reads.
304+ if user.token.as_deref().is_some_and(|token| !token.covers_repo(repo.id, repo.namespace)) {
305+ return None;
306+ }
290307 let namespace = repo.namespace.to_lowercase();
291308 let from_membership = user
292309 .workspaces
321338 let Some(role) = permission(viewer, repo) else {
322339 return false;
323340 };
341+ // Where a fine-grained token does not reach, it only reads.
342+ if capability != Capability::Read
343+ && viewer
344+ .and_then(|user| user.token.as_deref())
345+ .is_some_and(|token| !token.covers_repo(repo.id, repo.namespace))
346+ {
347+ return false;
348+ }
324349 if !allows(role, capability) {
325350 return false;
326351 }
+155−2
3434 PullRequests,
3535 Agents,
3636 Workflows,
37+ WorkflowFiles,
3738 Checks,
3839 Deployments,
3940 Memory,
4546 }
4647
4748 impl Resource {
48− pub const ALL: [Resource; 20] = [
49+ pub const ALL: [Resource; 21] = [
4950 Resource::Repo,
5051 Resource::Code,
5152 Resource::Security,
5455 Resource::PullRequests,
5556 Resource::Agents,
5657 Resource::Workflows,
58+ Resource::WorkflowFiles,
5759 Resource::Checks,
5860 Resource::Deployments,
5961 Resource::Memory,
8284 Resource::PullRequests => "pull_requests",
8385 Resource::Agents => "agents",
8486 Resource::Workflows => "workflows",
87+ Resource::WorkflowFiles => "workflow_files",
8588 Resource::Checks => "checks",
8689 Resource::Deployments => "deployments",
8790 Resource::Memory => "memory",
108111 Resource::PullRequests => "Pull requests",
109112 Resource::Agents => "g1t agents",
110113 Resource::Workflows => "Workflows",
114+ Resource::WorkflowFiles => "Workflow files",
111115 Resource::Checks => "Checks and statuses",
112116 Resource::Deployments => "Deployments",
113117 Resource::Memory => "Memory and context",
165169 AgentsRun,
166170 WorkflowsRead,
167171 WorkflowsWrite,
172+ WorkflowFilesWrite,
168173 ChecksRead,
169174 ChecksWrite,
170175 DeploymentsRead,
193198
194199 impl Scope {
195200 /// Every scope, grouped by resource, least first.
196− pub const ALL: [Scope; 41] = [
201+ pub const ALL: [Scope; 42] = [
197202 Scope::RepoRead,
198203 Scope::RepoWrite,
199204 Scope::RepoAdmin,
211216 Scope::AgentsRun,
212217 Scope::WorkflowsRead,
213218 Scope::WorkflowsWrite,
219+ Scope::WorkflowFilesWrite,
214220 Scope::ChecksRead,
215221 Scope::ChecksWrite,
216222 Scope::DeploymentsRead,
256262 Scope::AgentsRun => "agents:run",
257263 Scope::WorkflowsRead => "workflows:read",
258264 Scope::WorkflowsWrite => "workflows:write",
265+ Scope::WorkflowFilesWrite => "workflow_files:write",
259266 Scope::ChecksRead => "checks:read",
260267 Scope::ChecksWrite => "checks:write",
261268 Scope::DeploymentsRead => "deployments:read",
338345 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
339346 Scope::WorkflowsRead => "Read workflows, runs and logs",
340347 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
348+ Scope::WorkflowFilesWrite => "Add, change and delete workflow files under .g1t/workflows and .github/workflows, with git or the API",
341349 Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations",
342350 Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again",
343351 Scope::DeploymentsRead => "See deployments, their statuses and environments",
512520 /// token made a request. Absent where whoever resolved it did not say.
513521 #[serde(default, skip_serializing_if = "Option::is_none")]
514522 pub name: Option<String>,
523+ /// Set on a fine-grained personal access token: whose resources it
524+ /// reaches, and which of their repositories. Absent on a classic token,
525+ /// which reaches whatever its owner can.
526+ #[serde(default, skip_serializing_if = "Option::is_none")]
527+ pub fine_grained: Option<FineGrainedReach>,
528+ /// Set on a workspace's own token that an owner gave Admin when making
529+ /// it. Without it a workspace's token has Write on the workspace's
530+ /// repositories, as a member would (see [`crate::access`]).
531+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
532+ pub admin: bool,
533+ /// Set on what a repository's deploy key resolves to: the key's id. Its
534+ /// `repo` is the one repository it reaches.
535+ #[serde(default, skip_serializing_if = "Option::is_none")]
536+ pub deploy_key: Option<String>,
537+}
538+
539+/// Which of the resource owner's repositories a fine-grained token reaches.
540+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
541+#[serde(rename_all = "snake_case")]
542+pub enum RepositorySelection {
543+ /// Every repository of the workspace, ones made later included.
544+ #[default]
545+ All,
546+ /// The repositories chosen, by id.
547+ Selected,
548+ /// None of the workspace's private repositories: public repositories,
549+ /// read-only, and the workspace's own settings its permissions allow.
550+ Public,
515551 }
516552
553+impl RepositorySelection {
554+ pub fn as_str(self) -> &'static str {
555+ match self {
556+ RepositorySelection::All => "all",
557+ RepositorySelection::Selected => "selected",
558+ RepositorySelection::Public => "public",
559+ }
560+ }
561+
562+ pub fn parse(text: &str) -> Option<RepositorySelection> {
563+ match text.trim().to_ascii_lowercase().as_str() {
564+ "all" => Some(RepositorySelection::All),
565+ "selected" => Some(RepositorySelection::Selected),
566+ "public" | "public_only" | "none" => Some(RepositorySelection::Public),
567+ _ => None,
568+ }
569+ }
570+}
571+
572+/// What a fine-grained token reaches, as identity resolves it on each use.
573+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
574+pub struct FineGrainedReach {
575+ /// The resource owner: the workspace whose repositories and settings it
576+ /// reaches, by slug as it is now. Absent: the person's own account
577+ /// only, with public repositories read-only.
578+ #[serde(default, skip_serializing_if = "Option::is_none")]
579+ pub workspace: Option<String>,
580+ #[serde(default)]
581+ pub repositories: RepositorySelection,
582+ /// With [`RepositorySelection::Selected`]: the repositories' ids.
583+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
584+ pub repo_ids: Vec<String>,
585+}
586+
587+impl FineGrainedReach {
588+ /// Whether it reaches the repository with this id in the workspace
589+ /// `namespace` for more than what anyone may do with a public one.
590+ pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
591+ let Some(workspace) = self.workspace.as_deref() else {
592+ return false;
593+ };
594+ if !workspace.eq_ignore_ascii_case(namespace) {
595+ return false;
596+ }
597+ match self.repositories {
598+ RepositorySelection::All => true,
599+ RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
600+ RepositorySelection::Public => false,
601+ }
602+ }
603+
604+ /// Whether the workspace `slug` is its resource owner.
605+ pub fn owned_by(&self, slug: &str) -> bool {
606+ self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
607+ }
608+}
609+
610+/// Where workflow files live. Adding, changing or deleting a file under
611+/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
612+/// token: what GitHub's `workflow` scope and `workflows` permission do.
613+pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
614+
615+/// Whether `path` is a workflow file, or a file in one's directory.
616+pub fn is_workflow_file(path: &str) -> bool {
617+ let path = path.trim_start_matches('/');
618+ WORKFLOW_DIRS.iter().any(|dir| {
619+ path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
620+ }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
621+}
622+
623+/// Whether a token may add, change or delete the files at `paths`: a
624+/// refusal naming the first workflow file it may not touch, else `None`.
625+/// A signed-in person (no token) is never refused here; their role decides.
626+pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
627+ let access = access?;
628+ if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
629+ return None;
630+ }
631+ let path = paths.into_iter().find(|path| is_workflow_file(path))?;
632+ let why = if access.job.is_some() {
633+ "a workflow job's token can never add or change workflow files".to_owned()
634+ } else if access.fine_grained.is_some() {
635+ "it needs the Workflows permission (read and write), which maps to the workflow_files:write scope".to_owned()
636+ } else {
637+ format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
638+ };
639+ Some(Decision::deny(
640+ "token:workflows",
641+ format!("This access token cannot change the workflow file {path}: {why}."),
642+ ))
643+}
644+
517645 /// The workflow job a token was made for.
518646 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
519647 pub struct JobToken {
557685 Some(granted) => granted.iter().any(|held| held.includes(needed)),
558686 }
559687 }
688+
689+ /// Whether it reaches the repository with this id in `namespace` for
690+ /// more than reading a public one: every token but a fine-grained one
691+ /// outside its resource owner or repository selection. Its owner's role
692+ /// still decides; see [`crate::access`].
693+ pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool {
694+ self.fine_grained.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
695+ }
560696 }
561697
562698 /// Every operation of the API and MCP server, with the scope it needs. An
9591095 }
9601096 }
9611097 }
1098+ // A fine-grained token only reads outside its resource owner: public
1099+ // repositories, as anyone may. Inside it, its repository selection is
1100+ // checked with its owner's role (`access::granted`).
1101+ if let Some(reach) = &access.fine_grained
1102+ && let Some(repo) = input["repo"].as_str()
1103+ && !NO_SCOPE.contains(&operation)
1104+ {
1105+ let namespace = repo.split('/').next().unwrap_or_default();
1106+ let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1107+ if changes && !reach.owned_by(namespace) {
1108+ let owner = reach.workspace.as_deref().map_or_else(|| "your account".to_owned(), |workspace| format!("the workspace {workspace}"));
1109+ return Decision::deny(
1110+ "token:resource-owner",
1111+ format!("This fine-grained token's resource owner is {owner}: it can only read public repositories elsewhere, and {repo} is not its owner's."),
1112+ );
1113+ }
1114+ }
9621115 if access.scopes.is_some() {
9631116 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
9641117 if !known {
+4−1
1717 | "pull_requests"
1818 | "agents"
1919 | "workflows"
20+ | "workflow_files"
2021 | "checks"
2122 | "deployments"
2223 | "memory"
5152 { scope: "agents:run", description: "Put g1t agents to work and message them, which uses the workspace's money" },
5253 { scope: "workflows:read", description: "Read workflows, runs and logs" },
5354 { scope: "workflows:write", description: "Run, cancel, rerun and turn workflows on or off" },
55+ { scope: "workflow_files:write", description: "Add, change and delete workflow files under .g1t/workflows and .github/workflows, with git or the API" },
5456 { scope: "checks:read", description: "Read commits' statuses, check runs, check suites and annotations" },
5557 { scope: "checks:write", description: "Report statuses and check runs on commits, and ask for checks to run again" },
5658 { scope: "deployments:read", description: "See deployments, their statuses and environments" },
8991 { resource: "pull_requests", label: "Pull requests" },
9092 { resource: "agents", label: "g1t agents" },
9193 { resource: "workflows", label: "Workflows" },
94+ { resource: "workflow_files", label: "Workflow files" },
9295 { resource: "checks", label: "Checks and statuses" },
9396 { resource: "deployments", label: "Deployments" },
9497 { resource: "memory", label: "Memory and context" },
500503 { id: "packages", label: "Packages", scopes: ["packages:read", "packages:write"] },
501504 { id: "work", label: "Issues & pull requests", scopes: ["issues:read", "issues:write", "pull_requests:read", "pull_requests:write"] },
502505 { id: "agents", label: "Agents", scopes: ["agents:run"] },
503− { id: "workflows", label: "Workflows", scopes: ["workflows:read", "workflows:write"] },
506+ { id: "workflows", label: "Workflows", scopes: ["workflows:read", "workflows:write", "workflow_files:write"] },
504507 { id: "checks", label: "Checks", scopes: ["checks:read", "checks:write"] },
505508 { id: "deployments", label: "Deployments", scopes: ["deployments:read", "deployments:write"] },
506509 { id: "memory", label: "Memory & search", scopes: ["memory:read", "memory:write"] },