Skip to content

Commit adebfff

Browse files
committed
fix(orb): revoke-on-rotate and a self-hoster-reachable revoke path for broker enrollments (#9149)
issueOrbEnrollment was a bare INSERT: it never revoked, updated, or even counted sibling rows for the same installation_id, so re-running the install flow after a secret leak minted a SECOND simultaneously-valid secret and left the leaked one working forever. The only revoke path sat behind INTERNAL_JOB_TOKEN, unreachable by a self-hosting maintainer. - issueOrbEnrollment takes an optional { rotate: true }: when set, every prior live enrollment for the installation is revoked before the new one is minted. Defaults to false (append) so a blue/green container swap can still rely on two live enrollments briefly overlapping (#9150's sibling fix is what makes that overlap safe, not this one). - The OAuth landing page's callback now recognizes state=<installationId>: rotate / :revoke (installation_id has no channel back through a bare GitHub login/oauth/authorize bounce other than state) -- re-proving admin-of-installation via the SAME verifyInstallationAdmin gate the enrollment flow already uses, so a maintainer can rotate or revoke without operator involvement. Revoke deliberately skips the suspended/self-enrollment-disabled checks: locking down a leaked secret should never be blocked by unrelated administrative state. - The secret page now surfaces how many enrollments are live for the installation and links to the rotate/revoke actions; its response sets Cache-Control: no-store (it was previously a cacheable GET response rendering a plaintext secret). - The internal installations list and enrollment-issue routes gained the same live-enrollment-count visibility and an optional rotate flag, for operator-issued parity with the OAuth self-service path.
1 parent 07f2c60 commit adebfff

6 files changed

Lines changed: 340 additions & 16 deletions

File tree

src/api/routes.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ import {
166166
isOrbBrokerEnabled,
167167
issueOrbEnrollment,
168168
issueOrbStoredSecret,
169+
ORB_SECRET_TYPE_GITHUB_TOKEN,
169170
ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL,
170171
revokeOrbEnrollment,
171172
} from "../orb/broker";
@@ -4579,15 +4580,22 @@ export function createApp() {
45794580
// records lands at registered=0; only REGISTERED ones count toward the global public counter (getOrbGlobalStats)
45804581
// and are eligible for token brokering. Bearer-gated by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN). The
45814582
// list shows pending + registered installs so an operator knows what they're opting in.
4583+
//
4584+
// liveEnrollmentCount (#9149): how many enrollment secrets are currently live ('enrolled', not revoked) for
4585+
// this install — an operator previously had no way to see that a re-enrollment (issueOrbEnrollment was a bare
4586+
// INSERT, never revoking) had accumulated a second standing credential. A correlated subquery rather than a
4587+
// JOIN: each installation has at most a handful of enrollment rows, so this stays cheap without reshaping the
4588+
// one-row-per-installation result set a GROUP BY would require.
45824589
app.get("/v1/internal/orb/installations", async (c) => {
45834590
const rows = await c.env.DB
45844591
.prepare(
45854592
`SELECT installation_id AS installationId, account_login AS accountLogin, account_type AS accountType,
45864593
repository_selection AS repositorySelection, registered, suspended_at AS suspendedAt,
4587-
removed_at AS removedAt, first_seen_at AS firstSeenAt, last_event_at AS lastEventAt
4594+
removed_at AS removedAt, first_seen_at AS firstSeenAt, last_event_at AS lastEventAt,
4595+
(SELECT COUNT(*) FROM orb_enrollments oe WHERE oe.installation_id = orb_github_installations.installation_id AND oe.state = 'enrolled' AND oe.revoked_at IS NULL) AS liveEnrollmentCount
45884596
FROM orb_github_installations ORDER BY last_event_at DESC`,
45894597
)
4590-
.all<{ installationId: number; accountLogin: string | null; accountType: string | null; repositorySelection: string | null; registered: number; suspendedAt: string | null; removedAt: string | null; firstSeenAt: string; lastEventAt: string }>();
4598+
.all<{ installationId: number; accountLogin: string | null; accountType: string | null; repositorySelection: string | null; registered: number; suspendedAt: string | null; removedAt: string | null; firstSeenAt: string; lastEventAt: string; liveEnrollmentCount: number }>();
45914599
return c.json({ installations: (rows.results ?? []).map((r) => ({ ...r, registered: r.registered === 1 })) });
45924600
});
45934601

@@ -4623,9 +4631,15 @@ export function createApp() {
46234631
// secret issuance path control-plane's hosted provisioning core (#7180/#8066) calls instead, for a credential
46244632
// that already exists (a tenant's Postgres connection string) rather than a GitHub installation to bind.
46254633
// `installationId` is irrelevant to that path (see issueOrbStoredSecret's own header comment for why).
4634+
//
4635+
// Also accepts an optional `{ rotate: true }` (#9149): when set, every PRIOR live enrollment for this
4636+
// installation is revoked before the new one is minted, mirroring the OAuth landing page's `state=<id>:rotate`
4637+
// flow (oauth.ts) for the operator-issued path. Defaults to false (append, not replace) -- unchanged behavior
4638+
// for every existing caller, since a blue/green container swap legitimately relies on two live enrollments
4639+
// briefly overlapping (see #9150's sibling fix, which makes that overlap safe rather than assuming away).
46264640
app.post("/v1/internal/orb/enrollments", async (c) => {
46274641
if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404);
4628-
const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown; secretType?: unknown; secretValue?: unknown } | null;
4642+
const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown; secretType?: unknown; secretValue?: unknown; rotate?: unknown } | null;
46294643
if (payload?.secretType === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) {
46304644
const secretValue = typeof payload.secretValue === "string" ? payload.secretValue : "";
46314645
const result = await issueOrbStoredSecret(c.env, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, secretValue);
@@ -4634,7 +4648,7 @@ export function createApp() {
46344648
}
46354649
const installationId = Number(payload?.installationId);
46364650
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "installationId required" }, 400);
4637-
const result = await issueOrbEnrollment(c.env, installationId);
4651+
const result = await issueOrbEnrollment(c.env, installationId, undefined, ORB_SECRET_TYPE_GITHUB_TOKEN, { rotate: payload?.rotate === true });
46384652
if ("error" in result) return c.json(result, result.error === "installation_not_found" ? 404 : 409);
46394653
return c.json(result); // { enrollId, secret } — secret shown exactly once
46404654
});

