Skip to content

Commit 84c2e31

Browse files
committed
feat(orb): add a stored tenant-DB-credential secret type + generic revoke path to the token broker (#8064)
Adds ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL to src/orb/broker.ts: a STORED (not minted) secret type for a credential the caller already has in hand (e.g. a hosted tenant's Postgres connection string, #7180's provisioning core) rather than the GitHub-token type's mint-on-exchange shape. issueOrbStoredSecret encrypts and stores the value at issue time (new secret_value_ciphertext/iv/ salt/version columns, same shape as repositories.ts's BYOK provider-key storage); brokerOrbToken decrypts and returns it verbatim on exchange, with no installation-eligibility re-check, cache, or re-mint -- none of which apply to a value that isn't derived from a GitHub App. installation_id is always NULL on these rows: an AMS tenant has no GitHub installation at all, and even a hosted ORB tenant's installation lives in control-plane's own registry (#7181), not this table's orb_github_installations. Also adds revokeOrbEnrollment, a generic revoke path that works for ANY secret type -- brokerOrbToken's existing revoked_at check (since #7174) has always refused a revoked row, but nothing has ever written to that column until now. Idempotent: revoking an already-revoked enrollment succeeds without disturbing its original timestamp. POST /v1/internal/orb/enrollments gains an optional { secretType: "tenant_db_credential", secretValue } body for the new stored-secret issuance path; POST /v1/internal/orb/enrollments/:enrollId/revoke is the new admin-facing revoke route. Both sit behind the existing /v1/internal/* Bearer wall. The GitHub-token type's existing behavior is completely unchanged -- this is additive. Broker-side only: control-plane's own injectSecrets/revokeSecrets wiring against this is #8066, a separate, blocked-on-this sub-issue of #7852. Closes #8064
1 parent 1026fb3 commit 84c2e31

4 files changed

Lines changed: 308 additions & 15 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Loopover Orb token-broker (#8064) — a STORED (not minted) secret value, for a credential the caller already
2+
-- has in hand and just needs custody of (e.g. a hosted tenant's Postgres connection string, #7180's
3+
-- provisioning core) as opposed to the GitHub-token type's mint-on-exchange shape (#7174's secret_type
4+
-- discriminator). Same encrypted-at-rest triplet + explicit version column as repositories.ts's BYOK
5+
-- provider-key storage (repository_ai_keys) -- NOT broker.ts's own cached_token_json shape, which is a TTL'd
6+
-- mint CACHE, a different thing entirely from a value that must persist indefinitely with no re-derivation
7+
-- possible. NULL for every existing github_token row; that type never writes these columns.
8+
ALTER TABLE orb_enrollments ADD COLUMN secret_value_ciphertext TEXT;
9+
ALTER TABLE orb_enrollments ADD COLUMN secret_value_iv TEXT;
10+
ALTER TABLE orb_enrollments ADD COLUMN secret_value_salt TEXT;
11+
ALTER TABLE orb_enrollments ADD COLUMN secret_value_version INTEGER;

src/api/routes.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,14 @@ import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest";
155155
import { handleAmsIngest } from "../ams/ingest";
156156
import { handleOrbWebhook } from "../orb/webhook";
157157
import { handleOrbOAuthCallback } from "../orb/oauth";
158-
import { brokerOrbToken, isOrbBrokerEnabled, issueOrbEnrollment } from "../orb/broker";
158+
import {
159+
brokerOrbToken,
160+
isOrbBrokerEnabled,
161+
issueOrbEnrollment,
162+
issueOrbStoredSecret,
163+
ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL,
164+
revokeOrbEnrollment,
165+
} from "../orb/broker";
159166
import {
160167
enqueueConfigPushRelay,
161168
MAX_ORB_RELAY_REGISTER_BODY_BYTES,
@@ -4615,16 +4622,37 @@ export function createApp() {
46154622
// Operator-only: issue a one-time token-broker enrollment secret for a REGISTERED install, to hand to that
46164623
// maintainer's self-hosted container. The secret is returned ONCE (stored only hashed). Bearer-gated by the
46174624
// /v1/internal/* middleware (INTERNAL_JOB_TOKEN); flag-gated (404 until ORB_BROKER_ENABLED).
4625+
//
4626+
// Also accepts an optional `{ secretType: "tenant_db_credential", secretValue }` body (#8064) -- the STORED-
4627+
// secret issuance path control-plane's hosted provisioning core (#7180/#8066) calls instead, for a credential
4628+
// that already exists (a tenant's Postgres connection string) rather than a GitHub installation to bind.
4629+
// `installationId` is irrelevant to that path (see issueOrbStoredSecret's own header comment for why).
46184630
app.post("/v1/internal/orb/enrollments", async (c) => {
46194631
if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404);
4620-
const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown } | null;
4632+
const payload = (await c.req.json().catch(() => null)) as { installationId?: unknown; secretType?: unknown; secretValue?: unknown } | null;
4633+
if (payload?.secretType === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) {
4634+
const secretValue = typeof payload.secretValue === "string" ? payload.secretValue : "";
4635+
const result = await issueOrbStoredSecret(c.env, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, secretValue);
4636+
if ("error" in result) return c.json(result, result.error === "secret_value_required" ? 400 : 503);
4637+
return c.json(result); // { enrollId, secret } — secret shown exactly once
4638+
}
46214639
const installationId = Number(payload?.installationId);
46224640
if (!Number.isInteger(installationId) || installationId <= 0) return c.json({ error: "installationId required" }, 400);
46234641
const result = await issueOrbEnrollment(c.env, installationId);
46244642
if ("error" in result) return c.json(result, result.error === "installation_not_found" ? 404 : 409);
46254643
return c.json(result); // { enrollId, secret } — secret shown exactly once
46264644
});
46274645

