Skip to content

Commit 4658e57

Browse files
baozhoutaoclaude
andauthored
test(dogfood): cover /actions and /automation in the anonymous-deny proof artifact (#5570) (#5631)
`authz-conformance.matrix.ts` names `showcase-anonymous-deny-surfaces.dogfood.test.ts` as the proof artifact for #2567's "anonymous posture is uniform across HTTP surfaces" claim, but the suite drove only `/data` and `/meta`. #5519 found the claim false on exactly the two surfaces it did not drive — the dispatcher-mounted `/actions` and `/automation` — and the artifact was silent throughout. PR #5569 built the gate in `packages/runtime`; this is the evidence half. - six new anonymous cases on the shared showcase boot: POST a `script` action, POST `/automation/:name/trigger`, GET `/automation`, DELETE `/automation/:name` (all 401), plus the two authenticated contrasts. - one case pins that all four surfaces answer the same code and message, reading each family in its own declared envelope rather than through a tolerant `??` chain. - two matrix rows (`anonymous-deny-actions`, `anonymous-deny-automation`) with their `covers` keys, ratchet probes for both gates, and a `(h)` bites case, so deleting either gate fails CI as STALE covers. No `packages/runtime` change: this adds proof, not defence. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent c03fd9a commit 4658e57

3 files changed

Lines changed: 210 additions & 0 deletions

File tree

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
8989
enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth → shouldDenyAnonymous) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all',
9090
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
9191
covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'] },
92+
// #5519 — the two DISPATCHER-mounted execution surfaces. `@objectstack/rest`
93+
// gated `/data` and `/meta`; these routes are mounted by a SECOND
94+
// registration path (dispatcher-plugin.ts, straight onto the host
95+
// IHttpServer) and inherited none of it, so the "#2567 uniform posture"
96+
// claim above was false on them until PR #5569. The proof artifact was
97+
// silent too — #5570 is the evidence half, and these two rows are what make
98+
// the gate's removal fail CI instead of review.
99+
{ id: 'anonymous-deny-actions', summary: 'anonymous-deny on the business-action dispatch surface (#2567 surface 2 / #5519)', state: 'enforced',
100+
enforcement: 'runtime/domains/actions.ts handleActionsRequest — shouldDenyAnonymous as the handler\'s FIRST statement, ahead of the ADR-0066 D4 requiredPermissions gate and the ADR-0104 param contract; those keep their semantics and simply run after the auth baseline, so an anonymous caller never reaches action dispatch and never learns the route\'s shape',
101+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
102+
covers: ['actions:domains/actions.ts:anonymous-gate'],
103+
note: 'A `type: \'script\'` action body runs `isSystem: true` (elevated), so an ungated POST was an anonymous privilege-escalating WRITE, not merely an information leak — #5519 measured `POST /actions/showcase_task/showcase_mark_done/:id` answering 200 with the update applied. Internal dispatch is unaffected: this handler is a pure HTTP seam (the MCP `run_action` bridge enters through action-execution.invokeBusinessAction, declarative endpoints through the transport fallback seam with their own `authRequired` gate), so `authRequired: false` public endpoints stay public.' },
104+
{ id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (#2567 surface 3 / #5519)', state: 'enforced',
105+
enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation',
106+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
107+
covers: ['automation:domains/automation.ts:anonymous-gate'],
108+
note: 'Ungated, an anonymous caller could start real flow runs (`POST /:name/trigger`), read the full flow inventory (`GET /automation`), and DEREGISTER a registered flow (`DELETE /:name` → `{deleted:true}`) — the destructive one, which #5519 did not originally record. Gating the DOMAIN rather than each route is what keeps a newly added automation route from arriving ungated. Engine-internal triggers (record-change, schedule) never speak HTTP and are untouched.' },
92109

93110
// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
94111
// admission). Neither surface is reachable by a client today; these rows

packages/qa/dogfood/test/authz-conformance.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,25 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
4646
re: /async\s+(handleMetadata)\s*\(/g,
4747
key: (m) => `meta:http-dispatcher.ts:${m[1]}`,
4848
},
49+
// ── #5519 — the two dispatcher-mounted execution surfaces ──────────────
50+
// These are GATE pins, in the shape the MCP rows below already use: the key
51+
// exists only while the domain handler still consults `shouldDenyAnonymous`.
52+
// Delete the gate (the #5519 regression, in either domain) and the key
53+
// vanishes → the covering row goes STALE → red CI. `/actions` and
54+
// `/automation` are mounted by dispatcher-plugin.ts, a separate registration
55+
// path from the `@objectstack/rest` one that gates `/data` and `/meta`, which
56+
// is exactly why they diverged unnoticed.
57+
{
58+
file: 'packages/runtime/src/domains/actions.ts',
59+
re: /shouldDenyAnonymous\s*\(/g,
60+
key: () => 'actions:domains/actions.ts:anonymous-gate',
61+
},
62+
{
63+
file: 'packages/runtime/src/domains/automation.ts',
64+
re: /shouldDenyAnonymous\s*\(/g,
65+
key: () => 'automation:domains/automation.ts:anonymous-gate',
66+
},
67+
4968
// Raw-hono standard /data routes — genuinely pattern-based: ANY new
5069
// `rawApp.<verb>(`${prefix}/data...`)` → a new key → CI fails until a row covers it.
5170
{
@@ -149,6 +168,13 @@ const HIGH_RISK = [
149168
// entry point rather than gating it, so there is nothing left to mark
150169
// high-risk there)
151170
'anonymous-deny-meta',
171+
// #5519 — the dispatcher-mounted execution surfaces. `/actions` reaches a
172+
// `script` body that runs `isSystem: true` elevated and `/automation` starts,
173+
// lists and deregisters flows, so both guard the same object data as REST
174+
// `/data` through sibling entry points. Proven end-to-end by the same
175+
// surfaces proof (#5570).
176+
'anonymous-deny-actions',
177+
'anonymous-deny-automation',
152178
// #2948/#3003 — write-integrity face: without the strip, `readonly: true`
153179
// is false compliance (declared ≠ enforced) and approval/status columns are
154180
// one direct PATCH away from self-approval.
@@ -245,4 +271,22 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {
245271
);
246272
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(stdio))).toBe(true);
247273
});
274+
275+
// ── #5519 — the dispatcher execution-surface gates bite too ────────────
276+
it('(h) deleting either /actions or /automation anonymous gate → STALE covers failure (#5519)', () => {
277+
for (const gate of [
278+
'actions:domains/actions.ts:anonymous-gate',
279+
'automation:domains/automation.ts:anonymous-gate',
280+
]) {
281+
// Baseline sanity: the gate is in source TODAY. If this ever goes false
282+
// the surface has regressed to its pre-#5569 state, which is the whole
283+
// point of the pin.
284+
expect(discoverAnonymousDenySurfaces().has(gate), `${gate} must be in source`).toBe(true);
285+
const problems = checkLedger(
286+
AUTHZ_CONFORMANCE,
287+
opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== gate))),
288+
);
289+
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(gate))).toBe(true);
290+
}
291+
});
248292
});

packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,53 @@
88
// - the raw-hono standard `/data` routes (order-dependent shadowing) — that
99
// surface has since been deleted outright (#4073), which removes the entry
1010
// point rather than gating it
11+
// - the DISPATCHER-mounted execution surfaces `/actions/*` and `/automation/*`
12+
// (#5519, gated by PR #5569 — see the block below)
1113
//
1214
// This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the
1315
// verify harness passes no `requireAuth` override, so the flipped secure default
1416
// is what a fresh production deployment gets) and asserts every surface denies
1517
// an anonymous caller with 401 while an authenticated member is unaffected.
18+
//
19+
// ── Why `/actions` and `/automation` are here (#5570) ────────────────────────
20+
//
21+
// `authz-conformance.matrix.ts` names THIS FILE as the proof artifact for the
22+
// "#2567 anonymous posture is uniform across surfaces" claim. #5519 then found
23+
// the claim false on exactly two surfaces this file did not drive: anonymous
24+
// callers could POST a `script` action (whose body runs `isSystem: true`
25+
// elevated) and could trigger, list, or DEREGISTER automation flows. The
26+
// artifact was silent throughout — a declared ≠ proven gap living in the test
27+
// layer. PR #5569 built the gate in `packages/runtime`; #5570 is the evidence
28+
// half, so the proof file once again covers everything the matrix row claims
29+
// it covers.
30+
//
31+
// The value this boot adds OVER #5569's own runtime integration test
32+
// (`dispatcher-plugin.anonymous-gate.integration.test.ts`) is precisely the
33+
// comparison that test documented it could NOT make: it boots a LiteKernel that
34+
// mounts no `/data` and no `/meta`, so it had no second surface to contrast
35+
// against. Here all four surfaces are served by ONE process — and by TWO
36+
// different registration paths (`@objectstack/rest` owns `/data` + `/meta`;
37+
// `dispatcher-plugin.ts` mounts `/actions` + `/automation` straight onto the
38+
// host server) — which is the divergence #5519 was about in the first place.
1639

