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:
- English-only, in a UI whose every other string is localized;
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;
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
Summary
Sibling of #7307, different producer. #7307 covers the
DELETE_RESTRICTED(409) copy thrown bycascadeDeleteRelationsinpackages/objectql. The same problem exists forPermissionDeniedError(403) thrown bypackages/plugin-security: itsmessageis surfacedverbatim to end users by Console's toast, and it is English-only, embeds the machine object name,
and exposes the internal
positionsvocabulary.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):As with #7307, the transport is fine —
PERMISSION_DENIEDmaps to a real 403 and the message isnot 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:
Three things make it unusable as end-user copy:
app_child_objectis a machine name the operator has never seen — the object they acted onwas the parent, and this names a child they may not even know exists;
positions [org_member, everyone]is internal authorization vocabulary. It also reads as acontradiction 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
cascadeDeleteRelationsre-entersObjectQL.delete()for every cascade child, each child isre-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:
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;
messageshould be a short, localizable, business-readable sentence that does not nameinternal objects or positions;
plus the existing structured payload is already a strict improvement over the current string.
Related
DELETE_RESTRICTED(409) frompackages/objectql.