Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/permission-denied-user-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": minor
---

fix(plugin-security,spec): the `403 PERMISSION_DENIED` from the object CRUD gate stops handing a business user internal authorization vocabulary

An operation the caller's permission sets do not grant is correctly refused with
`403 PERMISSION_DENIED`, and the transport was never the problem. What reached
the end user was: `Error.message` is the body's human-readable string on every
transport (`mapDataError`'s `body.error`, the dispatcher's `error.message`) and
Console renders it verbatim in a toast. So an operator in a fully localized app
read

```
[Security] Access denied: operation 'delete' on object 'app_child_object'
is not permitted for positions [org_member, everyone]
```

English-only; naming a table they have never seen; ending in `positions [...]`,
internal authorization vocabulary that reads as a contradiction to someone who
does hold rights on the record they clicked. It is not confined to obviously
unauthorized actions either — `cascadeDeleteRelations` re-authorises every
cascade CHILD independently, so an ordinary delete of a parent the app
deliberately granted can surface a 403 naming a child object the operator never
addressed.

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 (`@objectstack/spec/system`, the mechanism
built for `DELETE_RESTRICTED`), overridable per deployment under
`errors.permission_denied`. It names no object, no operation and no position,
in any of the four shipped locales.
- `developerMessage` — the developer's half, the previous sentence byte for
byte. It is LOGGED at the throw site, not shipped to the client.

That last point is where this deliberately diverges from its sibling.
`DELETE_RESTRICTED` ships its developer half over the wire because the same body
already carries the API names it mentions; the 403 body does not. REST's
`mapDataError` builds `{ error, code, object? }` for a permission denial and
never reads `error.details`, so the positions, the operation and (on a cascade)
the child object's API name reach a client through nothing but the message —
shipping a `developerMessage` there would have ADDED a disclosure rather than
removed one. `developerMessage` is therefore a sibling of `details`, never a
member of it, because `details` is the field the runtime dispatcher serialises.

Enforcement is untouched: same 403, same `PERMISSION_DENIED`, same decision
logic, and the structured `details` payload (`operation`, `object`, `positions`,
`permissionSets`) is byte-identical to before.
1 change: 1 addition & 0 deletions packages/plugins/plugin-security/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"devDependencies": {
"@objectstack/metadata-core": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/service-i18n": "workspace:*",
"@types/node": "^26.1.2",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
27 changes: 26 additions & 1 deletion packages/plugins/plugin-security/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,40 @@
* Typed sentinel error thrown by `SecurityPlugin` when an operation is
* denied. Caught by `@objectstack/runtime`'s HTTP dispatcher and translated
* to HTTP 403.
*
* ## Two messages, two audiences (#7414)
*
* `message` is what an END USER reads: both transports ship it verbatim as the
* body's human-readable string (`mapDataError`'s `error`, the dispatcher's
* `error.message`) and Console renders it as-is in a toast. `developerMessage`
* is the operator's half — English, API names, the authorization vocabulary
* that explains WHY — and it is the throw site's job to route it somewhere a
* developer reads.
*
* ⛔ `developerMessage` is a sibling of `details`, deliberately NOT a member of
* it. `details` is SERIALISED to the client on the dispatcher transport
* (`http-dispatcher.ts`: `this.error(e.message, 403, { code, ...e.details })`,
* which `buildApiError` puts on the wire as `error.details`), so anything
* placed inside it reaches the browser. A developer sentence that names
* 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`.
*/
export class PermissionDeniedError extends Error {
readonly code = 'PERMISSION_DENIED';
readonly statusCode = 403;
readonly details?: Record<string, unknown>;
constructor(message: string, details?: Record<string, unknown>) {
/**
* The operator-facing half of a refusal whose `message` has been localized
* for an end user. Optional: a denial that never localized its message has
* exactly one audience and carries none.
*/
readonly developerMessage?: string;
constructor(message: string, details?: Record<string, unknown>, developerMessage?: string) {
super(message);
this.name = 'PermissionDeniedError';
this.details = details;
if (developerMessage !== undefined) this.developerMessage = developerMessage;
}
}

Expand Down
Loading
Loading