1740
import { describe, it, expect, beforeAll } from 'vitest';
1841
import { type VerifyStack } from '@objectstack/verify';
1942
import { getSharedShowcase } from './shared-showcase.js';
2043

2144
const OBJ = '/data/showcase_private_note';
2245

46+
// `showcase_mark_done` is a `type: 'script'` action declared on `showcase_task`
47+
// whose body performs an `api.write` update — the exact action #5519 measured
48+
// answering 200 to an anonymous caller. The record id is deliberately a
49+
// non-existent one: the gate is the FIRST statement of `handleActionsRequest`,
50+
// so an anonymous request is refused before any object, action or record is
51+
// resolved. Needing a real record to get a 401 would mean the gate had moved
52+
// behind the lookups.
53+
const ACTION = '/actions/showcase_task/showcase_mark_done/anon-probe-id';
54+
// A real showcase flow declaration, so the deregister case names something that
55+
// genuinely exists in the app's metadata.
56+
const FLOW = 'showcase_reassign_wizard';
57+
2358
describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => {
2459
let stack: VerifyStack;
2560
let memberToken: string;
@@ -30,6 +65,15 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
3065
memberToken = await stack.signUp('surfaces-member@verify.test');
3166
}, 60_000);
3267

68+
/** An HTTP call carrying no credential of any kind. */
69+
const anon = (method: string, path: string, body?: unknown) =>
70+
stack.api(path, {
71+
method,
72+
...(body === undefined
73+
? {}
74+
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
75+
});
76+
3377
// ── /meta ──────────────────────────────────────────────────────────────
3478
it('anonymous GET /meta is denied (401)', async () => {
3579
const r = await stack.api('/meta', { method: 'GET' });
@@ -51,4 +95,109 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
5195
const r = await stack.apiAs(memberToken, 'GET', OBJ);
5296
expect(r.status).toBe(200);
5397
});
98+
99+
// ── /actions (dispatcher-mounted; runtime domains/actions.ts) — #5519 ───
100+
it('anonymous POST of a script action is denied (401)', async () => {
101+
const r = await anon('POST', ACTION, { params: {} });
102+
expect(r.status, 'anonymous action dispatch must be 401').toBe(401);
103+
});
104+
105+
it('an authenticated member is NOT denied on the action surface', async () => {
106+
// Whatever the action surface answers a MEMBER — 200, a 400 from the param
107+
// contract, a 403 from `requiredPermissions` — it is an answer about
108+
// authorization, not about anonymity. The assertion is deliberately the
109+
// same `.not.toBe(401)` the /meta contrast uses: this file's subject is the
110+
// anonymous posture, and pinning the member's exact status here would make
111+
// it fail for reasons that belong to other proofs.
112+
const r = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} });
113+
expect(r.status, 'an authenticated caller must clear the auth gate').not.toBe(401);
114+
});
115+
116+
// ── /automation (dispatcher-mounted; runtime domains/automation.ts) ─────
117+
//
118+
// The gate is DOMAIN-WIDE and sits ahead of the `isServiceServeable` probe on
119+
// purpose: this stack installs no `@objectstack/service-automation`, so the
120+
// domain's own answer here is 501. If the gate ran after the probe, anonymous
121+
// and authenticated callers would both get 501 and the 401/501 difference
122+
// would fingerprint whether a deployment mounts automation at all. The
123+
// authenticated 501 case below is what gives these three cases their teeth:
124+
// in this one process, the same route answers 401 to anonymous and 501 to a
125+
// member, so the 401 can only be the gate's answer.
126+
it('anonymous POST /automation/:name/trigger is denied (401)', async () => {
127+
const r = await anon('POST', `/automation/${FLOW}/trigger`, { recordId: 'anon-probe-id' });
128+
expect(r.status, 'anonymous flow trigger must be 401').toBe(401);
129+
});
130+
131+
it('anonymous GET /automation is denied (401) — the flow inventory stays private', async () => {
132+
const r = await anon('GET', '/automation');
133+
expect(r.status, 'anonymous flow listing must be 401').toBe(401);
134+
});
135+
136+
it('anonymous DELETE /automation/:name is denied (401) — the destructive one', async () => {
137+
const r = await anon('DELETE', `/automation/${FLOW}`);
138+
expect(r.status, 'anonymous flow deregistration must be 401').toBe(401);
139+
});
140+
141+
it('an authenticated caller reaches the domain, which answers 501 — not 401', async () => {
142+
const r = await stack.apiAs(memberToken, 'GET', '/automation');
143+
expect(r.status, 'authenticated flow listing must clear the auth gate').not.toBe(401);
144+
// The domain's OWN answer on a stack with no automation service. Asserting
145+
// it (rather than only `.not.toBe(401)`) is what proves the anonymous 401
146+
// above is produced by the gate and not by the domain: drop the gate and
147+
// the anonymous cases collapse onto THIS status.
148+
expect(r.status, 'no @objectstack/service-automation is installed on this boot').toBe(501);
149+
});
150+
151+
// ── one code, one message — two wrappers ───────────────────────────────
152+
it('every denied surface answers the SAME code and message (the wrappers differ)', async () => {
153+
const rest = await Promise.all([
154+
anon('GET', '/meta').then((r) => r.json()),
155+
anon('GET', OBJ).then((r) => r.json()),
156+
]);
157+
const dispatcher = await Promise.all([
158+
anon('POST', ACTION, { params: {} }).then((r) => r.json()),
159+
anon('POST', `/automation/${FLOW}/trigger`, {}).then((r) => r.json()),
160+
anon('GET', '/automation').then((r) => r.json()),
161+
anon('DELETE', `/automation/${FLOW}`).then((r) => r.json()),
162+
]);
163+
164+
// Each family is read in ITS OWN declared shape — no `??` chain across the
165+
// two, because a tolerant reader here would hide the day one of them
166+
// changes. `@objectstack/rest` returns the flat `ANONYMOUS_DENY_BODY`
167+
// (`{ error: <CODE>, message }`); the dispatcher returns its standard
168+
// wrapper (`{ success: false, error: { code, message, httpStatus } }`).
169+
for (const body of rest) {
170+
expect(body).toEqual({
171+
error: 'UNAUTHENTICATED',
172+
message: 'Authentication is required to access this endpoint.',
173+
});
174+
}
175+
for (const body of dispatcher) {
176+
expect(body).toMatchObject({
177+
success: false,
178+
error: {
179+
code: 'UNAUTHENTICATED',
180+
message: 'Authentication is required to access this endpoint.',
181+
httpStatus: 401,
182+
},
183+
});
184+
}
185+
186+
// What is genuinely uniform — and what #2567 claims — is the SEMANTICS: one
187+
// status, one code, one message, whichever surface you knock on. The two
188+
// wrappers are a known, pre-existing platform-wide split (ADR-0112's
189+
// amendment records `@objectstack/rest`'s flat envelope and the dispatcher's
190+
// wrapped one as the two live shapes). Pinned here so that split cannot
191+
// quietly widen into two different DENIALS.
192+
const codes = new Set([
193+
...rest.map((b: any) => b.error),
194+
...dispatcher.map((b: any) => b.error.code),
195+
]);
196+
const messages = new Set([
197+
...rest.map((b: any) => b.message),
198+
...dispatcher.map((b: any) => b.error.message),
199+
]);
200+
expect([...codes]).toEqual(['UNAUTHENTICATED']);
201+
expect([...messages]).toEqual(['Authentication is required to access this endpoint.']);
202+
});
54203
});

0 commit comments

Comments
 (0)