Skip to content

Commit 0996899

Browse files
fix(runtime): /share-links denial keeps its own 403 through the domain catch (#6649) (#6718)
Route the domain's unified catch through the dispatcher's shared `errorFromThrown` mapper, which reads `status` OR `statusCode`. Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 129b378 commit 0996899

3 files changed

Lines changed: 291 additions & 7 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(runtime): a `/share-links` permission denial answers 403, not 500 (#6649)
6+
7+
The dispatcher's `/share-links` domain ended in a hand-written catch that read
8+
one status channel:
9+
10+
```
11+
return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? '…');
12+
```
13+
14+
Every refusal `ShareLinkService` raises itself carries `status` (its `makeError`
15+
sets `status` + `code`), which is why the 403 `FORBIDDEN` and 422
16+
`SHARING_NOT_ENABLED` answers were always correct. But the refusals that come
17+
out of the **security middleware** do not come from that service. Creating a
18+
link performs a visibility read — `svc.createLink` calls
19+
`engine.find(object, { context })` — and when the caller's permission sets grant
20+
no `allowRead` on the object, the CRUD gate throws
21+
`PermissionDeniedError { code = 'PERMISSION_DENIED'; statusCode = 403 }`, a class
22+
with **no `status` field at all** (`plugin-security/src/errors.ts`; runtime's own
23+
mirror in `security/resolve-execution-context.ts` has the same shape).
24+
`ShareLinkService` does not catch it, so it reached the domain catch, `err?.status`
25+
was `undefined`, and a 403-class refusal left as **HTTP 500** while `error.code`
26+
faithfully read `PERMISSION_DENIED`.
27+
28+
That envelope contradicted itself, and the contradiction is load-bearing on the
29+
client: 5xx is retryable to many SDKs and browser clients, so a permanent
30+
authorization answer was being retried, and a caller branching on the status saw
31+
"the server is broken" where the truth was "you may not read this record". It is
32+
reproducible on either tenancy posture, and — because `registerShareLinkRoutes:
33+
false` makes this domain the ONLY share-link surface on cloud's per-environment
34+
kernels — it is the primary surface there, not a fallback one.
35+
36+
The catch now exits through `deps.errorFromThrown`, the dispatcher's shared
37+
thrown-error mapper that `/meta`, `/actions` and `/mcp` already use. It reads
38+
`status` **or** `statusCode`, and it carries a thrown error's structured
39+
`issues` / `fields` details through instead of collapsing them to a message.
40+
Reaching for the shared mapper — rather than widening the hand-written chain to
41+
`err?.status ?? err?.statusCode ?? 500` — is the part that stops this exit
42+
re-diverging: a second hand-written copy is how the two drifted apart in the
43+
first place.
44+
45+
Two wire-visible consequences, both corrections:
46+
47+
- A permission denial on `POST` / `GET` / `DELETE /share-links` answers **403
48+
`PERMISSION_DENIED`** where it answered 500 `PERMISSION_DENIED`. Clients
49+
treating 5xx as retryable stop retrying a permanent refusal.
50+
- A throw carrying neither status channel nor a code answers **500
51+
`INTERNAL_ERROR`** where it answered 500 `INTERNAL`. `'INTERNAL'` was never
52+
registered for `@objectstack/runtime` in `ERROR_CODE_LEDGER` (only `rest`,
53+
`service-storage`, `service-i18n` and `plugin-sharing` register it, and the
54+
ledger's per-package rows are provenance) — so this domain was emitting a code
55+
it had not registered, and the required field is now filled by the catalogued
56+
derivation every other dispatcher exit uses (ADR-0112).
57+
58+
Refusals that already carried `status` are untouched: the mapper reads that
59+
channel on the same first branch the old chain did.

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

Lines changed: 196 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@ 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 { SecurityPlugin } from '@objectstack/plugin-security';
55+
import { PermissionDeniedError, SecurityPlugin } from '@objectstack/plugin-security';
5656
import { ShareLinkService } from '@objectstack/plugin-sharing';
57+
import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
5758
import { apiErrorResponse } from '../error-envelope.js';
5859
import { handleShareLinksRequest } from './share-links.js';
60+
import { HttpDispatcher } from '../http-dispatcher.js';
5961
import type { HttpProtocolContext } from '../http-dispatcher.js';
6062
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
6163

@@ -121,6 +123,24 @@ const EAST_VIEWER: PermissionSet = PermissionSetSchema.parse({
121123
],
122124
});
123125

126+
/**
127+
* [#6649] The CRUD-gate denial's input: an object grant with NO `allowRead`.
128+
*
129+
* The baseline is ADDITIVE for every human principal (ADR-0090 D5 — the
130+
* `fallbackPermissionSet` applies IN ADDITION to whatever else resolved), so a
131+
* caller only reaches the CRUD gate's deny branch when the baseline itself
132+
* withholds read. This set is therefore wired as BOTH the caller's explicit set
133+
* and the fallback in the #6649 cases below: `allowCreate` alone, so
134+
* `checkObjectPermission('find', 'crm_account', …)` is false and the security
135+
* middleware throws `PermissionDeniedError` — the `statusCode`-only shape whose
136+
* status the domain used to drop.
137+
*/
138+
const ACCT_NO_READ: PermissionSet = PermissionSetSchema.parse({
139+
name: 'acct_no_read',
140+
label: 'Account create-only (no read grant)',
141+
objects: { crm_account: { allowCreate: true } },
142+
});
143+
124144
const PERMISSION_SETS = [ACCT_MEMBER, EAST_VIEWER];
125145

126146
function matches(row: any, filter: any): boolean {
@@ -216,13 +236,21 @@ function makeEngine(tables: Record<string, any[]>) {
216236
* posture arrives the production way — via the `tenancy` service (ADR-0093
217237
* D4 / ADR-0105 D1).
218238
*/
219-
async function bootSecurity(engine: any, posture: 'single' | 'group'): Promise<void> {
239+
async function bootSecurity(
240+
engine: any,
241+
posture: 'single' | 'group',
242+
// [#6649] The permission-set world and its additive baseline are parameters
243+
// now; the defaults are byte-for-byte what every #6551 case above booted
244+
// with, so those verdicts are untouched.
245+
sets: PermissionSet[] = PERMISSION_SETS,
246+
fallback: string = 'acct_member',
247+
): Promise<void> {
220248
const services: Record<string, any> = {
221249
manifest: { register: vi.fn() },
222250
objectql: engine,
223251
metadata: {
224252
get: async (_type: string, name: string) => (name === OBJECT ? ACCOUNT_SCHEMA : null),
225-
list: async () => PERMISSION_SETS,
253+
list: async () => sets,
226254
},
227255
tenancy: { posture },
228256
};
@@ -236,11 +264,11 @@ async function bootSecurity(engine: any, posture: 'single' | 'group'): Promise<v
236264
},
237265
};
238266
const plugin = new SecurityPlugin({
239-
defaultPermissionSets: PERMISSION_SETS,
267+
defaultPermissionSets: sets,
240268
// The additive human baseline, as in production (member_default's role).
241269
// This is also exactly what a TRUNCATED context degrades to: with
242270
// `permissions` stripped, resolution falls back to this one set.
243-
fallbackPermissionSet: 'acct_member',
271+
fallbackPermissionSet: fallback,
244272
});
245273
await plugin.init(ctx);
246274
await plugin.start(ctx);
@@ -273,6 +301,24 @@ function envelopeFor(opts: {
273301
return ctx as ExecutionContext;
274302
}
275303

304+
/**
305+
* [#6649] The dispatcher's own thrown-error mapper — the REAL private method,
306+
* borrowed off a dispatcher constructed over a kernel stub exactly as
307+
* `error-envelope.conformance.test.ts`'s `makeDispatcher()` does.
308+
*
309+
* Deliberately NOT a hand-written `e?.status ?? e?.statusCode ?? 500` double: a
310+
* restatement here would make these cases green against the double's rules
311+
* rather than production's, which is the same class of mistake as the
312+
* hand-written catch this issue is about. Every branch it takes (status from
313+
* `status` OR `statusCode`, `.code` carried through `details` for
314+
* `buildApiError` to promote, `INTERNAL_ERROR` derived when the throw has no
315+
* code, the 5xx leak guard) is production's.
316+
*/
317+
const realErrorFromThrown = (() => {
318+
const dispatcher: any = new HttpDispatcher({ context: { getService: () => null } } as any);
319+
return (e: any, fallbackStatus?: number) => dispatcher.errorFromThrown(e, fallbackStatus);
320+
})();
321+
276322
function makeDeps(engine: any, svc: any): DomainHandlerDeps {
277323
const deps: any = {
278324
resolveService: async (_c: any, name: string) =>
@@ -284,6 +330,7 @@ function makeDeps(engine: any, svc: any): DomainHandlerDeps {
284330
// ADR-0112 shape, not a lookalike.
285331
error: (message: string, httpStatus = 500, details?: any) => apiErrorResponse({ message, httpStatus, details }),
286332
routeNotFound: (route: string) => apiErrorResponse({ message: `Route not found: ${route}`, httpStatus: 404 }),
333+
errorFromThrown: realErrorFromThrown,
287334
};
288335
return deps as DomainHandlerDeps;
289336
}
@@ -295,6 +342,10 @@ interface MintOptions {
295342
posture: 'single' | 'group';
296343
envelope: ExecutionContext | undefined;
297344
records: any[];
345+
/** [#6649] The permission-set world to boot; defaults to the #6551 one. */
346+
permissionSets?: PermissionSet[];
347+
/** [#6649] The additive baseline; defaults to the #6551 `acct_member`. */
348+
fallbackPermissionSet?: string;
298349
}
299350

300351
/**
@@ -305,7 +356,7 @@ interface MintOptions {
305356
async function mintOnDispatcher(opts: MintOptions): Promise<{ status: number; body: any }> {
306357
const tables: Record<string, any[]> = { [OBJECT]: opts.records, sys_share_link: [], sys_permission_set: [] };
307358
const engine = makeEngine(tables);
308-
await bootSecurity(engine, opts.posture);
359+
await bootSecurity(engine, opts.posture, opts.permissionSets, opts.fallbackPermissionSet);
309360
const svc = new ShareLinkService({ engine: engine as any });
310361
const deps = makeDeps(engine, svc);
311362
const res = await handleShareLinksRequest(
@@ -468,3 +519,142 @@ describe('[#6551] the dispatcher seam itself', () => {
468519
expect(svc.createLink).not.toHaveBeenCalled();
469520
});
470521
});
522+
523+
/**
524+
* [#6649] The domain's unified catch and the two channels a thrown refusal
525+
* carries its HTTP status on.
526+
*
527+
* The catch read `err?.status ?? 500` only. Every refusal `ShareLinkService`
528+
* raises itself carries `status` (its `makeError` sets `status` + `code`), which
529+
* is why the `403 FORBIDDEN` cases above were already correct and stayed correct
530+
* — but the SECURITY middleware's refusals do not come from that service. The
531+
* CRUD gate throws `PermissionDeniedError { code: 'PERMISSION_DENIED';
532+
* statusCode: 403 }` — no `status` field at all — straight out of
533+
* `svc.createLink`'s visibility read, past a service that does not catch it, into
534+
* this catch. `err?.status` was `undefined`, so the 403 left as a **500** while
535+
* `code` still read `PERMISSION_DENIED`: an envelope that contradicts itself, and
536+
* a status many clients treat as retryable when the answer is permanent.
537+
*
538+
* The fix routes the catch through `deps.errorFromThrown` — the dispatcher's
539+
* shared mapper, which `/meta`, `/actions` and `/mcp` already exit through and
540+
* which reads `status` OR `statusCode`. These cases assert `status` AND `code`
541+
* together on purpose: a denial arriving as 403 under the wrong code would be
542+
* just as wrong as the 500, and only the pair separates them.
543+
*/
544+
545+
/** The ADR-0112 envelope checks every case below shares. */
546+
function expectDeclaredEnvelope(res: { status: number; body: any }): any {
547+
expect(BaseResponseSchema.safeParse(res.body).success).toBe(true);
548+
expect(envelopeViolations(res.body), `not the declared envelope: ${JSON.stringify(res.body)}`).toEqual([]);
549+
const parsed = ApiErrorSchema.safeParse(res.body.error);
550+
// `ApiErrorSchema.code` validates against the CLOSED set (StandardErrorCode ∪
551+
// ERROR_CODE_LEDGER), so this is also what keeps the code out of the
552+
// free-string space the old `'INTERNAL'` fallback sat in.
553+
expect(parsed.error?.issues ?? []).toEqual([]);
554+
expect(res.body.error.httpStatus).toBe(res.status);
555+
return res.body.error;
556+
}
557+
558+
/** A `/share-links` call served by a service double that throws `thrown`. */
559+
async function refusalFromService(
560+
thrown: unknown,
561+
verb: 'POST' | 'GET' | 'DELETE' = 'POST',
562+
): Promise<{ status: number; body: any }> {
563+
const boom = async () => { throw thrown; };
564+
const svc = {
565+
createLink: vi.fn(boom),
566+
listLinks: vi.fn(boom),
567+
revokeLink: vi.fn(boom),
568+
resolveToken: vi.fn(async () => null),
569+
};
570+
const deps = makeDeps(makeEngine({ [OBJECT]: [], sys_share_link: [] }), svc);
571+
const res = await handleShareLinksRequest(
572+
deps,
573+
verb === 'DELETE' ? '/shl_x' : '',
574+
verb,
575+
verb === 'POST' ? { object: OBJECT, recordId: RECORD } : undefined,
576+
{},
577+
httpContext(envelopeFor({ memberOf: [ORG_A], permissions: ['acct_member'] })),
578+
);
579+
if (!res.handled || !res.response) throw new Error(`${verb} /share-links was not handled`);
580+
return res.response as { status: number; body: any };
581+
}
582+
583+
/** The caller of the repro: create-only grant, so the visibility read is denied. */
584+
const noReadCaller = (posture: 'single' | 'group') => ({
585+
posture,
586+
envelope: envelopeFor({ memberOf: [ORG_A], permissions: ['acct_no_read'] }),
587+
records: ownRecordInA(),
588+
permissionSets: [ACCT_NO_READ],
589+
fallbackPermissionSet: 'acct_no_read',
590+
});
591+
592+
describe('[#6649] a security-middleware refusal keeps its own status through the domain catch', () => {
593+
it('single posture: no allowRead on the object answers 403 PERMISSION_DENIED (was 500 + PERMISSION_DENIED)', async () => {
594+
const res = await mintOnDispatcher(noReadCaller('single'));
595+
596+
// The pair, together. Before the fix `code` was ALREADY
597+
// `PERMISSION_DENIED` here — only the status was wrong — so a case
598+
// asserting the code alone was green on the defect, and one asserting
599+
// "not 500" alone could not tell a right refusal from a wrong one.
600+
expect(res.status).toBe(403);
601+
expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED');
602+
// Coverage, NOT a discriminator: the message does not move between the
603+
// two directions of this fix (the pre-fix 500 exited through this
604+
// harness's `error` double, which has no leak guard, and the security
605+
// message trips no clause of `looksLikeInternalErrorLeak` anyway). It
606+
// pins the refusal's own reason against a FUTURE widening of that
607+
// heuristic swallowing an authorization answer.
608+
expect(res.body.error.message).toContain('Access denied');
609+
}, 30_000);
610+
611+
it('group posture: the same denial, the same envelope — the defect was never posture-specific', async () => {
612+
const res = await mintOnDispatcher(noReadCaller('group'));
613+
614+
expect(res.status).toBe(403);
615+
expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED');
616+
}, 30_000);
617+
618+
it('the catch is shared, so list and revoke answer the statusCode-only refusal identically', async () => {
619+
// Driven with a service double raising the REAL `PermissionDeniedError`
620+
// (the production class, `statusCode` and no `status`), because what is
621+
// under test here is the CATCH, not a second trip through the middleware.
622+
for (const verb of ['GET', 'DELETE'] as const) {
623+
const res = await refusalFromService(
624+
new PermissionDeniedError(`[Security] Access denied: operation on object '${OBJECT}'`),
625+
verb,
626+
);
627+
expect(res.status, `${verb} status`).toBe(403);
628+
expect(expectDeclaredEnvelope(res).code, `${verb} code`).toBe('PERMISSION_DENIED');
629+
}
630+
});
631+
632+
it('a throw carrying neither channel still answers 500 — under the CATALOGUED code, not the unregistered `INTERNAL`', async () => {
633+
const res = await refusalFromService(new Error('share link storage exploded'));
634+
635+
expect(res.status).toBe(500);
636+
// `'INTERNAL'` is registered in `ERROR_CODE_LEDGER` for `rest` /
637+
// `service-storage` / `service-i18n` / `plugin-sharing` — never for
638+
// `@objectstack/runtime`. The union dedupes, so the schema stayed green
639+
// while this domain emitted a code it had not registered; the shared
640+
// mapper answers with the derived `INTERNAL_ERROR` instead.
641+
expect(expectDeclaredEnvelope(res).code).toBe('INTERNAL_ERROR');
642+
});
643+
644+
it('a refusal that DOES carry `status` is untouched — the fix widens the channel, it does not switch it', async () => {
645+
// Honest note: this case is green in BOTH directions of the fix, by
646+
// construction — `ShareLinkService.makeError` sets `status`, which the
647+
// old chain read first and the shared mapper reads first. It is not a
648+
// regression pin for #6649 but for the NEXT change to this exit: it goes
649+
// red if the `status` channel is ever dropped in favour of `statusCode`.
650+
const res = await refusalFromService(
651+
Object.assign(new Error('Sharing is not enabled for this object'), {
652+
status: 422,
653+
code: 'SHARING_NOT_ENABLED',
654+
}),
655+
);
656+
657+
expect(res.status).toBe(422);
658+
expect(expectDeclaredEnvelope(res).code).toBe('SHARING_NOT_ENABLED');
659+
});
660+
});

packages/runtime/src/domains/share-links.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,41 @@ export async function handleShareLinksRequest(
264264

265265
return { handled: true, response: deps.routeNotFound(`/share-links${subPath}`) };
266266
} catch (err: any) {
267-
return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? 'Share link request failed');
267+
// [#6649] The dispatcher's SHARED thrown-error mapper, not a hand-written
268+
// status read. This catch used to be
269+
// `sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', …)`, and the two
270+
// channels it collapsed are the whole defect:
271+
//
272+
// 1. **Status.** The refusals that actually fly out of the enforcement
273+
// paths below carry `statusCode`, not `status`:
274+
// `PermissionDeniedError { code = 'PERMISSION_DENIED'; statusCode = 403 }`
275+
// (`plugin-security/src/errors.ts`, mirrored by runtime's own
276+
// `security/resolve-execution-context.ts`) is thrown by the security
277+
// middleware's CRUD gate when the caller's permission sets grant no
278+
// `allowRead` on the object — so `svc.createLink`'s visibility read
279+
// `engine.find(object, { context })` throws it, `ShareLinkService`
280+
// does not catch it, and it lands here. `err?.status` was `undefined`
281+
// on it, so a 403-class refusal left as a **500** while `code` read
282+
// `PERMISSION_DENIED` — an envelope that contradicts itself, and a
283+
// status many SDK/browser clients treat as retryable when the answer
284+
// is permanent. `errorFromThrown` reads `status` OR `statusCode`.
285+
// 2. **Code.** The `'INTERNAL'` fallback is not registered for
286+
// `@objectstack/runtime` in `ERROR_CODE_LEDGER` — only
287+
// `service-storage` / `service-i18n` / `rest` / `plugin-sharing`
288+
// register it, and the ledger's per-package rows are provenance, so
289+
// the global union kept `ApiErrorSchema` green while this domain
290+
// emitted a code it never registered. The shared mapper leaves the
291+
// required field to `standardErrorCodeForHttpStatus` instead, which
292+
// spells the catalogued `INTERNAL_ERROR` (ADR-0112) — the same
293+
// derived code every other dispatcher exit already answers with.
294+
//
295+
// `ShareLinkService`'s own refusals are unaffected: its `makeError` sets
296+
// `err.status` + `err.code`, which the mapper reads on the same first
297+
// branch the old chain did (403 `FORBIDDEN`, 422 `SHARING_NOT_ENABLED`,
298+
// …). What it adds on top is the structured `issues` / `fields` detail
299+
// the `/meta` and `/actions` domains already carry through this exit —
300+
// which is the point: a hand-written catch is exactly how this domain
301+
// diverged from the shared mapper in the first place.
302+
return { handled: true, response: deps.errorFromThrown(err, 500) };
268303
}
269304
}

0 commit comments

Comments
 (0)