| 1 | import type { Permissions, Workflow } from "@diffinery/workflow-yaml"; |
| 2 | import { 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 | |
| 10 | export 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; |
| 27 | export type PermissionScope = (typeof PERMISSION_SCOPES)[number]; |
| 28 | export type Access = "none" | "read" | "write"; |
| 29 | export type JobPermissions = Record<PermissionScope, Access>; |
| 30 | |
| 31 | const 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). */ |
| 33 | const CEILING: Partial<Record<PermissionScope, Access>> = { models: "read" }; |
| 34 | |
| 35 | const all = (f: (s: PermissionScope) => Access): JobPermissions => Object.fromEntries(PERMISSION_SCOPES.map((s) => [s, f(s)])) as JobPermissions; |
| 36 | const clamp = (s: PermissionScope, a: Access): Access => (CEILING[s] && LEVEL[a] > LEVEL[CEILING[s]!] ? CEILING[s]! : a); |
| 37 | |
| 38 | export const WRITE_ALL: JobPermissions = all((s) => clamp(s, "write")); |
| 39 | export 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). */ |
| 41 | export 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. */ |
| 43 | export 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. */ |
| 46 | export 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. */ |
| 59 | export 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. */ |
| 64 | export 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 | */ |
| 78 | export 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 | */ |
| 104 | export function roleForPermissions(p: JobPermissions): Role { |
| 105 | return PERMISSION_SCOPES.some((s) => s !== "id-token" && p[s] === "write") ? "write" : "read"; |
| 106 | } |
| 107 | |
| 108 | export type Requirement = { scopes: PermissionScope[]; access: Exclude<Access, "none"> } | { metadata: true } | { forbidden: string }; |
| 109 | |
| 110 | const ADMIN_ONLY = new Set(["hooks", "collaborators", "keys", "invitations", "settings", "rulesets", "teams", "transfer", "autolinks", "vulnerability-alerts"]); |
| 111 | const 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 | }; |
| 133 | const 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 | */ |
| 139 | export 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. */ |
| 171 | export 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. */ |
| 176 | export 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 | } |