Skip to content

PermissionDeniedError (403) message is shown verbatim to end users too — English-only, leaks the object API name and the internal positions vocabulary (sibling of #7307, different package) #7414

Closed

Description

@baozhoutao

Summary

Sibling of #7307, different producer. #7307 covers the DELETE_RESTRICTED (409) copy thrown by
cascadeDeleteRelations in packages/objectql. The same problem exists for
PermissionDeniedError (403) thrown by packages/plugin-security: its message is surfaced
verbatim to end users by Console's toast, and it is English-only, embeds the machine object name,
and exposes the internal positions vocabulary.

Filing separately rather than commenting on #7307 because the fix sites are different packages
— rewriting the objectql message does not touch this one. Happy for it to be closed as a duplicate
if you'd rather track both under #7307.

Where

packages/plugin-security (observed in @objectstack/plugin-security@17.0.0-rc.5,
dist/index.js, the object-permission gate):

const allowed = this.permissionEvaluator.checkObjectPermission(
  opCtx.operation, opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate },
);
if (!allowed) {
  throw new PermissionDeniedError(
    `[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' is not permitted for positions [${positions.join(", ")}]`,
    { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
  );
}

As with #7307, the transport is fine — PERMISSION_DENIED maps to a real 403 and the message is
not swallowed into a generic 500. This is purely about the user-facing copy.

Actual behaviour

A downstream business app (names generalised). A user deletes a parent record they are explicitly
authorised to delete; the engine cascades into a child object on which that user's role holds only
read. The cascade's re-authorisation of the child fails, and this red toast appears in an otherwise
fully localized UI:

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

Three things make it unusable as end-user copy:

  1. English-only, in a UI whose every other string is localized;
  2. app_child_object is a machine name the operator has never seen — the object they acted on
    was the parent, and this names a child they may not even know exists;
  3. positions [org_member, everyone] is internal authorization vocabulary. It also reads as a
    contradiction to the operator, who does have delete rights on the record they clicked.

The operator's takeaway is "the system is broken", not "you lack a permission" — and certainly not
anything actionable.

Why it lands on users more often than it looks

This message is not confined to obviously-unauthorized actions. Because
cascadeDeleteRelations re-enters ObjectQL.delete() for every cascade child, each child is
re-authorised independently. So a parent-level permission that the app deliberately granted can
still produce this 403, naming a child object the user never addressed. Any app where parent and
child object permissions differ — the normal case for derived/snapshot/audit child tables — will
surface it on an ordinary delete.

Expected

Same shape as #7307:

  • an error code (PERMISSION_DENIED) plus structured fields (operation, object, positions)
    are already attached — those are the right channel for the machine detail, and they are already
    there;
  • the message should be a short, localizable, business-readable sentence that does not name
    internal objects or positions;
  • if a single generic string is preferred, "You do not have permission to perform this action."
    plus the existing structured payload is already a strict improvement over the current string.

Related

Activity

  1. claude commented on Aug 10, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:identity.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 10, 2026
  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Claim — PM loop, domain:identity seat (execution).

    Two things the dispatch will require beyond the card's own ask, recorded here so the reasoning is public rather than buried in an envelope:

    1. Whether this fix actually closes the disclosure depends on a fact nobody has measured yet. The card says the structured fields (operation, object, positions) "are the right channel for the machine detail, and they are already there". That is right if the channel is server-side. If the ADR-0112 envelope serialises those fields to the HTTP client, then rewriting only the message leaves app_child_object and the positions vocabulary reaching the same browser through the payload, and the card's stated goal is not met. The dev is asked to measure where those fields land and to say so plainly — the answer changes what "fixed" means here, and it may turn part of this into a second card rather than something to decide inside a copy fix.

    2. Mirror DELETE_RESTRICTED (409) message is shown verbatim to end users: English-only, leaks API names, and contains developer-facing advice #7307's shape rather than inventing a second one. Same defect class, adjacent producer; two different remedies for one class is how a codebase ends up with two de-facto contracts (this lane just spent four cards keeping audit-writers.ts to a single shape). The dev will check DELETE_RESTRICTED (409) message is shown verbatim to end users: English-only, leaks API names, and contains developer-facing advice #7307's state first and follow whatever it established, or say explicitly why this producer cannot.

    ⛔ Enforcement is not in scope: the 403 stays a 403, PERMISSION_DENIED stays, the structured payload stays, and the gate's decision logic is untouched. This is user-facing copy only.

    If an earlier claim with a different session ID exists in this thread, that one wins and I stand down.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions