You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(plugin-security)!: split controlled_by_parent write refusals by true semantics (#7474)
`assertControlledByParentWrite` funnelled SIX distinct conditions through one
`deny()` helper, so all six answered `403 PERMISSION_DENIED` with one sentence
— "requires edit access to its master record". Three of them are genuine
authorization verdicts. The other three are not verdicts at all, and the shared
sentence was a false statement carrying a false remedy: "ask whoever owns the
parent record" cannot fix a null master FK, a deleted row, or an object that
declares `controlled_by_parent` with no `master_detail` relation to derive
access from.
Per the maintainer ruling of 2026-08-11 on #7474, the three genuine legs (no
object-level `update` on the master / master row outside the write RLS / no
`edit`-level share grant) keep `403 PERMISSION_DENIED` and their exact wording.
The three non-verdict legs get envelopes of their own, all drawn from the
existing ADR-0112 vocabulary — no new error code:
- `controlled_by_parent` with no `master_detail` → 422 INVALID_METADATA
- target detail row does not exist → 404 RECORD_NOT_FOUND
- detail's master reference is empty → 422 MISSING_REQUIRED_FIELD
Each new message opens with a prefix of its own rather than `[Security] Access
denied`: that exact prefix is a MATCHER at both transports, so borrowing it
would re-flatten the split back to 403 on the wire. The explanation lives in
`message` and never in `details`, which is not a carrier the client can rely on
(#7450).
Throwing directly (instead of routing through a `never`-returning helper) also
retires the non-null assertions the metadata-defect branch used to need —
`deny()` returning `never` only by throwing was the load-bearing half of the
same defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sXg2v6khHim6XdmRoAXje
Split `controlled_by_parent` write refusals by true semantics: three of the six legs stop answering `403 PERMISSION_DENIED`
6
+
7
+
A by-id write to a `controlled_by_parent` detail is refused for six distinct reasons, and all six used to answer with one envelope and one sentence — `403 PERMISSION_DENIED: … requires edit access to its master record`. Only three of them are authorization verdicts. The other three said something untrue and prescribed a remedy that could not work: "ask whoever owns the parent record" cannot fix a null master reference, a deleted row, or an object that declares `controlled_by_parent` with no `master_detail` relation to derive access from.
8
+
9
+
Unchanged — the three genuine verdicts keep `403 PERMISSION_DENIED` and their exact wording:
10
+
11
+
- the caller holds no object-level `update` on the master
12
+
- the master row lies outside the caller's write RLS
13
+
- the master carries no `edit`-level share grant
14
+
15
+
Changed — the three non-verdict conditions now answer for what they are:
16
+
17
+
| condition | before | after |
18
+
|---|---|---|
19
+
|`controlled_by_parent` declared with no `master_detail` relation |`403 PERMISSION_DENIED`|`422 INVALID_METADATA`|
20
+
| the target detail row does not exist |`403 PERMISSION_DENIED`|`404 RECORD_NOT_FOUND`|
21
+
| the detail's master reference is empty |`403 PERMISSION_DENIED`|`422 MISSING_REQUIRED_FIELD`|
22
+
23
+
Each carries a message written for the app author, naming the object, the operation and the remedy. The metadata-defect case is the one that matters most: it is a precisely detectable authoring defect that was disguised as routine RBAC noise, so nobody ever investigated it — and a false 403 steers debugging, human or agent, toward permission changes when the truth is broken metadata.
24
+
25
+
The 404 does not widen what a caller can learn. The detail row is probed under a system context, so a row hidden from the caller by row-level security is still found and falls through to the authorization legs; object-level CRUD and the row-level write pre-image check both run before this gate. Absence there is real absence.
26
+
27
+
All codes come from the existing ADR-0112 vocabulary — no new error code is introduced.
0 commit comments