diff --git a/backend/apps/api/src/connection-do.ts b/backend/apps/api/src/connection-do.ts index 48a26e2003ea..415b46ef5e06 100644 --- a/backend/apps/api/src/connection-do.ts +++ b/backend/apps/api/src/connection-do.ts @@ -215,7 +215,16 @@ export class ConnectionDO extends OwnerDO { return this.boundTo(entity) ? ghTokens.revokeTokens(this.ledger(), who, Date.now()).finally(() => this.scheduleAlarm()) : 0 } - async githubStatus(entity: string, principal: Principal) { return ghTokens.githubStatus(this.env, this.boundTo(entity) ? this.bind(entity).currentState : connectionsDomain.initial(), principal) } + async githubStatus(entity: string, principal: Principal) { + if (principal.team !== entity) return { configured: false, app_slug: null, connection: null } + return ghTokens.githubStatus(this.env, this.boundTo(entity) ? this.bind(entity).currentState : connectionsDomain.initial(), principal) + } + + async githubRepos(entity: string, principal: Principal, query: { readonly query?: string; readonly cursor?: string }) { + if (principal.team !== entity) return { ok: false as const, status: 403, code: "auth.forbidden", message: "not this team's connections" } + if (!this.boundTo(entity)) return { ok: false as const, status: 404, code: "integration.not_found", message: "this team has no GitHub connection" } + return ghTokens.githubRepos({ env: this.env, http: this.http, state: () => this.bind(entity).currentState }, principal, query) + } /** UserDO revoked an install (install.revoke, a machine delete's VM install): its sockets close and its GitHub tokens are revoked. */ override async closeInstall(entity: string, install: string, agent?: string): Promise { diff --git a/backend/apps/api/src/integrations/github-token-route.ts b/backend/apps/api/src/integrations/github-token-route.ts index 63c3565b83a5..3d4d659d1cde 100644 --- a/backend/apps/api/src/integrations/github-token-route.ts +++ b/backend/apps/api/src/integrations/github-token-route.ts @@ -1,5 +1,5 @@ import type { Principal } from "@cmux/ownership" -import { GitHubTokenRequest, GitHubTokenRevokeRequest, TeamId } from "@cmux/protocol" +import { GitHubMachineTokenRequest, GitHubTokenRequest, GitHubTokenRevokeRequest, TeamId } from "@cmux/protocol" import { Exit, Schema } from "effect" import { authenticate } from "../auth.ts" import { roleHas } from "../domains/team-roles.ts" @@ -7,7 +7,7 @@ import { personalTeamIdFor } from "../domains/user.ts" import type { Env } from "../env.ts" import { ssoGate } from "../policy-gate.ts" import { principalForOwner, selectTeam } from "../team-select.ts" -import type { TokenAsk, TokenReply } from "./github-tokens.ts" +import type { RepoListReply, TokenAsk, TokenReply } from "./github-tokens.ts" /** * The GitHub token routes (gh-app-product contract; the work is in github-tokens.ts): POST /token @@ -16,7 +16,7 @@ import type { TokenAsk, TokenReply } from "./github-tokens.ts" * `daemon`, `cli` or `mac` install, or a member's session; chief tokens (`agent`), team VMs, phone * and web installs are refused. Errors are `{error: {code, message}}` and never carry a token. */ -export const GITHUB_TOKEN_PATH = /^\/v1\/integrations\/github\/(token|token\/revoke|status)$/ +export const GITHUB_TOKEN_PATH = /^\/v1\/integrations\/github\/(token|machine-token|token\/revoke|status|repos)$/ const TOKEN_INSTALL_KINDS: ReadonlySet = new Set(["vm", "daemon", "cli", "mac"]) const NO_STORE = { "cache-control": "no-store" } @@ -30,6 +30,7 @@ interface ConnectionStub { githubToken(entity: string, p: Principal, ask: TokenAsk): Promise githubRevoke(entity: string, who: { principal: string } | { install: string }): Promise githubStatus(entity: string, p: Principal): Promise + githubRepos(entity: string, p: Principal, query: { readonly query?: string; readonly cursor?: string }): Promise } const connections = (env: Env, team: string) => env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(team)) as unknown as ConnectionStub @@ -75,6 +76,21 @@ const teamAdmin = async (env: Env, p: Principal, team: string): Promise => { + // Repository enumeration and the machine-only seam are development surfaces until the hosted + // connector rollout. Existing token/status/revoke routes retain their normal environments. + if ((sub === "repos" || sub === "machine-token") && env.ENVIRONMENT !== "development") return new Response("not found", { status: 404 }) + if (sub === "repos") { + if (request.method !== "GET") return failed(no(405, "request.invalid", "GET")) + const url = new URL(request.url) + const raw = url.searchParams.get("team") ?? undefined + const team = raw === undefined ? undefined : decode(TeamId as unknown as Schema.Codec, raw) + if (isFail(team)) return failed(team) + const who = await caller(request, env, team) + if (isFail(who)) return failed(who) + const query = { ...(url.searchParams.get("query") === null ? {} : { query: url.searchParams.get("query")! }), ...(url.searchParams.get("cursor") === null ? {} : { cursor: url.searchParams.get("cursor")! }) } + const listed = await connections(env, who.team).githubRepos(who.team, who.p, query).catch(() => null) + return listed === null ? failed(unreachable("the team's GitHub repositories")) : listed.ok ? Response.json(listed.value, { headers: NO_STORE }) : failed(listed) + } if (sub === "status") { if (request.method !== "GET") return failed(no(405, "request.invalid", "GET")) const raw = new URL(request.url).searchParams.get("team") ?? undefined @@ -85,15 +101,24 @@ export const handleGithubToken = async (request: Request, env: Env, sub: string) } if (request.method !== "POST") return failed(no(405, "request.invalid", "POST")) const json = (await request.json().catch(() => undefined)) as unknown - if (sub === "token") { - const body = decode(GitHubTokenRequest as unknown as Schema.Codec, json) + if (sub === "token" || sub === "machine-token") { + const body = sub === "machine-token" + ? decode(GitHubMachineTokenRequest as unknown as Schema.Codec, json) + : decode(GitHubTokenRequest as unknown as Schema.Codec, json) if (isFail(body)) return failed(body) if (Object.keys(body.permissions).length === 0) return failed(no(400, "request.invalid", "permissions must name at least one permission")) const who = await caller(request, env, body.team) if (isFail(who)) return failed(who) - // A person's install needs at least the read class (paired daemons hold read and mutate-own); the VM's vm-self grant is its machine's. - if (who.p.kind === "install" && who.p.install_kind !== "vm" && !who.p.grant_classes?.includes("read")) return failed(no(403, "auth.forbidden", "this install's grant does not cover read")) - const ask: TokenAsk = { repo: body.repo, permissions: body.permissions as Record, ...(body.label ? { label: body.label } : {}), ...(who.machine ? { machine: who.machine } : {}) } + if (sub === "machine-token" && (who.p.kind !== "install" || who.p.install_kind !== "vm" || !who.machine)) return failed(no(403, "auth.forbidden", "a machine install is required")) + if (who.p.kind === "install" && who.p.install_kind !== "vm" && !who.p.grant_classes?.includes("read")) { + return failed(no(403, "auth.forbidden", "this install's grant does not cover read")) + } + const writes = Object.values(body.permissions).some((access) => access === "write") + if (writes && who.p.kind === "install" && !who.p.grant_classes?.includes("mutate-own")) { + return failed(no(403, "auth.forbidden", "this install's grant does not cover write access")) + } + const ttlSec = sub === "machine-token" ? (body as typeof GitHubMachineTokenRequest.Type).ttlSec : undefined + const ask: TokenAsk = { repo: body.repo, permissions: body.permissions as Record, ...(body.label ? { label: body.label } : {}), ...(ttlSec === undefined ? {} : { ttlSec }), ...(who.machine ? { machine: who.machine } : {}) } const r = await connections(env, who.team).githubToken(who.team, who.p, ask).catch(() => null) return r === null ? failed(unreachable("the team's GitHub connection")) : r.ok ? Response.json(r.value, { headers: NO_STORE }) : failed(r) } diff --git a/backend/apps/api/src/integrations/github-tokens.ts b/backend/apps/api/src/integrations/github-tokens.ts index 88c84356dd0f..3f4a02e3da53 100644 --- a/backend/apps/api/src/integrations/github-tokens.ts +++ b/backend/apps/api/src/integrations/github-tokens.ts @@ -4,7 +4,7 @@ import type { Connection } from "@cmux/protocol" import { githubRepoAllowed, mayUse, policyOf, providerAllowed, type ConnectionsState } from "../domains/connections.ts" import type { Env } from "../env.ts" import { open, seal, type SealedSecret } from "./crypto.ts" -import { github, mintedToken, requestInstallationToken, revokeInstallationToken, type Http } from "./providers.ts" +import { github, installationToken, mintedToken, requestInstallationToken, revokeInstallationToken, type Http } from "./providers.ts" /** * GitHub installation tokens handed to callers (POST /v1/integrations/github/token, gh-app-product @@ -22,10 +22,14 @@ import { github, mintedToken, requestInstallationToken, revokeInstallationToken, const [REUSE_MARGIN_MS, MINT_RATE, REVOKE_RETRY_MAX_MS] = [15 * 60_000, { burst: 60, per_second: 1 }, 10 * 60_000] /** `machine`: a vm install's own machine (the route checked it through vmGate). */ -export type TokenAsk = { readonly repo: string; readonly permissions: Readonly>; readonly label?: string; readonly machine?: string } +export type TokenAsk = { readonly repo: string; readonly permissions: Readonly>; readonly label?: string; readonly machine?: string; readonly ttlSec?: number } export type TokenFail = { readonly ok: false; readonly status: number; readonly code: string; readonly message: string; readonly retry_after?: number } export type TokenReply = { readonly ok: true; readonly value: { token: string; expires_at: string; repo: string; permissions: Record; token_id: string } } | TokenFail +export type RepoListReply = + | { readonly ok: true; readonly value: { readonly repos: ReadonlyArray<{ readonly full_name: string; readonly private: boolean }>; readonly cursor: string | null } } + | TokenFail + /** What the ledger needs from its ConnectionDO. */ export interface LedgerHost { readonly sql: SqlStorage; readonly env: Env; readonly http: Http; readonly team: string @@ -51,12 +55,19 @@ export const principalKey = (p: Principal) => (p.kind === "install" && p.install export const createGithubTokenTable = (sql: SqlStorage) => { sql.exec(`CREATE TABLE IF NOT EXISTS github_tokens ( token_id TEXT PRIMARY KEY, cache_key TEXT NOT NULL, principal TEXT NOT NULL, install TEXT, team TEXT NOT NULL, repo TEXT NOT NULL, - permissions TEXT NOT NULL, expires_at INTEGER NOT NULL, sealed TEXT NOT NULL, revoking INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0)`) + permissions TEXT NOT NULL, expires_at INTEGER NOT NULL, provider_expires_at INTEGER NOT NULL DEFAULT 0, sealed TEXT NOT NULL, + revoking INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0)`) + try { + sql.exec(`ALTER TABLE github_tokens ADD COLUMN provider_expires_at INTEGER NOT NULL DEFAULT 0`) + } catch { + // The column already exists on current deployments. + } + sql.exec(`UPDATE github_tokens SET provider_expires_at = expires_at WHERE provider_expires_at = 0`) sql.exec(`CREATE INDEX IF NOT EXISTS github_tokens_cache ON github_tokens (cache_key)`) sql.exec(`CREATE INDEX IF NOT EXISTS github_tokens_principal ON github_tokens (principal)`) } -type Row = { token_id: string; principal: string; install: string | null; team: string; repo: string; permissions: string; expires_at: number; sealed: string } +type Row = { token_id: string; principal: string; install: string | null; team: string; repo: string; permissions: string; expires_at: number; provider_expires_at: number; sealed: string } const aad = (r: Omit) => enc.encode(`cmux-ghtok-v1|${canonicalJson([r.team, r.principal, r.install, r.repo, r.permissions, r.token_id, Number(r.expires_at)])}`) const openRow = (env: Env, r: Row) => open(env.INTEGRATIONS_KEK!, JSON.parse(r.sealed) as SealedSecret, aad(r)) @@ -103,7 +114,8 @@ export const githubToken = (h: LedgerHost, p: Principal, ask: TokenAsk, now: num if ("ok" in picked) return Promise.resolve(picked) const pk = principalKey(p) const permissions = Object.fromEntries(Object.entries(ask.permissions).sort(([a], [b]) => a.localeCompare(b))) - const cacheKey = sha256hex(canonicalJson([pk, picked.installation, ask.repo.toLowerCase(), permissions])) + // TTL is part of the cache identity: a short machine request must never reuse a longer-lived token. + const cacheKey = sha256hex(canonicalJson([pk, picked.installation, ask.repo.toLowerCase(), permissions, ask.ttlSec ?? null])) // One mint at a time per cache key: concurrent asks share it (and the token). const running = h.inflight.get(cacheKey) if (running) return running @@ -112,12 +124,148 @@ export const githubToken = (h: LedgerHost, p: Principal, ask: TokenAsk, now: num return work } +/** Cursor includes its original connection and a fence for the selected caller/query/policy/list. */ +export type RepoPageSelection = { + readonly ok: true + readonly index: number + readonly connectionId: string + readonly page: number + readonly fence: string +} +type RepoCursor = { readonly version: 2; readonly connectionId: string; readonly page: number; readonly fence: string } +type RepoConnection = { readonly id: string } +const CONNECTION_ID = /^conn_[a-z0-9]{20}$/ +const FENCE = /^[0-9a-f]{64}$/ +const MAX_REPO_CURSOR_BYTES = 4096 +const normalizedRepoQuery = (query?: string) => query?.trim().toLowerCase() ?? "" +export const repoPageFence = ( + connections: ReadonlyArray, principal: string, query: string | undefined, policy: string, +): string => sha256hex(canonicalJson([ + "cmux.github.repos.cursor/v2", principal, normalizedRepoQuery(query), policy, connections.map((c) => c.id), +])) +const encodeRepoCursor = (cursor: RepoCursor): string => + btoa(JSON.stringify(cursor)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "") +const decodeRepoCursor = (value: string): RepoCursor | TokenFail => { + if (!value || value.length > MAX_REPO_CURSOR_BYTES || !/^[A-Za-z0-9_-]+$/.test(value)) + return fail(400, "request.invalid", "cursor is invalid") + try { + const body = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=") + const parsed = JSON.parse(atob(body)) as Record + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || + parsed.version !== 2 || typeof parsed.connectionId !== "string" || !CONNECTION_ID.test(parsed.connectionId) || + !Number.isSafeInteger(parsed.page) || (parsed.page as number) < 1 || (parsed.page as number) > 100_000 || + typeof parsed.fence !== "string" || !FENCE.test(parsed.fence) || + Object.keys(parsed).sort().join(",") !== "connectionId,fence,page,version") + return fail(400, "request.invalid", "cursor is invalid") + return { version: 2, connectionId: parsed.connectionId, page: parsed.page as number, fence: parsed.fence } + } catch { + return fail(400, "request.invalid", "cursor is invalid") + } +} + +/** A changed account list, principal, query or policy always refuses rather than switching accounts. */ +export const selectRepoPage = ( + connections: ReadonlyArray, + principal: string, + query: string | undefined, + policy: string, + cursor?: string, +): RepoPageSelection | TokenFail => { + const decoded = cursor === undefined ? null : decodeRepoCursor(cursor) + if (decoded && "ok" in decoded) return decoded + if (!connections.length) return decoded ? fail(409, "cursor_stale", "repository selection changed; list again") : + fail(404, "integration.not_found", "this team has no GitHub connection") + const fence = repoPageFence(connections, principal, query, policy) + if (decoded && decoded.fence !== fence) return fail(409, "cursor_stale", "repository selection changed; list again") + const connectionId = decoded?.connectionId ?? connections[0]!.id + const index = connections.findIndex((c) => c.id === connectionId) + if (index < 0) return fail(409, "cursor_stale", "repository selection changed; list again") + return { ok: true, index, connectionId, page: decoded?.page ?? 1, fence } +} + +/** Advance within the same installation, then move to the next exact selected connection. */ +export const followingRepoPage = ( + selection: RepoPageSelection, + connections: ReadonlyArray, + hasNext: boolean, +): string | null => { + if (connections[selection.index]?.id !== selection.connectionId) return null + if (hasNext) return encodeRepoCursor({ + version: 2, connectionId: selection.connectionId, page: selection.page + 1, fence: selection.fence, + }) + const connectionId = connections[selection.index + 1]?.id + return connectionId ? encodeRepoCursor({ version: 2, connectionId, page: 1, fence: selection.fence }) : null +} + +/** Lists one bounded upstream page at a time, fencing the exact selected connection. */ +export const githubRepos = async ( + h: { readonly env: Env; readonly http: Http; readonly state: () => ConnectionsState }, + p: Principal, + query: { readonly query?: string; readonly cursor?: string }, +): Promise => { + if (p.kind === "install" && p.install_kind !== "vm" && !p.grant_classes?.includes("read")) return fail(403, "auth.forbidden", "this install's grant does not cover read") + if (!github.configured(h.env) || !h.env.INTEGRATIONS_KEK) return fail(503, "integration.unavailable", "the cmux GitHub App is not configured on this deployment") + const state = h.state() + if (!providerAllowed(policyOf(state), "github")) return fail(403, "policy.denied", "the team policy does not allow GitHub") + // Sort by immutable connection id: incidental object insertion order never changes pagination. + const active = (state: ConnectionsState) => + Object.values(state.connections) + .filter((c) => c.provider === "github" && c.status === "active" && mayUse(c, p)) + .sort((a, b) => a.id.localeCompare(b.id)) + const connections = active(state) + const snapshot = (state: ConnectionsState) => sha256hex(canonicalJson([ + policyOf(state), + active(state).map((c) => [c.id, c.status, c.updated_at, c.sharing, c.resources ?? null, c.account?.key ?? null]), + ])) + const selected = selectRepoPage(connections, principalKey(p), query.query, snapshot(state), query.cursor) + if (!selected.ok) return selected + const connection = connections[selected.index] + if (!connection) return fail(409, "cursor_stale", "repository selection changed; list again") + { + const installation = Number(/^github:installation:(\d+)$/.exec(connection.account?.key ?? "")?.[1]) + if (!Number.isSafeInteger(installation)) return fail(503, "integration.unavailable", "the GitHub connection has no installation") + let token: string + try { + token = await installationToken(h.env, h.http, installation) + } catch { + return fail(503, "integration.unavailable", "GitHub did not answer; retry") + } + const response = await h.http(new Request(`https://api.github.com/installation/repositories?per_page=100&page=${selected.page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": "cmux-cloud" } })).catch(() => null) + if (!response) return fail(503, "integration.unavailable", "GitHub did not answer; retry") + if (!response.ok) return response.status === 401 ? fail(409, "needs_reauth", "the GitHub App installation needs to be linked again") : fail(503, "integration.unavailable", "GitHub repository listing failed") + const body = (await response.json().catch(() => undefined)) as { repositories?: unknown; total_count?: unknown } | undefined + if (!body || !Array.isArray(body.repositories)) return fail(503, "integration.unavailable", "GitHub returned an invalid repository list") + const repos = body.repositories.flatMap((entry) => { + if (!entry || typeof entry !== "object") return [] + const repo = entry as { full_name?: unknown; private?: unknown } + const fullName = typeof repo.full_name === "string" ? repo.full_name : "" + return fullName && githubRepoAllowed(connection, policyOf(state), fullName) && (!query.query || fullName.toLowerCase().includes(query.query.trim().toLowerCase())) + ? [{ full_name: fullName, private: repo.private === true }] + : [] + }) + const hasNext = body.repositories.length >= 100 && (typeof body.total_count !== "number" || selected.page * 100 < body.total_count) + const current = h.state() + if (!providerAllowed(policyOf(current), "github")) return fail(403, "policy.denied", "the team policy does not allow GitHub") + // Revalidate even when the selected page has zero matching repos. A policy change during + // an awaited GitHub call must never produce a cursor leading to another installation. + if (repoPageFence(active(current), principalKey(p), query.query, snapshot(current)) !== selected.fence) + return fail(409, "cursor_stale", "repository selection changed; list again") + const stillAllowed = active(current).some( + (candidate) => + candidate.id === connection.id && + repos.every((repo) => githubRepoAllowed(candidate, policyOf(current), repo.full_name)), + ) + if (!stillAllowed) return fail(403, "policy.denied", "the team's GitHub connection changed; retry") + return { ok: true, value: { repos, cursor: followingRepoPage(selected, connections, hasNext) } } + } +} + /** A row is served only while it is live and nobody revoked it (checked after each await, with no await before the answer). */ const stillLive = (sql: SqlStorage, tokenId: string) => sql.exec(`SELECT 1 FROM github_tokens WHERE token_id = ? AND revoking = 0`, tokenId).toArray().length > 0 const mintOrReuse = async (h: LedgerHost, picked: { c: Connection; installation: number }, p: Principal, pk: string, cacheKey: string, ask: TokenAsk, now: number): Promise => { const generation = h.revokes.get(pk) ?? 0 - pruneExpired(h.sql, now) + expireRows(h.sql, now) const live = h.sql.exec(`SELECT * FROM github_tokens WHERE cache_key = ? AND principal = ? AND revoking = 0 AND expires_at > ? ORDER BY expires_at DESC LIMIT 1`, cacheKey, pk, now + REUSE_MARGIN_MS).toArray()[0] if (live) { const token = await openRow(h.env, live).catch(() => null) @@ -147,8 +295,19 @@ const mintOrReuse = async (h: LedgerHost, picked: { c: Connection; installation: } const minted = await mintedToken(res) if (!minted.token) return fail(503, "integration.unavailable", "GitHub returned no token") + if (!Number.isFinite(minted.expires_at) || minted.expires_at <= now) return fail(503, "integration.unavailable", "GitHub returned an expired token") const tokenId = sha256hex(minted.token).slice(0, 16) - const row = { token_id: tokenId, principal: pk, install: p.kind === "install" ? (p.install ?? null) : null, team: h.team, repo: ask.repo, permissions: JSON.stringify(minted.permissions), expires_at: minted.expires_at } + const requestedExpiry = ask.ttlSec === undefined ? minted.expires_at : Math.min(minted.expires_at, now + ask.ttlSec * 1000) + const row = { + token_id: tokenId, + principal: pk, + install: p.kind === "install" ? (p.install ?? null) : null, + team: h.team, + repo: ask.repo, + permissions: JSON.stringify(minted.permissions), + expires_at: requestedExpiry, + provider_expires_at: minted.expires_at, + } const sealed = JSON.stringify(await seal(h.env.INTEGRATIONS_KEK!, minted.token, aad(row))) // From here every minted token has a durable row: one we refuse is revoked at GitHub (retried from the alarm until it expires). const refuse = async (f: TokenFail) => { @@ -157,8 +316,8 @@ const mintOrReuse = async (h: LedgerHost, picked: { c: Connection; installation: return f } h.sql.exec( - `INSERT OR REPLACE INTO github_tokens (token_id, cache_key, principal, install, team, repo, permissions, expires_at, sealed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - row.token_id, cacheKey, row.principal, row.install, row.team, row.repo, row.permissions, row.expires_at, sealed + `INSERT OR REPLACE INTO github_tokens (token_id, cache_key, principal, install, team, repo, permissions, expires_at, provider_expires_at, sealed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.token_id, cacheKey, row.principal, row.install, row.team, row.repo, row.permissions, row.expires_at, row.provider_expires_at, sealed ) // Refuse rather than hand out less than was asked (GitHub never widens; it must not narrow either), or what the state no longer allows. const short = Object.entries(ask.permissions).find(([k, v]) => !(minted.permissions[k] === "write" || minted.permissions[k] === v)) @@ -179,13 +338,14 @@ const mintOrReuse = async (h: LedgerHost, picked: { c: Connection; installation: h.audit({ at: now, team: h.team, principal_kind: kind, user: p.user!, ...(row.install ? { install: row.install } : {}), ...(ask.machine ? { machine: ask.machine } : {}), ...(ask.label ? { label: ask.label } : {}), - repo: ask.repo, permissions: minted.permissions, token_id: tokenId, expires_at: minted.expires_at + repo: ask.repo, permissions: minted.permissions, token_id: tokenId, expires_at: row.expires_at }) console.log(JSON.stringify({ msg: "github.token.minted", team: h.team, principal_kind: kind, install: row.install, repo: ask.repo, token_id: tokenId, label: ask.label ?? null })) - return { ok: true, value: { token: minted.token, expires_at: new Date(minted.expires_at).toISOString(), repo: ask.repo, permissions: { ...minted.permissions }, token_id: tokenId } } + return { ok: true, value: { token: minted.token, expires_at: new Date(row.expires_at).toISOString(), repo: ask.repo, permissions: { ...minted.permissions }, token_id: tokenId } } } -const pruneExpired = (sql: SqlStorage, now: number) => sql.exec(`DELETE FROM github_tokens WHERE expires_at <= ?`, now) +const expireRows = (sql: SqlStorage, now: number) => + sql.exec(`UPDATE github_tokens SET revoking = 1, retry_at = ? WHERE expires_at <= ? AND revoking = 0`, now, now) /** * Revokes every live token of one principal (`install:` or `session:`), or of one install @@ -194,19 +354,25 @@ const pruneExpired = (sql: SqlStorage, now: number) => sql.exec(`DELETE FROM git export const revokeTokens = async (h: Pick, who: { principal: string } | { install: string }, now: number): Promise => { const key = "install" in who ? `install:${who.install}` : who.principal h.revokes.set(key, (h.revokes.get(key) ?? 0) + 1) - pruneExpired(h.sql, now) + expireRows(h.sql, now) const [col, val] = "install" in who ? ["install", who.install] : ["principal", who.principal] const n = h.sql.exec(`UPDATE github_tokens SET revoking = 1, retry_at = ? WHERE ${col} = ? AND revoking = 0`, now, val).rowsWritten await drainTokenRevocations(h, now) return n } -/** The alarm's pass: drops expired rows and retries the DELETEs that are due (backoff up to 10 minutes). */ +/** The alarm's pass: revokes expired rows and retries provider DELETEs (backoff up to 10 minutes). */ export const drainTokenRevocations = async (h: Pick, now: number) => { - pruneExpired(h.sql, now) + expireRows(h.sql, now) const due = h.sql.exec(`SELECT * FROM github_tokens WHERE revoking = 1 AND retry_at <= ? LIMIT 100`, now).toArray() await Promise.all( due.map(async (r) => { + // Once GitHub's native expiry has passed, the provider token is unusable even if DELETE + // remains unavailable. Drop the sealed row so a KEK or provider outage cannot retain it forever. + if (r.provider_expires_at > 0 && r.provider_expires_at <= now) { + h.sql.exec(`DELETE FROM github_tokens WHERE token_id = ?`, r.token_id) + return + } const token = await openRow(h.env, r).catch(() => null) // A row that does not open (a KEK misconfigured or rotated) stays for the next try and goes only at its expiry. if (token === null) console.error(JSON.stringify({ msg: "github token revoke: the ledger row does not open", token_id: r.token_id })) @@ -218,13 +384,13 @@ export const drainTokenRevocations = async (h: Pick { - const r = sql.exec<{ at: number | null }>(`SELECT MIN(CASE WHEN revoking = 1 AND retry_at < expires_at THEN retry_at ELSE expires_at END) AS at FROM github_tokens`).toArray()[0] + const r = sql.exec<{ at: number | null }>(`SELECT MIN(CASE WHEN revoking = 1 THEN retry_at ELSE expires_at END) AS at FROM github_tokens`).toArray()[0] return r?.at === null || r?.at === undefined ? null : Number(r.at) } /** GET /v1/integrations/github/status: whether the App is configured, and the connection this principal would use. */ export const githubStatus = (env: Env, state: ConnectionsState, p: Principal) => { - const usable = Object.values(state.connections).filter((c) => c.provider === "github" && mayUse(c, p) && c.status !== "revoked" && c.status !== "expired" && c.status !== "pending") + const usable = Object.values(state.connections).filter((c) => c.provider === "github" && mayUse(c, p) && c.status !== "revoked" && c.status !== "pending") const c = usable.find((x) => x.status === "active") ?? usable.sort((a, b) => b.updated_at - a.updated_at)[0] return { configured: Boolean(env.INTEGRATIONS_KEK) && github.configured(env), diff --git a/backend/apps/api/src/integrations/providers.ts b/backend/apps/api/src/integrations/providers.ts index 3c182dd640a4..75b89e7858d0 100644 --- a/backend/apps/api/src/integrations/providers.ts +++ b/backend/apps/api/src/integrations/providers.ts @@ -57,7 +57,7 @@ export const revokeInstallationToken = async (http: Http, token: string): Promis * Per isolate, never stored. Tokens handed to callers are narrowed and kept per principal in the ledger (github-tokens.ts). */ const installationTokens = new Map() -const installationToken = async (env: Env, http: Http, installation: number) => { +export const installationToken = async (env: Env, http: Http, installation: number) => { const cached = installationTokens.get(installation) if (cached && cached.expires_at - 5 * 60_000 > Date.now()) return cached.token const res = await http(new Request(`${GH_API}/app/installations/${installation}/access_tokens`, { method: "POST", headers: ghHeaders(await appJwt(env)) })) diff --git a/backend/apps/api/test/github-repos-cursor.test.ts b/backend/apps/api/test/github-repos-cursor.test.ts new file mode 100644 index 000000000000..d4f21a4b6614 --- /dev/null +++ b/backend/apps/api/test/github-repos-cursor.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import { followingRepoPage, selectRepoPage } from "../src/integrations/github-tokens.ts" + +const a = { id: "conn_" + "a".repeat(20) } +const b = { id: "conn_" + "b".repeat(20) } +const c = { id: "conn_" + "c".repeat(20) } +const principal = "session:user-a" +const policy = "policy-version-a" + +function first(ids = [a, b]) { + const picked = selectRepoPage(ids, principal, "reports", policy) + if (!picked.ok) throw new Error("initial page denied") + return picked +} +function next(ids = [a, b], hasNext = false) { + return followingRepoPage(first(ids), ids, hasNext) +} +function expectStale(ids: Array<{ id: string }>, cursor: string, actor = principal, filter = "reports", policyHash = policy) { + expect(selectRepoPage(ids, actor, filter, policyHash, cursor)).toMatchObject({ + ok: false, + status: 409, + code: "cursor_stale", + }) +} + +describe("GitHub repository cursors preserve selected installation identity", () => { + it("tracks the same selected connection through subsequent pages", () => { + const page2 = next([a, b], true) + expect(page2).toBeTruthy() + expect(selectRepoPage([a, b], principal, "reports", policy, page2!)).toMatchObject({ + ok: true, index: 0, connectionId: a.id, page: 2, + }) + const secondConnection = next([a, b], false) + expect(secondConnection).toBeTruthy() + expect(selectRepoPage([a, b], principal, "reports", policy, secondConnection!)).toMatchObject({ + ok: true, index: 1, connectionId: b.id, page: 1, + }) + const last = selectRepoPage([a, b], principal, "reports", policy, secondConnection!) + if (!last.ok) throw new Error("second connection missing") + expect(followingRepoPage(last, [a, b], false)).toBeNull() + }) + + it("refuses reorder, revoke or insertion instead of changing installations", () => { + const cursor = next()! + expectStale([b, a], cursor) + expectStale([a], cursor) + expectStale([a, c, b], cursor) + expectStale([c, a, b], cursor) + }) + + it("binds the cursor to exact caller, query and policy snapshot", () => { + const cursor = next()! + expectStale([a, b], cursor, "session:user-b") + expectStale([a, b], cursor, principal, "other-query") + expectStale([a, b], cursor, principal, "reports", "policy-version-b") + // Query normalization is stable across case and surrounding whitespace. + expect(selectRepoPage([a, b], principal, " REPORTS ", policy, cursor)).toMatchObject({ + ok: true, connectionId: b.id, page: 1, + }) + }) + + it("rejects index-based legacy cursors, malformed data and oversized input", () => { + const legacy = btoa(JSON.stringify({ installation: 1, page: 1 })) + for (const value of [legacy, "?oops", "a".repeat(4_097)]) { + expect(selectRepoPage([a, b], principal, "reports", policy, value)).toMatchObject({ + ok: false, status: 400, code: "request.invalid", + }) + } + }) + + it("returns an explicit refusal for an empty connection list", () => { + expect(selectRepoPage([], principal, "reports", policy)).toMatchObject({ + ok: false, code: "integration.not_found", + }) + }) +}) diff --git a/backend/apps/api/test/github-token.test.ts b/backend/apps/api/test/github-token.test.ts index a196083a140a..03b8f43e1bdf 100644 --- a/backend/apps/api/test/github-token.test.ts +++ b/backend/apps/api/test/github-token.test.ts @@ -52,6 +52,49 @@ const linkGitHub = async (session: string, team: string) => { return { gh } } +describe("GitHub repository paging in ConnectionDO (development-only route)", { timeout: 60_000 }, () => { + it("fences a selected connection against another principal and changed team policy", async () => { + const a = await signedInWithInstall("gh-repos-cursor", "cli") + const { gh } = await linkGitHub(a.session, a.team) + const upstream = "https://api.github.com/installation/repositories" + gh.routes[upstream] = (request) => { + const page = new URL(request.url).searchParams.get("page") + return page === "1" + ? ok({ repositories: [{ full_name: "acme/web", private: true }, ...Array.from({ length: 99 }, (_, i) => ({ full_name: `acme/hidden-${i}` }))], total_count: 101 }) + : ok({ repositories: [{ full_name: "acme/api", private: true }], total_count: 101 }) + } + // The public route is deliberately off in the test deployment. Exercise the exact authorized + // ConnectionDO method with principals matching the two verified tokens, without changing env. + const disabled = await worker.fetch(`https://api.test/v1/integrations/github/repos?team=${a.team}`, { + headers: { authorization: `Bearer ${a.installToken}` }, + }) + expect(disabled.status).toBe(404) + const stub = testEnv.CONNECTION_DO.get(testEnv.CONNECTION_DO.idFromName(a.team)) + const install = { + identity: `install:${a.install}`, kind: "install", install: a.install, + install_kind: "cli", user: a.user, team: a.team, grant_classes: ["read"], + } + const session = { identity: `session:${a.user}`, kind: "session", user: a.user, team: a.team } + const list = async (caller: object, query: { query?: string; cursor?: string }): Promise => { + let result: any + await inDO(stub, async (instance) => { result = await instance.githubRepos(a.team, caller, query) }) + return result + } + const first = await list(install, { query: "web" }) + expect(first).toMatchObject({ ok: true, value: { repos: [{ full_name: "acme/web" }] } }) + const cursor = first.value.cursor as string + expect(cursor).toMatch(/^[A-Za-z0-9_-]{20,}$/) + expect(gh.calls.filter((c) => c.url.startsWith(upstream))).toHaveLength(1) + + // A different principal cannot move the same cursor onto another installation. + expect(await list(session, { query: "web", cursor })).toMatchObject({ ok: false, status: 409, code: "cursor_stale" }) + expect(gh.calls.filter((c) => c.url.startsWith(upstream))).toHaveLength(1) + await policy(a.session, a.team, { github: { repo_allowlist: ["acme/api"] } }) + expect(await list(install, { query: "web", cursor })).toMatchObject({ ok: false, status: 409, code: "cursor_stale" }) + expect(gh.calls.filter((c) => c.url.startsWith(upstream))).toHaveLength(1) + }) +}) + describe("GitHub token endpoint", { timeout: 60_000 }, () => { it("mints a token narrowed to one repository and the asked permissions, reuses it per principal, never across principals", async () => { const a = await signedInWithInstall("gh-token-1", "cli") diff --git a/backend/packages/protocol/src/integrations.ts b/backend/packages/protocol/src/integrations.ts index c4dea88b6469..5da51eb0dc51 100644 --- a/backend/packages/protocol/src/integrations.ts +++ b/backend/packages/protocol/src/integrations.ts @@ -353,7 +353,20 @@ export const GitHubTokenPermissions = Schema.Struct(Object.fromEntries(GITHUB_TO export const GitHubRepoName = Schema.String.check(isPattern(/^(?!\.{1,2}\/)[A-Za-z0-9_.-]{1,100}\/(?!\.{1,2}$)[A-Za-z0-9_.-]{1,100}$/)) const Label = Schema.String.check(isPattern(/^[A-Za-z0-9._-]{1,64}$/)) /** `team` is required unless the caller is a vm install (then its machine's team); `label` is logged unverified. */ -export const GitHubTokenRequest = Schema.Struct({ team: Schema.optionalKey(TeamId), repo: GitHubRepoName, permissions: GitHubTokenPermissions, label: Schema.optionalKey(Label) }) +export const GitHubTokenRequest = Schema.Struct({ + team: Schema.optionalKey(TeamId), + repo: GitHubRepoName, + permissions: GitHubTokenPermissions, + label: Schema.optionalKey(Label), +}) +/** POST /v1/integrations/github/machine-token: the machine-only connector path may set a short TTL. */ +export const GitHubMachineTokenRequest = Schema.Struct({ + team: Schema.optionalKey(TeamId), + repo: GitHubRepoName, + permissions: GitHubTokenPermissions, + label: Schema.optionalKey(Label), + ttlSec: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(60), Schema.isLessThanOrEqualTo(3600))), +}) /** `install` defaults to the caller's own (a session: its session tokens); another install needs a team admin. */ export const GitHubTokenRevokeRequest = Schema.Struct({ team: Schema.optionalKey(TeamId), install: Schema.optionalKey(InstallId) })