import { env } from "cloudflare:workers"; import { first, run } from "../db"; import { repoDb, stmt } from "../scoped-db"; import { forgetLocator, locate, registerLocator } from "../scoped-db/locators"; import { randomToken, sha256Hex } from "../crypto"; import { credentialFromRequest } from "../auth"; import { publicUrl } from "../events/emit"; import { allows, restrictPermissions } from "./permissions"; import type { TokenPermissions } from "./permissions"; import { isUntrustedRun } from "./jobs"; import type { JobExecution } from "./jobs"; import type { Permission } from "~/lib/types"; export const JOB_TOKEN_PREFIX = "gitfor_job"; const TOKEN_GRACE_MS = 10 * 60 * 1000; export const JOB_TOKEN_MARKER_TTL_MS = 24 * 60 * 60 * 1000; export type JobTokenRow = { token_hash: string; job_id: string; repo_id: string; permissions: string; expires_at: number; }; export type JobTokenAccess = { jobId: string; repoId: string; permissions: TokenPermissions }; /** Mints the job's `GITHUB_TOKEN` equivalent; it is revoked when the job completes and expires after its timeout. */ export async function mintJobToken(execution: JobExecution): Promise { const token = randomToken(JOB_TOKEN_PREFIX); const forked = isUntrustedRun(execution); const permissions = forked ? restrictPermissions(execution.spec.permissions) : execution.spec.permissions; const hash = await sha256Hex(token); await repoDb(execution.repo.id).run( "INSERT INTO job_tokens (token_hash, job_id, repo_id, permissions, expires_at) VALUES (?, ?, ?, ?, ?)", hash, execution.job.id, execution.repo.id, JSON.stringify(permissions), Date.now() + execution.spec.timeoutMinutes * 60 * 1000 + TOKEN_GRACE_MS, ); await registerLocator("job_token", hash, execution.repo.id); return token; } /** * Resolves a job token for git, the cache API and OIDC. Besides the DB expiry (timeout plus grace), the job must * still be running: tokens are deleted by `completeJob`, but if that is never reached (Runner DO failure, missed * Workflow step) the token would otherwise stay valid for hours (CI-A6). The expiry remains as a backstop. */ export async function jobTokenAccess(request: Request): Promise { const credential = credentialFromRequest(request); if (!credential?.startsWith(`${JOB_TOKEN_PREFIX}_`)) return null; const hash = await sha256Hex(credential); const repoId = await locate("job_token", hash); if (!repoId) return null; const row = await repoDb(repoId).first( `SELECT t.*, j.status AS job_status, wr.actor_id FROM job_tokens t LEFT JOIN workflow_jobs j ON j.id = t.job_id LEFT JOIN workflow_runs wr ON wr.id = j.run_id WHERE t.token_hash = ?`, hash, ); if (!row || row.expires_at < Date.now()) return null; if (row.job_status !== "in_progress") return null; const owner = await first<{ owner_disabled: number | null; actor_disabled: number | null }>( `SELECT a.disabled_at AS owner_disabled, (SELECT disabled_at FROM accounts WHERE id = ?) AS actor_disabled FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?`, row.actor_id, row.repo_id, ); if (!owner || owner.owner_disabled !== null || owner.actor_disabled !== null) return null; return { jobId: row.job_id, repoId: row.repo_id, permissions: JSON.parse(row.permissions) }; } /** Repository permission a job token grants for git and the API: only on its own repo, via `contents`. */ export function jobTokenRepoPermission(access: JobTokenAccess, repoId: string): Permission { if (access.repoId !== repoId) return "none"; if (allows(access.permissions, "contents", "write")) return "write"; return allows(access.permissions, "contents", "read") ? "read" : "none"; } /** Environment for a job's runner process: forge API endpoint, job token and the OIDC request pair. */ export function jobTokenEnv(token: string, permissions: TokenPermissions): Record { const vars: Record = { GITFOR_API_URL: env.PUBLIC_URL.replace(/\/$/, ""), GITFOR_JOB_TOKEN: token }; if (allows(permissions, "id-token", "write")) { vars.ACTIONS_ID_TOKEN_REQUEST_URL = publicUrl("/api/actions/oidc/token?api-version=1"); vars.ACTIONS_ID_TOKEN_REQUEST_TOKEN = token; } return vars; } /** Pushes made with a job token don't trigger workflows (as on GitHub), which prevents CI feedback loops. */ export async function markJobTokenPush(repoId: string, shas: string[]): Promise { const now = Date.now(); await repoDb(repoId).batch( shas.map((sha) => stmt("INSERT OR IGNORE INTO job_token_pushes (repo_id, sha, created_at) VALUES (?, ?, ?)", repoId, sha, now), ), ); } export async function isJobTokenPush(repoId: string, sha: string): Promise { return ( (await repoDb(repoId).first("SELECT 1 AS ok FROM job_token_pushes WHERE repo_id = ? AND sha = ?", repoId, sha)) !== null ); } /** Run by the RepoState alarm when a job token or push marker expires. */ export async function purgeExpiredJobTokensForRepo(repoId: string, now: number): Promise { const db = repoDb(repoId); const expired = await db.all<{ token_hash: string }>( "DELETE FROM job_tokens WHERE expires_at < ? RETURNING token_hash", now, ); await Promise.all(expired.map((row) => forgetLocator("job_token", row.token_hash))); await db.run("DELETE FROM job_token_pushes WHERE created_at < ?", now - JOB_TOKEN_MARKER_TTL_MS); } /** Cron: the D1 receipts of workflow events (one directory table, bounded by the TTL). */ export async function purgeWorkflowEventReceipts(): Promise { await run("DELETE FROM workflow_event_receipts WHERE created_at < ?", Date.now() - JOB_TOKEN_MARKER_TTL_MS); }