src/orb/broker.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,26 @@ export type IssueResult = { enrollId: string; secret: string } | { error: "insta
5353
* hashed). Issued by the operator (internal endpoint) OR by a maintainer who proved install-admin via OAuth —
5454
* in the latter case the maintainer's GitHub identity is recorded for audit. installation_id is bound here and
5555
* read back (never from the request) at token-exchange time, so a secret can never mint a token for another
56-
* install. `secretType` defaults to the only mintable type today; every existing caller is unaffected. */
56+
* install. `secretType` defaults to the only mintable type today; every existing caller is unaffected.
57+
*
58+
* `opts.rotate` (#9149): this was previously a bare INSERT, so re-running the enrollment flow (e.g. after a
59+
* secret leak) minted a SECOND simultaneously-valid secret and revoked nothing — the leaked one kept working
60+
* forever. Deliberately NOT the default: a blue/green container swap or a deliberate multi-container setup
61+
* legitimately relies on two live enrollments overlapping for a short window (see #9150's sibling fix, which
62+
* makes that overlap safe rather than assuming it can't happen) — auto-revoking on every issuance would break
63+
* that. `rotate: true` is the caller's explicit "I want this to be the only valid secret now" confirmation
64+
* (the OAuth landing page's `?rotate=1`/`state=<id>:rotate` flow, oauth.ts). */
5765
export async function issueOrbEnrollment(
5866
env: Env,
5967
installationId: number,
6068
maintainer?: { login: string; githubId?: number | null | undefined },
6169
secretType: string = ORB_SECRET_TYPE_GITHUB_TOKEN,
70+
opts: { rotate?: boolean } = {},
6271
): Promise<IssueResult> {
6372
const install = await env.DB.prepare("SELECT registered FROM orb_github_installations WHERE installation_id = ?").bind(installationId).first<{ registered: number }>();
6473
if (!install) return { error: "installation_not_found" };
6574
if (install.registered !== 1) return { error: "installation_not_registered" };
75+
if (opts.rotate === true) await revokeAllLiveEnrollmentsForInstallation(env, installationId);
6676
const enrollId = createOpaqueToken("orbenr");
6777
const secret = createOpaqueToken("orbsec");
6878
await env.DB.prepare(
@@ -74,6 +84,28 @@ export async function issueOrbEnrollment(
7484
return { enrollId, secret };
7585
}
7686

87+
/** Revokes every currently-live ('enrolled', not yet revoked) enrollment for an installation, regardless of
88+
* secret_type — the self-hoster-reachable counterpart to the single-enrollment {@link revokeOrbEnrollment}
89+
* (#9149). Returns the count actually revoked (0 is a valid, non-error result: an install with no live
90+
* enrollment is not a failure, it's just already in the state the caller wanted). Deliberately does NOT gate
91+
* on `registered`/`suspended_at`/`removed_at` the way issuance does — revoking access should work EVEN ON an
92+
* installation the operator has since disabled or suspended, since a maintainer revoking a leaked secret has
93+
* no reason to be blocked by an unrelated administrative state. */
94+
export async function revokeAllLiveEnrollmentsForInstallation(env: Env, installationId: number): Promise<number> {
95+
const result = await env.DB.prepare("UPDATE orb_enrollments SET revoked_at = CURRENT_TIMESTAMP, state = 'revoked' WHERE installation_id = ? AND state = 'enrolled' AND revoked_at IS NULL")
96+
.bind(installationId)
97+
.run();
98+
return result.meta.changes;
99+
}
100+
101+
/** How many enrollments are currently live ('enrolled', not revoked) for an installation (#9149) — the count
102+
* surfaced to an operator (the internal installations list) and to a maintainer (the OAuth secret page) so
103+
* neither has to guess whether a re-enrollment minted an extra standing credential. */
104+
export async function countLiveEnrollmentsForInstallation(env: Env, installationId: number): Promise<number> {
105+
const row = await env.DB.prepare("SELECT COUNT(*) AS c FROM orb_enrollments WHERE installation_id = ? AND state = 'enrolled' AND revoked_at IS NULL").bind(installationId).first<{ c: number }>();
106+
return row?.c ?? 0;
107+
}
108+
77109
export type IssueStoredSecretResult = IssueResult | { error: "secret_value_required" | "encryption_unavailable" };
78110

79111
/** Issues a one-time enrollment secret for a STORED (not minted) credential (#8064) -- e.g. control-plane's

src/orb/oauth.ts

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import type { Context } from "hono";
1616
import { PRODUCT_USER_AGENT, timeoutFetch } from "../github/client";
1717
import { LOOPOVER_SITE_URL } from "../github/footer";
18-
import { isOrbBrokerEnabled, issueOrbEnrollment } from "./broker";
18+
import { countLiveEnrollmentsForInstallation, isOrbBrokerEnabled, issueOrbEnrollment, ORB_SECRET_TYPE_GITHUB_TOKEN, revokeAllLiveEnrollmentsForInstallation } from "./broker";
1919

2020
type GitHubUser = { login: string; id?: number };
2121
type GitHubOrgMembership = { role?: string; state?: string; organization?: { id?: number } };
@@ -67,7 +67,39 @@ export async function verifyInstallationAdmin(
6767
return body.state === "active" && body.role === "admin" && body.organization?.id === accountId;
6868
}
6969

70-
async function handleOrbEnrollment(c: Context<{ Bindings: Env }>, code: string, installationId: number): Promise<Response> {
70+
/** What a maintainer landing on the OAuth callback is asking to do, and which installation it's bound to.
71+
* `installation_id` normally comes from the GitHub-controlled query string (the install/update Setup-URL
72+
* redirect); `rotate`/`revoke` have no such channel (GitHub only ever echoes back `code` + `state` on a bare
73+
* `login/oauth/authorize` bounce, not arbitrary query params we didn't put there ourselves), so those two
74+
* actions are carried in `state` instead, as `"<installationId>:rotate"` / `"<installationId>:revoke"` (#9149)
75+
* — the value {@link orbOAuthAuthorizeUrl} puts in the "Rotate" / "Revoke all" links on the secret page. */
76+
type OrbOAuthIntent = { installationId: number | null; action: "enroll" | "rotate" | "revoke" };
77+
78+
function parseOAuthState(raw: string | undefined): OrbOAuthIntent {
79+
if (!raw) return { installationId: null, action: "enroll" };
80+
const [idPart, actionPart] = raw.split(":");
81+
const parsedId = Number(idPart);
82+
const installationId = Number.isInteger(parsedId) && parsedId > 0 ? parsedId : null;
83+
const action = actionPart === "rotate" ? "rotate" : actionPart === "revoke" ? "revoke" : "enroll";
84+
return { installationId, action };
85+
}
86+
87+
/** A `login/oauth/authorize` link that lands the maintainer back on THIS SAME callback with a fresh, single-
88+
* use `code` and `state=<installationId>:<action>` (#9149) — the only way to re-prove admin-of-installation
89+
* for a rotate/revoke action without our own persisted maintainer session. Null when the client id isn't
90+
* configured (broker effectively unusable anyway), so the caller can omit the link entirely rather than
91+
* point at a URL that can never work. */
92+
function orbOAuthAuthorizeUrl(env: Env, installationId: number, action: "rotate" | "revoke"): string | null {
93+
/* v8 ignore next 2 -- defensive: every caller of this function is already past exchangeOrbOAuthCode, which
94+
itself returns null (→ the identity-error page, never reaching secretPage) without ORB_GITHUB_CLIENT_ID
95+
set — so this branch can't be live-reached through the real callback flow. Kept so a future change that
96+
loosens that upstream guard degrades to an omitted link rather than a broken one. */
97+
if (!env.ORB_GITHUB_CLIENT_ID) return null;
98+
const params = new URLSearchParams({ client_id: env.ORB_GITHUB_CLIENT_ID, state: `${installationId}:${action}` });
99+
return `https://github.com/login/oauth/authorize?${params.toString()}`;
100+
}
101+
102+
async function handleOrbEnrollment(c: Context<{ Bindings: Env }>, code: string, installationId: number, action: "enroll" | "rotate" | "revoke" = "enroll"): Promise<Response> {
71103
// A thrown network error (DNS failure, or a timeout past timeoutFetch's own retry budget) from any of the three
72104
// GitHub calls below degrades to the same clean landing page a bad HTTP *response* already produces, instead of
73105
// escaping handleOrbOAuthCallback as an uncaught framework 500. Mirrors the failure-doesn't-escape convention in
@@ -102,26 +134,44 @@ async function handleOrbEnrollment(c: Context<{ Bindings: Env }>, code: string,
102134
return identityError();
103135
}
104136
if (!isAdmin) return c.html(landingPage(c.env, "Admin access required", "You must be an admin of this installation's account to enroll it for self-host."), 403);
137+
// #9149: a REVOKE request skips the active/disabled checks below on purpose — a maintainer revoking a
138+
// leaked secret must be able to do so EVEN ON an installation the operator has since suspended or disabled;
139+
// there is no reason unrelated administrative state should block someone locking down their own credential.
140+
// It also never touches `registered` (no auto-registration side effect for a request that isn't enrolling).
141+
if (action === "revoke") {
142+
const revokedCount = await revokeAllLiveEnrollmentsForInstallation(c.env, installationId);
143+
return c.html(revokedPage(revokedCount));
144+
}
105145
if (install.removed_at !== null || install.suspended_at !== null) return c.html(landingPage(c.env, "Installation not active", "This installation is suspended or uninstalled — re-install the Orb App, then retry."), 403);
106146
if (install.self_enrollment_disabled === 1) return c.html(landingPage(c.env, "Installation disabled", "This installation was disabled by the operator — contact the operator to re-enable self-host enrollment."), 403);
107147
// Zero-touch self-service: a verified admin of an ACTIVE, non-disabled install self-registers it (registered=1).
108148
// installation_id stays bound server-side in the enrollment, so brokered tokens remain scoped to this install.
109149
if (install.registered !== 1) {
110150
await c.env.DB.prepare("UPDATE orb_github_installations SET registered = 1, last_event_at = CURRENT_TIMESTAMP WHERE installation_id = ?").bind(installationId).run();
111151
}
112-
const result = await issueOrbEnrollment(c.env, installationId, { login: user.login, githubId: user.id ?? null });
152+
const result = await issueOrbEnrollment(c.env, installationId, { login: user.login, githubId: user.id ?? null }, ORB_SECRET_TYPE_GITHUB_TOKEN, { rotate: action === "rotate" });
113153
/* v8 ignore next -- defensive: the existence + admin + active checks above passed and we just set registered=1, so
114154
issueOrbEnrollment (which re-checks existence + registered) cannot return an error here; kept to degrade safely. */
115155
if ("error" in result) return c.html(landingPage(c.env, "Couldn't issue an enrollment", "Please retry, or contact the operator."), 409);
116-
return c.html(secretPage(result.secret));
156+
const liveCount = await countLiveEnrollmentsForInstallation(c.env, installationId);
157+
c.header("Cache-Control", "no-store"); // #9149: this response body is a plaintext secret shown exactly once — never cacheable
158+
return c.html(secretPage(c.env, result.secret, installationId, liveCount));
117159
}
118160

119161
export async function handleOrbOAuthCallback(c: Context<{ Bindings: Env }>): Promise<Response> {
120162
const code = c.req.query("code");
121-
const installationId = Number(c.req.query("installation_id"));
122-
// Self-enrollment: a maintainer authorized with an OAuth code + an installation_id, and the broker is enabled.
123-
if (code && Number.isInteger(installationId) && installationId > 0 && isOrbBrokerEnabled(c.env)) {
124-
return handleOrbEnrollment(c, code, installationId);
163+
// #9149: installation_id normally arrives in the query string (GitHub's own install/update Setup-URL
164+
// redirect); a rotate/revoke request instead carries it inside `state` (see parseOAuthState's doc comment
165+
// for why) — the query value, when itself valid, still wins, so a genuine install/update redirect is never
166+
// second-guessed by a stray/malformed `state`.
167+
const state = parseOAuthState(c.req.query("state"));
168+
const rawInstallationId = Number(c.req.query("installation_id"));
169+
const queryInstallationId = Number.isInteger(rawInstallationId) && rawInstallationId > 0 ? rawInstallationId : null;
170+
const installationId = queryInstallationId ?? state.installationId;
171+
// Self-enrollment (or a rotate/revoke of one): a maintainer authorized with an OAuth code + a resolved
172+
// installation_id, and the broker is enabled.
173+
if (code && installationId !== null && isOrbBrokerEnabled(c.env)) {
174+
return handleOrbEnrollment(c, code, installationId, state.action);
125175
}
126176
const updated = c.req.query("setup_action") === "update";
127177
return c.html(
@@ -146,10 +196,30 @@ function landingPage(env: Env, heading: string, message: string): string {
146196
}
147197

148198
/** Show the freshly-issued enrollment secret ONCE. The secret is a generated opaque token (no user input), safe
149-
* to embed; it is never logged. */
150-
function secretPage(secret: string): string {
199+
* to embed; it is never logged. #9149: also surfaces how many enrollments are now live for this installation
200+
* (an operator/maintainer previously had no way to see this had accumulated) and, when the client id is
201+
* configured, links to re-authorize with a `state`-carried rotate/revoke intent (see orbOAuthAuthorizeUrl) —
202+
* a self-hoster otherwise had no reachable path to either action at all. `installationId` is never
203+
* user-supplied at render time (bound server-side, same as the enrollment itself), so it is safe to embed. */
204+
function secretPage(env: Env, secret: string, installationId: number, liveCount: number): string {
205+
const rotateUrl = orbOAuthAuthorizeUrl(env, installationId, "rotate");
206+
const revokeUrl = orbOAuthAuthorizeUrl(env, installationId, "revoke");
207+
const manageSection =
208+
rotateUrl && revokeUrl
209+
? `<p>This installation now has <strong>${liveCount}</strong> live enrollment secret${liveCount === 1 ? "" : "s"}, including this one. <a href="${rotateUrl}">Rotate</a> (revokes every other one and issues this as the only valid secret) or <a href="${revokeUrl}">revoke all</a> if you no longer need self-host access for this installation.</p>`
210+
: "";
151211
return shell(
152212
"Your enrollment secret",
153-
`<p>Set this as <code>ORB_ENROLLMENT_SECRET</code> in your self-host <code>.env</code>, then restart the container. It is shown <strong>once</strong> — store it now.</p><pre>${secret}</pre>`,
213+
`<p>Set this as <code>ORB_ENROLLMENT_SECRET</code> in your self-host <code>.env</code>, then restart the container. It is shown <strong>once</strong> — store it now.</p><pre>${secret}</pre>${manageSection}`,
214+
);
215+
}
216+
217+
/** Confirms a revoke-all action (#9149) — the self-hoster-reachable counterpart to secretPage. `count` is
218+
* never user-supplied (it's the return of revokeAllLiveEnrollmentsForInstallation), so it's safe to embed. */
219+
function revokedPage(count: number): string {
220+
const plural = count === 1 ? "secret has" : "secrets have";
221+
return shell(
222+
"Enrollment secrets revoked",
223+
`<p><strong>${count}</strong> live enrollment ${plural} been revoked for this installation. Any self-hosted container still using ${count === 1 ? "it" : "one of them"} loses broker access immediately. Re-run the install flow to issue a new one.</p>`,
154224
);
155225
}

0 commit comments

Comments
 (0)