|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#12157] ADR-0126 §5 — WRITE AUTHORITY for the packaged-flow activation |
| 4 | +// switch, `POST /automation/:name/toggle`. |
| 5 | +// |
| 6 | +// ## The rule, and why it is posture-conditional |
| 7 | +// |
| 8 | +// The row this route writes is INSTALL-LEVEL (`organization_id NULL`): one |
| 9 | +// row, one environment, every tenant. So the authority it demands scales with |
| 10 | +// how far that reach goes: |
| 11 | +// |
| 12 | +// - `single` — one logical tenant, so install-level and org-level are the |
| 13 | +// SAME scope. The org admin who already passed the #10145 |
| 14 | +// `manage_metadata` gate is the right authority. |
| 15 | +// - `group` / |
| 16 | +// `isolated` — a real multi-organization deployment, where the switch |
| 17 | +// crosses tenants. The platform OPERATOR is required. |
| 18 | +// |
| 19 | +// ## What this is made durable against |
| 20 | +// |
| 21 | +// #10243, measured over HTTP: on a real `isolated` posture a tenant org owner |
| 22 | +// switched a shipped flow off through this very route and an unrelated tenant |
| 23 | +// in a DIFFERENT organization read it off — environment-wide reach from a |
| 24 | +// tenant caller. That leak went through a PROCESS-LOCAL map, so a cold boot |
| 25 | +// undid it ("mitigating but not exculpating"). ADR-0126 makes the switch |
| 26 | +// DURABLE, which removes that accidental limit — so a tenant-writable |
| 27 | +// install-wide row would be the same leak WITH persistence, i.e. strictly |
| 28 | +// worse than what was measured. This gate is what stops that. |
| 29 | +// |
| 30 | +// ## What the refusal cases assert |
| 31 | +// |
| 32 | +// `status` AND `code` (the ADR-0112 envelope), AND that `toggleFlow` was never |
| 33 | +// entered — a gate that refused after the ledger was already written would |
| 34 | +// still be the defect and would still satisfy a status-only assertion. |
| 35 | + |
| 36 | +import { describe, it, expect, vi } from 'vitest'; |
| 37 | + |
| 38 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 39 | +import type { HttpProtocolContext } from '../http-dispatcher.js'; |
| 40 | + |
| 41 | +const FLOW = 'vendor_lead_router'; |
| 42 | +const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; |
| 43 | + |
| 44 | +interface Harness { |
| 45 | + dispatcher: HttpDispatcher; |
| 46 | + toggleFlow: ReturnType<typeof vi.fn>; |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * A dispatcher whose `tenancy` service reports the given posture. |
| 51 | + * |
| 52 | + * `posture: null` is a deployment with NO tenancy service — the shape |
| 53 | + * `resolve-execution-context.ts` resolves to "no posture-conditional refusal", |
| 54 | + * and (ADR-0093 D4/D5) the same deployment shape as `single`. |
| 55 | + */ |
| 56 | +function boot(posture: 'single' | 'group' | 'isolated' | null): Harness { |
| 57 | + const toggleFlow = vi.fn(async () => undefined); |
| 58 | + |
| 59 | + const services: Record<string, unknown> = { |
| 60 | + automation: { |
| 61 | + handlerReady: true, |
| 62 | + toggleFlow, |
| 63 | + getFlow: vi.fn(async (name: string) => (name === FLOW ? DEFINITION : undefined)), |
| 64 | + }, |
| 65 | + }; |
| 66 | + if (posture) services.tenancy = { posture }; |
| 67 | + |
| 68 | + const resolve = (name: string): unknown => services[name]; |
| 69 | + const kernel = { |
| 70 | + getService: resolve, |
| 71 | + getServiceAsync: async (name: string) => resolve(name), |
| 72 | + context: { getService: resolve }, |
| 73 | + }; |
| 74 | + |
| 75 | + return { dispatcher: new HttpDispatcher(kernel as never), toggleFlow }; |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * A tenant org admin who DOES hold `manage_metadata` — so the #10145 gate one |
| 80 | + * tier up passes and this gate is the only thing left. That is the whole point: |
| 81 | + * the two gates ask different questions, and this test must not pass merely |
| 82 | + * because the other one refused. |
| 83 | + */ |
| 84 | +const TENANT_ADMIN = (): HttpProtocolContext => ({ |
| 85 | + request: {}, |
| 86 | + executionContext: { |
| 87 | + userId: 'u_northwind_owner', |
| 88 | + positions: ['org_owner', 'org_admin'], |
| 89 | + permissions: ['organization_admin'], |
| 90 | + systemPermissions: ['manage_metadata'], |
| 91 | + organizationId: 'org_northwind', |
| 92 | + }, |
| 93 | +} as HttpProtocolContext); |
| 94 | + |
| 95 | +/** The platform operator (ADR-0068 D2: `platform_admin`, NOT a tenant role). */ |
| 96 | +const PLATFORM_OPERATOR = (): HttpProtocolContext => ({ |
| 97 | + request: {}, |
| 98 | + executionContext: { |
| 99 | + userId: 'u_saas_operator', |
| 100 | + positions: ['platform_admin'], |
| 101 | + permissions: ['admin_full_access'], |
| 102 | + systemPermissions: ['manage_metadata'], |
| 103 | + organizationId: null, |
| 104 | + }, |
| 105 | +} as HttpProtocolContext); |
| 106 | + |
| 107 | +/** Engine self-invocation — never settable from the wire. */ |
| 108 | +const SYSTEM = (): HttpProtocolContext => ({ |
| 109 | + request: {}, |
| 110 | + executionContext: { userId: 'usr_system', isSystem: true }, |
| 111 | +} as HttpProtocolContext); |
| 112 | + |
| 113 | +const statusOf = (response: unknown): unknown => (response as any)?.status; |
| 114 | +const codeOf = (response: unknown): unknown => { |
| 115 | + const r = response as any; |
| 116 | + return r?.body?.error?.code ?? r?.body?.error?.details?.code; |
| 117 | +}; |
| 118 | +const messageOf = (response: unknown): string => { |
| 119 | + const r = response as any; |
| 120 | + return String(r?.body?.error?.message ?? ''); |
| 121 | +}; |
| 122 | + |
| 123 | +const toggle = (h: Harness, ctx: HttpProtocolContext) => |
| 124 | + h.dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: false }, ctx, undefined); |
| 125 | + |
| 126 | +describe('ADR-0126 §5 — the activation write is operator-gated in walled postures', () => { |
| 127 | + describe('`single` posture — the org admin suffices', () => { |
| 128 | + it('a tenant admin with `manage_metadata` may flip the switch', async () => { |
| 129 | + const h = boot('single'); |
| 130 | + |
| 131 | + const { response } = await toggle(h, TENANT_ADMIN()); |
| 132 | + |
| 133 | + expect(statusOf(response)).toBe(200); |
| 134 | + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); |
| 135 | + }); |
| 136 | + |
| 137 | + it('so may the platform operator', async () => { |
| 138 | + const h = boot('single'); |
| 139 | + |
| 140 | + const { response } = await toggle(h, PLATFORM_OPERATOR()); |
| 141 | + |
| 142 | + expect(statusOf(response)).toBe(200); |
| 143 | + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); |
| 144 | + }); |
| 145 | + |
| 146 | + it('no tenancy service at all behaves like `single` (ADR-0093 D4/D5)', async () => { |
| 147 | + const h = boot(null); |
| 148 | + |
| 149 | + const { response } = await toggle(h, TENANT_ADMIN()); |
| 150 | + |
| 151 | + // Refusing here would lock every single-tenant operator out of |
| 152 | + // their own switch, and an unenforceable wall resolves to `single`. |
| 153 | + expect(statusOf(response)).toBe(200); |
| 154 | + expect(h.toggleFlow).toHaveBeenCalled(); |
| 155 | + }); |
| 156 | + }); |
| 157 | + |
| 158 | + for (const posture of ['group', 'isolated'] as const) { |
| 159 | + describe(`\`${posture}\` posture — the install-wide switch needs the operator`, () => { |
| 160 | + it('REFUSES a tenant org admin, loudly, and never enters toggleFlow', async () => { |
| 161 | + const h = boot(posture); |
| 162 | + |
| 163 | + const { response } = await toggle(h, TENANT_ADMIN()); |
| 164 | + |
| 165 | + expect(statusOf(response)).toBe(403); |
| 166 | + expect(codeOf(response)).toBe('PERMISSION_DENIED'); |
| 167 | + // The load-bearing assertion: refused BEFORE the write. A gate |
| 168 | + // that wrote the row and then refused would satisfy the two |
| 169 | + // above and still be #10243. |
| 170 | + expect(h.toggleFlow).not.toHaveBeenCalled(); |
| 171 | + }); |
| 172 | + |
| 173 | + it('the refusal names the posture, the reason, and the sanctioned path', async () => { |
| 174 | + const h = boot(posture); |
| 175 | + |
| 176 | + const { response } = await toggle(h, TENANT_ADMIN()); |
| 177 | + const message = messageOf(response); |
| 178 | + |
| 179 | + expect(message).toContain(posture); |
| 180 | + expect(message).toContain('INSTALL-WIDE'); |
| 181 | + expect(message).toContain('ADR-0126 §5'); |
| 182 | + // ADR-0126 §7: a refusal names what the caller CAN do. Here |
| 183 | + // that is the clone path (§7.1), which needs no operator. |
| 184 | + expect(message).toMatch(/clone/i); |
| 185 | + // #7450 — a denial says nothing about the caller's own |
| 186 | + // positions or permission-set names. |
| 187 | + expect(message).not.toContain('org_owner'); |
| 188 | + expect(message).not.toContain('organization_admin'); |
| 189 | + }); |
| 190 | + |
| 191 | + it('ALLOWS the platform operator', async () => { |
| 192 | + const h = boot(posture); |
| 193 | + |
| 194 | + const { response } = await toggle(h, PLATFORM_OPERATOR()); |
| 195 | + |
| 196 | + expect(statusOf(response)).toBe(200); |
| 197 | + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); |
| 198 | + }); |
| 199 | + |
| 200 | + it('ALLOWS engine self-invocation', async () => { |
| 201 | + const h = boot(posture); |
| 202 | + |
| 203 | + const { response } = await toggle(h, SYSTEM()); |
| 204 | + |
| 205 | + expect(statusOf(response)).toBe(200); |
| 206 | + expect(h.toggleFlow).toHaveBeenCalled(); |
| 207 | + }); |
| 208 | + |
| 209 | + it('gates ENABLE as well as disable — the switch is install-wide in both directions', async () => { |
| 210 | + const h = boot(posture); |
| 211 | + |
| 212 | + const { response } = await h.dispatcher.handleAutomation( |
| 213 | + `/${FLOW}/toggle`, 'POST', { enabled: true }, TENANT_ADMIN(), undefined, |
| 214 | + ); |
| 215 | + |
| 216 | + expect(statusOf(response)).toBe(403); |
| 217 | + expect(h.toggleFlow).not.toHaveBeenCalled(); |
| 218 | + }); |
| 219 | + |
| 220 | + it('does NOT gate the clone door — cloning is the path the refusal recommends', async () => { |
| 221 | + const h = boot(posture); |
| 222 | + |
| 223 | + const { response } = await h.dispatcher.handleAutomation( |
| 224 | + `/${FLOW}/clone`, 'POST', { name: 'my_lead_router', label: 'My Lead Router' }, |
| 225 | + TENANT_ADMIN(), undefined, |
| 226 | + ); |
| 227 | + |
| 228 | + // Whatever the clone route answers, it must not be THIS gate: |
| 229 | + // a clone creates an ordinary new artifact and takes nothing |
| 230 | + // away from any tenant (§7.1). |
| 231 | + expect(codeOf(response)).not.toBe('PERMISSION_DENIED'); |
| 232 | + }); |
| 233 | + |
| 234 | + it('does not over-block the legacy execution door for a flow named `toggle`', async () => { |
| 235 | + const h = boot(posture); |
| 236 | + |
| 237 | + await h.dispatcher.handleAutomation('/trigger/toggle', 'POST', {}, TENANT_ADMIN(), undefined); |
| 238 | + |
| 239 | + // `POST /automation/trigger/toggle` RUNS a flow literally named |
| 240 | + // `toggle`; gating it would over-block an execution door, which |
| 241 | + // is the one thing the #10243 ruling did not do. |
| 242 | + expect(h.toggleFlow).not.toHaveBeenCalled(); |
| 243 | + }); |
| 244 | + }); |
| 245 | + } |
| 246 | +}); |
0 commit comments