Skip to content

Commit 520f66f

Browse files
fix(plugin-security): PermissionDeniedError carries status beside statusCode, so a share-link permission refusal answers 403 at both doors (#21429)
Fixes #21405 Clause-②: no ## What was wrong `PermissionDeniedError` (`plugin-security/src/errors.ts`) declared `statusCode = 403` and no `status`. It was the only error class in that module that did. A door that reads `status` alone derived no status from it. `plugin-sharing`'s share-link route door reads `err?.status ?? 500`, and that door serves `/api/v1/share-links` on the standalone server. Measured on a showcase boot (`@objectstack/verify`, with the app's own default profile), as a plain member, at `main` 6d67ad5: | request | plugin route door | runtime dispatcher `/share-links` domain | |---|---|---| | `POST /share-links` on a `showcase_client_brief` record the member cannot read | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | `GET /share-links` | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | The dispatcher door was driven in-process, the way `@objectstack/verify`'s own handle drives it: an `HttpDispatcher` over the same booted kernel, with the same bearer token. ## The change (the triage ruling on the card) - `PermissionDeniedError` carries `readonly status = 403` beside `statusCode = 403`. The code, the message, `details`, `developerMessage` and `statusCode` are unchanged. No door is edited, and no status helper is added anywhere. - The module's "Why each carries BOTH `status` and `statusCode`" note now names the readers as they stand today. The share-link route door and the sandbox boundary's passthrough read `status` alone. `errorFromThrown` and `mapDataError` read both spellings. The note used to say `mapDataError` reads `status` alone, which stopped being true when it learned both spellings. ## Pins - **Enumeration** (`plugin-security/src/errors.test.ts`). Every `Error` subclass that `errors.ts` exports is constructed, and each must carry a numeric `status` and `statusCode` with equal values. The population is read from the module's exports, so a class added later is checked without being listed. A floor names the eight classes exported today, so the enumeration cannot pass over nothing. - **One refusal, both doors** (`runtime/src/domains/share-links-enforcement-context.test.ts`, the new `[#21405]` block). The harness has one engine double with the whole `SecurityPlugin` middleware booted on it, one `ShareLinkService` and one envelope. It drives both production entries: `registerShareLinkRoutes` mounted on a route recorder, and `handleShareLinksRequest` over the dispatcher's own `errorFromThrown`. A create by a caller with no `allowRead` on the object answers 403 `PERMISSION_DENIED` through both doors, under the `single` and the `group` posture, and writes no link. - **Create only.** PR #21403 (for #21328, merged into this branch) made a member's own list a self-scoped read. Measured on the showcase boot after that merge: `GET /share-links` answers 200 through both doors, with no filter, with the Share dialog's object and record filter, and with `includeRevoked`. No list request reaches the refusal any more, so no list case is pinned. Before the merge, a list case was written, and it went red under the ablation below. - The existing `[#6649]` shared-catch case drove the production class to reach the dispatcher catch's `statusCode` channel. The class now carries `status` as well, so the case adds a `statusCode`-only throw beside it, and that channel stays pinned. ## Tests (head 01bb1de unless noted) - Runtime: `share-links-enforcement-context`, `data-permission-denied-envelope`, `permission-denied-error-parity` and `share-links-internal-hash-probe` give 4 files, 39 passed. - `pnpm --filter @objectstack/runtime typecheck` is OK. Its test layer holds 27 files / 190 errors / 68 signatures in its ledger, unchanged. - `pnpm --filter @objectstack/plugin-security typecheck` is OK. `errors.test.ts` is in the `tsconfig.test.json` program (`--listFiles`: 1 hit). - `pnpm --filter @objectstack/plugin-security test`: 161 files, 3502 passed, 33 skipped. `pnpm --filter @objectstack/plugin-sharing test`: 38 files, 928 passed. Both ran at 46b09ea. Since then, the only change in either package is a comment in `errors.test.ts`. - Importers whose answer could move: the 13 `rest` test files that import `@objectstack/plugin-security` give 209 passed. The dogfood share-link files (`share-links-self-list`, `showcase-client-liaison-fixtures`, `audit-log-internal-fields`) pass. Both ran at 46b09ea. - Gates: `dispatch-gates --commands` at 01bb1de derives 67 commands, and all 67 exit 0. The `--ran` reconciliation reports 67 derived, 67 run and 0 NOT-MEASURED, derived from the recorded exit codes. At an earlier head, `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 8 unbuilt packages). Those were built, and every later run exits 0. - Lint (narrowed): `eslint --no-inline-config --format json` over the 3 changed TS files reports 3 files, 0 errors and 0 warnings, and the config resolves for each file. `eslint.config.mjs` enables no type-aware linting (its own note: no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Ablations Each leg ran from a committed head through `scripts/ablation-replace.mjs`: the anchor hit, the blob changed, and the restore was proven by an empty `git diff HEAD`. `plugin-security` resolves from `dist/` in the runtime and dogfood suites, so each of those legs rebuilt it, and `scripts/ablation-dist-preflight.mjs` proved the marker in `dist/` (4 files) and then absent (all 6 files, tree clean). | mutation in `errors.ts` | suite | red | green | |---|---|---|---| | `status` renamed off `PermissionDeniedError` (`status_ablated_21405`) | runtime `share-links-enforcement-context` (head d9f9aab) | 2: both `[#21405]` postures, plugin door `expected 500 to be 403` | 18, every `[#6649]` dispatcher case included (that door reads `statusCode`) | | same | `errors.test.ts` | 2: the enumeration (`PermissionDeniedError: status=undefined statusCode=403`) and the 403 case | 1 (the floor) | | same, at b7fdf64 | the showcase dogfood pin then on this branch (create and list, both doors) | 2: create 500 vs 403, list 500 vs 403 | 1 (persona) | | a scratch `export class AblatedStatusCodeOnlyError extends Error { readonly statusCode = 418; }` | `errors.test.ts` | 1: `AblatedStatusCodeOnlyError: status=undefined statusCode=418 — declare both` | 2 | | `status = 404` on `PermissionDeniedError` | `errors.test.ts` | 2: `status 404 !== statusCode 403` and the 403 case | 1 | The restore legs pass: runtime 20/20 and `errors.test.ts` 3/3. The first attempt at the planted-class leg did not run. `ablation-replace` refused it before any test, because its replacement contained the anchor, so the anchor count did not fall. It was redone with an anchor the replacement does not contain. ## Docs `content/docs/**` (outside `releases/`) and `skills/**` hold no sentence this change makes false. The status table in `protocol/kernel/error-handling.mdx` gives 403 for insufficient permissions, and that is now true at the share-link door. `permissions/field-level-security.mdx` shows a partial dump of the thrown error without `status`. The dump states nothing false, so it is left alone (an edit was made on this branch and reverted). ## Acceptance notes - **Pin location.** The claim put the route pin in `packages/qa/dogfood/test/` or beside the plugin. A dogfood version came first: a showcase boot, both doors, create and list. It passed, and it went red under ablation. It reached the dispatcher door by importing `runtime/src/http-dispatcher.ts`, and `check:test-source-alias` refused that (exit 1): four new unaliased artifact imports into dogfood (`metadata-protocol`, `observability`, `rest`, `service-datasource`). Fixing that means aliasing them in dogfood's vitest config, which changes every dogfood boot. The plugin packages cannot import runtime, because of the dependency direction. The runtime package already imports both doors, so the pin moved there, beside the dispatcher's own `[#6649]` block. - **Doors this fixes, named and not edited.** Each reads `status` alone: - `plugin-sharing/src/share-link-routes.ts`, five catches (create, list, revoke, resolve, messages). Create and list are measured above. Resolve and messages read under the system context, so they never meet this refusal. Revoke is not measured. - The sandbox boundary. `SANDBOX_ERROR_PASSTHROUGH` in `runtime/src/sandbox/quickjs-runner.ts` carries `code`, `fields`, `status` and `userMessage`, but not `statusCode`. A `PermissionDeniedError` from a host call inside a sandboxed body now crosses with its 403. Not measured. - `metadata-protocol/src/protocol.ts`, the `deleteMetaItem` catch (`e.status = err?.status ?? 500`). Not measured. - `service-analytics/src/analytics-service.ts`, `hasDeclaredErrorEnvelope` (a numeric `status` plus a `code`). A `PermissionDeniedError` now counts as declared and is re-thrown before the missing-source heuristic. Not measured. - `runtime/src/domains/actions.ts`, the `setActionActive` catch (`status`, else 503). Not measured, and this refusal is unlikely there. - **Readers whose wire answer does not move.** - `rest`'s `resolveErrorResponse` passthrough reads `status` alone. A `PermissionDeniedError` used to fall through to `mapDataError`, which answered 403 `PERMISSION_DENIED` with the message and `object`. It now takes the passthrough arm, with the same status, code and `object`. The message passes the 500-character client bound and the declared-code-prefix strip, and neither changes a message inside those limits. This is read from the code; the 13 `rest` test files above pass. - `rest-server`'s analytics envelope reader ① now answers this refusal where ①b answered it, with the same status and code. Read from the code, not measured. - `plugin-auth`'s `createOAuthClient` catch reads `status` alone, but it only meets better-auth errors. - **A stale comment in a door file, not edited.** `runtime/src/domains/share-links.ts` (the catch's docblock) says the enforcement refusals "carry `statusCode`, not `status`". That is no longer true of `PermissionDeniedError`. Carrier: whoever next touches that file; no carrier is named. - `plugin-security/src/packaged-permission-set-lock.ts` holds two more 403 classes outside `errors.ts`. Both already carry both spellings. As ruled, the enumeration covers `errors.ts` exports only. - #21329 (`createLink` refusals) is not addressed here. Triage orders it after this card, and its pins can now read either door. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8b123c0 commit 520f66f

4 files changed

Lines changed: 265 additions & 24 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`PermissionDeniedError` declares its 403 as `status` as well as `statusCode`, so a permission refusal answers 403 at every door (#21405).
6+
7+
Clause-②: no
8+
9+
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.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Every error class `errors.ts` exports declares its HTTP status under BOTH
5+
* spellings, `status` and `statusCode`, with equal values.
6+
*
7+
* The rule is the module's own ("Why each carries BOTH `status` and
8+
* `statusCode`"): the doors read different property names, so a class that
9+
* declares one spelling answers a status at the doors that read it and
10+
* nothing at the doors that read the other. `PermissionDeniedError` was that
11+
* class — `statusCode` alone — and `plugin-sharing`'s share-link route door,
12+
* which reads `status`, answered its 403 refusal as a 500. Its two-door pin is
13+
* the `[#21405]` block of
14+
* `packages/runtime/src/domains/share-links-enforcement-context.test.ts`.
15+
*
16+
* The population is ENUMERATED from the module's exports, never listed by
17+
* hand, so a class added later is held to the rule the day it lands. The floor
18+
* below is what keeps the enumeration from passing over nothing: it names the
19+
* classes the module exports today and fails if any of them is not found.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
import * as errorsModule from './errors.js';
24+
import { PermissionDeniedError } from './errors.js';
25+
26+
type ErrorClass = new (...args: unknown[]) => Error;
27+
28+
/** The exported values that are `Error` subclasses, by export name. */
29+
const ERROR_CLASSES: Array<[string, ErrorClass]> = Object.entries(errorsModule)
30+
.filter(([, value]) => typeof value === 'function' && value.prototype instanceof Error)
31+
.map(([name, value]) => [name, value as unknown as ErrorClass]);
32+
33+
/**
34+
* The classes the module exports today. A floor, not the population: a new
35+
* class is checked without being added here.
36+
*/
37+
const FLOOR = [
38+
'PermissionDeniedError',
39+
'MasterDetailRelationMissingError',
40+
'DetailRecordNotFoundError',
41+
'MasterReferenceMissingError',
42+
'MaskedValueWriteError',
43+
'PermissionSetReadUnansweredError',
44+
'ExplainObjectNotFoundError',
45+
'PermissionSetNameConflictError',
46+
];
47+
48+
/**
49+
* One argument list every class here accepts. Each constructor reads its
50+
* parameters as text (interpolated, or through `String()`) or as a list
51+
* (`join`, spread), and a one-element array serves as both.
52+
*/
53+
const ARGS: unknown[] = [['a'], ['b'], ['c'], ['d']];
54+
55+
describe('errors.ts — every exported error class declares status and statusCode, equal', () => {
56+
it('the enumeration finds every class the module exports today', () => {
57+
expect(ERROR_CLASSES.map(([name]) => name)).toEqual(expect.arrayContaining(FLOOR));
58+
});
59+
60+
it('each one carries both spellings, with equal numeric values', () => {
61+
const violations: string[] = [];
62+
for (const [name, ErrorClass] of ERROR_CLASSES) {
63+
let instance: Error & { status?: unknown; statusCode?: unknown };
64+
try {
65+
instance = new ErrorClass(...ARGS);
66+
} catch (e) {
67+
violations.push(`${name}: cannot be constructed from the shared argument list (${String(e)})`);
68+
continue;
69+
}
70+
const { status, statusCode } = instance;
71+
if (typeof status !== 'number' || typeof statusCode !== 'number') {
72+
violations.push(`${name}: status=${String(status)} statusCode=${String(statusCode)} — declare both`);
73+
} else if (status !== statusCode) {
74+
violations.push(`${name}: status ${status} !== statusCode ${statusCode}`);
75+
}
76+
}
77+
expect(violations).toEqual([]);
78+
});
79+
80+
it('PermissionDeniedError answers 403 under both spellings', () => {
81+
const e = new PermissionDeniedError('[Security] Access denied: read on crm_account');
82+
expect(e.code).toBe('PERMISSION_DENIED');
83+
expect(e.status).toBe(403);
84+
expect(e.statusCode).toBe(403);
85+
});
86+
});

‎packages/plugins/plugin-security/src/errors.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,23 @@ import { unresolvedPostureRemedy } from './unresolved-posture.js';
2424
* positions and permission sets must not travel that way — see the throw site
2525
* in `security-plugin.ts` and the measurement recorded in
2626
* `permission-denied-user-copy.test.ts`.
27+
*
28+
* ## Both `status` and `statusCode`, like every class in this file
29+
*
30+
* This class used to declare `statusCode` alone — the only class here that
31+
* did — so a door that reads `status` alone derived no status from it.
32+
* Measured on a showcase boot: a plain member's `POST /api/v1/share-links` on
33+
* a record they cannot read answered `500 PERMISSION_DENIED` through
34+
* `plugin-sharing`'s share-link route door (`err?.status ?? 500`), and `403`
35+
* through the runtime dispatcher's `/share-links` domain, for the same throw.
36+
* The class now carries both spellings with equal values, for the reason the
37+
* note below gives its siblings, so a door that reads either one answers
38+
* `403`. `errors.test.ts` holds every error class this module exports to that
39+
* rule.
2740
*/
2841
export class PermissionDeniedError extends Error {
2942
readonly code = 'PERMISSION_DENIED';
43+
readonly status = 403;
3044
readonly statusCode = 403;
3145
readonly details?: Record<string, unknown>;
3246
/**
@@ -61,12 +75,16 @@ export class PermissionDeniedError extends Error {
6175
*
6276
* ### Why each carries BOTH `status` and `statusCode`
6377
*
64-
* The two transports read different property names, and this gate throws on the
65-
* DATA path, which reaches both: `@objectstack/rest`'s `mapDataError` passes a
66-
* domain error through on `.status` alone, while the runtime dispatcher's
67-
* `errorFromThrown` reads `.status` then falls back to `.statusCode`. Declaring
68-
* one spelling would leave the other transport deriving a status from nothing —
69-
* which is the defect this split exists to remove, reintroduced at the edge.
78+
* The doors read different property names, and this gate throws on the DATA
79+
* path, which reaches all of them. `status` is the spelling every door reads
80+
* first, and some read nothing else: `plugin-sharing`'s share-link route door
81+
* (`err?.status ?? 500`), and the sandbox boundary, whose passthrough list
82+
* carries `status` and not `statusCode` (`SANDBOX_ERROR_PASSTHROUGH` in
83+
* `runtime/src/sandbox/quickjs-runner.ts`). The runtime dispatcher's
84+
* `errorFromThrown` and `@objectstack/rest`'s `mapDataError` read `.status`
85+
* then fall back to `.statusCode`. Declaring one spelling would leave a door
86+
* that reads the other deriving a status from nothing — which is the defect
87+
* this split exists to remove, reintroduced at the edge.
7088
*
7189
* ### Why none of them starts with `[Security] Access denied`
7290
*
@@ -412,10 +430,9 @@ export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409;
412430
*
413431
* ## Why BOTH `status` and `statusCode`
414432
*
415-
* The same reason every class above records: the two transports read different
416-
* property names (`mapDataError` passes a domain error through on `.status`;
417-
* the runtime dispatcher's `errorFromThrown` reads `.status` then falls back to
418-
* `.statusCode`), and this throws on the DATA path, which reaches both.
433+
* The same reason every class above records: the doors read different property
434+
* names (see "Why each carries BOTH `status` and `statusCode`" above), and this
435+
* throws on the DATA path, which reaches them all.
419436
*
420437
* The message is byte-identical to the bare `Error`'s — the wording was never
421438
* the defect, and the flat door's 4xx arm ships it verbatim.

‎packages/runtime/src/domains/share-links-enforcement-context.test.ts‎

Lines changed: 143 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security';
5252
import type { PermissionSet } from '@objectstack/spec/security';
5353
import type { ExecutionContext } from '@objectstack/spec/kernel';
5454
import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts';
55+
import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts';
5556
import { PermissionDeniedError, SecurityPlugin } from '@objectstack/plugin-security';
56-
import { ShareLinkService } from '@objectstack/plugin-sharing';
57+
import { ShareLinkService, registerShareLinkRoutes } from '@objectstack/plugin-sharing';
5758
import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
5859
import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system';
5960
import { apiErrorResponse } from '../error-envelope.js';
@@ -133,8 +134,8 @@ const EAST_VIEWER: PermissionSet = PermissionSetSchema.parse({
133134
* withholds read. This set is therefore wired as BOTH the caller's explicit set
134135
* and the fallback in the #6649 cases below: `allowCreate` alone, so
135136
* `checkObjectPermission('find', 'crm_account', …)` is false and the security
136-
* middleware throws `PermissionDeniedError` — the `statusCode`-only shape whose
137-
* status the domain used to drop.
137+
* middleware throws `PermissionDeniedError` — the shape whose status the domain
138+
* used to drop, because it declared `statusCode` alone until #21405.
138139
*/
139140
const ACCT_NO_READ: PermissionSet = PermissionSetSchema.parse({
140141
name: 'acct_no_read',
@@ -550,6 +551,11 @@ describe('[#6551] the dispatcher seam itself', () => {
550551
* which reads `status` OR `statusCode`. These cases assert `status` AND `code`
551552
* together on purpose: a denial arriving as 403 under the wrong code would be
552553
* just as wrong as the 500, and only the pair separates them.
554+
*
555+
* [#21405] The paragraphs above describe the class as #6649 found it. It now
556+
* carries `status` beside `statusCode`, which is what fixed the OTHER door, and
557+
* the shared-catch case below keeps a `statusCode`-only throw so this catch's
558+
* second channel stays pinned.
553559
*/
554560

555561
/** 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
635641
expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED');
636642
}, 30_000);
637643

638-
it('the catch is shared, so list and revoke answer the statusCode-only refusal identically', async () => {
639-
// Driven with a service double raising the REAL `PermissionDeniedError`
640-
// (the production class, `statusCode` and no `status`), because what is
641-
// under test here is the CATCH, not a second trip through the middleware.
642-
for (const verb of ['GET', 'DELETE'] as const) {
643-
const res = await refusalFromService(
644-
new PermissionDeniedError(`[Security] Access denied: operation on object '${OBJECT}'`),
645-
verb,
646-
);
647-
expect(res.status, `${verb} status`).toBe(403);
648-
expect(expectDeclaredEnvelope(res).code, `${verb} code`).toBe('PERMISSION_DENIED');
644+
it('the catch is shared, so list and revoke answer the refusal identically — the production class and a statusCode-only throw', async () => {
645+
// Driven with a service double, because what is under test here is the
646+
// CATCH, not a second trip through the middleware. Two throws: the REAL
647+
// `PermissionDeniedError`, and a `statusCode`-only twin of the shape that
648+
// class had until #21405. The class now carries `status` beside
649+
// `statusCode`, so it no longer reaches the catch's `statusCode` channel
650+
// by itself; the twin keeps that channel pinned.
651+
const throws = {
652+
production: () => new PermissionDeniedError(`[Security] Access denied: operation on object '${OBJECT}'`),
653+
statusCodeOnly: () => Object.assign(
654+
new Error(`[Security] Access denied: operation on object '${OBJECT}'`),
655+
{ code: 'PERMISSION_DENIED', statusCode: 403 },
656+
),
657+
};
658+
for (const [shape, thrown] of Object.entries(throws)) {
659+
for (const verb of ['GET', 'DELETE'] as const) {
660+
const res = await refusalFromService(thrown(), verb);
661+
expect(res.status, `${shape} ${verb} status`).toBe(403);
662+
expect(expectDeclaredEnvelope(res).code, `${shape} ${verb} code`).toBe('PERMISSION_DENIED');
663+
}
649664
}
650665
});
651666

@@ -917,3 +932,117 @@ describe('[#14637] the dispatcher probe reads the standing policy before it answ
917932
expectIndistinguishable(await h.resolve(token, { password: 'hunter2' }), await h.resolve(UNKNOWN_TOKEN));
918933
});
919934
});
935+
936+
/**
937+
* [#21405] The SAME refusal through the OTHER door.
938+
*
939+
* `/share-links` has two doors. This file's subject is the dispatcher domain;
940+
* `plugin-sharing`'s `registerShareLinkRoutes` is the other, and it is the one
941+
* that serves `/api/v1/share-links` on the standalone server (the dispatcher
942+
* plugin mounts no route for the path there). Both hand the caller's envelope
943+
* to `ShareLinkService`, so one caller and one request reach one refusal, and
944+
* the only thing left to differ is how each door's catch reads the throw's
945+
* status.
946+
*
947+
* They differed. The plugin door's catch reads `err?.status ?? 500`, and
948+
* `PermissionDeniedError` declared `statusCode = 403` with no `status`, so the
949+
* #6649 refusal below — the CRUD gate's denial on `createLink`'s visibility
950+
* read — answered 403 here and 500 there. Measured on a showcase boot as a
951+
* plain member, `POST /api/v1/share-links` on a record they cannot read: 500
952+
* `PERMISSION_DENIED` through the plugin door, 403 through this domain. The
953+
* ruled fix is in the class: `PermissionDeniedError` carries `status` beside
954+
* `statusCode`, as every sibling in `plugin-security/src/errors.ts` does, so
955+
* no door's catch changes.
956+
*
957+
* ## What is real here
958+
*
959+
* Both doors, their production entries: `registerShareLinkRoutes` mounted on a
960+
* route recorder, and `handleShareLinksRequest` over the dispatcher's own
961+
* `errorFromThrown`. ONE engine double with the WHOLE `SecurityPlugin`
962+
* middleware booted on it, ONE `ShareLinkService` over that engine, ONE
963+
* envelope — so the throw each door catches is the middleware's own
964+
* `PermissionDeniedError` from the same read, not a double's.
965+
*
966+
* ## Why create only
967+
*
968+
* The list's refusal (the member's read of `sys_share_link`) no longer
969+
* reaches either door: the member's own list became a self-scoped read
970+
* (#21328), and both doors force the creator filter to the caller. Measured on
971+
* the showcase boot after that change: `GET /share-links` answers 200 through
972+
* both doors.
973+
*/
974+
975+
/** The smallest `IHttpServer` that keeps the handlers a registrar mounts. */
976+
class RouteRecorder implements IHttpServer {
977+
readonly routes = new Map<string, RouteHandler>();
978+
get(path: string, handler: RouteHandler) { this.routes.set(`GET ${path}`, handler); }
979+
post(path: string, handler: RouteHandler) { this.routes.set(`POST ${path}`, handler); }
980+
put(path: string, handler: RouteHandler) { this.routes.set(`PUT ${path}`, handler); }
981+
delete(path: string, handler: RouteHandler) { this.routes.set(`DELETE ${path}`, handler); }
982+
patch(path: string, handler: RouteHandler) { this.routes.set(`PATCH ${path}`, handler); }
983+
use() { /* no middleware is mounted by the registrar under test */ }
984+
async listen() { /* never listens: handlers are driven in-process */ }
985+
}
986+
987+
/** `POST /share-links` through the plugin door, with `envelope` as the resolved caller. */
988+
async function mintOnPluginDoor(
989+
engine: any,
990+
svc: ShareLinkService,
991+
envelope: ExecutionContext,
992+
): Promise<{ status: number; body: any }> {
993+
const http = new RouteRecorder();
994+
registerShareLinkRoutes(http, svc, engine, { contextFromRequest: () => envelope });
995+
const handler = http.routes.get('POST /api/v1/share-links');
996+
if (!handler) throw new Error('registerShareLinkRoutes mounted no POST /api/v1/share-links');
997+
const captured: { status: number; body: any } = { status: 200, body: undefined };
998+
const res: IHttpResponse = {
999+
json: (data: any) => { captured.body = data; },
1000+
send: () => { /* the share-link routes answer JSON only */ },
1001+
status: (code: number) => { captured.status = code; return res; },
1002+
header: () => res,
1003+
};
1004+
const req: IHttpRequest = {
1005+
params: {},
1006+
query: {},
1007+
body: { object: OBJECT, recordId: RECORD },
1008+
headers: {},
1009+
method: 'POST',
1010+
path: '/api/v1/share-links',
1011+
};
1012+
await handler(req, res);
1013+
return captured;
1014+
}
1015+
1016+
describe('[#21405] the plugin route door answers the same refusal with the same status', () => {
1017+
for (const posture of ['single', 'group'] as const) {
1018+
it(`${posture} posture: no allowRead on the object answers 403 PERMISSION_DENIED through BOTH doors`, async () => {
1019+
const caller = noReadCaller(posture);
1020+
const tables: Record<string, any[]> = {
1021+
[OBJECT]: caller.records,
1022+
sys_share_link: [],
1023+
sys_permission_set: [],
1024+
};
1025+
const engine = makeEngine(tables);
1026+
await bootSecurity(engine, posture, caller.permissionSets, caller.fallbackPermissionSet);
1027+
const svc = new ShareLinkService({ engine: engine as any });
1028+
1029+
const plugin = await mintOnPluginDoor(engine, svc, caller.envelope);
1030+
const dispatched = await handleShareLinksRequest(
1031+
makeDeps(engine, svc),
1032+
'',
1033+
'POST',
1034+
{ object: OBJECT, recordId: RECORD },
1035+
{},
1036+
httpContext(caller.envelope),
1037+
);
1038+
const dispatcher = dispatched.response as { status: number; body: any };
1039+
1040+
expect(plugin.status, `plugin door: ${JSON.stringify(plugin.body)}`).toBe(403);
1041+
expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } });
1042+
expect(dispatcher.status, `dispatcher door: ${JSON.stringify(dispatcher.body)}`).toBe(403);
1043+
expect(expectDeclaredEnvelope(dispatcher).code).toBe('PERMISSION_DENIED');
1044+
// Neither refused mint wrote a link.
1045+
expect(tables.sys_share_link).toEqual([]);
1046+
}, 30_000);
1047+
}
1048+
});

0 commit comments

Comments
 (0)