import { COUNTED } from "./issue-counters"; import { waitUntil } from "cloudflare:workers"; import { all, batch, stmt } from "./db"; 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"; 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(); await batch([ stmt( "UPDATE issues SET state = 'closed', closed_at = ?, closed_by = NULL, updated_at = ? WHERE id = ? AND state = 'open'", now, now, pull.issue_id, ), stmt( `UPDATE repos SET open_pulls_count = MAX(open_pulls_count - 1, 0) WHERE id = ? AND ${COUNTED}`, pull.repo_id, pull.issue_id, ), ]); await addIssueEvent(pull.issue_id, null, "closed", { reason: "access_revoked" }); waitUntil(dropPullRefsForIssue(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 pulls = await 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.repo_id = ?1 AND p.head_repo_id = ?2) OR (p.repo_id = ?2 AND p.head_repo_id = ?1)) LIMIT ?3`, fork.id, parentId, 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); } 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 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, repo.id, 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 repos = 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.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) AND (EXISTS (SELECT 1 FROM repos f WHERE f.fork_of_id = r.id AND f.owner_id = ?2) OR EXISTS (SELECT 1 FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE i.author_id = ?2 AND i.state = 'open' AND (p.repo_id = r.id OR p.head_repo_id = r.id))) LIMIT ?3`, orgId, userId, CAPS.revocationSweepRows, ); 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))); }