Found while implementing #7858. Filed unassigned, and deliberately not fixed in that PR: #7858's scope was ruled to be the two bulk filters, and the paths below fail closed, so widening them is a security-relevant change that deserves its own analysis rather than a rider.
What #7858 fixed, and what it did not
hasOwnerField (packages/plugins/plugin-sharing/src/sharing-service.ts:141) has five consumers. #7858 guarded two of them — buildReadFilter and buildWriteFilter — with a provenance test: an owner_id byte-identical to the shipped OWNER_FIELD_DEF on an external object is the registry's injected anchor, not a real column, so ownership scoping contributes nothing.
The other three still gate on the raw field-existence answer:
checkEdit (:549 pre-PR numbering)
checkDelete (:631)
assertSharingEnabled (:872)
The code path
checkEdit / checkDelete both reach the shared ownership fast-path matchesOwnerScope, which selects the phantom column straight off the remote table:
const own = await this.engine.find(object, {
where: { id: recordId },
fields: ['id', OWNER_FIELD], // OWNER_FIELD = 'owner_id'
limit: 1,
context: SYSTEM_CTX,
});
const owner = Array.isArray(own) && own[0] ? (own[0] as any)[OWNER_FIELD] : undefined;
if (owner == null) return false;
On a federated object the platform provisions no storage (Engine.syncObjectSchema returns early for external != null), so owner_id is not in the remote table. The ownership fast-path can therefore never admit: either the driver raises and the try/catch routes to writeGateFailClosed, or the value comes back absent and matchesOwnerScope returns false. Both land on deny once the share-grant and modifyAllRecords branches also miss.
assertSharingEnabled is the mirror image: it currently reports a phantom-anchor federated object as share-able (hasOwnerField is true), so a share row can be minted on an object whose gates can never consult it.
Why this is filed rather than fixed
The direction is opposite to #7858's. There, the phantom predicate made a readable object unreadable; here the same phantom column makes the gates refuse, which is fail-closed and safe. Flipping checkEdit / checkDelete to abstain would hand the decision to other layers and could turn a refusal into an allow — that needs deciding, not guessing. assertSharingEnabled's answer is a separate question again (refusing to mint a useless share row is arguably the improvement).
Not measured
⚠️ This is a code-path reading, not a boot measurement — unlike #7858's body, which carried real filter output from a booted stack. I did not run a federated single-record write. The dialect behaviour of selecting a nonexistent column in the SELECT list (as opposed to #7858's comparison-position degradation on SQLite) is specifically unverified. Worth measuring before acting.
Related
Generated by Claude Code
Found while implementing #7858. Filed unassigned, and deliberately not fixed in that PR: #7858's scope was ruled to be the two bulk filters, and the paths below fail closed, so widening them is a security-relevant change that deserves its own analysis rather than a rider.
What #7858 fixed, and what it did not
hasOwnerField(packages/plugins/plugin-sharing/src/sharing-service.ts:141) has five consumers. #7858 guarded two of them —buildReadFilterandbuildWriteFilter— with a provenance test: anowner_idbyte-identical to the shippedOWNER_FIELD_DEFon anexternalobject is the registry's injected anchor, not a real column, so ownership scoping contributes nothing.The other three still gate on the raw field-existence answer:
checkEdit(:549pre-PR numbering)checkDelete(:631)assertSharingEnabled(:872)The code path
checkEdit/checkDeleteboth reach the shared ownership fast-pathmatchesOwnerScope, which selects the phantom column straight off the remote table:On a federated object the platform provisions no storage (
Engine.syncObjectSchemareturns early forexternal != null), soowner_idis not in the remote table. The ownership fast-path can therefore never admit: either the driver raises and thetry/catchroutes towriteGateFailClosed, or the value comes back absent andmatchesOwnerScopereturnsfalse. Both land ondenyonce the share-grant andmodifyAllRecordsbranches also miss.assertSharingEnabledis the mirror image: it currently reports a phantom-anchor federated object as share-able (hasOwnerFieldis true), so a share row can be minted on an object whose gates can never consult it.Why this is filed rather than fixed
The direction is opposite to #7858's. There, the phantom predicate made a readable object unreadable; here the same phantom column makes the gates refuse, which is fail-closed and safe. Flipping
checkEdit/checkDeletetoabstainwould hand the decision to other layers and could turn a refusal into an allow — that needs deciding, not guessing.assertSharingEnabled's answer is a separate question again (refusing to mint a useless share row is arguably the improvement).Not measured
Related
__readScopeown/unit to anowner_idpredicate on federated objects, whereowner_idis a phantom column #7858 — the two bulk filters, fixed.applySystemFieldsinjects platform anchors intoexternalobjects the platform provisions no storage for — three consumers have now independently re-derived "that column is not really there" #7865 — the registry-level producer decision (direction B: keep injecting, add a provenance marker). If that marker lands, all of these converge on reading it.Generated by Claude Code