Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

181 lines · 9.7 KBRaw History
1import type { Permissions, Workflow } from "@diffinery/workflow-yaml";
2import { atLeast, type Role } from "../repo/types.js";
3
4/**
5 * GitHub's permission model for a job's GITHUB_TOKEN: the effective
6 * `permissions:` of each job, and which permission each API request needs.
7 * https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#permissions
8 */
9
10export const PERMISSION_SCOPES = [
11 "actions",
12 "attestations",
13 "checks",
14 "contents",
15 "deployments",
16 "discussions",
17 "id-token",
18 "issues",
19 "models",
20 "packages",
21 "pages",
22 "pull-requests",
23 "repository-projects",
24 "security-events",
25 "statuses",
26] as const;
27export type PermissionScope = (typeof PERMISSION_SCOPES)[number];
28export type Access = "none" | "read" | "write";
29export type JobPermissions = Record<PermissionScope, Access>;
30
31const LEVEL: Record<Access, number> = { none: 0, read: 1, write: 2 };
32/** Scopes that only take some levels (id-token: write or none; models: read or none). */
33const CEILING: Partial<Record<PermissionScope, Access>> = { models: "read" };
34
35const all = (f: (s: PermissionScope) => Access): JobPermissions => Object.fromEntries(PERMISSION_SCOPES.map((s) => [s, f(s)])) as JobPermissions;
36const clamp = (s: PermissionScope, a: Access): Access => (CEILING[s] && LEVEL[a] > LEVEL[CEILING[s]!] ? CEILING[s]! : a);
37
38export const WRITE_ALL: JobPermissions = all((s) => clamp(s, "write"));
39export const READ_ALL: JobPermissions = all((s) => (s === "id-token" ? "none" : "read"));
40/** The repository default when a workflow sets no `permissions:`, set to read and write (GitHub's permissive default). */
41export const PERMISSIVE_DEFAULT: JobPermissions = all((s) => (s === "id-token" ? "none" : clamp(s, "write")));
42/** The repository default set to read-only (GitHub's restricted default): contents and packages, read. */
43export const RESTRICTED_DEFAULT: JobPermissions = all((s) => (s === "contents" || s === "packages" ? "read" : "none"));
44
45/** `permissions:` as written (`read-all`, `write-all`, `{}` or a mapping) to a full table; listed-out scopes get none. */
46export function resolvePermissions(p: Permissions): JobPermissions {
47 if (p === "write-all") return { ...WRITE_ALL };
48 if (p === "read-all") return { ...READ_ALL };
49 return all((s) => {
50 const v = p[s];
51 if (v !== "read" && v !== "write") return "none";
52 // id-token has no read level; `id-token: read` grants nothing.
53 if (s === "id-token" && v === "read") return "none";
54 return clamp(s, v);
55 });
56}
57
58/** The lower of each scope, for capping a called workflow by its caller. */
59export function minPermissions(a: JobPermissions, b: JobPermissions): JobPermissions {
60 return all((s) => (LEVEL[a[s]] <= LEVEL[b[s]] ? a[s] : b[s]));
61}
62
63/** The parts of an expanded run (repo/reusable.ts) the permission chain needs. */
64export interface PermissionTree {
65 workflow: Workflow;
66 calls: Map<string, { parent: string | null; job: { permissions?: Permissions }; workflow: Workflow }>;
67 parentOf: Map<string, string | null>;
68}
69
70/**
71 * A job's effective token permissions. Job-level `permissions:` replace the
72 * workflow's; without either, the repository default applies (read and write,
73 * or read-only: the repository's Actions setting).
74 * In a reusable workflow, a job without `permissions:` inherits its caller's,
75 * and nothing it declares can exceed what the calling job has. Pull requests
76 * from another repository (a fork) never get write.
77 */
78export function permissionsForJob(tree: PermissionTree, key: string, o: { untrusted?: boolean; repoDefault?: "read" | "write" } = {}): JobPermissions {
79 const top = o.untrusted ? READ_ALL : WRITE_ALL;
80 const base = o.untrusted ? READ_ALL : o.repoDefault === "read" ? RESTRICTED_DEFAULT : PERMISSIVE_DEFAULT;
81 const wfOf = (call: string | null) => (call === null ? tree.workflow : tree.calls.get(call)!.workflow);
82 // What jobs inside `call` may have at most: the calling job's permissions.
83 const cap = (call: string | null): JobPermissions => (call === null ? top : callerJob(call));
84 // A workflow's own level: its `permissions:`, or what it inherits.
85 const level = (call: string | null): JobPermissions => {
86 const declared = wfOf(call).permissions;
87 if (declared !== undefined) return minPermissions(resolvePermissions(declared), cap(call));
88 return call === null ? base : callerJob(call);
89 };
90 const callerJob = (call: string): JobPermissions => {
91 const site = tree.calls.get(call)!;
92 return site.job.permissions !== undefined ? minPermissions(resolvePermissions(site.job.permissions), cap(site.parent)) : level(site.parent);
93 };
94 const parent = tree.parentOf.get(key) ?? null;
95 const declared = tree.workflow.jobs[key]?.permissions;
96 return declared !== undefined ? minPermissions(resolvePermissions(declared), cap(parent)) : level(parent);
97}
98
99/**
100 * The repository-level role the token gets; requests are further limited per
101 * scope by {@link requestPermission}. A job token never administers or
102 * maintains the repository, so branch protection always applies to it.
103 */
104export function roleForPermissions(p: JobPermissions): Role {
105 return PERMISSION_SCOPES.some((s) => s !== "id-token" && p[s] === "write") ? "write" : "read";
106}
107
108export type Requirement = { scopes: PermissionScope[]; access: Exclude<Access, "none"> } | { metadata: true } | { forbidden: string };
109
110const ADMIN_ONLY = new Set(["hooks", "collaborators", "keys", "invitations", "settings", "rulesets", "teams", "transfer", "autolinks", "vulnerability-alerts"]);
111const BY_SEGMENT: Record<string, PermissionScope[]> = {
112 issues: ["issues", "pull-requests"],
113 labels: ["issues", "pull-requests"],
114 milestones: ["issues", "pull-requests"],
115 assignees: ["issues", "pull-requests"],
116 pulls: ["pull-requests"],
117 pull: ["pull-requests"],
118 statuses: ["statuses"],
119 "check-runs": ["checks"],
120 "check-suites": ["checks"],
121 deployments: ["deployments"],
122 environments: ["deployments"],
123 actions: ["actions"],
124 pages: ["pages"],
125 discussions: ["discussions"],
126 "code-scanning": ["security-events"],
127 "secret-scanning": ["security-events"],
128 dependabot: ["security-events"],
129 packages: ["packages"],
130 projects: ["repository-projects"],
131 attestations: ["attestations"],
132};
133const CONTENTS = new Set(["contents", "commits", "commit", "compare", "git", "branches", "tags", "releases", "merges", "tarball", "zipball", "readme", "dispatches", "comments", "edit", "new", "merge-upstream", "languages", "contributors", "stats", "archive", "blob", "tree", "raw", "info", "git-upload-pack", "git-receive-pack"]);
134
135/**
136 * The permission a job token needs for a request. `min` is the role the route
137 * itself asks for: git fetches and LFS downloads are POSTs that only read.
138 */
139export function requestPermission(method: string, path: string, min: Role): Requirement {
140 const readOnlyPost = /\/git-upload-pack$|\/info\/lfs\/objects\/batch$/.test(path);
141 const safe = method === "GET" || method === "HEAD" || method === "OPTIONS";
142 const access = atLeast(min, "triage") || (!safe && !readOnlyPost) ? "write" : "read";
143 // uploads.github.com is served at /api/uploads (release assets).
144 const api = /^\/api\/(?:v3|uploads)\/repos\/[^/]+\/[^/]+(?:\/(.*))?$/.exec(path);
145 // Web and git paths are /<owner>/<repo>[.git]/...
146 const web = api ? null : /^\/[^/]+\/[^/]+(?:\/(.*))?$/.exec(path);
147 const rest = api ? (api[1] ?? "") : web ? (web[1] ?? "") : undefined;
148 if (rest === undefined) return { forbidden: "this endpoint is not available to a workflow's GITHUB_TOKEN" };
149 const segs = rest.split("/").filter(Boolean);
150 const first = segs[0] ?? "";
151 // The repository itself: metadata through the API; its code page on the web.
152 if (!first && !api && access === "read") return { scopes: ["contents"], access };
153 if (!first) return access === "read" ? { metadata: true } : { forbidden: "the repository's settings cannot be changed with a workflow's GITHUB_TOKEN" };
154 const adminOnly =
155 ADMIN_ONLY.has(first) ||
156 (first === "actions" && /^(secrets|variables|runners|permissions|oidc)$/.test(segs[1] ?? "")) ||
157 (first === "environments" && (access === "write" || /^(secrets|variables)$/.test(segs[2] ?? ""))) ||
158 (first === "branches" && segs.slice(2).includes("protection"));
159 if (adminOnly) return { forbidden: "this endpoint is not available to a workflow's GITHUB_TOKEN" };
160 if (first === "issues" && segs.length === 1) return { scopes: ["issues"], access };
161 // Merging, auto-merge (which merges later) and updating the branch write code, as on GitHub.
162 if (first === "pulls" && /^(merge|update-branch|auto-merge)$/.test(segs[2] ?? "")) return { scopes: ["contents"], access };
163 if (first === "commits" && /^(statuses|status)$/.test(segs[2] ?? "")) return { scopes: ["statuses"], access };
164 if (first === "commits" && /^(check-runs|check-suites)$/.test(segs[2] ?? "")) return { scopes: ["checks"], access };
165 if (BY_SEGMENT[first]) return { scopes: BY_SEGMENT[first]!, access };
166 if (CONTENTS.has(first) || first.endsWith(".git")) return { scopes: ["contents"], access };
167 return access === "read" ? { metadata: true } : { forbidden: "this endpoint is not available to a workflow's GITHUB_TOKEN" };
168}
169
170/** A request that only reads: anyone may make it on a public repository, whatever the token's permissions. */
171export function readOnly(req: Requirement): boolean {
172 return "metadata" in req || ("scopes" in req && req.access === "read");
173}
174
175/** Throws-free check: null when allowed, else the reason for a 403. */
176export function denied(p: JobPermissions, req: Requirement): string | null {
177 if ("metadata" in req) return null;
178 if ("forbidden" in req) return req.forbidden;
179 if (req.scopes.some((s) => LEVEL[p[s]] >= LEVEL[req.access])) return null;
180 return `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ${req.scopes.join(" or ")}: ${req.access})`;
181}