Skip to content

Commit 3494297

Browse files
authored
fix(control-plane): revoke orphaned secrets when createContainer fails after injectSecrets (#8255)
The #8202 reorder (database -> injectSecrets -> createContainer) opened a window where injectSecrets succeeds and mints secretRef, then createContainer fails right after -- since provisionTenant always rethrows rather than returning on a step failure, secretRef never reached the caller to persist and later revoke, permanently orphaning a live credential in the broker. Unreachable before the reorder, since injectSecrets used to be the last step. provisionTenant now best-effort revokes that secretRef itself before rethrowing, and the PagerDuty alert carries it too as a fallback for when the revoke itself fails (e.g. broker unreachable).
1 parent 95b758c commit 3494297

4 files changed

Lines changed: 180 additions & 8 deletions

File tree

control-plane/src/pagerduty-notify.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,17 @@ function warnProvisioningPagerDutyFailed(tenantName: string, error: unknown): vo
5050

5151
/** Build the alert payload for a provisioning or deprovisioning failure (#7667). Pure -- no IO. `error` is
5252
* coerced through the same {@link pagerDutyFailMessage} helper the IO path uses for its own failure logging, so
53-
* the paged summary and any local warn log agree on the same truncated message. */
53+
* the paged summary and any local warn log agree on the same truncated message. `secretRef` (#8202, optional)
54+
* is included in `customDetails` when the caller already had one at failure time -- provisionTenant's own
55+
* best-effort revoke (provisioning.ts) is the primary defense against a dangling broker secret, but a revoke
56+
* can itself fail (e.g. broker unreachable), so the page still needs to hand an operator something to manually
57+
* revoke by rather than nothing at all. */
5458
export function buildProvisioningPagerDutyAlert(input: {
5559
tenantName: string;
5660
product: string;
5761
phase: "provision" | "deprovision";
5862
error: unknown;
63+
secretRef?: string;
5964
}): ProvisioningPagerDutyAlert {
6065
const message = pagerDutyFailMessage(input.error);
6166
return {
@@ -65,7 +70,13 @@ export function buildProvisioningPagerDutyAlert(input: {
6570
summary: `${input.product} tenant ${input.phase} failed for ${input.tenantName}: ${message}`,
6671
severity: "critical",
6772
dedupKey: `control_plane_${input.phase}_failed:${input.product}:${input.tenantName}`,
68-
customDetails: { tenantName: input.tenantName, product: input.product, phase: input.phase, message },
73+
customDetails: {
74+
tenantName: input.tenantName,
75+
product: input.product,
76+
phase: input.phase,
77+
message,
78+
...(input.secretRef !== undefined ? { secretRef: input.secretRef } : {}),
79+
},
6980
};
7081
}
7182

control-plane/src/provisioning.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ function pageAndRethrow(
6969
phase: "provision" | "deprovision",
7070
error: unknown,
7171
options: ProvisioningPagerDutyOptions,
72+
secretRef?: string,
7273
): never {
73-
const alert = buildProvisioningPagerDutyAlert({ tenantName: tenant.name, product, phase, error });
74+
const alert = buildProvisioningPagerDutyAlert({ tenantName: tenant.name, product, phase, error, secretRef });
7475
const notify = options.notify ?? notifyProvisioningFailure;
7576
const env = options.env ?? process.env;
7677
const warnNotifyFailed = (notifyError: unknown): void => {
@@ -93,9 +94,13 @@ function pageAndRethrow(
9394
* just the tenant identity every other step operates on. `createContainer` is in turn called with `database`
9495
* still attached AND `bootstrapSecret` newly attached (#8202) whenever `injectSecrets` returned one -- a real
9596
* container driver delivers it into the container's own cold-boot environment. A step failure pages (#7667) and
96-
* always rethrows — provisioning never fails silently. `onFailure` (#7677, optional) runs first in that failure
97-
* path — the caller's seam for persisting the `"failed"` lifecycle state — and is best-effort: its own
98-
* rejection is swallowed so it can never mask the step error. */
97+
* always rethrows — provisioning never fails silently. If `createContainer` is what failed AFTER `injectSecrets`
98+
* already succeeded (#8202), this function best-effort revokes that just-injected secret itself before
99+
* rethrowing -- since it always throws rather than returning on failure, no caller ever gets a chance to persist
100+
* `secretRef` for a later `deprovisionTenant` otherwise, which would permanently orphan a live credential in the
101+
* broker. `onFailure` (#7677, optional) runs after that in the same failure path — the caller's seam for
102+
* persisting the `"failed"` lifecycle state — and, like the revoke attempt, is best-effort: neither's own
103+
* rejection can mask the step error. */
99104
export async function provisionTenant(
100105
tenant: Tenant,
101106
product: Product,
@@ -112,12 +117,34 @@ export async function provisionTenant(
112117
secretRef = injected.secretRef;
113118
await driver.createContainer({ ...request, database, ...(injected.bootstrapSecret !== undefined ? { bootstrapSecret: injected.bootstrapSecret } : {}) });
114119
} catch (error) {
120+
// #8202: injectSecrets can succeed (custodying a real secret + minting secretRef) and createContainer can
121+
// still fail right after it (Cloudflare quota, a transient container-API error) -- since this function
122+
// always rethrows rather than returning on a step failure, secretRef would otherwise never reach the caller
123+
// to persist and later revoke, permanently orphaning a live, exchangeable credential in the broker (this
124+
// was unreachable before #8202: injectSecrets used to be the LAST step, so nothing after it could fail once
125+
// secretRef was set). Best-effort revoke it here, before rethrowing, so this function cleans up after
126+
// itself rather than counting on a caller that has no way to know the secret exists. Swallowed like
127+
// onFailure below: a revoke failure (e.g. broker unreachable) must never mask the real provisioning error --
128+
// that's exactly why the PagerDuty alert below still carries secretRef, as an operator's last resort.
129+
if (secretRef !== undefined) {
130+
await driver.revokeSecrets({ ...request, secretRef }).catch((revokeError: unknown) => {
131+
console.warn(
132+
JSON.stringify({
133+
event: "provisioning_orphaned_secret_revoke_failed",
134+
tenant: tenant.name,
135+
product,
136+
secretRef,
137+
message: pagerDutyFailMessage(revokeError),
138+
}),
139+
);
140+
});
141+
}
115142
// #7677 (ratified 2026-07-21): give the caller its chance to transition the tenant's registry record to
116143
// "failed" BEFORE the rethrow, so a customer polling the read path sees a terminal "Setup failed" instead
117144
// of a record stuck at "provisioning" forever. Best-effort by design: a failure writing the failed state
118145
// must never mask the provisioning error itself, which still pages and rethrows exactly as before.
119146
if (onFailure) await onFailure().catch(() => undefined);
120-
pageAndRethrow(tenant, product, "provision", error, pagerDuty);
147+
pageAndRethrow(tenant, product, "provision", error, pagerDuty, secretRef);
121148
}
122149
return { tenant, product, state: "active", database, ...(secretRef !== undefined ? { secretRef } : {}) };
123150
}
@@ -141,7 +168,9 @@ export async function deprovisionTenant(
141168
await driver.dropDatabase(request);
142169
await driver.destroyContainer(request);
143170
} catch (error) {
144-
pageAndRethrow(tenant, product, "deprovision", error, pagerDuty);
171+
// secretRef is already known here (the caller's own input, not something this function minted) -- passed
172+
// along so an operator paged for a deprovision failure doesn't have to go look it up separately.
173+
pageAndRethrow(tenant, product, "deprovision", error, pagerDuty, secretRef);
145174
}
146175
return { tenant, product, state: "torn down" };
147176
}

control-plane/test/pagerduty-notify.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ test("buildProvisioningPagerDutyAlert: provision failure builds a critical alert
4343
});
4444
});
4545

46+
test("buildProvisioningPagerDutyAlert: includes secretRef in customDetails when given (#8202)", () => {
47+
const alert = buildProvisioningPagerDutyAlert({
48+
tenantName: "acme",
49+
product: "orb",
50+
phase: "provision",
51+
error: new Error("container quota exceeded"),
52+
secretRef: "orbenr_abc",
53+
});
54+
55+
assert.equal(alert.customDetails.secretRef, "orbenr_abc");
56+
});
57+
4658
test("buildProvisioningPagerDutyAlert: deprovision failure coerces a non-Error thrown value (#7667)", () => {
4759
const alert = buildProvisioningPagerDutyAlert({
4860
tenantName: "acme",

control-plane/test/provisioning-pagerduty.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type ProvisioningPagerDutyAlert,
1313
type Tenant,
1414
type TenantProvisioningDriver,
15+
type TenantProvisioningRequest,
1516
} from "../dist/index.js";
1617

1718
/** A driver where exactly one named step throws `error`; every other step is a no-op success. */
@@ -153,3 +154,122 @@ test("deprovisionTenant defaults to the real notifyProvisioningFailure + process
153154

154155
await assert.rejects(deprovisionTenant(tenant, "ams", driver), /db drop failed/);
155156
});
157+
158+
// #8202: injectSecrets moved ahead of createContainer, so secretRef can now be minted and THEN orphaned if
159+
// createContainer fails right after -- provisionTenant always rethrows rather than returning, so no caller ever
160+
// gets secretRef to persist and revoke later otherwise. These prove the fix: a best-effort self-revoke, safe
161+
// even when that revoke itself fails, and correctly scoped to only fire once a real secretRef actually exists.
162+
163+
test("#8202: provisionTenant best-effort revokes the just-injected secret when createContainer fails right after, before rethrowing", async () => {
164+
const revokeCalls: TenantProvisioningRequest[] = [];
165+
const driver: TenantProvisioningDriver = {
166+
...createFakeTenantProvisioningDriver(),
167+
injectSecrets: async () => ({ secretRef: "orbenr_abc", bootstrapSecret: "orbsec_xyz" }),
168+
createContainer: async () => {
169+
throw new Error("container quota exceeded");
170+
},
171+
revokeSecrets: async (request) => {
172+
revokeCalls.push(request);
173+
},
174+
};
175+
const tenant: Tenant = { name: "acme" };
176+
177+
await assert.rejects(provisionTenant(tenant, "orb", driver), /container quota exceeded/);
178+
179+
assert.equal(revokeCalls.length, 1);
180+
assert.equal(revokeCalls[0]?.secretRef, "orbenr_abc");
181+
});
182+
183+
test("#8202: a failure in the best-effort revoke itself does not mask the real createContainer error", async () => {
184+
const driver: TenantProvisioningDriver = {
185+
...createFakeTenantProvisioningDriver(),
186+
injectSecrets: async () => ({ secretRef: "orbenr_abc" }),
187+
createContainer: async () => {
188+
throw new Error("container quota exceeded");
189+
},
190+
revokeSecrets: async () => {
191+
throw new Error("broker unreachable");
192+
},
193+
};
194+
const tenant: Tenant = { name: "acme" };
195+
196+
await assert.rejects(provisionTenant(tenant, "orb", driver), /container quota exceeded/);
197+
});
198+
199+
test("#8202: provisionTenant does NOT attempt a revoke when no secretRef was ever obtained (e.g. provisionDatabase itself failed)", async () => {
200+
const revokeCalls: TenantProvisioningRequest[] = [];
201+
const driver: TenantProvisioningDriver = {
202+
...driverThatThrowsOn("provisionDatabase", new Error("db provisioning failed")),
203+
revokeSecrets: async (request) => {
204+
revokeCalls.push(request);
205+
},
206+
};
207+
const tenant: Tenant = { name: "acme" };
208+
209+
await assert.rejects(provisionTenant(tenant, "orb", driver), /db provisioning failed/);
210+
211+
assert.equal(revokeCalls.length, 0);
212+
});
213+
214+
test("#8202: provisionTenant does NOT attempt a revoke when injectSecrets itself is the step that failed", async () => {
215+
const revokeCalls: TenantProvisioningRequest[] = [];
216+
const driver: TenantProvisioningDriver = {
217+
...driverThatThrowsOn("injectSecrets", new Error("secret injection failed")),
218+
revokeSecrets: async (request) => {
219+
revokeCalls.push(request);
220+
},
221+
};
222+
const tenant: Tenant = { name: "acme" };
223+
224+
await assert.rejects(provisionTenant(tenant, "orb", driver), /secret injection failed/);
225+
226+
assert.equal(revokeCalls.length, 0);
227+
});
228+
229+
test("#8202: the PagerDuty alert carries secretRef when injectSecrets had already succeeded before the failing step", async () => {
230+
const calls: ProvisioningPagerDutyAlert[] = [];
231+
const notify: NotifyProvisioningFailure = async (alert) => {
232+
calls.push(alert);
233+
};
234+
const driver: TenantProvisioningDriver = {
235+
...createFakeTenantProvisioningDriver(),
236+
injectSecrets: async () => ({ secretRef: "orbenr_abc" }),
237+
createContainer: async () => {
238+
throw new Error("container quota exceeded");
239+
},
240+
};
241+
const tenant: Tenant = { name: "acme" };
242+
243+
await assert.rejects(provisionTenant(tenant, "orb", driver, { notify }), /container quota exceeded/);
244+
await Promise.resolve();
245+
246+
assert.equal(calls[0]?.customDetails.secretRef, "orbenr_abc");
247+
});
248+
249+
test("#8202: the PagerDuty alert omits secretRef entirely when none was ever obtained", async () => {
250+
const calls: ProvisioningPagerDutyAlert[] = [];
251+
const notify: NotifyProvisioningFailure = async (alert) => {
252+
calls.push(alert);
253+
};
254+
const driver = driverThatThrowsOn("provisionDatabase", new Error("db provisioning failed"));
255+
const tenant: Tenant = { name: "acme" };
256+
257+
await assert.rejects(provisionTenant(tenant, "orb", driver, { notify }), /db provisioning failed/);
258+
await Promise.resolve();
259+
260+
assert.equal("secretRef" in (calls[0]?.customDetails ?? {}), false);
261+
});
262+
263+
test("#8202: deprovisionTenant's PagerDuty alert carries the secretRef it was given, for operator convenience", async () => {
264+
const calls: ProvisioningPagerDutyAlert[] = [];
265+
const notify: NotifyProvisioningFailure = async (alert) => {
266+
calls.push(alert);
267+
};
268+
const driver = driverThatThrowsOn("dropDatabase", new Error("db drop failed"));
269+
const tenant: Tenant = { name: "acme" };
270+
271+
await assert.rejects(deprovisionTenant(tenant, "ams", driver, { notify }, "orbenr_abc"), /db drop failed/);
272+
await Promise.resolve();
273+
274+
assert.equal(calls[0]?.customDetails.secretRef, "orbenr_abc");
275+
});

0 commit comments

Comments
 (0)