Skip to content

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

Description

@os-zhuang

Measured while running the #6736 bulk-path probe (PR #7274). Filed unassigned, out of that PR's scope, for triage.

What was measured

ISecurityService.checkAuthoredRowWrite resolves its verdict by re-reading the target row through the caller's own execution context:

const parts = [{ id: recordId }, ...(layer0 ? [layer0] : []), layer1];
const row = await this.ql.findOne(object, { where: { $and: parts }, context });
return row ? 'admit' : 'abstain';

packages/plugins/plugin-security/src/security-plugin.ts, in checkAuthoredRowWrite.

That findOne re-enters the middleware chain, so plugin-sharing's read filter applies. buildReadFilter scopes a private-OWD object to owner-match OR shares — so a cross-owner row is invisible to the caller, findOne returns null, and the verdict is abstain rather than admit. The middleware's refusal then stands and the write is refused 403, even though the app declared a widener that admits the row by name.

Measured end-to-end on the real stack (bootStack + real SecurityPlugin + real SharingServicePlugin + real ObjectQL engine). Two objects, identical in every respect except the OWD, same app-authored widener (operation: 'update', using: "stage == 'open'"), same principal, same cross-owner row shape:

object OWD checkAuthoredRowWrite on the cross-owner admitted row by-id PATCH
probe_note public_read admit 200, row changes
probe_secret private abstain 403, row unchanged

Control ruling out an inert or mis-parsed widener: on the same private object, checkAuthoredRowWrite for the caller's own open row answers admit. The declaration is live there; it is the cross-owner read that removes it.

Why this matters

private is the posture the widener surface was built for. #5493's own measured defect is described as occurring exactly where record sharing enforces — "a private/public_read OWD and an owner_id field" — and the sharing middleware's deferral comment says the probe exists so that gate does not refuse a row an app-authored widener admits. On private, it still refuses: the deferral is asked, and structurally cannot answer admit for any row the caller cannot already read.

So the shipped by-id half functions on read-open objects and stands down on read-closed ones — discriminated by a property the widener's author never mentions, which is the same shape #5493 was filed about one layer up.

The second half: the unit test cannot see this

packages/plugins/plugin-security/src/row-write-widener-composition.test.ts asserts

await expect(
  stack.security.checkAuthoredRowWrite('crm_opportunity', OPP_THEIRS.id, 'update', WIDENED_CTX),
).resolves.toBe('admit');

on crm_opportunity, which is declared sharingModel: 'private' — the posture that answers abstain in production. It passes because that file's makeEngine() fake implements find as a direct row filter with no middleware chain, so the re-read inside checkAuthoredRowWrite is never scoped by the sharing read filter. The double is looser than the producer on precisely the axis the method's verdict depends on, so the test is green over behaviour the real stack does not have.

This half is actionable regardless of how the first half is ruled: if the abstention is intended (fail-closed, "you may not write what you cannot read"), the unit test is asserting the opposite of the contract and should be inverted plus the intent written into the method's doc; if it is not intended, the test is why it shipped unnoticed.

Two readings, offered honestly

I measured behaviour, not intent, and both readings are defensible:

  1. Intended fail-closed. Resolving the verdict under the caller's own scope is a deliberate refusal to let a write reach rows the caller cannot see. Then the gap is documentation plus the misleading test — and app authors need to be told that an update-widener on a private object requires matching read widening (viewAllRecords, a read policy, or a share) to do anything.
  2. Unintended. The verdict is meant to answer "does the declaration admit this row", which is a question about the row and the policy, not about the caller's read scope; re-reading under the caller's context silently folds a read decision into a write decision. Then the read should be resolved under a scope that can actually see the row while the write decision stays with the pre-image gate.

Deliberately not choosing here — the difference is a contract question about what checkAuthoredRowWrite means, which belongs to the maintainer, not to the agent that tripped over it.

Repro

The measurement was taken with a throwaway variant of the probe added in PR #7274 (a second object identical except sharingModel: 'private', plus a case logging both verdicts and both HTTP statuses). It was deliberately not committed — that PR is scoped to the bulk path. The committed harness in packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts is two dozen lines away from re-taking it.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions