From b7fdf64feb7bd9260dca800de32f76214f40f849 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:34:29 +0000 Subject: [PATCH 1/5] fix(plugin-security): PermissionDeniedError declares status beside statusCode The class declared statusCode alone, the only error class in errors.ts that did, so plugin-sharing's share-link route door (which reads status alone) answered a plain member's permission refusal as 500 while the runtime dispatcher answered the same throw with 403. It now carries both spellings with equal values. Pins: an enumeration over every error class errors.ts exports (both spellings, equal values), and a showcase-boot dogfood pin driving one refusal through both share-link doors for create and list. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21405-permission-denied-status.md | 9 + .../plugin-security/src/errors.test.ts | 85 ++++++++++ .../plugins/plugin-security/src/errors.ts | 36 ++-- ...s-permission-denied-status.dogfood.test.ts | 158 ++++++++++++++++++ 4 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 .changeset/21405-permission-denied-status.md create mode 100644 packages/plugins/plugin-security/src/errors.test.ts create mode 100644 packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts diff --git a/.changeset/21405-permission-denied-status.md b/.changeset/21405-permission-denied-status.md new file mode 100644 index 00000000000..4c0111f057f --- /dev/null +++ b/.changeset/21405-permission-denied-status.md @@ -0,0 +1,9 @@ +--- +'@objectstack/plugin-security': patch +--- + +`PermissionDeniedError` declares its 403 as `status` as well as `statusCode`, so a permission refusal answers 403 at every door (#21405). + +Clause-②: no + +The class declared `statusCode` alone, unlike every other error class in `errors.ts`. A door that reads `status` alone derived no status from it. On a showcase boot, a plain member's `POST /api/v1/share-links` on a record they cannot read, and their `GET /api/v1/share-links`, answered `500` with code `PERMISSION_DENIED` through `plugin-sharing`'s route door, while the runtime dispatcher's `/share-links` domain answered the same refusal with `403`. Both doors now answer `403 PERMISSION_DENIED`. The code, the message and `statusCode` are unchanged. diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts new file mode 100644 index 00000000000..e64d8d34a5a --- /dev/null +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every error class `errors.ts` exports declares its HTTP status under BOTH + * spellings, `status` and `statusCode`, with equal values. + * + * The rule is the module's own ("Why each carries BOTH `status` and + * `statusCode`"): the doors read different property names, so a class that + * declares one spelling answers a status at the doors that read it and + * nothing at the doors that read the other. `PermissionDeniedError` was that + * class — `statusCode` alone — and `plugin-sharing`'s share-link route door, + * which reads `status`, answered its 403 refusal as a 500. Its two-door pin is + * `packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts`. + * + * The population is ENUMERATED from the module's exports, never listed by + * hand, so a class added later is held to the rule the day it lands. The floor + * below is what keeps the enumeration from passing over nothing: it names the + * classes the module exports today and fails if any of them is not found. + */ + +import { describe, expect, it } from 'vitest'; +import * as errorsModule from './errors.js'; +import { PermissionDeniedError } from './errors.js'; + +type ErrorClass = new (...args: unknown[]) => Error; + +/** The exported values that are `Error` subclasses, by export name. */ +const ERROR_CLASSES: Array<[string, ErrorClass]> = Object.entries(errorsModule) + .filter(([, value]) => typeof value === 'function' && value.prototype instanceof Error) + .map(([name, value]) => [name, value as unknown as ErrorClass]); + +/** + * The classes the module exports today. A floor, not the population: a new + * class is checked without being added here. + */ +const FLOOR = [ + 'PermissionDeniedError', + 'MasterDetailRelationMissingError', + 'DetailRecordNotFoundError', + 'MasterReferenceMissingError', + 'MaskedValueWriteError', + 'PermissionSetReadUnansweredError', + 'ExplainObjectNotFoundError', + 'PermissionSetNameConflictError', +]; + +/** + * One argument list every class here accepts. Each constructor reads its + * parameters as text (interpolated, or through `String()`) or as a list + * (`join`, spread), and a one-element array serves as both. + */ +const ARGS: unknown[] = [['a'], ['b'], ['c'], ['d']]; + +describe('errors.ts — every exported error class declares status and statusCode, equal', () => { + it('the enumeration finds every class the module exports today', () => { + expect(ERROR_CLASSES.map(([name]) => name)).toEqual(expect.arrayContaining(FLOOR)); + }); + + it('each one carries both spellings, with equal numeric values', () => { + const violations: string[] = []; + for (const [name, ErrorClass] of ERROR_CLASSES) { + let instance: Error & { status?: unknown; statusCode?: unknown }; + try { + instance = new ErrorClass(...ARGS); + } catch (e) { + violations.push(`${name}: cannot be constructed from the shared argument list (${String(e)})`); + continue; + } + const { status, statusCode } = instance; + if (typeof status !== 'number' || typeof statusCode !== 'number') { + violations.push(`${name}: status=${String(status)} statusCode=${String(statusCode)} — declare both`); + } else if (status !== statusCode) { + violations.push(`${name}: status ${status} !== statusCode ${statusCode}`); + } + } + expect(violations).toEqual([]); + }); + + it('PermissionDeniedError answers 403 under both spellings', () => { + const e = new PermissionDeniedError('[Security] Access denied: read on crm_account'); + expect(e.code).toBe('PERMISSION_DENIED'); + expect(e.status).toBe(403); + expect(e.statusCode).toBe(403); + }); +}); diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index 69c62eeac34..95814367837 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -24,9 +24,22 @@ import { unresolvedPostureRemedy } from './unresolved-posture.js'; * positions and permission sets must not travel that way — see the throw site * in `security-plugin.ts` and the measurement recorded in * `permission-denied-user-copy.test.ts`. + * + * ## Both `status` and `statusCode`, like every class in this file + * + * This class used to declare `statusCode` alone — the only class here that + * did — so a door that reads `status` alone derived no status from it. + * Measured on a showcase boot as a plain member: `plugin-sharing`'s share-link + * route door (`err?.status ?? 500`) answered `POST` and `GET + * /api/v1/share-links` with `500 PERMISSION_DENIED`, while the runtime + * dispatcher's `/share-links` domain answered the same throw with `403`. The + * class now carries both spellings with equal values, for the reason the note + * below gives its siblings, so a door that reads either one answers `403`. + * `errors.test.ts` holds every error class this module exports to that rule. */ export class PermissionDeniedError extends Error { readonly code = 'PERMISSION_DENIED'; + readonly status = 403; readonly statusCode = 403; readonly details?: Record; /** @@ -61,12 +74,16 @@ export class PermissionDeniedError extends Error { * * ### Why each carries BOTH `status` and `statusCode` * - * The two transports read different property names, and this gate throws on the - * DATA path, which reaches both: `@objectstack/rest`'s `mapDataError` passes a - * domain error through on `.status` alone, while the runtime dispatcher's - * `errorFromThrown` reads `.status` then falls back to `.statusCode`. Declaring - * one spelling would leave the other transport deriving a status from nothing — - * which is the defect this split exists to remove, reintroduced at the edge. + * The doors read different property names, and this gate throws on the DATA + * path, which reaches all of them. `status` is the spelling every door reads + * first, and some read nothing else: `plugin-sharing`'s share-link route door + * (`err?.status ?? 500`), and the sandbox boundary, whose passthrough list + * carries `status` and not `statusCode` (`SANDBOX_ERROR_PASSTHROUGH` in + * `runtime/src/sandbox/quickjs-runner.ts`). The runtime dispatcher's + * `errorFromThrown` and `@objectstack/rest`'s `mapDataError` read `.status` + * then fall back to `.statusCode`. Declaring one spelling would leave a door + * that reads the other deriving a status from nothing — which is the defect + * this split exists to remove, reintroduced at the edge. * * ### Why none of them starts with `[Security] Access denied` * @@ -412,10 +429,9 @@ export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409; * * ## Why BOTH `status` and `statusCode` * - * The same reason every class above records: the two transports read different - * property names (`mapDataError` passes a domain error through on `.status`; - * the runtime dispatcher's `errorFromThrown` reads `.status` then falls back to - * `.statusCode`), and this throws on the DATA path, which reaches both. + * The same reason every class above records: the doors read different property + * names (see "Why each carries BOTH `status` and `statusCode`" above), and this + * throws on the DATA path, which reaches them all. * * The message is byte-identical to the bare `Error`'s — the wording was never * the defect, and the flat door's 4xx arm ships it verbatim. diff --git a/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts b/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts new file mode 100644 index 00000000000..c5ddc0d370d --- /dev/null +++ b/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21405 — one permission refusal answers the same status through both + * share-link doors. + * + * ## The two doors + * + * `/share-links` has two implementations. `plugin-sharing`'s + * `registerShareLinkRoutes` serves `/api/v1/share-links` in this composition + * (the standalone server mounts no dispatcher route for the path). The runtime + * dispatcher's `/share-links` domain is the designed primary surface for + * cloud's per-environment kernels. Here it is driven in-process, the way + * `@objectstack/verify`'s own handle drives it: an `HttpDispatcher` over the + * SAME booted kernel, with the same bearer token. Both doors hand the caller's + * envelope to the same `ShareLinkService`, so one caller and one request reach + * one refusal, and only the door differs. + * + * ## The defect + * + * The refusal is the security middleware's object-CRUD gate, which throws + * `PermissionDeniedError` when a plain member reads an object their permission + * sets grant nothing on. That class declared `statusCode = 403` and no + * `status`. The plugin door's catch reads `err?.status ?? 500` and the + * dispatcher's `errorFromThrown` reads `status` then `statusCode`. Measured on + * this boot at the merge base, as a plain member: + * + * | request | plugin door | dispatcher door | + * |-------------------------|--------------------------|--------------------------| + * | POST /share-links | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | + * | GET /share-links | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | + * + * The ruled fix is in the class, not the door: `PermissionDeniedError` carries + * both spellings, as every sibling in `plugin-security/src/errors.ts` does. + * That also fixes every other door that reads `status` alone. + * + * ## Why create pins 403 and list pins agreement + * + * The create refusal is the visibility read in `createLink`: the member asks + * to share a record of `showcase_client_brief` (which opts into + * `publicSharing`, so the request reaches that read) and holds no read grant on + * it. The list refusal is the read of `sys_share_link` under the member's + * context. #21328 makes a member's own list a self-scoped read, after which the + * member's list answers 200 through both doors. So the list case asserts what + * holds on both sides of that change: the two doors give the same status and + * the same code, and neither gives a server fault. Before this fix the plugin + * door's 500 breaks it, whichever side of #21328 the tree is on. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +// The dispatcher class the boot mounts, read from source the way this suite's +// other runtime readers are (`route-ledger-live-mount-parity`): it is not part +// of this package's dependencies, and its source is a declared input of this +// suite's turbo task. +import { HttpDispatcher } from '../../../runtime/src/http-dispatcher.js'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; +const OBJECT = 'showcase_client_brief'; +/** A seeded brief that passes the object's `eligibility` predicate. */ +const PUBLISHED_BRIEF = 'Northwind — Website Relaunch brief'; +const MEMBER_EMAIL = 'share-links.denied.21405@verify.test'; + +interface DoorAnswer { + status: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: any; +} + +describe('#21405: a share-link permission refusal answers one status through both doors', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let dispatcher: HttpDispatcher; + let memberTok = ''; + let memberId = ''; + let briefId = ''; + + /** The plugin door: an HTTP request through the booted Hono app. */ + const viaPluginDoor = async (method: 'GET' | 'POST', body?: unknown): Promise => { + const res = await stack.apiAs(memberTok, method, '/share-links', body); + const text = await res.text(); + let parsed: unknown = text; + try { parsed = JSON.parse(text); } catch { /* the status still reports */ } + return { status: res.status, body: parsed }; + }; + + /** The dispatcher door: the runtime `/share-links` domain, same kernel, same token. */ + const viaDispatcherDoor = async (method: 'GET' | 'POST', body?: unknown): Promise => { + const res = await dispatcher.dispatch(method, '/share-links', body, {}, { + request: { + method, + url: 'http://localhost/api/v1/share-links', + headers: { + authorization: `Bearer ${memberTok}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + }, + }); + if (!res.handled || !res.response) throw new Error(`the dispatcher did not handle ${method} /share-links`); + return { status: res.response.status, body: res.response.body }; + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + await stack.signIn(); // the seeded admin first, so the sign-up below is a plain member + memberTok = await stack.signUp(MEMBER_EMAIL); + ql = await stack.kernel.getServiceAsync('objectql'); + memberId = String((await ql.findOne('sys_user', { where: { email: MEMBER_EMAIL }, context: SYS }))?.id ?? ''); + briefId = String((await ql.findOne(OBJECT, { where: { title: PUBLISHED_BRIEF }, context: SYS }))?.id ?? ''); + expect(memberId, 'the member exists').toBeTruthy(); + expect(briefId, 'the seeded published brief').toBeTruthy(); + dispatcher = new HttpDispatcher(stack.kernel); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('[persona] the member is plain and holds no read grant on the object', async () => { + const res = await stack.apiAs(memberTok, 'GET', `/security/explain?object=${OBJECT}&operation=read`); + const text = await res.text(); + expect(res.status, text).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layers: any[] = JSON.parse(text)?.layers ?? []; + const setNames = layers.flatMap((l) => l.contributors ?? []).map((c: { name?: unknown }) => String(c?.name)); + expect(setNames, 'the persona must not be an admin').not.toContain('admin_full_access'); + expect(layers.find((l) => l.layer === 'object_crud')?.verdict, 'the refusal is the object-CRUD gate').toBe('denies'); + }); + + it('[create] both doors answer 403 PERMISSION_DENIED, and mint nothing', async () => { + const request = { object: OBJECT, recordId: briefId }; + const plugin = await viaPluginDoor('POST', request); + const dispatched = await viaDispatcherDoor('POST', request); + + expect(plugin.status, `plugin door: ${JSON.stringify(plugin.body)}`).toBe(403); + expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + expect(dispatched.status, `dispatcher door: ${JSON.stringify(dispatched.body)}`).toBe(403); + expect(dispatched.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + + const minted = await ql.find('sys_share_link', { where: { created_by: memberId }, context: SYS }); + expect(minted, 'a refused mint writes no link').toEqual([]); + }); + + it('[list] both doors give the same status and code, and neither gives a server fault', async () => { + const plugin = await viaPluginDoor('GET'); + const dispatched = await viaDispatcherDoor('GET'); + const detail = `plugin door ${plugin.status} ${JSON.stringify(plugin.body)} · ` + + `dispatcher door ${dispatched.status} ${JSON.stringify(dispatched.body)}`; + + expect(plugin.status, detail).toBe(dispatched.status); + expect(plugin.body?.error?.code, detail).toBe(dispatched.body?.error?.code); + expect(plugin.status, detail).toBeLessThan(500); + }); +}); From 46b09eaa19b506304a5b60b50c506490fda43e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:51:10 +0000 Subject: [PATCH 2/5] test(dogfood): pin the share-link create refusal alone once the member's list is self-scoped The self-scoped list from origin/main answers a plain member's list 200 through both doors, so no list request reaches the refusal any more; the pin keeps the create refusal, and the class comment and changeset name it. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21405-permission-denied-status.md | 2 +- .../plugins/plugin-security/src/errors.ts | 15 +++--- ...s-permission-denied-status.dogfood.test.ts | 48 ++++++++----------- 3 files changed, 28 insertions(+), 37 deletions(-) diff --git a/.changeset/21405-permission-denied-status.md b/.changeset/21405-permission-denied-status.md index 4c0111f057f..d25380cc13d 100644 --- a/.changeset/21405-permission-denied-status.md +++ b/.changeset/21405-permission-denied-status.md @@ -6,4 +6,4 @@ Clause-②: no -The class declared `statusCode` alone, unlike every other error class in `errors.ts`. A door that reads `status` alone derived no status from it. On a showcase boot, a plain member's `POST /api/v1/share-links` on a record they cannot read, and their `GET /api/v1/share-links`, answered `500` with code `PERMISSION_DENIED` through `plugin-sharing`'s route door, while the runtime dispatcher's `/share-links` domain answered the same refusal with `403`. Both doors now answer `403 PERMISSION_DENIED`. The code, the message and `statusCode` are unchanged. +The class declared `statusCode` alone, unlike every other error class in `errors.ts`, and a door that reads `status` alone derived no status from it. On a showcase boot, a plain member's `POST /api/v1/share-links` on a record they cannot read answered `500` with code `PERMISSION_DENIED` through `plugin-sharing`'s route door, while the runtime dispatcher's `/share-links` domain answered the same refusal with `403`. Both doors now answer `403 PERMISSION_DENIED`. The code, the message and `statusCode` are unchanged. diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index 95814367837..99d282ed9fd 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -29,13 +29,14 @@ import { unresolvedPostureRemedy } from './unresolved-posture.js'; * * This class used to declare `statusCode` alone — the only class here that * did — so a door that reads `status` alone derived no status from it. - * Measured on a showcase boot as a plain member: `plugin-sharing`'s share-link - * route door (`err?.status ?? 500`) answered `POST` and `GET - * /api/v1/share-links` with `500 PERMISSION_DENIED`, while the runtime - * dispatcher's `/share-links` domain answered the same throw with `403`. The - * class now carries both spellings with equal values, for the reason the note - * below gives its siblings, so a door that reads either one answers `403`. - * `errors.test.ts` holds every error class this module exports to that rule. + * Measured on a showcase boot: a plain member's `POST /api/v1/share-links` on + * a record they cannot read answered `500 PERMISSION_DENIED` through + * `plugin-sharing`'s share-link route door (`err?.status ?? 500`), and `403` + * through the runtime dispatcher's `/share-links` domain, for the same throw. + * The class now carries both spellings with equal values, for the reason the + * note below gives its siblings, so a door that reads either one answers + * `403`. `errors.test.ts` holds every error class this module exports to that + * rule. */ export class PermissionDeniedError extends Error { readonly code = 'PERMISSION_DENIED'; diff --git a/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts b/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts index c5ddc0d370d..5318d1e4e70 100644 --- a/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts +++ b/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts @@ -23,7 +23,7 @@ * sets grant nothing on. That class declared `statusCode = 403` and no * `status`. The plugin door's catch reads `err?.status ?? 500` and the * dispatcher's `errorFromThrown` reads `status` then `statusCode`. Measured on - * this boot at the merge base, as a plain member: + * this boot as a plain member, before the fix (`main` at 6d67ad5ec): * * | request | plugin door | dispatcher door | * |-------------------------|--------------------------|--------------------------| @@ -34,17 +34,18 @@ * both spellings, as every sibling in `plugin-security/src/errors.ts` does. * That also fixes every other door that reads `status` alone. * - * ## Why create pins 403 and list pins agreement + * ## Why only create is pinned * * The create refusal is the visibility read in `createLink`: the member asks * to share a record of `showcase_client_brief` (which opts into * `publicSharing`, so the request reaches that read) and holds no read grant on - * it. The list refusal is the read of `sys_share_link` under the member's - * context. #21328 makes a member's own list a self-scoped read, after which the - * member's list answers 200 through both doors. So the list case asserts what - * holds on both sides of that change: the two doors give the same status and - * the same code, and neither gives a server fault. Before this fix the plugin - * door's 500 breaks it, whichever side of #21328 the tree is on. + * it. The list refusal in the table above was the read of `sys_share_link` + * under the member's context. #21328 made a member's own list a self-scoped + * read, and both doors force the creator filter to the caller, so no list + * request reaches that refusal any more. Measured on this boot after #21328: + * `GET /share-links` answers 200 through both doors, with no filter, with the + * Share dialog's object and record filter, and with `includeRevoked`. A list + * case here would pin a refusal no request can produce. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -78,20 +79,20 @@ describe('#21405: a share-link permission refusal answers one status through bot let memberId = ''; let briefId = ''; - /** The plugin door: an HTTP request through the booted Hono app. */ - const viaPluginDoor = async (method: 'GET' | 'POST', body?: unknown): Promise => { - const res = await stack.apiAs(memberTok, method, '/share-links', body); + /** `POST /share-links` through the plugin door: an HTTP request into the booted Hono app. */ + const mintViaPluginDoor = async (body: unknown): Promise => { + const res = await stack.apiAs(memberTok, 'POST', '/share-links', body); const text = await res.text(); let parsed: unknown = text; try { parsed = JSON.parse(text); } catch { /* the status still reports */ } return { status: res.status, body: parsed }; }; - /** The dispatcher door: the runtime `/share-links` domain, same kernel, same token. */ - const viaDispatcherDoor = async (method: 'GET' | 'POST', body?: unknown): Promise => { - const res = await dispatcher.dispatch(method, '/share-links', body, {}, { + /** `POST /share-links` through the dispatcher door: same kernel, same token. */ + const mintViaDispatcherDoor = async (body: unknown): Promise => { + const res = await dispatcher.dispatch('POST', '/share-links', body, {}, { request: { - method, + method: 'POST', url: 'http://localhost/api/v1/share-links', headers: { authorization: `Bearer ${memberTok}`, @@ -100,7 +101,7 @@ describe('#21405: a share-link permission refusal answers one status through bot }, }, }); - if (!res.handled || !res.response) throw new Error(`the dispatcher did not handle ${method} /share-links`); + if (!res.handled || !res.response) throw new Error('the dispatcher did not handle POST /share-links'); return { status: res.response.status, body: res.response.body }; }; @@ -133,8 +134,8 @@ describe('#21405: a share-link permission refusal answers one status through bot it('[create] both doors answer 403 PERMISSION_DENIED, and mint nothing', async () => { const request = { object: OBJECT, recordId: briefId }; - const plugin = await viaPluginDoor('POST', request); - const dispatched = await viaDispatcherDoor('POST', request); + const plugin = await mintViaPluginDoor(request); + const dispatched = await mintViaDispatcherDoor(request); expect(plugin.status, `plugin door: ${JSON.stringify(plugin.body)}`).toBe(403); expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); @@ -144,15 +145,4 @@ describe('#21405: a share-link permission refusal answers one status through bot const minted = await ql.find('sys_share_link', { where: { created_by: memberId }, context: SYS }); expect(minted, 'a refused mint writes no link').toEqual([]); }); - - it('[list] both doors give the same status and code, and neither gives a server fault', async () => { - const plugin = await viaPluginDoor('GET'); - const dispatched = await viaDispatcherDoor('GET'); - const detail = `plugin door ${plugin.status} ${JSON.stringify(plugin.body)} · ` - + `dispatcher door ${dispatched.status} ${JSON.stringify(dispatched.body)}`; - - expect(plugin.status, detail).toBe(dispatched.status); - expect(plugin.body?.error?.code, detail).toBe(dispatched.body?.error?.code); - expect(plugin.status, detail).toBeLessThan(500); - }); }); From d9f9aab31aa7edba18809c9f8c5ede34f02a9931 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:29:17 +0000 Subject: [PATCH 3/5] test(runtime): pin one share-link refusal through both doors beside the dispatcher domain The dogfood pin reached the dispatcher door only by importing runtime source, which check:test-source-alias refuses (four new unaliased artifact imports into dogfood). The runtime package imports both doors already, so the two-door create pin moves beside the dispatcher's own #6649 block: one engine with the real SecurityPlugin middleware, one ShareLinkService, one envelope, and both production door entries. The shared-catch case keeps a statusCode-only throw now that the production class carries status too. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../plugin-security/src/errors.test.ts | 3 +- ...s-permission-denied-status.dogfood.test.ts | 148 ----------------- .../share-links-enforcement-context.test.ts | 157 ++++++++++++++++-- 3 files changed, 145 insertions(+), 163 deletions(-) delete mode 100644 packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index e64d8d34a5a..a1ff1f2badd 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -10,7 +10,8 @@ * nothing at the doors that read the other. `PermissionDeniedError` was that * class — `statusCode` alone — and `plugin-sharing`'s share-link route door, * which reads `status`, answered its 403 refusal as a 500. Its two-door pin is - * `packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts`. + * the `[#21405]` block of + * `packages/runtime/src/domains/share-links-enforcement-context.test.ts`. * * The population is ENUMERATED from the module's exports, never listed by * hand, so a class added later is held to the rule the day it lands. The floor diff --git a/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts b/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts deleted file mode 100644 index 5318d1e4e70..00000000000 --- a/packages/qa/dogfood/test/share-links-permission-denied-status.dogfood.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #21405 — one permission refusal answers the same status through both - * share-link doors. - * - * ## The two doors - * - * `/share-links` has two implementations. `plugin-sharing`'s - * `registerShareLinkRoutes` serves `/api/v1/share-links` in this composition - * (the standalone server mounts no dispatcher route for the path). The runtime - * dispatcher's `/share-links` domain is the designed primary surface for - * cloud's per-environment kernels. Here it is driven in-process, the way - * `@objectstack/verify`'s own handle drives it: an `HttpDispatcher` over the - * SAME booted kernel, with the same bearer token. Both doors hand the caller's - * envelope to the same `ShareLinkService`, so one caller and one request reach - * one refusal, and only the door differs. - * - * ## The defect - * - * The refusal is the security middleware's object-CRUD gate, which throws - * `PermissionDeniedError` when a plain member reads an object their permission - * sets grant nothing on. That class declared `statusCode = 403` and no - * `status`. The plugin door's catch reads `err?.status ?? 500` and the - * dispatcher's `errorFromThrown` reads `status` then `statusCode`. Measured on - * this boot as a plain member, before the fix (`main` at 6d67ad5ec): - * - * | request | plugin door | dispatcher door | - * |-------------------------|--------------------------|--------------------------| - * | POST /share-links | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | - * | GET /share-links | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | - * - * The ruled fix is in the class, not the door: `PermissionDeniedError` carries - * both spellings, as every sibling in `plugin-security/src/errors.ts` does. - * That also fixes every other door that reads `status` alone. - * - * ## Why only create is pinned - * - * The create refusal is the visibility read in `createLink`: the member asks - * to share a record of `showcase_client_brief` (which opts into - * `publicSharing`, so the request reaches that read) and holds no read grant on - * it. The list refusal in the table above was the read of `sys_share_link` - * under the member's context. #21328 made a member's own list a self-scoped - * read, and both doors force the creator filter to the caller, so no list - * request reaches that refusal any more. Measured on this boot after #21328: - * `GET /share-links` answers 200 through both doors, with no filter, with the - * Share dialog's object and record filter, and with `includeRevoked`. A list - * case here would pin a refusal no request can produce. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; -// The dispatcher class the boot mounts, read from source the way this suite's -// other runtime readers are (`route-ledger-live-mount-parity`): it is not part -// of this package's dependencies, and its source is a declared input of this -// suite's turbo task. -import { HttpDispatcher } from '../../../runtime/src/http-dispatcher.js'; -import { showcaseAppDefaultSecurity } from './showcase-security.js'; - -const SYS = { isSystem: true } as const; -const OBJECT = 'showcase_client_brief'; -/** A seeded brief that passes the object's `eligibility` predicate. */ -const PUBLISHED_BRIEF = 'Northwind — Website Relaunch brief'; -const MEMBER_EMAIL = 'share-links.denied.21405@verify.test'; - -interface DoorAnswer { - status: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - body: any; -} - -describe('#21405: a share-link permission refusal answers one status through both doors', () => { - let stack: VerifyStack; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let ql: any; - let dispatcher: HttpDispatcher; - let memberTok = ''; - let memberId = ''; - let briefId = ''; - - /** `POST /share-links` through the plugin door: an HTTP request into the booted Hono app. */ - const mintViaPluginDoor = async (body: unknown): Promise => { - const res = await stack.apiAs(memberTok, 'POST', '/share-links', body); - const text = await res.text(); - let parsed: unknown = text; - try { parsed = JSON.parse(text); } catch { /* the status still reports */ } - return { status: res.status, body: parsed }; - }; - - /** `POST /share-links` through the dispatcher door: same kernel, same token. */ - const mintViaDispatcherDoor = async (body: unknown): Promise => { - const res = await dispatcher.dispatch('POST', '/share-links', body, {}, { - request: { - method: 'POST', - url: 'http://localhost/api/v1/share-links', - headers: { - authorization: `Bearer ${memberTok}`, - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - }); - if (!res.handled || !res.response) throw new Error('the dispatcher did not handle POST /share-links'); - return { status: res.response.status, body: res.response.body }; - }; - - beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); - await stack.signIn(); // the seeded admin first, so the sign-up below is a plain member - memberTok = await stack.signUp(MEMBER_EMAIL); - ql = await stack.kernel.getServiceAsync('objectql'); - memberId = String((await ql.findOne('sys_user', { where: { email: MEMBER_EMAIL }, context: SYS }))?.id ?? ''); - briefId = String((await ql.findOne(OBJECT, { where: { title: PUBLISHED_BRIEF }, context: SYS }))?.id ?? ''); - expect(memberId, 'the member exists').toBeTruthy(); - expect(briefId, 'the seeded published brief').toBeTruthy(); - dispatcher = new HttpDispatcher(stack.kernel); - }, 300_000); - - afterAll(async () => { - await stack?.stop?.(); - }); - - it('[persona] the member is plain and holds no read grant on the object', async () => { - const res = await stack.apiAs(memberTok, 'GET', `/security/explain?object=${OBJECT}&operation=read`); - const text = await res.text(); - expect(res.status, text).toBe(200); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const layers: any[] = JSON.parse(text)?.layers ?? []; - const setNames = layers.flatMap((l) => l.contributors ?? []).map((c: { name?: unknown }) => String(c?.name)); - expect(setNames, 'the persona must not be an admin').not.toContain('admin_full_access'); - expect(layers.find((l) => l.layer === 'object_crud')?.verdict, 'the refusal is the object-CRUD gate').toBe('denies'); - }); - - it('[create] both doors answer 403 PERMISSION_DENIED, and mint nothing', async () => { - const request = { object: OBJECT, recordId: briefId }; - const plugin = await mintViaPluginDoor(request); - const dispatched = await mintViaDispatcherDoor(request); - - expect(plugin.status, `plugin door: ${JSON.stringify(plugin.body)}`).toBe(403); - expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); - expect(dispatched.status, `dispatcher door: ${JSON.stringify(dispatched.body)}`).toBe(403); - expect(dispatched.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); - - const minted = await ql.find('sys_share_link', { where: { created_by: memberId }, context: SYS }); - expect(minted, 'a refused mint writes no link').toEqual([]); - }); -}); diff --git a/packages/runtime/src/domains/share-links-enforcement-context.test.ts b/packages/runtime/src/domains/share-links-enforcement-context.test.ts index fbbad1ff01e..244d3b8dca8 100644 --- a/packages/runtime/src/domains/share-links-enforcement-context.test.ts +++ b/packages/runtime/src/domains/share-links-enforcement-context.test.ts @@ -52,8 +52,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; +import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { PermissionDeniedError, SecurityPlugin } from '@objectstack/plugin-security'; -import { ShareLinkService } from '@objectstack/plugin-sharing'; +import { ShareLinkService, registerShareLinkRoutes } from '@objectstack/plugin-sharing'; import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; import { apiErrorResponse } from '../error-envelope.js'; @@ -133,8 +134,8 @@ const EAST_VIEWER: PermissionSet = PermissionSetSchema.parse({ * withholds read. This set is therefore wired as BOTH the caller's explicit set * and the fallback in the #6649 cases below: `allowCreate` alone, so * `checkObjectPermission('find', 'crm_account', …)` is false and the security - * middleware throws `PermissionDeniedError` — the `statusCode`-only shape whose - * status the domain used to drop. + * middleware throws `PermissionDeniedError` — the shape whose status the domain + * used to drop, because it declared `statusCode` alone until #21405. */ const ACCT_NO_READ: PermissionSet = PermissionSetSchema.parse({ name: 'acct_no_read', @@ -550,6 +551,11 @@ describe('[#6551] the dispatcher seam itself', () => { * which reads `status` OR `statusCode`. These cases assert `status` AND `code` * together on purpose: a denial arriving as 403 under the wrong code would be * just as wrong as the 500, and only the pair separates them. + * + * [#21405] The paragraphs above describe the class as #6649 found it. It now + * carries `status` beside `statusCode`, which is what fixed the OTHER door, and + * the shared-catch case below keeps a `statusCode`-only throw so this catch's + * second channel stays pinned. */ /** The ADR-0112 envelope checks every case below shares. */ @@ -635,17 +641,26 @@ describe('[#6649] a security-middleware refusal keeps its own status through the expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED'); }, 30_000); - it('the catch is shared, so list and revoke answer the statusCode-only refusal identically', async () => { - // Driven with a service double raising the REAL `PermissionDeniedError` - // (the production class, `statusCode` and no `status`), because what is - // under test here is the CATCH, not a second trip through the middleware. - for (const verb of ['GET', 'DELETE'] as const) { - const res = await refusalFromService( - new PermissionDeniedError(`[Security] Access denied: operation on object '${OBJECT}'`), - verb, - ); - expect(res.status, `${verb} status`).toBe(403); - expect(expectDeclaredEnvelope(res).code, `${verb} code`).toBe('PERMISSION_DENIED'); + it('the catch is shared, so list and revoke answer the refusal identically — the production class and a statusCode-only throw', async () => { + // Driven with a service double, because what is under test here is the + // CATCH, not a second trip through the middleware. Two throws: the REAL + // `PermissionDeniedError`, and a `statusCode`-only twin of the shape that + // class had until #21405. The class now carries `status` beside + // `statusCode`, so it no longer reaches the catch's `statusCode` channel + // by itself; the twin keeps that channel pinned. + const throws = { + production: () => new PermissionDeniedError(`[Security] Access denied: operation on object '${OBJECT}'`), + statusCodeOnly: () => Object.assign( + new Error(`[Security] Access denied: operation on object '${OBJECT}'`), + { code: 'PERMISSION_DENIED', statusCode: 403 }, + ), + }; + for (const [shape, thrown] of Object.entries(throws)) { + for (const verb of ['GET', 'DELETE'] as const) { + const res = await refusalFromService(thrown(), verb); + expect(res.status, `${shape} ${verb} status`).toBe(403); + expect(expectDeclaredEnvelope(res).code, `${shape} ${verb} code`).toBe('PERMISSION_DENIED'); + } } }); @@ -917,3 +932,117 @@ describe('[#14637] the dispatcher probe reads the standing policy before it answ expectIndistinguishable(await h.resolve(token, { password: 'hunter2' }), await h.resolve(UNKNOWN_TOKEN)); }); }); + +/** + * [#21405] The SAME refusal through the OTHER door. + * + * `/share-links` has two doors. This file's subject is the dispatcher domain; + * `plugin-sharing`'s `registerShareLinkRoutes` is the other, and it is the one + * that serves `/api/v1/share-links` on the standalone server (the dispatcher + * plugin mounts no route for the path there). Both hand the caller's envelope + * to `ShareLinkService`, so one caller and one request reach one refusal, and + * the only thing left to differ is how each door's catch reads the throw's + * status. + * + * They differed. The plugin door's catch reads `err?.status ?? 500`, and + * `PermissionDeniedError` declared `statusCode = 403` with no `status`, so the + * #6649 refusal below — the CRUD gate's denial on `createLink`'s visibility + * read — answered 403 here and 500 there. Measured on a showcase boot as a + * plain member, `POST /api/v1/share-links` on a record they cannot read: 500 + * `PERMISSION_DENIED` through the plugin door, 403 through this domain. The + * ruled fix is in the class: `PermissionDeniedError` carries `status` beside + * `statusCode`, as every sibling in `plugin-security/src/errors.ts` does, so + * no door's catch changes. + * + * ## What is real here + * + * Both doors, their production entries: `registerShareLinkRoutes` mounted on a + * route recorder, and `handleShareLinksRequest` over the dispatcher's own + * `errorFromThrown`. ONE engine double with the WHOLE `SecurityPlugin` + * middleware booted on it, ONE `ShareLinkService` over that engine, ONE + * envelope — so the throw each door catches is the middleware's own + * `PermissionDeniedError` from the same read, not a double's. + * + * ## Why create only + * + * The list's refusal (the member's read of `sys_share_link`) no longer + * reaches either door: the member's own list became a self-scoped read + * (#21328), and both doors force the creator filter to the caller. Measured on + * the showcase boot after that change: `GET /share-links` answers 200 through + * both doors. + */ + +/** The smallest `IHttpServer` that keeps the handlers a registrar mounts. */ +class RouteRecorder implements IHttpServer { + readonly routes = new Map(); + get(path: string, handler: RouteHandler) { this.routes.set(`GET ${path}`, handler); } + post(path: string, handler: RouteHandler) { this.routes.set(`POST ${path}`, handler); } + put(path: string, handler: RouteHandler) { this.routes.set(`PUT ${path}`, handler); } + delete(path: string, handler: RouteHandler) { this.routes.set(`DELETE ${path}`, handler); } + patch(path: string, handler: RouteHandler) { this.routes.set(`PATCH ${path}`, handler); } + use() { /* no middleware is mounted by the registrar under test */ } + async listen() { /* never listens: handlers are driven in-process */ } +} + +/** `POST /share-links` through the plugin door, with `envelope` as the resolved caller. */ +async function mintOnPluginDoor( + engine: any, + svc: ShareLinkService, + envelope: ExecutionContext, +): Promise<{ status: number; body: any }> { + const http = new RouteRecorder(); + registerShareLinkRoutes(http, svc, engine, { contextFromRequest: () => envelope }); + const handler = http.routes.get('POST /api/v1/share-links'); + if (!handler) throw new Error('registerShareLinkRoutes mounted no POST /api/v1/share-links'); + const captured: { status: number; body: any } = { status: 200, body: undefined }; + const res: IHttpResponse = { + json: (data: any) => { captured.body = data; }, + send: () => { /* the share-link routes answer JSON only */ }, + status: (code: number) => { captured.status = code; return res; }, + header: () => res, + }; + const req: IHttpRequest = { + params: {}, + query: {}, + body: { object: OBJECT, recordId: RECORD }, + headers: {}, + method: 'POST', + path: '/api/v1/share-links', + }; + await handler(req, res); + return captured; +} + +describe('[#21405] the plugin route door answers the same refusal with the same status', () => { + for (const posture of ['single', 'group'] as const) { + it(`${posture} posture: no allowRead on the object answers 403 PERMISSION_DENIED through BOTH doors`, async () => { + const caller = noReadCaller(posture); + const tables: Record = { + [OBJECT]: caller.records, + sys_share_link: [], + sys_permission_set: [], + }; + const engine = makeEngine(tables); + await bootSecurity(engine, posture, caller.permissionSets, caller.fallbackPermissionSet); + const svc = new ShareLinkService({ engine: engine as any }); + + const plugin = await mintOnPluginDoor(engine, svc, caller.envelope); + const dispatched = await handleShareLinksRequest( + makeDeps(engine, svc), + '', + 'POST', + { object: OBJECT, recordId: RECORD }, + {}, + httpContext(caller.envelope), + ); + const dispatcher = dispatched.response as { status: number; body: any }; + + expect(plugin.status, `plugin door: ${JSON.stringify(plugin.body)}`).toBe(403); + expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + expect(dispatcher.status, `dispatcher door: ${JSON.stringify(dispatcher.body)}`).toBe(403); + expect(expectDeclaredEnvelope(dispatcher).code).toBe('PERMISSION_DENIED'); + // Neither refused mint wrote a link. + expect(tables.sys_share_link).toEqual([]); + }, 30_000); + } +}); From 1c68c42785cb61f0c1820ec817b4c69286c1f38a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:55:16 +0000 Subject: [PATCH 4/5] docs(permissions): the field-write denial example shows status beside statusCode PermissionDeniedError now carries both spellings with equal values, so the example dump of the thrown error lists both. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- content/docs/permissions/field-level-security.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index fa8f08b7f1c..cd5702d809a 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -55,6 +55,7 @@ names in `details.forbiddenFields`: { "name": "PermissionDeniedError", "code": "PERMISSION_DENIED", + "status": 403, "statusCode": 403, "message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'", "details": { From 01bb1de91b7b468df9c63ee2d0017fcf07b65cca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:55:59 +0000 Subject: [PATCH 5/5] Revert the field-write denial example edit The example is a partial dump of the thrown error (it lists no developerMessage either), so it states nothing false without status. The docs sweep finds no sentence this change makes false. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- content/docs/permissions/field-level-security.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index cd5702d809a..fa8f08b7f1c 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -55,7 +55,6 @@ names in `details.forbiddenFields`: { "name": "PermissionDeniedError", "code": "PERMISSION_DENIED", - "status": 403, "statusCode": 403, "message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'", "details": {