Skip to content

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

Description

@os-zhuang

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:

object OWD verdict after #7400 by-id PATCH
wscope_note public_read admit 200, row changes
wscope_secret private admit 403, row unchanged

The refusal on private, verbatim:

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):

visible = await this.ql.findOne(opCtx.object, {
  where: { $and: [{ id: targetId }, ...writeParts] },
  context: opCtx.context,
});

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.

Why this is not simply "the rest of #7281"

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:

  1. 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.
  2. Unintended. The declaration names the row by predicate, the composed write filter already admits it, and the only thing refusing is a read scope that the write question never asked about. Then the gate's probe read wants the same treatment checkAuthoredRowWrite abstains on every 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions