Skip to content

Commit 8d101ed

Browse files
authored
fix(orb): harden the federated peer-intelligence trust boundary (#9147, #9148, #9149, #9150, #9166) (#9243)
* fix(orb): mint a dedicated federated signing key, separate from the anon secret (#9147) Enrolling a federated peer previously handed them this instance's dedicated anonymization secret (the SAME key that HMACs repo/pr identifiers on the always-on #1255 orb export), since buildFederatedBundle signed with getOrCreateAnonSecret. A peer holding that key could dictionary-invert any repo_hash it observed and forge this instance's x-orb-signature. - Add getOrCreateFederatedSigningSecret: a separate, dedicated system_flags secret used only to sign federated bundles. - Decouple instanceId from the anon secret via getOrCreateInstanceIdentitySecret, a third independent secret seeded from the current anon secret on first use (so an upgrading instance keeps its existing instanceId) but stored in its own row from then on, so rotating the anon secret no longer resets the export watermark or the federated peer-median's per-instance accounting. - Correct anonymize.ts's key-separation docstring, which was false for any federated-enabled instance. * fix(orb): default-engage the federated rate limiter and drop stale ingest comments (#9166) The documented per-collector rate limit (6/min) was inert at every real call site: rateLimitAllows treated a caller-omitted bucket as "unlimited" instead of falling back to a real bucket, and the only production caller (buildFederatedBenchmark) never supplied one. evaluateLocalRateLimit is also pure (read-only), so nothing was ever advancing a bucket's count either. - Add module-level default push/pull buckets in federated-collector.ts; rateLimitAllows now always consults (and advances) a real bucket, whether caller-supplied or the module default. - Bound pullPeerBundles' response read with the same reader /v1/orb/ingest uses (generalized readOrbIngestBody to accept any body-bearing source, so it works for both a Request and a collector Response), and cap the parsed array before any per-element shape check. - Remove two stale "FAIL-OPEN by default" comment blocks in routes.ts left over from #9046, which made isAuthorizedIngest's ingest gate (now fail-closed) read backwards from what the code actually does. pushFederatedBundle wiring into a real tick lands with the #9148 commit, which introduces the background refresh job this naturally slots into. * fix(orb): scope pull-mode relay drain to the winning enrollment (#9150) orb_relay_pending was scoped only by installation_id, so any enrollment secret valid for an installation could drain and destructively-ack another live enrollment's queued webhooks -- a stale re-enrolled container silently stealing a fresh one's events (blue/green swap, secret rotation, or a rebuilt container issued a fresh secret while the old one is still inside its drain timer). - Add orb_relay_pending.enroll_id, tagged at enqueue time with the SAME winning enrollment forwardOrbEvent already elects for the push path (#1783's deterministic ORDER BY). NULL for config_push rows (installation- wide, not consumer-specific) and for any pre-#9150 row, so an untagged row is never orphaned -- pullRelayPending's WHERE matches enroll_id = ? OR enroll_id IS NULL. - forwardOrbEvent now fetches every live enrollment row for an installation (not just the elected winner) so a second live enrollment is observable rather than silent, and increments a new counter, loopover_orb_relay_multiple_live_enrollments_total, whenever more than one exists. - pullRelayPending/enqueueRelayPending both accept an optional enrollId to scope the SELECT and the ack-DELETE together, closing the hole where one container could ack another's rows. * fix(orb): harden the federated peer-median trust boundary (#9148) One allowlisted key could mint unlimited Sybil peers and own the median outright: verifyFederatedBundle only checked whether SOME allowlisted key signed a bundle, never bound a key to an instanceId, and importPeerBundles had no per-instance dedup, no range checks, no freshness/replay protection, and pulled peer bundles synchronously on every maintainer dashboard load. - Range-validate every numeric field (rates in [0,1], non-negative counts, cycleP50Ms <= cycleP95Ms) as its own "out_of_range" rejection, distinct from a wrong-typed "malformed" field. - Reject a windowDays that doesn't match the local instance's own resolved window, and a generatedAt outside a bounded freshness window (7 days) or more than 5 minutes in the future. - Enforce decided >= MIN_DECIDED receiver-side rather than trusting the sender's own eligibility claim. - Dedup a batch to the LAST bundle per instanceId (importPeerBundles), then add a persisted, cross-tick per-instance replay/rollback watermark and a per-key Sybil cap (MAX_INSTANCES_PER_KEY) via the new applyFederatedPeerWatermarks, backed by a system_flags JSON blob rather than a new table -- a self-hosted federation's peer count is expected to stay small enough that an in-memory scan/cap-check is trivially cheap. - Move the peer pull off the dashboard's request path entirely: a new "federated-peer-sync" queue job (10-minute cadence, gated on the loopover self-repo's federatedIntelligence.enabled) now runs the pull + trust-gate + persist pipeline AND pushes this instance's own bundle (wiring pushFederatedBundle into a real tick, closing the other half of #9166). buildFederatedBenchmark still computes the local half live (a fast local DB query) but reads the peer half from a cache the tick refreshes. - peerCount now naturally counts distinct contributing INSTANCES (a side effect of the per-instance dedup), matching what its own doc comment always claimed but the old code never enforced. * 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. * chore: regenerate cf-typegen/selfhost env-reference after rebase
1 parent 8eb8e7e commit 8d101ed

26 files changed

Lines changed: 1629 additions & 222 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Pull-mode drain isolation (#9150): orb_relay_pending was scoped only by installation_id, so ANY enrollment
2+
-- secret valid for an installation could drain and destructively-ack another live consumer's queued webhooks
3+
-- (a stale re-enrolled container silently stealing a fresh one's events). This column lets an enqueue tag
4+
-- which enrollment a GitHub-webhook row was queued for (the same "winning enrollment" forwardOrbEvent already
5+
-- picks for the push path, #1783); NULL for every existing row and for config_push notices (which remain
6+
-- intentionally installation-wide, not consumer-specific -- see enqueueConfigPushRelay). NULL also means "not
7+
-- yet tagged", so pullRelayPending's WHERE clause matches untagged rows regardless of which enrollment asks --
8+
-- a safe default during rollout, not a bypass (an enrollment can only ever add matches, never lose ones scoped
9+
-- to a DIFFERENT enroll_id).
10+
ALTER TABLE orb_relay_pending ADD COLUMN enroll_id TEXT;
11+
CREATE INDEX IF NOT EXISTS idx_orb_relay_pending_enroll ON orb_relay_pending (installation_id, enroll_id);

packages/loopover-engine/src/telemetry/anonymize.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import { createHmac, randomBytes } from "node:crypto";
1414
* once per instance in its own store and reuses it on every export, so the same raw value always hashes the
1515
* same way. Never derived from, or shared with, any other credential (App private keys, webhook secrets) --
1616
* key separation means a leaked anonymization secret can't be used to forge or decrypt anything else.
17+
*
18+
* #9147: this now also holds for Orb's opt-in federated peer-benchmark export (src/orb/federated-bundle.ts).
19+
* That feature signs its bundles with its OWN dedicated secret (getOrCreateFederatedSigningSecret) rather
20+
* than this one, and derives its instance identity from a third, independent secret
21+
* (getOrCreateInstanceIdentitySecret) -- so enrolling a federated peer, or rotating any one of the three
22+
* secrets, can never affect this anonymization secret or the always-on export that uses it.
1723
*/
1824
export function generateAnonSecret(): string {
1925
return randomBytes(32).toString("hex");

src/api/routes.ts

Lines changed: 31 additions & 17 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";
@@ -1761,14 +1762,15 @@ export function createApp() {
17611762
});
17621763
// Federated benchmark (#6481): "your gate precision vs peer median". Reads the opt-in from the loopover
17631764
// self-repo's manifest (mirrors prReconciliation/publicStats/etc.'s fleet-wide override lookup) rather
1764-
// than any of the maintainer's own repos — federatedIntelligence is operator-level, not per-repo. Bounded
1765-
// to a single, short-timeout attempt so an unreachable or slow collector degrades the panel to its
1766-
// existing empty state instead of holding up the whole dashboard load.
1765+
// than any of the maintainer's own repos — federatedIntelligence is operator-level, not per-repo.
1766+
// #9148: buildFederatedBenchmark no longer pulls a peer collector on this request path at all — the
1767+
// local half is a single fast DB query, and the peer half is read from a cache the "federated-peer-sync"
1768+
// background queue job refreshes on its own cadence. This used to be a live, rate-limited network call
1769+
// on every dashboard load (N maintainers hitting refresh was N requests/second at the peer collector);
1770+
// now a slow/unreachable collector can only make the CACHE stale, never hold up this request.
17671771
const federatedIntelligenceManifest = await loadRepoFocusManifest(c.env, resolveLoopOverSelfRepoFullName(c.env));
17681772
const federatedBenchmark = await buildFederatedBenchmark(federatedIntelligenceManifest, c.env.DB, {
17691773
now: Date.parse(generatedAt),
1770-
timeoutMs: 5_000,
1771-
maxAttempts: 1,
17721774
});
17731775
const qualityDashboard = {
17741776
...buildMaintainerQualityDashboard({
@@ -4441,16 +4443,19 @@ export function createApp() {
44414443
} catch {
44424444
ack = undefined; // tolerate an empty/invalid body — just no ack this round
44434445
}
4444-
const events = await pullRelayPending(c.env, enrollment.installationId, { ack }).catch(dbBrokerError);
4446+
// #9150: scope the drain to THIS enrollment (or untagged/legacy rows) — not merely the installation — so a
4447+
// second live enrollment for the same install can never steal and destructively-ack this one's events.
4448+
const events = await pullRelayPending(c.env, enrollment.installationId, { ack, enrollId: enrollment.enrollId }).catch(dbBrokerError);
44454449
if (events === null) return c.json({ error: "broker_error" }, 503);
44464450
return c.json({ events }, 200);
44474451
});
44484452

44494453
// LoopOver Orb (#1255) — central fleet-calibration collector. Receives anonymized, reversal-aware outcome
44504454
// batches from self-hosted instances. Sender-side HMAC anonymization is for privacy, not authentication.
4451-
// OPTIONAL shared-token gate (#1285): unset ⇒ OPEN ingress (the live fleet keeps working, as before); set
4452-
// ⇒ the collector REQUIRES it, so an operator can lock the write path down after distributing the matching
4453-
// ORB_COLLECTOR_TOKEN to exporters. Bounded by a hard body ceiling, and dedup'd via UNIQUE(instance_id, repo_hash, pr_hash).
4455+
// #9046/#9166: FAILS CLOSED when ORB_INGEST_TOKEN is unset — isAuthorizedIngest (below, shared with
4456+
// /v1/ams/ingest) requires an exact bearer match against the configured token; an unconfigured collector
4457+
// rejects rather than accepting anonymous writes. Bounded by a hard body ceiling, and dedup'd via
4458+
// UNIQUE(instance_id, repo_hash, pr_hash).
44544459
app.post("/v1/orb/ingest", async (c) => {
44554460
if (!(await isAuthorizedIngest(c.env.ORB_INGEST_TOKEN, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401);
44564461
const body = await readOrbIngestBody(c.req.raw, c.req.header("content-length"));
@@ -4575,15 +4580,22 @@ export function createApp() {
45754580
// records lands at registered=0; only REGISTERED ones count toward the global public counter (getOrbGlobalStats)
45764581
// and are eligible for token brokering. Bearer-gated by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN). The
45774582
// 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.
45784589
app.get("/v1/internal/orb/installations", async (c) => {
45794590
const rows = await c.env.DB
45804591
.prepare(
45814592
`SELECT installation_id AS installationId, account_login AS accountLogin, account_type AS accountType,
45824593
repository_selection AS repositorySelection, registered, suspended_at AS suspendedAt,
4583-
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
45844596
FROM orb_github_installations ORDER BY last_event_at DESC`,
45854597
)
4586-
.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 }>();
45874599
return c.json({ installations: (rows.results ?? []).map((r) => ({ ...r, registered: r.registered === 1 })) });
45884600
});
45894601

@@ -4619,9 +4631,15 @@ export function createApp() {
46194631
// secret issuance path control-plane's hosted provisioning core (#7180/#8066) calls instead, for a credential
46204632
// that already exists (a tenant's Postgres connection string) rather than a GitHub installation to bind.
46214633
// `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).
46224640
app.post("/v1/internal/orb/enrollments", async (c) => {
46234641
if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404);
4624-
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;
46254643
if (payload?.secretType === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) {
46264644
const secretValue = typeof payload.secretValue === "string" ? payload.secretValue : "";
46274645
const result = await issueOrbStoredSecret(c.env, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, secretValue);
@@ -4630,7 +4648,7 @@ export function createApp() {
46304648
}
46314649
const installationId = Number(payload?.installationId);
46324650
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "installationId required" }, 400);
4633-
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 });
46344652
if ("error" in result) return c.json(result, result.error === "installation_not_found" ? 404 : 409);
46354653
return c.json(result); // { enrollId, secret } — secret shown exactly once
46364654
});
@@ -6690,10 +6708,6 @@ function toIsoQueryDate(value: string): string | undefined {
66906708
}
66916709

66926710

6693-
// Optional Orb-ingest auth (#1285). FAIL-OPEN by default: with no ORB_INGEST_TOKEN configured the ingress stays
6694-
// OPEN (matching today's live fleet — deploying this is non-breaking). Once the operator sets the token, the
6695-
// collector REQUIRES an exact bearer match, so the write path can be locked down after the matching
6696-
// ORB_COLLECTOR_TOKEN is rolled out to exporters.
66976711
/**
66986712
* #9046: the SINGLE authorization rule for every telemetry-collector ingest endpoint (Orb and AMS), so the two
66996713
* products cannot drift apart again. They previously had separate, near-identical copies — and AMS ended up

src/db/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,11 @@ export const orbRelayPending = sqliteTable(
977977
// enqueueConfigPushRelay). The dispatch side (#7523) branches on this before treating raw_body as a
978978
// GitHubWebhookPayload.
979979
kind: text("kind").notNull().default("github_webhook"),
980+
// Pull-mode drain isolation (#9150): which enrollment a GitHub-webhook row was queued for (the same
981+
// "winning enrollment" forwardOrbEvent picks for the push path). NULL for config_push rows (installation-
982+
// wide, not consumer-specific) and for any pre-#9150 row -- pullRelayPending's WHERE matches NULL
983+
// regardless of the asking enrollment, so an untagged row is never silently orphaned.
984+
enrollId: text("enroll_id"),
980985
},
981986
(table) => ({
982987
installation: index("idx_orb_relay_pending_install").on(table.installationId, table.createdAt),
@@ -986,6 +991,8 @@ export const orbRelayPending = sqliteTable(
986991
// pruneRelayPending (src/orb/relay.ts) filters/deletes by created_at alone (fleet-wide TTL sweep, not
987992
// scoped to one installation) -- neither index above leads with created_at, so that scan was unindexed.
988993
createdAt: index("idx_orb_relay_pending_created_at").on(table.createdAt),
994+
// #9150: pullRelayPending's SELECT/DELETE now filter on (installation_id, enroll_id) together.
995+
enroll: index("idx_orb_relay_pending_enroll").on(table.installationId, table.enrollId),
989996
}),
990997
);
991998

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isLoopEscalationSweepEnabled } from "./review/loop-escalation-wire";
1414
import { isAprRepoTransferPollEnabled } from "./orb/apr-repo-transfer";
1515
import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride } from "./review/pr-reconciliation";
1616
import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride } from "./review/active-review-reconciliation";
17+
import { resolveFederatedIntelligenceManifestOverride } from "./orb/federated-benchmark";
1718
import { isRagEnabled } from "./review/rag-wire";
1819
import { isDecisionAuditEnabled } from "./review/decision-audit";
1920
import { isRiskControlEnabled } from "./review/risk-control-wire";
@@ -226,6 +227,16 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController):
226227
jobs.push({ type: "reconcile-active-review-tracking", requestedBy: "schedule" });
227228
}
228229
}
230+
// Federated peer-sync tick (#9148/#9166). Same 10-minute cadence as the reconciliation jobs above — a
231+
// best-effort background sync has no business running any more often than that, and the collector's own
232+
// module-level rate limiter (federated-collector.ts, 6/min) further bounds any single tick's calls. Opt-in
233+
// via the loopover self-repo's `.loopover.yml federatedIntelligence:` block only — there is no env-var
234+
// equivalent for this one (unlike prReconciliation/activeReviewReconciliation above), since federated intel
235+
// has never had one; flag-OFF (default, `present: false`) this job is never created.
236+
if (isReconciliationWindow) {
237+
const federatedIntelligenceManifestOverride = await resolveFederatedIntelligenceManifestOverride(env);
238+
if (federatedIntelligenceManifestOverride.enabled) jobs.push({ type: "federated-peer-sync", requestedBy: "schedule" });
239+
}
229240
if (isHourly) {
230241
// Isolation (#experimental-gittensor-plugin): on self-host, refresh-registry both FETCHES from and
231242
// PERSISTS the whole upstream gittensor-subnet registry (entrius/gittensor has no server-side filtering,

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

0 commit comments

Comments
 (0)