Skip to content

Commit 6be2ca1

Browse files
committed
feat(events): grade cross-tenant isolation via the fixture's tenant controls
The rule is that two tenants subscribing to the same `(name, arguments)` with the same callback get distinct subscriptions, because the principal is part of the key. A run authenticates as one principal for its lifetime, so the harness could only ever supply one side of that comparison and the row reported untestable. A fixture MAY now register on another principal's behalf and answer whether a given principal's subscription still exists. With both, the check does the comparison the rule is about: same event, same callback, principal the only thing that differs. Two things are graded, and the second is the one that bites. Distinct ids show the principal reached the key at all. A surviving subscription after the other tenant unsubscribes shows the two are genuinely independent, which is what a tenant would notice if it were not true — one tenant able to cancel another's subscription. askControl joins fireControl in helpers for the controls that answer with text rather than just succeeding. Servers without the controls are unaffected and keep reporting untestable, now naming the two tools that would make the row gradeable. Against mcpkit: events-webhook 17/23 to 18/23. Refs #504
1 parent f9eb9a4 commit 6be2ca1

2 files changed

Lines changed: 140 additions & 9 deletions

File tree

‎src/scenarios/server/events/helpers.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,37 @@ export const EVENTS_CONTROL_YIELD_GAP = 'events_conformance_yield_gap';
209209
* nothing else in the run depends on.
210210
*/
211211
export const EVENTS_CONTROL_TERMINATE = 'events_conformance_terminate';
212+
/**
213+
* Registers a subscription on another principal's behalf, returning its derived
214+
* id. A run authenticates as one principal for its lifetime, so this is the
215+
* only way to construct the two-tenant case the key-composition rule is about.
216+
*/
217+
export const EVENTS_CONTROL_SUBSCRIBE_AS = 'events_conformance_subscribe_as';
218+
/** Reports whether a given principal's subscription is still registered. */
219+
export const EVENTS_CONTROL_SUBSCRIPTION_EXISTS =
220+
'events_conformance_subscription_exists';
221+
222+
/**
223+
* Fire a control that answers with text, returning the text or undefined.
224+
* Distinct from fireControl, which only cares that the call succeeded.
225+
*/
226+
export async function askControl(
227+
conn: Connection,
228+
tool: string,
229+
args: Record<string, unknown>
230+
): Promise<string | undefined> {
231+
try {
232+
const res = await conn.request<{
233+
content?: { type?: string; text?: string }[];
234+
isError?: boolean;
235+
}>('tools/call', { name: tool, arguments: args });
236+
if (res.isError) return undefined;
237+
const text = (res.content ?? []).find((c) => c?.type === 'text')?.text;
238+
return typeof text === 'string' ? text : undefined;
239+
} catch {
240+
return undefined;
241+
}
242+
}
212243