4646+
// Operator-only: revoke a token-broker enrollment (#8064) -- works for ANY secret type (GitHub-token or
4647+
// stored), since brokerOrbToken's own revoked_at check (unchanged, #7174) already refuses any revoked row on
4648+
// its very next exchange attempt. Idempotent: revoking an already-revoked enrollment still reports success.
4649+
app.post("/v1/internal/orb/enrollments/:enrollId/revoke", async (c) => {
4650+
if (!isOrbBrokerEnabled(c.env)) return c.json({ error: "not_found" }, 404);
4651+
const result = await revokeOrbEnrollment(c.env, c.req.param("enrollId"));
4652+
if ("error" in result) return c.json(result, 404);
4653+
return c.json(result);
4654+
});
4655+
46284656
// Convergence (ops / observability, flag LOOPOVER_REVIEW_OPS). Cross-repo review-OUTCOME aggregate (gate-block
46294657
// ledger + recommendation/slop calibration) for an operator dashboard. Bearer-gated by the `/v1/internal/*`
46304658
// middleware above (INTERNAL_JOB_TOKEN). Flag-OFF (default) → 404, so the endpoint does not exist and the

src/orb/broker.ts

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@ import { createOrbInstallationToken } from "./app-auth";
1919
// entry is never handed out (covers clock skew + the engine's own ~5m cache margin).
2020
const ORB_TOKEN_CACHE_MIN_REMAINING_MS = 10 * 60_000;
2121

22-
// The only secret type this broker actually knows how to mint today (#7174). The `secret_type` column exists
23-
// so a future AI-provider-key / DB-credential mint strategy (the hosted control-plane's provisioning core,
24-
// #7180) can record what an enrollment row is FOR without inventing a second table — but until that strategy
25-
// exists, any row carrying a different value is a config/data error brokerOrbToken must refuse, not silently
26-
// GitHub-mint against.
22+
// The original secret type this broker knows how to mint (#7174). The `secret_type` column exists so a future
23+
// AI-provider-key / DB-credential strategy (the hosted control-plane's provisioning core, #7180) can record
24+
// what an enrollment row is FOR without inventing a second table — any row carrying a value this file doesn't
25+
// recognize is a config/data error brokerOrbToken must refuse, not silently GitHub-mint against.
2726
export const ORB_SECRET_TYPE_GITHUB_TOKEN = "github_token";
2827

28+
// A STORED (not minted) secret type (#8064, split from #7852/#7180): a credential the caller already has in
29+
// hand (e.g. a hosted tenant's Postgres connection string) that this broker just holds custody of, encrypted
30+
// at rest, and hands back verbatim on exchange — no installation-eligibility re-check, no mint/cache TTL logic,
31+
// none of which apply to a value that isn't derived from a GitHub App at all. See issueOrbStoredSecret and
32+
// brokerOrbToken's own secret_type branch below.
33+
export const ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL = "tenant_db_credential";
34+
2935
export function isOrbBrokerEnabled(env: Env): boolean {
3036
return /^(1|true|yes|on)$/i.test(String(env.ORB_BROKER_ENABLED ?? "").trim());
3137
}
@@ -57,27 +63,90 @@ export async function issueOrbEnrollment(
5763
return { enrollId, secret };
5864
}
5965

