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
assertControlledByParentWrite answers a metadata defect and a missing row with the same 403 PERMISSION_DENIED "requires edit access to its master record" #7474
Found while classifying the [Security] Access denied family for #7451 (PR #7471). Filed rather than fixed: that card is user-facing copy for the gates it converted, and this one is not a copy bug — the sentence is wrong because the branch is wrong, so fixing it decides something about the envelope.
What was measured
packages/plugins/plugin-security/src/security-plugin.ts — assertControlledByParentWrite funnels six distinct conditions through one deny() helper, which composes one sentence and one code:
constdeny=(reason: string,recordId?: unknown)=>{thrownewPermissionDeniedError(`[Security] Access denied: ${operation} on '${object}' requires edit access to its master record (${reason})`,{ operation, object, recordId },);};
The six reason values it is called with:
reason
What actually happened
Is "requires edit access to its master record" true?
no edit permission on master '…'
caller lacks object-level update on the master
yes
master '…' not editable by this user (row-level security)
master row outside the caller's write RLS
yes
master '…' not editable by this user (record sharing)
no OWD / record-share edit grant
yes
controlled_by_parent declared but no master_detail relation
the app's metadata is broken — the object declares the sharing model with no relation to hang it on
no
target record not found
the detail row does not exist
no
detail record has no master reference
the row's master FK is null
no
The bottom three are not authorization verdicts. The caller is told, with a 403, that they lack access to a record — when the truth is that their app is misconfigured, or the row is gone.
Why it matters in both directions
To the user, it is a false statement with a false remedy: "ask whoever owns the parent record" cannot fix a null FK or a missing master_detail relation.
To the app author, it is worse. controlled_by_parent declared but no master_detail relation is a genuine authoring defect that ObjectStack can detect precisely, and it is currently disguised as a routine permission denial — the class of thing an author will never see, because 403s on a detail object look like ordinary RBAC noise. This is the "declared = enforced, and say so loudly" principle inverted.
The first condition is also unreachable-looking but not unreachable: deny() returns never only by throwing, and if (!rel) deny(...) is followed by non-null assertions (rel!.fk), so the metadata-defect branch is load-bearing rather than defensive.
Note also that 'target record not found' turns a concurrent delete into a 403 rather than a 404 — an answer that leaks less but says something untrue, and the two transports already disagree about what a PERMISSION_DENIED body carries (#7450).
Should the metadata-defect branch be a lint finding at publish time instead of (or as well as) a runtime refusal? controlled_by_parent with no master_detail relation is statically detectable.
packages/plugins/plugin-security/src/security-plugin.ts — assertControlledByParentWrite, the deny() helper and its six call sites
packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts:315 — the current pin, which asserts only the shared sentence and so cannot tell the six apart
examples/app-crm/src/objects/opportunity-line-item.object.ts, showcase's invoice / expense-report — real business objects that reach this gate
Found while classifying the
[Security] Access deniedfamily for #7451 (PR #7471). Filed rather than fixed: that card is user-facing copy for the gates it converted, and this one is not a copy bug — the sentence is wrong because the branch is wrong, so fixing it decides something about the envelope.What was measured
packages/plugins/plugin-security/src/security-plugin.ts—assertControlledByParentWritefunnels six distinct conditions through onedeny()helper, which composes one sentence and one code:The six
reasonvalues it is called with:reasonno edit permission on master '…'updateon the mastermaster '…' not editable by this user (row-level security)master '…' not editable by this user (record sharing)controlled_by_parent declared but no master_detail relationtarget record not founddetail record has no master referenceThe bottom three are not authorization verdicts. The caller is told, with a 403, that they lack access to a record — when the truth is that their app is misconfigured, or the row is gone.
Why it matters in both directions
master_detailrelation.controlled_by_parent declared but no master_detail relationis a genuine authoring defect that ObjectStack can detect precisely, and it is currently disguised as a routine permission denial — the class of thing an author will never see, because 403s on a detail object look like ordinary RBAC noise. This is the "declared = enforced, and say so loudly" principle inverted.deny()returnsneveronly by throwing, andif (!rel) deny(...)is followed by non-null assertions (rel!.fk), so the metadata-defect branch is load-bearing rather than defensive.Note also that
'target record not found'turns a concurrent delete into a 403 rather than a 404 — an answer that leaks less but says something untrue, and the two transports already disagree about what aPERMISSION_DENIEDbody carries (#7450).What a fix has to decide
PERMISSION_DENIED, or get their own codes (a metadata/configuration error, and a not-found)? That is an ADR-0112 vocabulary question, not a copy edit — which is why this is filed rather than folded into fix(plugin-security,spec): the end-user-facing[Security] Access deniedgates stop showing developer copy (#7451) #7471.controlled_by_parentwith nomaster_detailrelation is statically detectable.[Security] Access deniedgates stop showing developer copy (#7451) #7471 converted the row-level gates — it is one of the end-user-facing gates that card identified but deliberately did not touch for this reason.Pointers
packages/plugins/plugin-security/src/security-plugin.ts—assertControlledByParentWrite, thedeny()helper and its six call sitespackages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts:315— the current pin, which asserts only the shared sentence and so cannot tell the six apartexamples/app-crm/src/objects/opportunity-line-item.object.ts, showcase'sinvoice/expense-report— real business objects that reach this gate[Security] Access deniedgates stop showing developer copy (#7451) #7471 — the classification that surfaced it; The other ~40[Security] Access deniedsentences are still English-only developer copy shown verbatim to end users (same class as #7414, one template fixed) #7451 — the parent sweep card; The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 — the related envelope question