213244
/**
214245
* Whether the server exposes a given diagnostic control.

‎src/scenarios/server/events/webhook.ts‎

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import { JsonRpcError } from '../../../connection';
3333
import { untestableCheck } from '../../untestable';
3434
import {
3535
EVENTS_EXTENSION_ID,
36+
EVENTS_CONTROL_SUBSCRIBE_AS,
37+
EVENTS_CONTROL_SUBSCRIPTION_EXISTS,
38+
hasControl,
39+
askControl,
3640
extensionsOf,
3741
EVENTS_NOT_FOUND,
3842
EVENTS_SPEC_REF,
@@ -323,6 +327,7 @@ export class EventsWebhookScenario implements ClientScenario {
323327
);
324328
checks.push(
325329
...(await this.identityChecks(
330+
conn,
326331
subscribe,
327332
release,
328333
first.result,
@@ -793,7 +798,110 @@ export class EventsWebhookScenario implements ClientScenario {
793798
}
794799

795800
/** The compound key, the derived id, and what a refresh does. */
801+
/**
802+
* Grade sep-9999-subscribe-cross-tenant-isolation, which asserts that two
803+
* tenants subscribing to the same `(name, arguments)` with the same callback
804+
* get distinct subscriptions, because the principal is part of the key.
805+
*
806+
* A run authenticates as one principal for its lifetime, so the harness can
807+
* only ever supply one side of that comparison. The other side comes from a
808+
* fixture control that registers on another principal's behalf, using the
809+
* same identity function the subscribe handler uses.
810+
*
811+
* Two things are checked, and the second is the one that bites. Distinct ids
812+
* show the principal reached the key at all. A surviving subscription after
813+
* the other tenant unsubscribes shows the two are genuinely independent,
814+
* which is what a tenant would actually notice.
815+
*/
816+
private async crossTenantChecks(
817+
conn: Connection,
818+
name: string,
819+
subscribe: (
820+
params: Record<string, unknown>
821+
) => Promise<{ result: SubscribeResult } | { error: JsonRpcError }>
822+
): Promise<ConformanceCheck[]> {
823+
const id = 'sep-9999-subscribe-cross-tenant-isolation';
824+
const description =
825+
'Because the key includes `principal` and `delivery.url`, two distinct tenants subscribing to the same `(name, arguments)` get distinct subscriptions.';
826+
827+
const canAct = await hasControl(conn, EVENTS_CONTROL_SUBSCRIBE_AS);
828+
const canAsk = await hasControl(conn, EVENTS_CONTROL_SUBSCRIPTION_EXISTS);
829+
if (!canAct || !canAsk) {
830+
return [
831+
untestableCheck(
832+
id,
833+
id,
834+
description,
835+
`A run holds one principal, so the two-tenant case cannot be constructed. Needs a fixture exposing the \`${EVENTS_CONTROL_SUBSCRIBE_AS}\` and \`${EVENTS_CONTROL_SUBSCRIPTION_EXISTS}\` controls. The \`delivery.url\` half of the same rule is graded by sep-9999-subscribe-key-composition.`,
836+
[EVENTS_SPEC_REF]
837+
)
838+
];
839+
}
840+
841+
// Same event, same callback. The principal is the only thing that differs,
842+
// which is what makes a shared id mean what it means.
843+
const url = `${CALLBACK_BASE}/cross-tenant`;
844+
const other = { principal: 'conformance-tenant-b', name, url };
845+
846+
const otherId = await askControl(conn, EVENTS_CONTROL_SUBSCRIBE_AS, other);
847+
if (!otherId) {
848+
return [
849+
untestableCheck(
850+
id,
851+
id,
852+
description,
853+
`The \`${EVENTS_CONTROL_SUBSCRIBE_AS}\` control did not return a subscription id, so the second tenant could not be established.`,
854+
[EVENTS_SPEC_REF]
855+
)
856+
];
857+
}
858+
859+
const mine = await subscribe({
860+
name,
861+
arguments: {},
862+
delivery: { mode: 'webhook', url, secret: freshSecret() }
863+
});
864+
if ('error' in mine) {
865+
return [
866+
eventsCheck(id, description, 'FAILURE', {
867+
errorMessage: `Subscribing as this run's own principal to the same event and callback failed with ${mine.error.code} ${mine.error.message}, so the two subscriptions could not be compared.`
868+
})
869+
];
870+
}
871+
872+
const myId = mine.result.id;
873+
if (myId === otherId) {
874+
return [
875+
eventsCheck(id, description, 'FAILURE', {
876+
errorMessage: `Two principals subscribing to \`${name}\` with the same callback received the same subscription id (${myId}), so the principal is not part of the key. One tenant can address, refresh or cancel another's subscription.`,
877+
details: { id: myId }
878+
})
879+
];
880+
}
881+
882+
await conn
883+
.request(EVENTS_UNSUBSCRIBE_METHOD, { id: myId })
884+
.catch(() => undefined);
885+
const survived = await askControl(
886+
conn,
887+
EVENTS_CONTROL_SUBSCRIPTION_EXISTS,
888+
other
889+
);
890+
891+
return [
892+
survived === 'true'
893+
? eventsCheck(id, description, 'SUCCESS', {
894+
details: { mine: myId, other: otherId }
895+
})
896+
: eventsCheck(id, description, 'FAILURE', {
897+
errorMessage: `The two tenants received distinct ids, but unsubscribing \`${myId}\` also removed the other tenant's subscription, so they are not independent.`,
898+
details: { mine: myId, other: otherId }
899+
})
900+
];
901+
}
902+
796903
private async identityChecks(
904+
conn: Connection,
797905
subscribe: (
798906
p: Record<string, unknown>
799907
) => Promise<{ result: SubscribeResult } | { error: JsonRpcError }>,
@@ -993,15 +1101,7 @@ export class EventsWebhookScenario implements ClientScenario {
9931101
)
9941102
);
9951103

996-
out.push(
997-
untestableCheck(
998-
'sep-9999-subscribe-cross-tenant-isolation',
999-
'sep-9999-subscribe-cross-tenant-isolation',
1000-
'Because the key includes `principal` and `delivery.url`, two distinct tenants subscribing to the same `(name, arguments)` get distinct subscriptions.',
1001-
'A run holds one principal, so the two-tenant case cannot be constructed. The `delivery.url` half of the same rule is graded by sep-9999-subscribe-key-composition.',
1002-
[EVENTS_SPEC_REF]
1003-
)
1004-
);
1104+
out.push(...(await this.crossTenantChecks(conn, name, subscribe)));
10051105

10061106
out.push(
10071107
untestableCheck(

0 commit comments

Comments
 (0)