66+
export type IssueStoredSecretResult = IssueResult | { error: "secret_value_required" | "encryption_unavailable" };
67+
68+
/** Issues a one-time enrollment secret for a STORED (not minted) credential (#8064) -- e.g. control-plane's
69+
* hosted tenant Postgres connection details (#7180's provisioning core). Deliberately does NOT reuse
70+
* issueOrbEnrollment's installation-registration gate: that gate exists because a GitHub-token enrollment is
71+
* a maintainer's self-hosted container proving it administers a REAL, registered GitHub installation -- a
72+
* stored tenant secret has no GitHub installation to bind to at all (an AMS tenant has none; even a hosted
73+
* ORB tenant's installation lives in control-plane's own registry, #7181, not this table's
74+
* orb_github_installations). `installation_id` is therefore always NULL on these rows. This issuance path's
75+
* authority is the caller already holding the internal admin token -- the same /v1/internal/* middleware
76+
* every other operator-only route in routes.ts sits behind -- not installation registration. */
77+
export async function issueOrbStoredSecret(env: Env, secretType: string, secretValue: string): Promise<IssueStoredSecretResult> {
78+
if (!secretValue) return { error: "secret_value_required" };
79+
if (!env.TOKEN_ENCRYPTION_SECRET) return { error: "encryption_unavailable" };
80+
const enrollId = createOpaqueToken("orbenr");
81+
const secret = createOpaqueToken("orbsec");
82+
const encrypted = await encryptSecret(secretValue, env.TOKEN_ENCRYPTION_SECRET);
83+
await env.DB.prepare(
84+
`INSERT INTO orb_enrollments
85+
(enroll_id, installation_id, secret_hash, secret_type, state, authorized_at, enrolled_at,
86+
secret_value_ciphertext, secret_value_iv, secret_value_salt, secret_value_version)
87+
VALUES (?, NULL, ?, ?, 'enrolled', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?, ?, ?)`,
88+
)
89+
.bind(enrollId, await hashToken(secret), secretType, encrypted.ciphertext, encrypted.iv, encrypted.salt, encrypted.version)
90+
.run();
91+
return { enrollId, secret };
92+
}
93+
94+
export type RevokeResult = { revoked: true } | { error: "enrollment_not_found" };
95+
96+
/** Generic revoke path (#8064): works for ANY secret type, since brokerOrbToken's very first gate (both the
97+
* original GitHub-token mint flow and the new stored-secret flow below) already refuses any row with a
98+
* non-null revoked_at -- that check has existed since #7174 but nothing has ever WRITTEN to the column until
99+
* now. Idempotent: revoking an already-revoked enrollment succeeds without disturbing its original
100+
* revoked_at (COALESCE keeps the first revocation's timestamp, matching every other driver's teardown
101+
* contract in this codebase -- a repeat revoke is a no-op, not a second event). */
102+
export async function revokeOrbEnrollment(env: Env, enrollId: string): Promise<RevokeResult> {
103+
const existing = await env.DB.prepare("SELECT enroll_id FROM orb_enrollments WHERE enroll_id = ?").bind(enrollId).first<{ enroll_id: string }>();
104+
if (!existing) return { error: "enrollment_not_found" };
105+
await env.DB.prepare("UPDATE orb_enrollments SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP), state = 'revoked' WHERE enroll_id = ?")
106+
.bind(enrollId)
107+
.run();
108+
return { revoked: true };
109+
}
110+
60111
export type BrokerResult =
61112
| { token: string; installationId: number; expiresAt: string; permissions: Record<string, string> }
113+
| { secretValue: string; secretType: string }
62114
| { error: "invalid_enrollment" | "installation_not_eligible" | "broker_misconfigured" | "unsupported_secret_type" };
63115

