Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions migrations/0074_orb_self_enrollment_disabled.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE orb_github_installations ADD COLUMN self_enrollment_disabled INTEGER NOT NULL DEFAULT 0;
7 changes: 4 additions & 3 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3001,16 +3001,17 @@ 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);
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "installationId required" }, 400);
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 });
});

Expand Down
16 changes: 9 additions & 7 deletions src/orb/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
}
Expand Down
6 changes: 6 additions & 0 deletions test/integration/orb-broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions test/integration/orb-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
Loading