Skip to content

Commit 02b4123

Browse files
Elon Muskclaude
andauthored
test(dogfood): record the measured cross-organization reach of the automation toggle (#10996)
#10243 splits into an exposure half — already measured and pinned by #10145's `automation-write-capability-gate.test.ts` audit block — and a blast-radius half that had never been reproduced over HTTP: whether flow ENABLEMENT is environment-scoped such that one tenant's toggle reaches every organization. This is that measurement, recorded. On a real, non-degraded `isolated` posture with three principals (two org owners in two different organizations, plus the platform admin), tenant A's `POST /automation/:name/toggle {enabled:false}` answers 200 and both tenant B and the platform admin read the flow back as `enabled: false`; re-enabling is symmetric. The file records reality and rules nothing. Whether toggle belongs in the `manage_metadata` write set is the maintainer's decision; when it is made, this file is where it lands alongside the existing audit pin. Part of #10243 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5337ef1 commit 02b4123

1 file changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #10243 — the BLAST-RADIUS half of the toggle card, measured over HTTP.
5+
*
6+
* ## ⛔ What this file is, and what it deliberately is not
7+
*
8+
* It **records a measurement**. It does **not** rule. Whether
9+
* `POST /automation/:name/toggle` belongs in the `manage_metadata` write set is
10+
* a product and security decision for the maintainer, and nothing here argues
11+
* either way — no severity, no recommendation. When the ruling lands, this file
12+
* is one of the two places it lands (the other is the ungated-execution audit
13+
* block in `packages/runtime/src/domains/automation-write-capability-gate.test.ts`):
14+
* a ruling that toggle IS an authoring write flips these expectations to a 403,
15+
* and the flip is the point — an unrecorded verdict cannot be revisited.
16+
*
17+
* ## The question, and why only half of it was open
18+
*
19+
* #10145 gated the automation DEFINITION writes (`POST /automation`,
20+
* `PUT /automation/:name`, `DELETE /automation/:name`) on `manage_metadata` and
21+
* deliberately left `toggle` ungated, on the rule that authoring and executing
22+
* are different questions. Two separable facts follow from that, and only the
23+
* second was ever open:
24+
*
25+
* 1. `toggle` is reachable by any authenticated caller with no authoring
26+
* capability — MEASURED and pinned by #10145's audit block.
27+
* 2. flow ENABLEMENT is environment-scoped, so one tenant's toggle reaches
28+
* every organization — asserted from the scoping #10145 measured for flow
29+
* DEFINITIONS, and never reproduced for enablement itself.
30+
*
31+
* This file is (2): toggle as tenant A, read the enabled state back as tenant B
32+
* and as the platform admin — the same three principals and the same read-back
33+
* table #10145's report used for definitions.
34+
*
35+
* ## ⚠️ The vacuity trap this harness has to stay clear of, stated up front
36+
*
37+
* `multiTenant: 'posture-only'` activates the tenancy POSTURE and no row wall
38+
* (see `BootOptions.multiTenant`) — the enterprise `@objectstack/organizations`
39+
* runtime is cloud-private and genuinely absent from this workspace. A fixture
40+
* that booted this way and asserted *isolation* would assert nothing and pass.
41+
* The mirror image is just as real and is the trap for THIS file: in a stack
42+
* with no wall, "tenant B saw tenant A's write" is true of everything, and
43+
* would prove nothing about a walled deployment.
44+
*
45+
* What keeps the measurement honest is that the bit under test never reaches
46+
* the plane a wall operates on. An organization wall scopes ROWS. The enabled
47+
* bit is not a row: `toggleFlow(name, enabled)` writes the automation engine's
48+
* in-process `flowEnabled` map, keyed by flow name and nothing else, and
49+
* `getFlowRuntimeStates()` reads that same map with no caller, no organization
50+
* and no argument at all. `it('mutates ENGINE state, not the persisted
51+
* definition')` below measures exactly that discriminator over HTTP — after the
52+
* toggle the flow's persisted `status` is still `active` while its runtime
53+
* `enabled` is `false` — and it is the leg that would fail, loudly, if
54+
* enablement ever became org-stamped state that a wall could scope. Until it
55+
* does, no wall has anything to scope, which is why the result does not depend
56+
* on the stand-in.
57+
*
58+
* ## The harness
59+
*
60+
* - app: `@objectstack/example-crm`, whose shipped `crm_convert_lead_wizard`
61+
* stands in for #10145's HotCRM `lead_auto_assignment`.
62+
* - boot: `bootStack(crm, { automation: true, multiTenant: 'posture-only' })`
63+
* — a real, non-degraded `isolated` posture (the deployment shape #10145
64+
* measured, `OS_TENANCY_POSTURE=isolated`), with the automation service
65+
* registered so the routes resolve an engine instead of 501.
66+
* - principals: platform admin (seeded first user), and two org owners who
67+
* each create their OWN organization over
68+
* `POST /auth/organization/create` — so the two tenants carry genuinely
69+
* different `activeOrganizationId`s, which the first test asserts rather
70+
* than assumes.
71+
*/
72+
73+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
74+
import crmStack from '@objectstack/example-crm';
75+
import { bootStack, type VerifyStack } from '@objectstack/verify';
76+
77+
/** The CRM app's own shipped flow — a real definition, not one this test injects. */
78+
const FLOW = 'crm_convert_lead_wizard';
79+
80+
interface RuntimeFlowState {
81+
name: string;
82+
enabled: boolean;
83+
bound: boolean;
84+
status?: string;
85+
}
86+
87+
interface AutomationEngineShape {
88+
getFlowRuntimeStates(): RuntimeFlowState[];
89+
}
90+
91+
/**
92+
* Read one flow's runtime state out of `GET /automation/_status`.
93+
*
94+
* Pulls the entry out explicitly and fails on its absence rather than folding a
95+
* missing row into `enabled: false` — a `?? false` here would report "the route
96+
* stopped listing this flow" as "the flow is disabled", which is the one
97+
* confusion this whole file exists to avoid.
98+
*/
99+
async function readEnabled(stack: VerifyStack, token: string): Promise<RuntimeFlowState> {
100+
const res = await stack.apiAs(token, 'GET', '/automation/_status');
101+
const text = await res.clone().text();
102+
expect(res.status, `/automation/_status returned ${res.status}: ${text}`).toBe(200);
103+
const body = (await res.json()) as { data?: { flows?: RuntimeFlowState[] } };
104+
const flows = body.data?.flows;
105+
expect(flows, `no flows array in /automation/_status: ${text}`).toBeDefined();
106+
const entry = flows!.find((f) => f.name === FLOW);
107+
expect(entry, `flow '${FLOW}' absent from /automation/_status: ${text}`).toBeDefined();
108+
return entry!;
109+
}
110+
111+
describe('#10243 — cross-organization reach of POST /automation/:name/toggle', () => {
112+
let stack: VerifyStack;
113+
/** Platform admin — the seeded first user. #10145's `founder`. */
114+
let adminToken: string;
115+
/** Tenant A org owner — the actor. #10145's `northwind`. */
116+
let tenantAToken: string;
117+
/** Tenant B org owner — an unrelated tenant. #10145's `contoso`. */
118+
let tenantBToken: string;
119+
let orgAId: string;
120+
let orgBId: string;
121+
122+
beforeAll(async () => {
123+
stack = await bootStack(crmStack as never, { automation: true, multiTenant: 'posture-only' });
124+
adminToken = await stack.signIn();
125+
126+
tenantAToken = await stack.signUp('tenant-a-owner@issue10243.test');
127+
tenantBToken = await stack.signUp('tenant-b-owner@issue10243.test');
128+
129+
const createA = await stack.apiAs(tenantAToken, 'POST', '/auth/organization/create', {
130+
name: 'Tenant A', slug: 'tenant-a-10243',
131+
});
132+
expect(createA.status, `org A create: ${await createA.clone().text()}`).toBe(200);
133+
orgAId = ((await createA.json()) as { id: string }).id;
134+
135+
const createB = await stack.apiAs(tenantBToken, 'POST', '/auth/organization/create', {
136+
name: 'Tenant B', slug: 'tenant-b-10243',
137+
});
138+
expect(createB.status, `org B create: ${await createB.clone().text()}`).toBe(200);
139+
orgBId = ((await createB.json()) as { id: string }).id;
140+
141+
// better-auth binds the creator to the new org, but the ACTIVE org is what
142+
// rides the session into the execution context — set it explicitly so the
143+
// two tenants are org-bound in the only field that reaches `ExecutionContext`.
144+
for (const [token, slug] of [[tenantAToken, 'tenant-a-10243'], [tenantBToken, 'tenant-b-10243']] as const) {
145+
const res = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationSlug: slug });
146+
expect(res.status, `set-active ${slug}: ${await res.clone().text()}`).toBe(200);
147+
}
148+
}, 180_000);
149+
150+
afterAll(async () => {
151+
await stack?.stop?.();
152+
});
153+
154+
it('guard the guard: a real walled posture, and three genuinely distinct principals', async () => {
155+
// If the stand-in ever stopped activating the posture, every read-back
156+
// below would still agree — and would be measuring a single-tenant stack.
157+
const tenancy = await stack.kernel.getServiceAsync<{
158+
posture: string; requestedPosture: string; isolationActive: boolean; degraded: boolean;
159+
}>('tenancy');
160+
expect(tenancy.requestedPosture).toBe('isolated');
161+
expect(tenancy.posture).toBe('isolated');
162+
expect(tenancy.isolationActive).toBe(true);
163+
expect(tenancy.degraded).toBe(false);
164+
165+
// Two DIFFERENT organizations. Asserted, not assumed: if both tenants
166+
// landed in one org (or in none), "B saw A's toggle" would be a statement
167+
// about one tenant, not about a tenant wall.
168+
expect(orgAId).toBeTruthy();
169+
expect(orgBId).toBeTruthy();
170+
expect(orgAId).not.toBe(orgBId);
171+
172+
const sessionOf = async (token: string) => {
173+
const res = await stack.apiAs(token, 'GET', '/auth/get-session');
174+
expect(res.status).toBe(200);
175+
return (await res.json()) as {
176+
user: { isPlatformAdmin: boolean; positions: string[] };
177+
session: { activeOrganizationId: string | null };
178+
};
179+
};
180+
181+
const a = await sessionOf(tenantAToken);
182+
const b = await sessionOf(tenantBToken);
183+
const admin = await sessionOf(adminToken);
184+
185+
expect(a.session.activeOrganizationId).toBe(orgAId);
186+
expect(b.session.activeOrganizationId).toBe(orgBId);
187+
expect(a.user.isPlatformAdmin).toBe(false);
188+
expect(b.user.isPlatformAdmin).toBe(false);
189+
expect(admin.user.isPlatformAdmin).toBe(true);
190+
});
191+
192+
it('control: tenant A is genuinely unprivileged — the gated neighbours refuse it', async () => {
193+
// The same control #10145's report used to prove the account is not
194+
// secretly entitled. Asserts `code` AND `status` — the repo's minimum for a
195+
// refusal case, since a bare "it threw" passes for the wrong reasons.
196+
const meta = await stack.apiAs(tenantAToken, 'PUT', '/meta/object/crm_lead', { name: 'crm_lead' });
197+
expect(meta.status).toBe(403);
198+
expect(((await meta.json()) as { error?: { code?: string } }).error?.code).toBe('FORBIDDEN');
199+
200+
for (const [method, path] of [['POST', '/automation'], ['DELETE', `/automation/${FLOW}`]] as const) {
201+
const body = method === 'POST'
202+
? { name: 'probe_flow_10243', label: 'Probe', type: 'autolaunched', nodes: [], edges: [] }
203+
: undefined;
204+
const res = await stack.apiAs(tenantAToken, method, path, body);
205+
expect(res.status, `${method} ${path}`).toBe(403);
206+
expect(((await res.json()) as { error?: { code?: string } }).error?.code).toBe('PERMISSION_DENIED');
207+
}
208+
});
209+
210+
it('baseline: all three principals read the shipped flow as enabled', async () => {
211+
for (const [who, token] of [['admin', adminToken], ['tenantA', tenantAToken], ['tenantB', tenantBToken]] as const) {
212+
const state = await readEnabled(stack, token);
213+
expect(state.enabled, `${who} baseline`).toBe(true);
214+
}
215+
});
216+
217+
it('MEASURED: tenant A toggles the flow off, and tenant B and the platform admin both read it off', async () => {
218+
const toggle = await stack.apiAs(tenantAToken, 'POST', `/automation/${FLOW}/toggle`, { enabled: false });
219+
expect(toggle.status, `toggle as tenant A: ${await toggle.clone().text()}`).toBe(200);
220+
expect((await toggle.json()) as unknown).toMatchObject({ data: { name: FLOW, enabled: false } });
221+
222+
// The read-back table. Tenant B holds no membership of tenant A's
223+
// organization and the platform admin is org-less; both nevertheless
224+
// observe the actor's mutation.
225+
expect((await readEnabled(stack, tenantBToken)).enabled, 'tenant B after A toggled off').toBe(false);
226+
expect((await readEnabled(stack, adminToken)).enabled, 'platform admin after A toggled off').toBe(false);
227+
expect((await readEnabled(stack, tenantAToken)).enabled, 'tenant A after A toggled off').toBe(false);
228+
});
229+
230+
it('mutates ENGINE state, not the persisted definition — the bit an organization wall has nothing to scope', async () => {
231+
// Runs after the toggle above (file order is the sequence). The persisted
232+
// `status` — the flow's authored metadata, the thing an org overlay could
233+
// carry — is untouched, while the runtime `enabled` bit is off. That
234+
// divergence is where the state lives, and it is what makes the read-back
235+
// above independent of whether a row wall is installed.
236+
const state = await readEnabled(stack, adminToken);
237+
expect(state.enabled).toBe(false);
238+
expect(state.status).toBe('active');
239+
240+
const definition = await stack.apiAs(adminToken, 'GET', `/automation/${FLOW}`);
241+
expect(definition.status).toBe(200);
242+
const body = (await definition.json()) as { data?: { status?: string } };
243+
expect(body.data?.status, 'the authored definition was not modified by the toggle').toBe('active');
244+
245+
// One engine for the whole environment: the same service instance the HTTP
246+
// route mutated, reachable from the kernel, reporting the same bit through
247+
// a method that takes no caller and no organization.
248+
const engine = await stack.kernel.getServiceAsync<AutomationEngineShape>('automation');
249+
const again = await stack.kernel.getServiceAsync<AutomationEngineShape>('automation');
250+
expect(engine, 'the automation service is one instance per environment').toBe(again);
251+
const fromEngine = engine.getFlowRuntimeStates().find((f) => f.name === FLOW);
252+
expect(fromEngine?.enabled, 'engine state after the HTTP toggle').toBe(false);
253+
});
254+
255+
it('symmetric: tenant A switches it back on, and the other two read it on again', async () => {
256+
const toggle = await stack.apiAs(tenantAToken, 'POST', `/automation/${FLOW}/toggle`, { enabled: true });
257+
expect(toggle.status).toBe(200);
258+
259+
expect((await readEnabled(stack, tenantBToken)).enabled, 'tenant B after A re-enabled').toBe(true);
260+
expect((await readEnabled(stack, adminToken)).enabled, 'platform admin after A re-enabled').toBe(true);
261+
});
262+
});

0 commit comments

Comments
 (0)