64-
/** The container's token-exchange: a valid enrollment secret → a short-lived installation token for the BOUND
65-
* install. installation_id is read from the enrollment row, never the caller; the install must still be
66-
* registered=1 and neither suspended nor removed at mint time (the gate is re-checked, not trusted from issue). */
116+
type OrbEnrollmentRow = {
117+
enroll_id: string;
118+
installation_id: number;
119+
state: string;
120+
revoked_at: string | null;
121+
cached_token_json: string | null;
122+
secret_type: string;
123+
secret_value_ciphertext: string | null;
124+
secret_value_iv: string | null;
125+
secret_value_salt: string | null;
126+
};
127+
128+
/** The container's token-exchange: a valid enrollment secret → either a short-lived GitHub installation token
129+
* (the original, mint-style flow) or a decrypted stored secret value (#8064's store-style flow), branching on
130+
* the enrollment row's own secret_type. installation_id/eligibility only apply to the GitHub-token flow — a
131+
* stored secret has no GitHub installation to re-check at all (see issueOrbStoredSecret's header comment). */
67132
export async function brokerOrbToken(env: Env, secret: string, options: { forceRefresh?: boolean } = {}): Promise<BrokerResult> {
68133
// Warn when TOKEN_ENCRYPTION_SECRET is absent — without it, the broker cache is bypassed and every exchange hits
69134
// GitHub's token endpoint, dramatically increasing exposure to throttle-induced failures.
70135
if (!env.TOKEN_ENCRYPTION_SECRET) {
71136
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." }));
72137
}
73138
const row = await env.DB
74-
.prepare("SELECT enroll_id, installation_id, state, revoked_at, cached_token_json, secret_type FROM orb_enrollments WHERE secret_hash = ?")
139+
.prepare(
140+
`SELECT enroll_id, installation_id, state, revoked_at, cached_token_json, secret_type,
141+
secret_value_ciphertext, secret_value_iv, secret_value_salt
142+
FROM orb_enrollments WHERE secret_hash = ?`,
143+
)
75144
.bind(await hashToken(secret))
76-
.first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null; cached_token_json: string | null; secret_type: string }>();
145+
.first<OrbEnrollmentRow>();
77146
if (!row || row.state !== "enrolled" || row.revoked_at !== null) return { error: "invalid_enrollment" };
147+
if (row.secret_type === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) return resolveStoredSecret(env, row);
78148
// Checked once the caller is already proven to hold a valid enrollment (same ordering rationale as the App-
79-
// credential check below, #2710) — this endpoint only ever mints GitHub installation tokens; a row recorded
80-
// for a different secret type belongs to a different mint strategy that doesn't exist yet.
149+
// credential check below, #2710) — anything else here belongs to a mint strategy that doesn't exist yet.
81150
if (row.secret_type !== ORB_SECRET_TYPE_GITHUB_TOKEN) return { error: "unsupported_secret_type" };
82151
const install = await env.DB
83152
.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
107176
return { token: minted.token, installationId: row.installation_id, expiresAt: minted.expiresAt, permissions: minted.permissions };
108177
}
109178

179+
/** Decrypts and returns a STORED secret value (#8064) -- the exchange-time counterpart to
180+
* issueOrbStoredSecret's encrypt-and-store. Unlike the GitHub-token flow above, there is no cache, no
181+
* re-mint, and no installation-eligibility check: the value was already fixed at issue time, so the ONLY way
182+
* this can fail is a server-side config/data problem (no encryption key configured, a rotated key that can no
183+
* longer decrypt an older value, or -- defensively -- a row that claims this secret_type but never actually
184+
* got a value written, which should be impossible via issueOrbStoredSecret but is checked anyway). Every
185+
* failure reuses broker_misconfigured: none of them are the caller's fault, matching this file's existing
186+
* posture that a bad App-credential config (above) is never reported as "invalid_enrollment". */
187+
async function resolveStoredSecret(env: Env, row: OrbEnrollmentRow): Promise<BrokerResult> {
188+
if (!env.TOKEN_ENCRYPTION_SECRET || !row.secret_value_ciphertext || !row.secret_value_iv) {
189+
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." }));
190+
return { error: "broker_misconfigured" };
191+
}
192+
try {
193+
const secretValue = await decryptSecret(row.secret_value_ciphertext, row.secret_value_iv, env.TOKEN_ENCRYPTION_SECRET, row.secret_value_salt);
194+
await touchLastToken(env, row.enroll_id);
195+
return { secretValue, secretType: row.secret_type };
196+
} catch (error) {
197+
console.warn(JSON.stringify({ level: "warn", event: "orb_broker_stored_secret_decrypt_failed", enrollId: row.enroll_id, message: String(error).slice(0, 120) }));
198+
return { error: "broker_misconfigured" };
199+
}
200+
}
201+
110202
async function touchLastToken(env: Env, enrollId: string): Promise<void> {
111203
try {
112204
await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(enrollId).run();

0 commit comments

Comments
 (0)