diff --git a/migrations/0173_orb_enrollment_secret_value.sql b/migrations/0173_orb_enrollment_secret_value.sql new file mode 100644 index 0000000000..ddfd4776f3 --- /dev/null +++ b/migrations/0173_orb_enrollment_secret_value.sql @@ -0,0 +1,11 @@ +-- Loopover Orb token-broker (#8064) — a STORED (not minted) secret value, for a credential the caller already +-- has in hand and just needs custody of (e.g. a hosted tenant's Postgres connection string, #7180's +-- provisioning core) as opposed to the GitHub-token type's mint-on-exchange shape (#7174's secret_type +-- discriminator). Same encrypted-at-rest triplet + explicit version column as repositories.ts's BYOK +-- provider-key storage (repository_ai_keys) -- NOT broker.ts's own cached_token_json shape, which is a TTL'd +-- mint CACHE, a different thing entirely from a value that must persist indefinitely with no re-derivation +-- possible. NULL for every existing github_token row; that type never writes these columns. +ALTER TABLE orb_enrollments ADD COLUMN secret_value_ciphertext TEXT; +ALTER TABLE orb_enrollments ADD COLUMN secret_value_iv TEXT; +ALTER TABLE orb_enrollments ADD COLUMN secret_value_salt TEXT; +ALTER TABLE orb_enrollments ADD COLUMN secret_value_version INTEGER; diff --git a/src/api/routes.ts b/src/api/routes.ts index 672bd69003..37329957c4 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -155,7 +155,14 @@ import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; import { handleAmsIngest } from "../ams/ingest"; import { handleOrbWebhook } from "../orb/webhook"; import { handleOrbOAuthCallback } from "../orb/oauth"; -import { brokerOrbToken, isOrbBrokerEnabled, issueOrbEnrollment } from "../orb/broker"; +import { + brokerOrbToken, + isOrbBrokerEnabled, + issueOrbEnrollment, + issueOrbStoredSecret, + ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, + revokeOrbEnrollment, +} from "../orb/broker"; import { enqueueConfigPushRelay, MAX_ORB_RELAY_REGISTER_BODY_BYTES, @@ -4615,9 +4622,20 @@ export function createApp() { // Operator-only: issue a one-time token-broker enrollment secret for a REGISTERED install, to hand to that // maintainer's self-hosted container. The secret is returned ONCE (stored only hashed). Bearer-gated by the // /v1/internal/* middleware (INTERNAL_JOB_TOKEN); flag-gated (404 until ORB_BROKER_ENABLED). + // + // Also accepts an optional `{ secretType: "tenant_db_credential", secretValue }` body (#8064) -- the STORED- + // secret issuance path control-plane's hosted provisioning core (#7180/#8066) calls instead, for a credential + // that already exists (a tenant's Postgres connection string) rather than a GitHub installation to bind. + // `installationId` is irrelevant to that path (see issueOrbStoredSecret's own header comment for why). app.post("/v1/internal/orb/enrollments", async (c) => { if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404); - const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown } | null; + const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown; secretType?: unknown; secretValue?: unknown } | null; + if (payload?.secretType === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) { + const secretValue = typeof payload.secretValue === "string" ? payload.secretValue : ""; + const result = await issueOrbStoredSecret(c.env, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, secretValue); + if ("error" in result) return c.json(result, result.error === "secret_value_required" ? 400 : 503); + return c.json(result); // { enrollId, secret } — secret shown exactly once + } const installationId = Number(payload?.installationId); if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "installationId required" }, 400); const result = await issueOrbEnrollment(c.env, installationId); @@ -4625,6 +4643,16 @@ export function createApp() { return c.json(result); // { enrollId, secret } — secret shown exactly once }); + // Operator-only: revoke a token-broker enrollment (#8064) -- works for ANY secret type (GitHub-token or + // stored), since brokerOrbToken's own revoked_at check (unchanged, #7174) already refuses any revoked row on + // its very next exchange attempt. Idempotent: revoking an already-revoked enrollment still reports success. + app.post("/v1/internal/orb/enrollments/:enrollId/revoke", async (c) => { + if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404); + const result = await revokeOrbEnrollment(c.env, c.req.param("enrollId")); + if ("error" in result) return c.json(result, 404); + return c.json(result); + }); + // Convergence (ops / observability, flag LOOPOVER_REVIEW_OPS). Cross-repo review-OUTCOME aggregate (gate-block // ledger + recommendation/slop calibration) for an operator dashboard. Bearer-gated by the `/v1/internal/*` // middleware above (INTERNAL_JOB_TOKEN). Flag-OFF (default) → 404, so the endpoint does not exist and the diff --git a/src/orb/broker.ts b/src/orb/broker.ts index a493533e16..ddec6a16bf 100644 --- a/src/orb/broker.ts +++ b/src/orb/broker.ts @@ -19,13 +19,19 @@ import { createOrbInstallationToken } from "./app-auth"; // entry is never handed out (covers clock skew + the engine's own ~5m cache margin). const ORB_TOKEN_CACHE_MIN_REMAINING_MS = 10 * 60_000; -// The only secret type this broker actually knows how to mint today (#7174). The `secret_type` column exists -// so a future AI-provider-key / DB-credential mint strategy (the hosted control-plane's provisioning core, -// #7180) can record what an enrollment row is FOR without inventing a second table — but until that strategy -// exists, any row carrying a different value is a config/data error brokerOrbToken must refuse, not silently -// GitHub-mint against. +// The original secret type this broker knows how to mint (#7174). The `secret_type` column exists so a future +// AI-provider-key / DB-credential strategy (the hosted control-plane's provisioning core, #7180) can record +// what an enrollment row is FOR without inventing a second table — any row carrying a value this file doesn't +// recognize is a config/data error brokerOrbToken must refuse, not silently GitHub-mint against. export const ORB_SECRET_TYPE_GITHUB_TOKEN = "github_token"; +// A STORED (not minted) secret type (#8064, split from #7852/#7180): a credential the caller already has in +// hand (e.g. a hosted tenant's Postgres connection string) that this broker just holds custody of, encrypted +// at rest, and hands back verbatim on exchange — no installation-eligibility re-check, no mint/cache TTL logic, +// none of which apply to a value that isn't derived from a GitHub App at all. See issueOrbStoredSecret and +// brokerOrbToken's own secret_type branch below. +export const ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL = "tenant_db_credential"; + export function isOrbBrokerEnabled(env: Env): boolean { return /^(1|true|yes|on)$/i.test(String(env.ORB_BROKER_ENABLED ?? "").trim()); } @@ -57,13 +63,72 @@ export async function issueOrbEnrollment( return { enrollId, secret }; } +export type IssueStoredSecretResult = IssueResult | { error: "secret_value_required" | "encryption_unavailable" }; + +/** Issues a one-time enrollment secret for a STORED (not minted) credential (#8064) -- e.g. control-plane's + * hosted tenant Postgres connection details (#7180's provisioning core). Deliberately does NOT reuse + * issueOrbEnrollment's installation-registration gate: that gate exists because a GitHub-token enrollment is + * a maintainer's self-hosted container proving it administers a REAL, registered GitHub installation -- a + * stored tenant secret has no GitHub installation to bind to at all (an AMS tenant has none; even a hosted + * ORB tenant's installation lives in control-plane's own registry, #7181, not this table's + * orb_github_installations). `installation_id` is therefore always NULL on these rows. This issuance path's + * authority is the caller already holding the internal admin token -- the same /v1/internal/* middleware + * every other operator-only route in routes.ts sits behind -- not installation registration. */ +export async function issueOrbStoredSecret(env: Env, secretType: string, secretValue: string): Promise { + if (!secretValue) return { error: "secret_value_required" }; + if (!env.TOKEN_ENCRYPTION_SECRET) return { error: "encryption_unavailable" }; + const enrollId = createOpaqueToken("orbenr"); + const secret = createOpaqueToken("orbsec"); + const encrypted = await encryptSecret(secretValue, env.TOKEN_ENCRYPTION_SECRET); + await env.DB.prepare( + `INSERT INTO orb_enrollments + (enroll_id, installation_id, secret_hash, secret_type, state, authorized_at, enrolled_at, + secret_value_ciphertext, secret_value_iv, secret_value_salt, secret_value_version) + VALUES (?, NULL, ?, ?, 'enrolled', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?, ?, ?)`, + ) + .bind(enrollId, await hashToken(secret), secretType, encrypted.ciphertext, encrypted.iv, encrypted.salt, encrypted.version) + .run(); + return { enrollId, secret }; +} + +export type RevokeResult = { revoked: true } | { error: "enrollment_not_found" }; + +/** Generic revoke path (#8064): works for ANY secret type, since brokerOrbToken's very first gate (both the + * original GitHub-token mint flow and the new stored-secret flow below) already refuses any row with a + * non-null revoked_at -- that check has existed since #7174 but nothing has ever WRITTEN to the column until + * now. Idempotent: revoking an already-revoked enrollment succeeds without disturbing its original + * revoked_at (COALESCE keeps the first revocation's timestamp, matching every other driver's teardown + * contract in this codebase -- a repeat revoke is a no-op, not a second event). */ +export async function revokeOrbEnrollment(env: Env, enrollId: string): Promise { + const existing = await env.DB.prepare("SELECT enroll_id FROM orb_enrollments WHERE enroll_id = ?").bind(enrollId).first<{ enroll_id: string }>(); + if (!existing) return { error: "enrollment_not_found" }; + await env.DB.prepare("UPDATE orb_enrollments SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP), state = 'revoked' WHERE enroll_id = ?") + .bind(enrollId) + .run(); + return { revoked: true }; +} + export type BrokerResult = | { token: string; installationId: number; expiresAt: string; permissions: Record } + | { secretValue: string; secretType: string } | { error: "invalid_enrollment" | "installation_not_eligible" | "broker_misconfigured" | "unsupported_secret_type" }; -/** The container's token-exchange: a valid enrollment secret → a short-lived installation token for the BOUND - * install. installation_id is read from the enrollment row, never the caller; the install must still be - * registered=1 and neither suspended nor removed at mint time (the gate is re-checked, not trusted from issue). */ +type OrbEnrollmentRow = { + enroll_id: string; + installation_id: number; + state: string; + revoked_at: string | null; + cached_token_json: string | null; + secret_type: string; + secret_value_ciphertext: string | null; + secret_value_iv: string | null; + secret_value_salt: string | null; +}; + +/** The container's token-exchange: a valid enrollment secret → either a short-lived GitHub installation token + * (the original, mint-style flow) or a decrypted stored secret value (#8064's store-style flow), branching on + * the enrollment row's own secret_type. installation_id/eligibility only apply to the GitHub-token flow — a + * stored secret has no GitHub installation to re-check at all (see issueOrbStoredSecret's header comment). */ export async function brokerOrbToken(env: Env, secret: string, options: { forceRefresh?: boolean } = {}): Promise { // Warn when TOKEN_ENCRYPTION_SECRET is absent — without it, the broker cache is bypassed and every exchange hits // GitHub's token endpoint, dramatically increasing exposure to throttle-induced failures. @@ -71,13 +136,17 @@ export async function brokerOrbToken(env: Env, secret: string, options: { forceR console.warn(JSON.stringify({ level: "warn", event: "orb_broker_no_encryption_key", message: "TOKEN_ENCRYPTION_SECRET is not set; broker token cache is disabled. Set this variable to enable caching and reduce GitHub throttle risk." })); } const row = await env.DB - .prepare("SELECT enroll_id, installation_id, state, revoked_at, cached_token_json, secret_type FROM orb_enrollments WHERE secret_hash = ?") + .prepare( + `SELECT enroll_id, installation_id, state, revoked_at, cached_token_json, secret_type, + secret_value_ciphertext, secret_value_iv, secret_value_salt + FROM orb_enrollments WHERE secret_hash = ?`, + ) .bind(await hashToken(secret)) - .first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null; cached_token_json: string | null; secret_type: string }>(); + .first(); if (!row || row.state !== "enrolled" || row.revoked_at !== null) return { error: "invalid_enrollment" }; + if (row.secret_type === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) return resolveStoredSecret(env, row); // Checked once the caller is already proven to hold a valid enrollment (same ordering rationale as the App- - // credential check below, #2710) — this endpoint only ever mints GitHub installation tokens; a row recorded - // for a different secret type belongs to a different mint strategy that doesn't exist yet. + // credential check below, #2710) — anything else here belongs to a mint strategy that doesn't exist yet. if (row.secret_type !== ORB_SECRET_TYPE_GITHUB_TOKEN) return { error: "unsupported_secret_type" }; const install = await env.DB .prepare("SELECT registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") @@ -107,6 +176,29 @@ export async function brokerOrbToken(env: Env, secret: string, options: { forceR return { token: minted.token, installationId: row.installation_id, expiresAt: minted.expiresAt, permissions: minted.permissions }; } +/** Decrypts and returns a STORED secret value (#8064) -- the exchange-time counterpart to + * issueOrbStoredSecret's encrypt-and-store. Unlike the GitHub-token flow above, there is no cache, no + * re-mint, and no installation-eligibility check: the value was already fixed at issue time, so the ONLY way + * this can fail is a server-side config/data problem (no encryption key configured, a rotated key that can no + * longer decrypt an older value, or -- defensively -- a row that claims this secret_type but never actually + * got a value written, which should be impossible via issueOrbStoredSecret but is checked anyway). Every + * failure reuses broker_misconfigured: none of them are the caller's fault, matching this file's existing + * posture that a bad App-credential config (above) is never reported as "invalid_enrollment". */ +async function resolveStoredSecret(env: Env, row: OrbEnrollmentRow): Promise { + if (!env.TOKEN_ENCRYPTION_SECRET || !row.secret_value_ciphertext || !row.secret_value_iv) { + console.error(JSON.stringify({ level: "error", event: "orb_broker_misconfigured", message: "TOKEN_ENCRYPTION_SECRET is not set, or this enrollment has no stored secret value; the broker cannot serve a stored secret." })); + return { error: "broker_misconfigured" }; + } + try { + const secretValue = await decryptSecret(row.secret_value_ciphertext, row.secret_value_iv, env.TOKEN_ENCRYPTION_SECRET, row.secret_value_salt); + await touchLastToken(env, row.enroll_id); + return { secretValue, secretType: row.secret_type }; + } catch (error) { + console.warn(JSON.stringify({ level: "warn", event: "orb_broker_stored_secret_decrypt_failed", enrollId: row.enroll_id, message: String(error).slice(0, 120) })); + return { error: "broker_misconfigured" }; + } +} + async function touchLastToken(env: Env, enrollId: string): Promise { try { await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(enrollId).run(); diff --git a/test/integration/orb-broker.test.ts b/test/integration/orb-broker.test.ts index ad87840fc6..9878cc9160 100644 --- a/test/integration/orb-broker.test.ts +++ b/test/integration/orb-broker.test.ts @@ -1,6 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "../../src/api/routes"; -import { brokerOrbToken, isOrbBrokerEnabled, issueOrbEnrollment, ORB_SECRET_TYPE_GITHUB_TOKEN } from "../../src/orb/broker"; +import { hashToken } from "../../src/auth/security"; +import { + brokerOrbToken, + isOrbBrokerEnabled, + issueOrbEnrollment, + issueOrbStoredSecret, + ORB_SECRET_TYPE_GITHUB_TOKEN, + ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, + revokeOrbEnrollment, +} from "../../src/orb/broker"; import { MAX_ORB_RELAY_REGISTER_BODY_BYTES } from "../../src/orb/relay"; import { createTestEnv, type TestD1Database } from "../helpers/d1"; @@ -72,6 +81,70 @@ describe("issueOrbEnrollment", () => { }); }); +describe("issueOrbStoredSecret", () => { + it("#8064: rejects an empty secret value without touching the database", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-stored-secret-test" }); + expect(await issueOrbStoredSecret(e, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, "")).toEqual({ error: "secret_value_required" }); + expect(await db(e).prepare("SELECT COUNT(*) AS n FROM orb_enrollments").first<{ n: number }>()).toMatchObject({ n: 0 }); + }); + + it("#8064: rejects issuance with no TOKEN_ENCRYPTION_SECRET configured (never stores a value unencrypted)", async () => { + const e = await brokerEnv(); + expect(await issueOrbStoredSecret(e, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, "postgres://tenant-acme")).toEqual({ error: "encryption_unavailable" }); + }); + + it("#8064: issues a hashed enrollment secret with the value encrypted at rest, installation_id NULL", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-stored-secret-test" }); + const issued = await issueOrbStoredSecret(e, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, "postgres://tenant-acme:hunter2@neon/acme"); + expect(issued).toMatchObject({ enrollId: expect.stringMatching(/^orbenr_/), secret: expect.stringMatching(/^orbsec_/) }); + const { enrollId, secret } = issued as { enrollId: string; secret: string }; + const row = await db(e) + .prepare("SELECT state, installation_id, secret_hash, secret_type, secret_value_ciphertext FROM orb_enrollments WHERE enroll_id = ?") + .bind(enrollId) + .first<{ state: string; installation_id: number | null; secret_hash: string; secret_type: string; secret_value_ciphertext: string }>(); + expect(row).toMatchObject({ state: "enrolled", installation_id: null, secret_type: ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL }); + expect(row?.secret_hash).not.toContain("orbsec"); // stored hashed, never plaintext, same as issueOrbEnrollment + expect(row?.secret_value_ciphertext).not.toContain("postgres://"); // encrypted, never plaintext, on the WIRE-adjacent column too + expect(secret).not.toContain("postgres://"); + }); +}); + +describe("revokeOrbEnrollment", () => { + it("#8064: 404s an unknown enrollment id", async () => { + const e = await brokerEnv(); + expect(await revokeOrbEnrollment(e, "orbenr_bogus")).toEqual({ error: "enrollment_not_found" }); + }); + + it("#8064: revokes a real enrollment, and every existing read path (brokerOrbToken) then treats it as invalid", async () => { + const e = await brokerEnv(); + await seedInstall(e, 500, { registered: 1 }); + const { enrollId, secret } = (await issueOrbEnrollment(e, 500)) as { enrollId: string; secret: string }; + + expect(await revokeOrbEnrollment(e, enrollId)).toEqual({ revoked: true }); + + const row = await db(e).prepare("SELECT state, revoked_at FROM orb_enrollments WHERE enroll_id = ?").bind(enrollId).first<{ state: string; revoked_at: string | null }>(); + expect(row?.state).toBe("revoked"); + expect(row?.revoked_at).not.toBeNull(); + expect(await brokerOrbToken(e, secret)).toEqual({ error: "invalid_enrollment" }); + }); + + it("#8064: is idempotent — revoking twice keeps the original revoked_at instead of overwriting it", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-25T07:00:00Z")); + const e = await brokerEnv(); + await seedInstall(e, 501, { registered: 1 }); + const { enrollId } = (await issueOrbEnrollment(e, 501)) as { enrollId: string }; + await revokeOrbEnrollment(e, enrollId); + const firstRevokedAt = (await db(e).prepare("SELECT revoked_at FROM orb_enrollments WHERE enroll_id = ?").bind(enrollId).first<{ revoked_at: string }>())?.revoked_at; + + vi.setSystemTime(new Date("2026-06-25T09:00:00Z")); + expect(await revokeOrbEnrollment(e, enrollId)).toEqual({ revoked: true }); + + const secondRevokedAt = (await db(e).prepare("SELECT revoked_at FROM orb_enrollments WHERE enroll_id = ?").bind(enrollId).first<{ revoked_at: string }>())?.revoked_at; + expect(secondRevokedAt).toBe(firstRevokedAt); + }); +}); + describe("brokerOrbToken", () => { it("mints a token for a valid enrollment on a registered install (id bound server-side)", async () => { const e = await brokerEnv(); @@ -236,6 +309,40 @@ describe("brokerOrbToken", () => { expect(await brokerOrbToken(e, secret)).toEqual({ error: "installation_not_eligible" }); }); + it("#8064: exchanges a stored tenant-db-credential secret verbatim, with no App credentials or install eligibility involved", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-stored-secret-test" }); + const { secret } = (await issueOrbStoredSecret(e, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, "postgres://tenant-acme:hunter2@neon/acme")) as { secret: string }; + + expect(await brokerOrbToken(e, secret)).toEqual({ secretValue: "postgres://tenant-acme:hunter2@neon/acme", secretType: ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL }); + expect((await db(e).prepare("SELECT last_token_at FROM orb_enrollments WHERE secret_hash = ?").bind(await hashToken(secret)).first<{ last_token_at: string | null }>())?.last_token_at).not.toBeNull(); + }); + + it("#8064: refuses to serve a stored secret with no TOKEN_ENCRYPTION_SECRET configured at exchange time", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-stored-secret-test" }); + const { secret } = (await issueOrbStoredSecret(e, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, "postgres://tenant-acme")) as { secret: string }; + + const eNoKey = await brokerEnvMissingAppCreds("both", { DB: e.DB }); + expect(await brokerOrbToken(eNoKey, secret)).toEqual({ error: "broker_misconfigured" }); + }); + + it("#8064: refuses to serve a stored secret it can no longer decrypt (e.g. a rotated encryption key)", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-stored-secret-test" }); + const { secret } = (await issueOrbStoredSecret(e, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, "postgres://tenant-acme")) as { secret: string }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const eRotatedKey = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "a-completely-different-key", DB: e.DB }); + expect(await brokerOrbToken(eRotatedKey, secret)).toEqual({ error: "broker_misconfigured" }); + expect(warn.mock.calls.map((c) => String(c[0])).some((line) => line.includes("orb_broker_stored_secret_decrypt_failed"))).toBe(true); + }); + + it("#8064: refuses to serve a row that claims the stored-secret type but has no value written (defensive)", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-stored-secret-test" }); + await seedInstall(e, 502, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 502, undefined, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL)) as { secret: string }; + + expect(await brokerOrbToken(e, secret)).toEqual({ error: "broker_misconfigured" }); + }); + it("serves a still-fresh cached token even with no Orb App credentials at all (mint is never reached)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-25T07:00:00Z")); @@ -353,4 +460,59 @@ describe("broker endpoints", () => { expect((await app.request("/v1/internal/orb/enrollments", { method: "POST", headers: auth, body: JSON.stringify({ installationId: 402 }) }, e)).status).toBe(409); expect((await app.request("/v1/internal/orb/enrollments", { method: "POST", headers: auth, body: JSON.stringify({ installationId: 999 }) }, e)).status).toBe(404); }); + + it("#8064: the full stored-secret issue → exchange flow over HTTP, and installationId is irrelevant to it", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-route-stored-secret" }); + const issueRes = await app.request( + "/v1/internal/orb/enrollments", + { method: "POST", headers: auth, body: JSON.stringify({ secretType: "tenant_db_credential", secretValue: "postgres://tenant-acme" }) }, + e, + ); + expect(issueRes.status).toBe(200); + const { secret } = (await issueRes.json()) as { secret: string }; + + const tokRes = await app.request("/v1/orb/token", { method: "POST", headers: { authorization: `Bearer ${secret}` } }, e); + expect(tokRes.status).toBe(200); + expect(await tokRes.json()).toEqual({ secretValue: "postgres://tenant-acme", secretType: "tenant_db_credential" }); + }); + + it("#8064: POST /v1/internal/orb/enrollments 400s a stored-secret request with no secretValue, and 503s with no encryption key", async () => { + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-route-stored-secret" }); + const missingValue = await app.request( + "/v1/internal/orb/enrollments", + { method: "POST", headers: auth, body: JSON.stringify({ secretType: "tenant_db_credential" }) }, + e, + ); + expect(missingValue.status).toBe(400); + expect(await missingValue.json()).toEqual({ error: "secret_value_required" }); + + const eNoKey = await brokerEnv(); + const noKey = await app.request( + "/v1/internal/orb/enrollments", + { method: "POST", headers: auth, body: JSON.stringify({ secretType: "tenant_db_credential", secretValue: "postgres://tenant-acme" }) }, + eNoKey, + ); + expect(noKey.status).toBe(503); + expect(await noKey.json()).toEqual({ error: "encryption_unavailable" }); + }); + + it("#8064: POST /v1/internal/orb/enrollments/:enrollId/revoke 404s when the broker flag is off, 404s an unknown id, revokes a real one", async () => { + const off = createTestEnv({ INTERNAL_JOB_TOKEN: "dev-internal-token" }); + expect((await app.request("/v1/internal/orb/enrollments/orbenr_x/revoke", { method: "POST", headers: auth }, off)).status).toBe(404); + + const e = await brokerEnv(); + const unknown = await app.request("/v1/internal/orb/enrollments/orbenr_bogus/revoke", { method: "POST", headers: auth }, e); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toEqual({ error: "enrollment_not_found" }); + + await seedInstall(e, 600, { registered: 1 }); + const { enrollId, secret } = (await issueOrbEnrollment(e, 600)) as { enrollId: string; secret: string }; + const revokeRes = await app.request(`/v1/internal/orb/enrollments/${enrollId}/revoke`, { method: "POST", headers: auth }, e); + expect(revokeRes.status).toBe(200); + expect(await revokeRes.json()).toEqual({ revoked: true }); + + const afterRevoke = await app.request("/v1/orb/token", { method: "POST", headers: { authorization: `Bearer ${secret}` } }, e); + expect(afterRevoke.status).toBe(401); + expect(await afterRevoke.json()).toEqual({ error: "invalid_enrollment" }); + }); });