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
The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead on private even once checkAuthoredRowWrite admits it #7401
Measured while implementing #7281's maintainer ruling (PR #7400). Filed unassigned, out of that PR's ruled scope, for triage.
Related: #7281 (the probe half, ruled and fixed), #5493 (the widener surface this exists for). Not blocked by anything — #7400 is independently correct — but the two together decide whether the by-id widener functions on private at all.
What was measured
#7281's ruling moved checkAuthoredRowWrite's probe read to an elevated scope, so on a private-OWD object the verdict for a cross-owner row the declaration admits went abstain -> admit. The end-to-end by-id write did not change: it is still refused, 403, row unchanged.
Real stack (bootStack + real SecurityPlugin + real SharingServicePlugin + real ObjectQL engine), two objects identical in every respect except their OWD, same app-authored widener (operation: 'update', using: "stage == 'open'"), same principal, same cross-owner row shape:
403 {"error":"[Security] Access denied: not permitted to update this 'wscope_secret' record (row-level security)",
"code":"PERMISSION_DENIED","object":"wscope_secret"}
Mechanism
The error shape is the tell, and it is not the one #7281's card assumed. That sentence belongs to plugin-security's by-id write pre-image gate, not to the sharing middleware (whose refusal reads FORBIDDEN: insufficient privileges to update …). The security middleware runs first, so on this posture it refuses before the sharing middleware is reached at all — which means the #5493 deferral that consumes the now-corrected verdict never runs on private.
The gate, at packages/plugins/plugin-security/src/security-plugin.ts:1301-1306 (verified at d53bd0ba9):
opCtx.context is the caller's own execution context, so that findOne re-enters the middleware chain and picks up plugin-sharing's READ filter — which on a private OWD scopes to owner-match OR shares. The composed writeParts already admit the row (the authored policy OR-combines past the platform ownership floor); the row is simply invisible to the read that asks. Structurally the same shape #7281 named one layer up.
The gate's caller-scoped read is deliberate and documented in place:
A read denial (e.g. no read permission) is itself a "cannot touch this row" signal — fall through to the deny below.
and the neighbouring owner-echo pre-image states the same stance with a second reason (it closes an owner-enumeration oracle a system read would open). So this is not an oversight to be corrected the way #7281's was; changing it answers a public-contract question the #7281 ruling did not put to the maintainer:
May a caller write a row they cannot read, when an app-authored declaration admits it by predicate?
Two readings, offered without choosing:
Intended. "You may not write what you cannot see" is a floor, and an app author who wants a cross-owner update-widener on a private object must widen READ to match (viewAllRecords, a read policy, or a share). Then the gap is documentation — and app authors need telling, because nothing in the widener's own vocabulary mentions the OWD.
The cost of reading 1 is that the by-id widener surface — everything #5493, #6735 and #7281 built — remains inert on private, the posture #5493 was filed for. The cost of reading 2 is a genuine widening of write reach on read-closed objects, which is exactly the kind of change that needs to be ruled rather than inferred.
Repro
packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts (landed by PR #7400) pins today's behaviour on both postures, including this refusal and its exact envelope, in case ⚠️ [E2E private]. Nothing new needs writing to reproduce it.
Measured while implementing #7281's maintainer ruling (PR #7400). Filed unassigned, out of that PR's ruled scope, for triage.
Related: #7281 (the probe half, ruled and fixed), #5493 (the widener surface this exists for). Not blocked by anything — #7400 is independently correct — but the two together decide whether the by-id widener functions on
privateat all.What was measured
#7281's ruling moved
checkAuthoredRowWrite's probe read to an elevated scope, so on aprivate-OWD object the verdict for a cross-owner row the declaration admits wentabstain->admit. The end-to-end by-id write did not change: it is still refused, 403, row unchanged.Real stack (
bootStack+ real SecurityPlugin + real SharingServicePlugin + real ObjectQL engine), two objects identical in every respect except their OWD, same app-authored widener (operation: 'update',using: "stage == 'open'"), same principal, same cross-owner row shape:wscope_notepublic_readadmitwscope_secretprivateadmitThe refusal on
private, verbatim:Mechanism
The error shape is the tell, and it is not the one #7281's card assumed. That sentence belongs to
plugin-security's by-id write pre-image gate, not to the sharing middleware (whose refusal readsFORBIDDEN: insufficient privileges to update …). The security middleware runs first, so on this posture it refuses before the sharing middleware is reached at all — which means the #5493 deferral that consumes the now-corrected verdict never runs onprivate.The gate, at
packages/plugins/plugin-security/src/security-plugin.ts:1301-1306(verified atd53bd0ba9):opCtx.contextis the caller's own execution context, so thatfindOnere-enters the middleware chain and picks upplugin-sharing's READ filter — which on aprivateOWD scopes to owner-match OR shares. The composedwritePartsalready admit the row (the authored policy OR-combines past the platform ownership floor); the row is simply invisible to the read that asks. Structurally the same shape #7281 named one layer up.Why this is not simply "the rest of #7281"
The gate's caller-scoped read is deliberate and documented in place:
and the neighbouring owner-echo pre-image states the same stance with a second reason (it closes an owner-enumeration oracle a system read would open). So this is not an oversight to be corrected the way #7281's was; changing it answers a public-contract question the #7281 ruling did not put to the maintainer:
Two readings, offered without choosing:
privateobject must widen READ to match (viewAllRecords, a read policy, or a share). Then the gap is documentation — and app authors need telling, because nothing in the widener's own vocabulary mentions the OWD.private-OWD cross-owner row, so #5493's by-id widener deferral is inert on the posture it was filed for — and its unit test cannot see it (fake engine bypasses middleware) #7281 gave the verdict — with the owner-enumeration concern answered separately (the gate returns a boolean, not the row).The cost of reading 1 is that the by-id widener surface — everything #5493, #6735 and #7281 built — remains inert on
private, the posture #5493 was filed for. The cost of reading 2 is a genuine widening of write reach on read-closed objects, which is exactly the kind of change that needs to be ruled rather than inferred.Repro
packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts(landed by PR #7400) pins today's behaviour on both postures, including this refusal and its exact envelope, in case⚠️ [E2E private]. Nothing new needs writing to reproduce it.