diff --git a/migrations/0074_orb_self_enrollment_disabled.sql b/migrations/0074_orb_self_enrollment_disabled.sql new file mode 100644 index 0000000000..176817df86 --- /dev/null +++ b/migrations/0074_orb_self_enrollment_disabled.sql @@ -0,0 +1 @@ +ALTER TABLE orb_github_installations ADD COLUMN self_enrollment_disabled INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/routes.ts b/src/api/routes.ts index ae2a8ff286..feb5c68610 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -3001,8 +3001,9 @@ export function createApp() { }); // Opt an installation into (or out of) the registry. Body: { installationId, registered? } (registered defaults - // true). 404 when the installation isn't recorded yet — an install MUST arrive via the webhook first (unlike the - // fleet instances there's no account context to upsert a never-seen installation from). + // true). Opting out also blocks OAuth self-enrollment until an operator opts back in. 404 when the installation + // isn't recorded yet — an install MUST arrive via the webhook first (unlike the fleet instances there's no account + // context to upsert a never-seen installation from). app.post("/v1/internal/orb/installations/register", async (c) => { const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown; registered?: unknown } | null; const installationId = Number(payload?.installationId); @@ -3010,7 +3011,7 @@ export function createApp() { const existing = await c.env.DB.prepare("SELECT installation_id FROM orb_github_installations WHERE installation_id = ?").bind(installationId).first(); if (!existing) return c.json({ error: "installation_not_found" }, 404); const registered = payload?.registered === false ? 0 : 1; - await c.env.DB.prepare("UPDATE orb_github_installations SET registered = ? WHERE installation_id = ?").bind(registered, installationId).run(); + await c.env.DB.prepare("UPDATE orb_github_installations SET registered = ?, self_enrollment_disabled = ? WHERE installation_id = ?").bind(registered, registered === 1 ? 0 : 1, installationId).run(); return c.json({ installationId, registered: registered === 1 }); }); diff --git a/src/orb/oauth.ts b/src/orb/oauth.ts index cfe438bc7a..4e0becfb11 100644 --- a/src/orb/oauth.ts +++ b/src/orb/oauth.ts @@ -8,9 +8,10 @@ // attacker-controllable query param, so a stolen OAuth code paired with a VICTIM's installation_id must NEVER // enroll the victim's install. We require: a valid OAuth code (single-use, GitHub-issued) → the authenticated // user → that user is an admin of the install's account (org admin, or the user account owner) → the install is -// active (not suspended/removed). A verified admin AUTO-REGISTERS the install (registered=1) — zero-touch, no -// operator step — and installation_id is then bound server-side in the enrollment (read back at token-exchange, -// never from a request). No request input is echoed into the markup (no injection surface). +// active (not suspended/removed) and not operator-disabled. A verified admin AUTO-REGISTERS the install +// (registered=1) — zero-touch, no operator step — and installation_id is then bound server-side in the enrollment +// (read back at token-exchange, never from a request). No request input is echoed into the markup (no injection +// surface). import type { Context } from "hono"; import { isOrbBrokerEnabled, issueOrbEnrollment } from "./broker"; @@ -66,17 +67,18 @@ async function handleOrbEnrollment(c: Context<{ Bindings: Env }>, code: string, if (!token) return c.html(landingPage("Couldn't verify your GitHub identity", "The authorization didn't complete — re-run the install from GitHub and try again."), 400); const user = await fetchOrbOAuthUser(token); if (!user) return c.html(landingPage("Couldn't verify your GitHub identity", "We couldn't read your GitHub account — try the install again."), 400); - const install = await c.env.DB.prepare("SELECT account_login, account_type, registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") + const install = await c.env.DB.prepare("SELECT account_login, account_type, registered, self_enrollment_disabled, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") .bind(installationId) - .first<{ account_login: string | null; account_type: string | null; registered: number; suspended_at: string | null; removed_at: string | null }>(); + .first<{ account_login: string | null; account_type: string | null; registered: number; self_enrollment_disabled: number; suspended_at: string | null; removed_at: string | null }>(); if (!install) return c.html(landingPage("Installation not recognized", "We haven't recorded this installation yet — give it a moment after installing, then retry."), 404); // The admin-of-installation check is the authorization gate — it runs BEFORE we reveal or change any state, so a // non-admin learns nothing about the install and can never enroll someone else's. const isAdmin = await verifyInstallationAdmin(token, user.login, install.account_login, install.account_type); if (!isAdmin) return c.html(landingPage("Admin access required", "You must be an admin of this installation's account to enroll it for self-host."), 403); if (install.removed_at !== null || install.suspended_at !== null) return c.html(landingPage("Installation not active", "This installation is suspended or uninstalled — re-install the Orb App, then retry."), 403); - // Zero-touch self-service: a verified admin of an ACTIVE install self-registers it (registered=1) with no operator - // step. installation_id stays bound server-side in the enrollment, so brokered tokens remain scoped to this install. + if (install.self_enrollment_disabled === 1) return c.html(landingPage("Installation disabled", "This installation was disabled by the operator — contact the operator to re-enable self-host enrollment."), 403); + // Zero-touch self-service: a verified admin of an ACTIVE, non-disabled install self-registers it (registered=1). + // installation_id stays bound server-side in the enrollment, so brokered tokens remain scoped to this install. if (install.registered !== 1) { await c.env.DB.prepare("UPDATE orb_github_installations SET registered = 1, last_event_at = CURRENT_TIMESTAMP WHERE installation_id = ?").bind(installationId).run(); } diff --git a/test/integration/orb-broker.test.ts b/test/integration/orb-broker.test.ts index 871addc79e..33128be800 100644 --- a/test/integration/orb-broker.test.ts +++ b/test/integration/orb-broker.test.ts @@ -105,6 +105,12 @@ describe("broker endpoints", () => { it("/v1/internal/orb/enrollments: 400 missing id, 409 unregistered, 404 unknown", async () => { const e = await brokerEnv(); await seedInstall(e, 402, { registered: 0 }); + expect((await db(e).prepare("SELECT self_enrollment_disabled FROM orb_github_installations WHERE installation_id=402").first<{ self_enrollment_disabled: number }>())?.self_enrollment_disabled).toBe(0); + expect((await app.request("/v1/internal/orb/installations/register", { method: "POST", headers: auth, body: JSON.stringify({ installationId: 402, registered: false }) }, e)).status).toBe(200); + expect((await db(e).prepare("SELECT registered, self_enrollment_disabled FROM orb_github_installations WHERE installation_id=402").first<{ registered: number; self_enrollment_disabled: number }>())).toMatchObject({ registered: 0, self_enrollment_disabled: 1 }); + expect((await app.request("/v1/internal/orb/installations/register", { method: "POST", headers: auth, body: JSON.stringify({ installationId: 402 }) }, e)).status).toBe(200); + expect((await db(e).prepare("SELECT registered, self_enrollment_disabled FROM orb_github_installations WHERE installation_id=402").first<{ registered: number; self_enrollment_disabled: number }>())).toMatchObject({ registered: 1, self_enrollment_disabled: 0 }); + await db(e).prepare("UPDATE orb_github_installations SET registered=0 WHERE installation_id=402").run(); expect((await app.request("/v1/internal/orb/enrollments", { method: "POST", headers: auth, body: "{}" }, e)).status).toBe(400); expect((await app.request("/v1/internal/orb/enrollments", { method: "POST", headers: auth, body: "{bad" }, e)).status).toBe(400); // unparseable JSON → catch → null expect((await app.request("/v1/internal/orb/enrollments", { method: "POST", headers: auth, body: JSON.stringify({ installationId: 402 }) }, e)).status).toBe(409); diff --git a/test/integration/orb-oauth.test.ts b/test/integration/orb-oauth.test.ts index da9a52de48..c28ecb739b 100644 --- a/test/integration/orb-oauth.test.ts +++ b/test/integration/orb-oauth.test.ts @@ -121,6 +121,18 @@ describe("maintainer self-enrollment via the OAuth callback", () => { expect(row?.registered).toBe(1); // self-registered, no operator step }); + it("an operator-disabled install cannot be self-reenabled through OAuth", async () => { + const e = brokeredEnv(); + await seedInstall(e, { installation_id: 503, account_login: "acme", account_type: "Organization", registered: 0, self_enrollment_disabled: 1 }); + stubGitHub(); + const res = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=503", {}, e); + expect(res.status).toBe(403); + expect(await res.text()).toContain("Installation disabled"); + expect(await db(e).prepare("SELECT 1 AS x FROM orb_enrollments WHERE installation_id=503").first()).toBeUndefined(); + const row = await db(e).prepare("SELECT registered FROM orb_github_installations WHERE installation_id=503").first<{ registered: number }>(); + expect(row?.registered).toBe(0); + }); + it("a SUSPENDED or UNINSTALLED install is refused (403 not active), even for an admin", async () => { const e = brokeredEnv(); await seedInstall(e, { installation_id: 507, account_login: "acme", account_type: "Organization", registered: 1, suspended_at: "2026-01-01T00:00:00Z" });