Skip to content

Commit 3ffc26e

Browse files
committed
Key the health probe gate structurally to prevent cross-tenant collisions
1 parent bfd0fa8 commit 3ffc26e

2 files changed

Lines changed: 97 additions & 2 deletions

File tree

packages/core/sdk/src/connections.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
IntegrationSlug,
1818
ProviderItemId,
1919
ProviderKey,
20+
Subject,
21+
Tenant,
2022
ToolAddress,
2123
ToolName,
2224
} from "./ids";
@@ -1431,3 +1433,85 @@ describe("heal-on-use", () => {
14311433
}),
14321434
);
14331435
});
1436+
1437+
describe("health probe gate key integrity", () => {
1438+
// The in-flight probe gate is shared across every executor holding the same
1439+
// root db handle, so the key must be collision-free across tenants. A
1440+
// colon-join is not: tenant and subject are opaque strings that may contain
1441+
// colons, so (tenant "a", subject "user:b") and (tenant "a:user", subject
1442+
// "b") both read "a:user:user:b:<integration>:<name>" — and colliding keys
1443+
// share one Deferred, serving one tenant's probe outcome (run with ITS
1444+
// credentials) as the other tenant's health verdict.
1445+
it.effect("colliding colon-join identities run two distinct probes, not one shared gate", () =>
1446+
Effect.gen(function* () {
1447+
const counters = { probes: 0 };
1448+
const gate = yield* Deferred.make<void>();
1449+
// Every probe increments the shared counter and then parks on the gate,
1450+
// so both checks are provably in flight at once: nothing is persisted,
1451+
// and a collided gate would let the second check join the first probe's
1452+
// Deferred instead of starting its own.
1453+
const probingPlugin = definePlugin(() => ({
1454+
id: "healthgate" as const,
1455+
credentialProviders: [memoryProvider()],
1456+
storage: () => ({}),
1457+
resolveTools: () =>
1458+
Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }),
1459+
invokeTool: ({ toolRow, credential }) =>
1460+
Effect.succeed({ ran: toolRow.name, value: credential.value }),
1461+
checkHealth: () =>
1462+
Effect.suspend(() => {
1463+
counters.probes += 1;
1464+
return Deferred.await(gate).pipe(
1465+
Effect.map(() => ({
1466+
status: "healthy" as const,
1467+
checkedAt: Date.now(),
1468+
detail: "probe ok",
1469+
})),
1470+
);
1471+
}),
1472+
extension: (ctx) => ({
1473+
seed: () =>
1474+
ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }),
1475+
}),
1476+
}));
1477+
1478+
// Both executors share ONE root db handle — and therefore one gate map;
1479+
// only the key separates their probes.
1480+
const configA = makeTestConfig({
1481+
plugins: [probingPlugin()] as const,
1482+
tenant: "a",
1483+
subject: "user:b",
1484+
});
1485+
const executorA = yield* createExecutor(configA);
1486+
const executorB = yield* createExecutor({
1487+
...configA,
1488+
tenant: Tenant.make("a:user"),
1489+
subject: Subject.make("b"),
1490+
plugins: [probingPlugin()] as const,
1491+
});
1492+
yield* executorA.healthgate.seed();
1493+
yield* executorB.healthgate.seed();
1494+
const ref = {
1495+
owner: "user",
1496+
name: ConnectionName.make("main"),
1497+
integration: INTEG,
1498+
} as const;
1499+
yield* executorA.connections.create({ ...ref, template: TEMPLATE, value: "token-a" });
1500+
yield* executorB.connections.create({ ...ref, template: TEMPLATE, value: "token-b" });
1501+
1502+
const checkA = yield* Effect.forkChild(executorA.connections.checkHealth(ref));
1503+
const checkB = yield* Effect.forkChild(executorB.connections.checkHealth(ref));
1504+
// Give both fibers real time to reach the probe path while the gate
1505+
// holds every probe open; the counter then says how many started.
1506+
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)));
1507+
expect(counters.probes).toBe(2);
1508+
1509+
yield* Deferred.succeed(gate, void 0);
1510+
const resultA = yield* Fiber.join(checkA);
1511+
const resultB = yield* Fiber.join(checkB);
1512+
expect(resultA.status).toBe("healthy");
1513+
expect(resultB.status).toBe("healthy");
1514+
expect(counters.probes).toBe(2);
1515+
}),
1516+
);
1517+
});

packages/core/sdk/src/executor.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,16 @@ const healthProbeGateFor = (rootDb: object): HealthProbeGate => {
931931
return created;
932932
};
933933

934+
/** Gate key for one connection's in-flight probe. Structured (a JSON array),
935+
* never delimiter-joined: `tenant` and `subject` are opaque strings that may
936+
* themselves contain any delimiter, so a colon-join lets distinct identities
937+
* collide — tenant "a" + subject "user:b" reads exactly like tenant "a:user"
938+
* + subject "b" — and colliding identities would share one Deferred, serving
939+
* one tenant's probe outcome (run with ITS credentials) as another tenant's
940+
* health verdict. Same structured-key idiom as #1537's refresh gate. */
941+
const healthProbeGateKey = (tenant: string, row: ConnectionRow): string =>
942+
JSON.stringify([tenant, row.owner, row.subject, row.integration, row.name]);
943+
934944
const rowToConnection = (row: ConnectionRow): Connection => {
935945
const owner = row.owner as Owner;
936946
const integration = IntegrationSlug.make(row.integration);
@@ -1759,7 +1769,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17591769
};
17601770
const rootDb = withQueryContext(rootDbUntyped, ownerContext);
17611771
// Shared across executors over one database, so the gate key must carry the
1762-
// full partition (`tenant` + `connectionKey`), not just this closure's view.
1772+
// full partition (`healthProbeGateKey`: tenant + connection identity), not
1773+
// just this closure's view.
17631774
const healthProbeInFlight = healthProbeGateFor(rootDbUntyped);
17641775
const fuma = makeFumaClient(rootDb);
17651776
const core = makeCoreDb(fuma);
@@ -4031,7 +4042,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
40314042
// entry; each caller awaits the shared deferred and stamps its own
40324043
// span with the outcome.
40334044
const outcome = yield* Effect.suspend(() => {
4034-
const key = `${tenant}:${connectionKey(connectionRow)}`;
4045+
const key = healthProbeGateKey(tenant, connectionRow);
40354046
const existing = healthProbeInFlight.get(key);
40364047
if (existing) return Deferred.await(existing);
40374048
const deferred = Deferred.makeUnsafe<HealthProbeOutcome, StorageFailure>();

0 commit comments

Comments
 (0)