import { waitUntil } from "cloudflare:workers"; import { all, batch, stmt } from "./db"; import { adjustOpenCounter } from "./issue-counters"; import { repoDb } from "./scoped-db"; import { pullBaseRepoIds } from "./pull-scope"; import { loadUserById, toViewer } from "./auth"; import { pullHeadAccessible } from "./git/bridge-policy"; import { dropHiddenRefsForRepo, dropPullRefsForIssue, requestHiddenRefCleanup } from "./git/pull-bridge"; import { CAPS } from "./limits"; import { addIssueEvent } from "./pulls"; import type { PullRow } from "./pulls"; import { hasPermission, loadRepoById, repoPermission } from "./repos"; import type { RepoRow, Viewer } from "~/lib/types"; import { issuesChanged } from "./repo-state"; type GuardedPull = Pick; /** Closes a pull request because its author can no longer read one of its repositories. */ async function closeForRevokedAccess(pull: GuardedPull): Promise { const now = Date.now(); const closed = await repoDb(pull.repo_id).run( "UPDATE issues SET state = 'closed', closed_at = ?, closed_by = NULL, updated_at = ? WHERE id = ? AND state = 'open'", now, now, pull.issue_id, ); if (closed.meta.changes > 0) { await adjustOpenCounter({ id: pull.issue_id, repo_id: pull.repo_id, is_pull: 1 }, -1); } issuesChanged(pull.repo_id, [pull.issue_id]); await addIssueEvent(pull, null, "closed", { reason: "access_revoked" }); waitUntil(dropPullRefsForIssue(pull.repo_id, pull.issue_id)); } /** * Whether a fork pull request may still synchronize, bridge or run CI: the author must currently read * both repositories and the head must be a live fork of the base. When that stopped being true the pull * request is closed and its hidden refs dropped, so revoked readers stop receiving (and feeding) history. */ export async function ensurePullHeadAccessible(pull: GuardedPull): Promise { if (pull.repo_id === pull.head_repo_id) return true; const [baseRepo, headRepo] = await Promise.all([loadRepoById(pull.repo_id), loadRepoById(pull.head_repo_id)]); if (!baseRepo || !headRepo) return false; if (await pullHeadAccessible(pull, baseRepo, headRepo)) return true; if (pull.state === "open") await closeForRevokedAccess(pull); return false; } /** * Cuts a private fork loose from its network: hidden refs on both sides are dropped, open pull requests * between fork and parent are closed, and `fork_of_id` is cleared so the fork no longer inherits LFS * objects or counts as a pull request head. The fork keeps the objects it already holds (GitHub deletes * such forks; detaching preserves the collaborator's own work while stopping any further flow). */ export async function detachFork(fork: RepoRow): Promise { if (!fork.fork_of_id) return; const parentId = fork.fork_of_id; const between = (baseId: string, headId: string) => repoDb(baseId).all( `SELECT p.issue_id, p.repo_id, p.head_repo_id, i.author_id, i.state FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE i.state = 'open' AND p.head_repo_id = ? LIMIT ?`, headId, CAPS.revocationSweepRows, ); const pulls = (await Promise.all([between(parentId, fork.id), between(fork.id, parentId)])) .flat() .slice(0, CAPS.revocationSweepRows); for (const pull of pulls) await closeForRevokedAccess(pull); await batch([ stmt("UPDATE repos SET fork_of_id = NULL, detached = 1, updated_at = ? WHERE id = ?", Date.now(), fork.id), stmt("UPDATE repos SET forks_count = MAX(forks_count - 1, 0) WHERE id = ?", parentId), ]); await requestHiddenRefCleanup(fork.id); await dropHiddenRefsForRepo(fork.id); } /** `userId`'s open fork pull requests based in `repoId` or headed in it (the latter live in its parent). */ async function openCrossRepoPullsBy(repoId: string, userId: string): Promise { const lists = await Promise.all( (await pullBaseRepoIds(repoId)).map((baseId) => repoDb(baseId).all( `SELECT p.issue_id, p.repo_id, p.head_repo_id, i.author_id, i.state FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE i.state = 'open' AND i.author_id = ?1 AND p.repo_id != p.head_repo_id AND (p.repo_id = ?2 OR p.head_repo_id = ?2) LIMIT ?3`, userId, repoId, CAPS.revocationSweepRows, ), ), ); return lists.flat(); } async function viewerFor(userId: string): Promise { const user = await loadUserById(userId); return user ? toViewer(user) : null; } /** * After `userId` lost access to `repo`: closes their open pull requests that use it (in either role) and * detaches their private forks of it. Access is re-evaluated live, so a user who still reads the repo * through another path (org membership, public visibility) is left alone. Bounded; best effort. */ export async function sweepRevokedRepoAccess(repo: RepoRow, userId: string): Promise { const viewer = await viewerFor(userId); if (viewer && hasPermission(await repoPermission(repo, viewer), "read")) return; const pulls = (await openCrossRepoPullsBy(repo.id, userId)).slice(0, CAPS.revocationSweepRows); for (const pull of pulls) await ensurePullHeadAccessible(pull); if (repo.visibility !== "private") return; const forks = await all( `SELECT r.*, a.name AS owner_name, a.disabled_at AS owner_disabled_at, a.disabled_reason AS owner_disabled_reason FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.fork_of_id = ? AND r.owner_id = ? AND r.visibility = 'private' LIMIT ?`, repo.id, userId, CAPS.revocationSweepRows, ); for (const fork of forks) await detachFork(fork); } /** After `userId` left an organization: sweeps every private repository of the org they can no longer read. */ export async function sweepRevokedOrgAccess(orgId: string, userId: string): Promise { const candidates = await all( `SELECT r.*, a.name AS owner_name, a.disabled_at AS owner_disabled_at, a.disabled_reason AS owner_disabled_reason, EXISTS (SELECT 1 FROM repos f WHERE f.fork_of_id = r.id AND f.owner_id = ?2) AS has_fork FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.owner_id = ?1 AND r.visibility = 'private' AND NOT EXISTS (SELECT 1 FROM collaborators c WHERE c.repo_id = r.id AND c.user_id = ?2)`, orgId, userId, ); const repos: RepoRow[] = []; for (const { has_fork: hasFork, ...repo } of candidates) { if (repos.length >= CAPS.revocationSweepRows) break; if (hasFork === 1 || (await openCrossRepoPullsBy(repo.id, userId)).length > 0) repos.push(repo); } for (const repo of repos) await sweepRevokedRepoAccess(repo, userId); } /** Runs a revocation sweep after the response; failures are logged, never surfaced to the admin action. */ export function scheduleRevocationSweep(task: () => Promise): void { waitUntil(task().catch((error: unknown) => console.warn("access revocation sweep failed", error))); }