From 5d109979fff57221a493d8ce2048df68998f0520 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 03:56:41 +0000 Subject: [PATCH] test(rest): pin that a permission-store outage is indistinguishable from a capability denial at the packages door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the measurement from #13279 as an assertion in the tree: driven through the real RestServer (constructor seams only) and the real registerPackageRoutes, a healthy store answers 200, a store whose every read throws answers 403 FORBIDDEN, and a reachable-but-empty store answers the same 403 — byte-identical under JSON.stringify. Divergence pinned, disposition open. Whether `tryFind` should distinguish "no rows" from "the read failed" is deliberately unruled on #13279 (it is a behaviour change on the shared authorization resolver every transport inherits), and nothing here asserts 403 is correct. The equality is expected to be flipped deliberately when that ruling lands. Part of #13279 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- ...ge-door-execctx-fault-reachability.test.ts | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 packages/rest/src/package-door-execctx-fault-reachability.test.ts diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts new file mode 100644 index 0000000000..23b06f730f --- /dev/null +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13279] What an UNREACHABLE PERMISSION STORE reads as at the packages door. + * + * ## ⚠️ DIVERGENCE PINNED, DISPOSITION OPEN + * + * This file records a MEASUREMENT, not a judgment. It states what the code + * DOES today; it says nothing about what it SHOULD do. + * + * ⛔ Nothing here asserts that 403 is the correct answer for a store outage. + * The single fact pinned is that, today, the two situations below are + * INDISTINGUISHABLE to the caller and to the operator: + * + * - the permission store is unreachable (every read throws), and + * - the permission store is reachable and the caller genuinely holds nothing. + * + * The DISPOSITION — whether `tryFind` in + * `packages/core/src/security/resolve-authz-context.ts` ought to distinguish + * "no rows" from "the read failed" — is owned by #13279 and is DELIBERATELY + * UNRULED there, because it is a behaviour change on the SHARED authorization + * resolver: every transport that authorizes through `resolveAuthzContext` + * inherits it, not just REST. This pin does not pre-empt that ruling in either + * direction, and must not be read as evidence for either answer. + * + * ⭐ WHEN THAT RULING LANDS, THIS ASSERTION IS EXPECTED TO BE FLIPPED — on + * purpose. The day `tryFind` starts distinguishing the two cases, the + * "byte-identical" assertion in section 4 SHOULD go red. That is the pin doing + * its job, not a regression: it is what makes a deliberate change visible + * instead of silent. Whoever flips it will find the reasoning on #13279 — flip + * it there, with that card's ruling quoted, rather than deleting it. + * + * ## The seam being measured (not modified) + * + * `tryFind` wraps every permission-store read `resolveUserAuthzGrants` issues + * (`sys_user`, `sys_member`, `sys_user_position`, `sys_user_permission_set`, + * `sys_permission_set`, ...) in `try { ... } catch { return []; }`. So a read + * FAILURE and an EMPTY RESULT arrive at the aggregation as the same value, and + * `resolveAuthzContext` keeps the contract its own docblock states — "Always + * resolves — never throws". + * + * ⇒ On a store outage the resolution does not fail. It SUCCEEDS, carrying an + * AUTHENTICATED principal whose capability set is empty. The packages door's + * capability clause then refuses with 403 FORBIDDEN and a message naming a + * capability, which is the same thing it says to a real capability denial. + * + * ## Why this is not #13255 / #12537's seam — measured in section 3 + * + * That reading is CONTEXT LOST: `resolveExecCtx`'s `.catch(() => undefined)` + * and the `computeExecCtx` swallow behind it produce an `undefined` context, + * the anonymous floor sees no `userId`, and the answer is 401. This one is + * GRANTS LOST: identity SURVIVES (`userId` is still the real caller), only the + * aggregation is empty, the deciding clause is a different one and the status + * is different. Section 3 asserts the surviving `userId` precisely so the two + * cannot be confused, and so a repair aimed at either `.catch` is not mistaken + * for a repair of this. + * + * ## ⭐ The positive controls are LOAD-BEARING, not decoration + * + * "The two 403s are byte-identical" passes just as happily in a harness that + * never invoked anything at all — two responses that were never produced + * compare equal too. So every equality below is stated next to a same-shaped + * INEQUALITY on the same comparator (section 4), and the store injection is + * shown to actually reach the shared resolver (section 2: the healthy store's + * rows arrive as capabilities, and the faulting store's `find` is really + * called). A zero from an instrument never shown to produce a one is not a + * measurement. + * + * ## Wiring + * + * The real `RestServer` (CONSTRUCTOR SEAMS ONLY — no private member replaced) + * and the real `registerPackageRoutes`, joined exactly the way + * `rest-api-plugin.ts` joins them: + * `resolveExecutionContext: (req) => restServer.resolvePackageRouteExecutionContext(req)`. + * The only injected things are the two providers the plugin also supplies — + * `authServiceProvider` (identity) and `objectQLProvider` (the permission + * store). No external service is needed, so this runs on the ordinary CI lane. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { RouteHandler } from '@objectstack/spec/contracts'; +import { registerPackageRoutes } from './package-routes.js'; +import { RestServer } from './rest-server.js'; + +const PKGS = '/api/v1/packages'; + +/** The capabilities the packages door gates on: read wants one, write the other. */ +const GRANTED = ['manage_metadata', 'studio.access']; + +const USER_ID = 'u_admin'; +const PS_ID = 'ps_studio_operator'; + +/** + * The rows a HEALTHY permission store answers for {@link USER_ID}. Shaped for + * the reads `resolveUserAuthzGrants` actually issues: a user-scoped grant row + * pointing at a permission set whose `system_permissions` carry the two + * capabilities. Tables not listed answer empty, which is what a real store + * with no memberships/positions does. + */ +const HEALTHY_ROWS: Record = { + sys_user: [{ id: USER_ID, email: 'admin@example.test' }], + sys_user_permission_set: [{ user_id: USER_ID, permission_set_id: PS_ID }], + sys_permission_set: [{ id: PS_ID, name: 'studio_operator', system_permissions: GRANTED }], +}; + +/** ObjectQL seam as `resolveAuthzContext` consumes it: `find(object, query)`. */ +type Store = { find: (object: string, query: unknown) => Promise }; + +/** Healthy: reachable, and it returns the grant rows. */ +const healthyStore = (): Store => ({ + find: vi.fn(async (object: string) => HEALTHY_ROWS[object] ?? []), +}); + +/** Outage: reachable-in-code, but EVERY read throws. */ +const faultingStore = (): Store => ({ + find: vi.fn(async () => { throw new Error('permission store unreachable'); }), +}); + +/** Genuinely empty: reachable, and the caller really holds nothing. */ +const emptyStore = (): Store => ({ + find: vi.fn(async () => []), +}); + +/** An auth service that authenticates {@link USER_ID} — same shape the plugin's provider hands over. */ +const AUTH_SERVICE = { + api: { + getSession: async () => ({ user: { id: USER_ID, email: 'admin@example.test' }, session: {} }), + }, +}; + +interface Captured { + status: number; + body: any; +} + +interface Door { + routes: Map; + rest: RestServer; + store: Store; +} + +/** + * The production join, over one permission store. `RestServer` is constructed + * through its CONSTRUCTOR only; the door reads identity through the same public + * `resolvePackageRouteExecutionContext` the plugin wires. + */ +function doorOver(store: Store): Door { + const routes = new Map(); + const server = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, + delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); }, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + } as any; + const rest = new RestServer( + server, + {} as any, + {} as any, + undefined, + undefined, + undefined, + async () => AUTH_SERVICE, + async () => store, + ); + registerPackageRoutes(server, () => ({ list: async () => [] }) as any, '/api/v1', { + resolveExecutionContext: (req: any) => rest.resolvePackageRouteExecutionContext(req), + } as any); + return { routes, rest, store }; +} + +async function drive( + routes: Map, + method: string, + path: string, + req: Record = {}, +): Promise { + const handler = routes.get(`${method}:${path}`); + if (!handler) throw new Error(`no handler for ${method} ${path}`); + const captured: Captured = { status: 0, body: undefined }; + const res: any = { + json(data: any) { captured.body = data; }, + send() {}, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + await handler( + { params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as any, + res, + ); + return captured; +} + +/** `GET /packages` through the real door, over one permission store. */ +const listOver = (store: Store) => drive(doorOver(store).routes, 'GET', PKGS); + +/** The execution context the door itself resolves, over one permission store. */ +const contextOver = (store: Store) => + doorOver(store).rest.resolvePackageRouteExecutionContext({ + params: {}, query: {}, headers: {}, method: 'GET', path: PKGS, + }); + +// --------------------------------------------------------------------------- +// 1. CONTROLS — the instrument produces a ONE before any zero is read. +// --------------------------------------------------------------------------- + +describe('[#13279] controls — the harness really drives the door and the store', () => { + it('CONTROL (allow is observable): a healthy store is served 200', async () => { + const captured = await listOver(healthyStore()); + expect(captured.status).toBe(200); + expect(captured.body?.success).toBe(true); + }); + + it('CONTROL (the store is really read): the faulting store\'s `find` is called', async () => { + const store = faultingStore(); + await drive(doorOver(store).routes, 'GET', PKGS); + // ⚠️ If this were 0 the whole file would be measuring an unwired harness. + expect((store.find as any).mock.calls.length).toBeGreaterThan(0); + }); + + it('CONTROL (the fault really faults): the injected store rejects', async () => { + await expect(faultingStore().find('sys_user', {})).rejects.toThrow('permission store unreachable'); + }); +}); + +// --------------------------------------------------------------------------- +// 2. THE INJECTION REACHES THE SHARED RESOLVER — the healthy store's rows +// arrive as capabilities, so an empty capability set elsewhere is a +// property of the STORE, not of a resolver that never saw one. +// --------------------------------------------------------------------------- + +describe('[#13279] the permission store injection reaches `resolveAuthzContext`', () => { + it('a healthy store yields the two granted capabilities on the resolved context', async () => { + const ctx: any = await contextOver(healthyStore()); + expect(ctx?.userId).toBe(USER_ID); + for (const capability of GRANTED) expect(ctx?.systemPermissions).toContain(capability); + }); +}); + +// --------------------------------------------------------------------------- +// 3. THE DEGRADE IS "GRANTS LOST", NOT "CONTEXT LOST". +// +// Asserted so this reading can never be folded into #13255 / #12537: there +// the context becomes `undefined` and the anonymous floor answers 401. Here +// the IDENTITY SURVIVES and only the aggregation is empty, which is why the +// refusal comes from the capability clause instead. +// --------------------------------------------------------------------------- + +describe('[#13279] a store outage resolves an AUTHENTICATED principal holding nothing', () => { + it('the resolution SUCCEEDS: the caller is still `u_admin`, with an empty capability set', async () => { + const ctx: any = await contextOver(faultingStore()); + // Not `undefined` — that is the other card's degrade, and its status is 401. + expect(ctx).toBeDefined(); + expect(ctx?.userId).toBe(USER_ID); + expect(ctx?.systemPermissions).toEqual([]); + }); + + it('a reachable, genuinely EMPTY store resolves the same shape', async () => { + const ctx: any = await contextOver(emptyStore()); + expect(ctx?.userId).toBe(USER_ID); + expect(ctx?.systemPermissions).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 4. ⭐ THE PIN — the outage and the genuine denial are ONE answer on the wire. +// +// ⛔ Read this as "indistinguishable today", never as "403 is correct". +// Disposition: open, owned by #13279's deliberately-unruled question. +// --------------------------------------------------------------------------- + +describe('[#13279] a store OUTAGE is byte-identical to a genuine capability DENIAL', () => { + it('healthy 200, faulting 403 FORBIDDEN, empty 403 FORBIDDEN — the table as driven', async () => { + const [healthy, faulted, empty] = await Promise.all([ + listOver(healthyStore()), + listOver(faultingStore()), + listOver(emptyStore()), + ]); + expect(healthy.status).toBe(200); + + expect(faulted.status).toBe(403); + expect(faulted.body?.error?.code).toBe('FORBIDDEN'); + expect(empty.status).toBe(403); + expect(empty.body?.error?.code).toBe('FORBIDDEN'); + + // The divergence itself. ⚠️ EXPECTED TO BE FLIPPED, deliberately, on the + // day `tryFind` distinguishes "no rows" from "the read failed" — the + // reasoning for the flip belongs on #13279, which does not rule it here. + expect(JSON.stringify(faulted)).toBe(JSON.stringify(empty)); + }); + + it('⭐ CONTROL: the SAME comparator SEPARATES the healthy 200 from the refusal', async () => { + const [healthy, faulted] = await Promise.all([listOver(healthyStore()), listOver(faultingStore())]); + // ⚠️ LOAD-BEARING. Without this, the equality above would also pass in a + // harness that produced nothing at all — two absent answers compare equal. + // This shows the comparator discriminates on responses this harness really + // produced, so the equality above is a measured sameness, not a vacuous one. + expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(faulted)); + }); + + it('the refusal names a capability — the operator is told about entitlement, during an outage', async () => { + const faulted = await listOver(faultingStore()); + expect(faulted.body?.error?.message).toContain('studio.access'); + // Same sentence for the genuine denial: the MESSAGE carries no discriminator either. + const empty = await listOver(emptyStore()); + expect(empty.body?.error?.message).toBe(faulted.body?.error?.message); + }); + + it('the write half reads the same way — outage and denial agree there too', async () => { + const publish = { body: { manifest: { id: 'com.acme.crm', version: '1.0.0' }, metadata: {} } }; + const [faulted, empty] = await Promise.all([ + drive(doorOver(faultingStore()).routes, 'POST', `${PKGS}/publish`, publish), + drive(doorOver(emptyStore()).routes, 'POST', `${PKGS}/publish`, publish), + ]); + expect(faulted.status).toBe(403); + expect(JSON.stringify(faulted)).toBe(JSON.stringify(empty)); + }); +});