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
11 changes: 11 additions & 0 deletions migrations/0173_orb_enrollment_secret_value.sql
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 30 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4615,16 +4622,37 @@ 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);
if ("error" in result) return c.json(result, result.error === "installation_not_found" ? 404 : 409);
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
Expand Down
116 changes: 104 additions & 12 deletions src/orb/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -57,27 +63,90 @@ 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<IssueStoredSecretResult> {
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<RevokeResult> {
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<string, string> }
| { 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<BrokerResult> {
// 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.
if (!env.TOKEN_ENCRYPTION_SECRET) {
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<OrbEnrollmentRow>();
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 = ?")
Expand Down Expand Up @@ -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<BrokerResult> {
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<void> {
try {
await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(enrollId).run();
Expand Down
Loading