Skip to content

Commit 58023c7

Browse files
committed
fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414)
The 403 refusal is correct and its transport is fine; the message was not. `Error.message` is the body's human-readable string on every transport and Console renders it verbatim in a toast, so a business user in a localized app got an English sentence naming an object API name they had never seen and ending in `positions [org_member, everyone]`. The error now carries two messages because it has two audiences: - `message` — the user's half, rendered in `ExecutionContext.locale` through the SHARED operation-message catalog #7307 built (`errors.permission_denied`). It names no object, no operation and no position, in any shipped locale. - `developerMessage` — the developer's half, the previous sentence byte for byte. LOGGED at the throw site rather than shipped: unlike the 409, this body carries no structured API names, so shipping it would have added a disclosure instead of removing one. It is a sibling of `details`, never a member of it, because `details` is what the dispatcher serialises. `code` / `statusCode` / `details` unchanged; one PERMISSION_DENIED (ADR-0112), two sentences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
1 parent 2ef1807 commit 58023c7

9 files changed

Lines changed: 685 additions & 6 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
fix(plugin-security,spec): the `403 PERMISSION_DENIED` from the object CRUD gate stops handing a business user internal authorization vocabulary
7+
8+
An operation the caller's permission sets do not grant is correctly refused with
9+
`403 PERMISSION_DENIED`, and the transport was never the problem. What reached
10+
the end user was: `Error.message` is the body's human-readable string on every
11+
transport (`mapDataError`'s `body.error`, the dispatcher's `error.message`) and
12+
Console renders it verbatim in a toast. So an operator in a fully localized app
13+
read
14+
15+
```
16+
[Security] Access denied: operation 'delete' on object 'app_child_object'
17+
is not permitted for positions [org_member, everyone]
18+
```
19+
20+
English-only; naming a table they have never seen; ending in `positions [...]`,
21+
internal authorization vocabulary that reads as a contradiction to someone who
22+
does hold rights on the record they clicked. It is not confined to obviously
23+
unauthorized actions either — `cascadeDeleteRelations` re-authorises every
24+
cascade CHILD independently, so an ordinary delete of a parent the app
25+
deliberately granted can surface a 403 naming a child object the operator never
26+
addressed.
27+
28+
The error now carries two messages because it has two audiences:
29+
30+
- `message` — the user's half, rendered in `ExecutionContext.locale` through the
31+
shared operation-message catalog (`@objectstack/spec/system`, the mechanism
32+
built for `DELETE_RESTRICTED`), overridable per deployment under
33+
`errors.permission_denied`. It names no object, no operation and no position,
34+
in any of the four shipped locales.
35+
- `developerMessage` — the developer's half, the previous sentence byte for
36+
byte. It is LOGGED at the throw site, not shipped to the client.
37+
38+
That last point is where this deliberately diverges from its sibling.
39+
`DELETE_RESTRICTED` ships its developer half over the wire because the same body
40+
already carries the API names it mentions; the 403 body does not. REST's
41+
`mapDataError` builds `{ error, code, object? }` for a permission denial and
42+
never reads `error.details`, so the positions, the operation and (on a cascade)
43+
the child object's API name reach a client through nothing but the message —
44+
shipping a `developerMessage` there would have ADDED a disclosure rather than
45+
removed one. `developerMessage` is therefore a sibling of `details`, never a
46+
member of it, because `details` is the field the runtime dispatcher serialises.
47+
48+
Enforcement is untouched: same 403, same `PERMISSION_DENIED`, same decision
49+
logic, and the structured `details` payload (`operation`, `object`, `positions`,
50+
`permissionSets`) is byte-identical to before.

packages/plugins/plugin-security/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"devDependencies": {
2727
"@objectstack/metadata-core": "workspace:*",
2828
"@objectstack/plugin-sharing": "workspace:*",
29+
"@objectstack/service-i18n": "workspace:*",
2930
"@types/node": "^26.1.2",
3031
"typescript": "^6.0.3",
3132
"vitest": "^4.1.10"

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,40 @@
44
* Typed sentinel error thrown by `SecurityPlugin` when an operation is
55
* denied. Caught by `@objectstack/runtime`'s HTTP dispatcher and translated
66
* to HTTP 403.
7+
*
8+
* ## Two messages, two audiences (#7414)
9+
*
10+
* `message` is what an END USER reads: both transports ship it verbatim as the
11+
* body's human-readable string (`mapDataError`'s `error`, the dispatcher's
12+
* `error.message`) and Console renders it as-is in a toast. `developerMessage`
13+
* is the operator's half — English, API names, the authorization vocabulary
14+
* that explains WHY — and it is the throw site's job to route it somewhere a
15+
* developer reads.
16+
*
17+
* ⛔ `developerMessage` is a sibling of `details`, deliberately NOT a member of
18+
* it. `details` is SERIALISED to the client on the dispatcher transport
19+
* (`http-dispatcher.ts`: `this.error(e.message, 403, { code, ...e.details })`,
20+
* which `buildApiError` puts on the wire as `error.details`), so anything
21+
* placed inside it reaches the browser. A developer sentence that names
22+
* positions and permission sets must not travel that way — see the throw site
23+
* in `security-plugin.ts` and the measurement recorded in
24+
* `permission-denied-user-copy.test.ts`.
725
*/
826
export class PermissionDeniedError extends Error {
927
readonly code = 'PERMISSION_DENIED';
1028
readonly statusCode = 403;
1129
readonly details?: Record<string, unknown>;
12-
constructor(message: string, details?: Record<string, unknown>) {
30+
/**
31+
* The operator-facing half of a refusal whose `message` has been localized
32+
* for an end user. Optional: a denial that never localized its message has
33+
* exactly one audience and carries none.
34+
*/
35+
readonly developerMessage?: string;
36+
constructor(message: string, details?: Record<string, unknown>, developerMessage?: string) {
1337
super(message);
1438
this.name = 'PermissionDeniedError';
1539
this.details = details;
40+
if (developerMessage !== undefined) this.developerMessage = developerMessage;
1641
}
1742
}
1843

0 commit comments

Comments
 (0)