Skip to content

Commit 71a8b5e

Browse files
fix(plugin-security): explain's record-grained write verdict composes the by-id write gate's authored-row-write deferral (#24713)
Fixes #24647 Clause-②: no ## What this changes `security/explain`'s record-grained write verdict now composes the by-id write gate's authored-row-write deferral, the composition the sharing middleware already runs. When record sharing's per-record update or delete gate refuses a row, the door asks whether an app-authored row-level policy admits that row for the gate's verb (`ISecurityService.checkAuthoredRowWrite`, reached through `SharingService.probeAuthoredRowWrite`), and on `admit` hands the row to the pre-image gate. Explain asked the gate alone, so it answered `record.visible: false`, `decidedBy: 'sharing'` for a row whose `PATCH` is admitted. The divergence was restrictive: explain refused what the door admits, and nothing leaked. - `packages/plugins/plugin-security/src/explain-engine.ts`: a new optional dependency, `probeAuthoredRowWrite`, asked only when the write gate answered `false`: for `update` and `delete` with the gate's verb, and for `transfer` as an `update`. Only the literal `admit` lifts the refusal. `abstain`, any other answer and a rejection keep it: the door's probe catches its own failure and keeps its refusal, so a failing probe is not reported as a failing request. - `packages/plugins/plugin-security/src/security-plugin.ts`: the explain binding wires it to the sharing service's own probe, when the service has one, with the context the gate is asked with (`withWriteScope`). A sharing service without the probe defers nothing, and explain reports the gate's refusal as before. - `.changeset/24647-explain-authored-row-write.md`: `@objectstack/plugin-security` patch. ### What the record story reads for the new row `record: { recordId, visible: true, decidedBy: 'rls' }`. `rls` is the layer that last admitted the row, which is the published meaning of `decidedBy`; no vocabulary value is added and `packages/spec` is untouched. The `sharing` layer's record keeps `outcome: 'excluded'` (record sharing itself still refuses), and its detail adds: "The by-id update does not stop at that refusal: an app-authored row-level update policy admits this record, so the write gate defers to row-level security (see the rls layer)." The `rls` layer's record reads `admitted`. ### Why the composition sits in the engine (a deviation from the suggested route) Composing at the binding alone (`canEditRecord` answering `canEdit || admit`) makes the row `visible: true` but leaves the story's `sharing` layer at `admitted` with "The sharing service grants write on this record (ownership or an edit/full share)" and `decidedBy: 'sharing'`. That is a wrong "why" naming a share that does not exist, which is the cost the card names. So the deferral is a dependency the engine composes beside the gate, the way it already composes the master-detail check. This touches `explain-engine.ts`, which the claim's file surface did not list. No other open PR touches it. ## `delete` and `transfer`, included in place - The door's delete path defers too: `buildSharingMiddleware` probes with the refusing gate's verb for both `update` and `delete`. A transfer's door is the by-id `PATCH` of `owner_id`, which the update gate judges and the same deferral lifts, and explain's transfer verdict rides the update gate. Measured before this change (the ablated run below): all three answered `visible: false`, `decidedBy: 'sharing'` beside an admitted door. - The four in-place conditions hold: (1) the same defect class; (2) a mechanical fix, one composition with the verb its gate asked; (3) no other claim holds these files; (4) the same parity table, so no new validation surface. Each verb has its own parity row. - `restore` and `purge` are not deferred: they have no by-id door, and the object gate refuses both to every principal. ## Security floor: no enforcement answer moves - The door (`plugin-sharing`) and the `sys_attachment` / `sys_comment` parent gates are not edited. - Both parent gates ask record sharing's `checkEdit` first and refuse on `deny` without asking explain. On the card's row the real `SharingService.checkEdit` answers `deny` under the gates' envelope, both with this change and with it ablated, so neither gate's answer can change. Each gate's own parity table already pins "`sharing deny` ⇒ refused, explain never called", with an explain double that would admit (`comment-parent-update-parity.test.ts`, `attachment-access-hooks.test.ts`). - No path was found where explain's verdict decides a write alone today. The other `explain` callers ask object-level `create` or `read`, and objectui reads the verdict for UI affordance only. **Pins a sibling card flips:** `packages/plugins/plugin-security/src/explain-enforce-parity.test.ts`, test "an app-authored row-level write policy the by-id write gate defers to (ADR-0105 D3) › the parent gates' first question, record sharing, still refuses the row explain now admits". #24646 remains open. Its ruling (option B, comment 6279880675) has both gates ask only explain's record-grained update verdict, and that flips this pin: the gate then admits exactly what the parent's by-id update admits, which for this scenario, with this deferral, is admitted. The flip is pre-registered in a comment beside the assertion. ## Tests All on head `2fdf7b1987`, each through `scripts/pm/os-verify-lock.sh`, verdict line `command-exit 0`: - `pnpm --filter '@objectstack/plugin-security^...' build` (dependency closure), then the `./packages/**` build after the `main` merge. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: `Test Files 189 passed (189)`, `Tests 3794 passed | 45 skipped (3839)`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, and `check:test-typecheck: OK` (the test layer compiles under `tsconfig.test.json`, so the new rows are type-checked). - `vitest run --reporter=verbose src/explain-enforce-parity.test.ts`: `Tests 106 passed (106)`. New cases: the table rows for update, delete and transfer of a row an authored policy admits (door admitted, explain `visible: true`), the two controls (a row no authored policy admits: door 403 `PERMISSION_DENIED`, explain `visible: false`), and three cases: the story (`decidedBy: 'rls'`, `sharing` layer `excluded`, `rls` layer `admitted`), the parent gates' first question (`checkEdit` is `deny` on the row explain now admits) and its positive control (the writer's own row: `checkEdit` `allow`, explain and the door admit). ## Ablation (a one-time proof; no permanent test file) `node scripts/ablation-replace.mjs` on `security-plugin.ts`, removing the binding's wiring of the probe (anchor count 1 to 0), on head `2fdf7b1987`: - Mutated: `Tests 5 failed | 101 passed (106)`, failing in the expected direction (red). The update, delete and transfer rows show explain at `record: {"recordId":"l_theirs","visible":false,"decidedBy":"sharing"}` beside `enforcement {"kind":"admitted"}`, which is the card's divergence reproduced. The story case and the parent-gate case fail at the explain assertion. The parent-gate case's `checkEdit` assertion (`deny`) passes before that point, so the gates' first answer is the same with and without the change. The positive control and both refusal controls stay green. - Restored: `git checkout HEAD -- ABS_PATH`, blob equal to HEAD (`56d547d5ed1f`), `git diff HEAD` empty. The same ablation on the pre-merge head `d2e245471` gave the same 5 failures. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run on head `2fdf7b1987` (the merge head) against the real diff: 69 commands, each run with its exit code captured before any pipe. All 69 exit 0. `dispatch-gates --ran`: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED". The derived list is identical to the one derived before the merge. Before the merge, three families first answered `PREREQUISITE NOT MET` (exit 3): `check-plugin-teardown-shape --self-test` (its pinned fixture commit was not reachable yet), `check:i18n` (CLI not built) and `check:dual-build-cjs-loads` (not every package built). All three were re-run once their prerequisite was met, and all exit 0 in the final union. CI's own check-runs on this PR are the authority for every family CI runs. ## Acceptance notes - `main` was merged once before this PR opened (merge commit `2fdf7b1987`): the claim's serial constraint said whichever of this card and #24623 lands second merges `main`, and #24623 landed (`d11fa8cc19`) with a change to `security-plugin.ts` at a disjoint region. The merge was clean. The build, the suite, the typecheck and the gate union were re-run on the merge head. - The deferral's code tags read `[#5493 / ADR-0105 D3]`, following this file's existing tag for `checkAuthoredRowWrite`. ADR-0105 D3's own text is about stripping only the platform's wildcard tenant policies; the deferral's provenance is the #5493 ruling. A citation observation, not filed. - Explain's `purge` record verdict asks the update gate (`engineOp === 'delete' ? canDeleteRecord : canEditRecord`), though a purge destroys like a delete. The object gate refuses `purge` to every principal, so the record verdict never reaches that gate. Dormant, not filed. - `checkAuthoredRowWrite`'s docblock says a private-OWD cross-owner by-id write is still refused end to end. That holds for a caller whose read scope hides the row (the dogfood pin's principal). With `org` read depth the write is admitted, which the new parity row measures. Not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b4be0b commit 71a8b5e

4 files changed

Lines changed: 228 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `security/explain`'s record-grained `update`, `delete` and `transfer` verdicts compose the by-id write gate's authored-row-write deferral, so a row an app-authored row-level write policy admits is reported writable, as its `PATCH` / `DELETE` answers
6+
7+
Clause-②: no (no accept set or public surface moves: enforcement is unchanged, and explain's record verdict now gives the answer the by-id write already gives)
8+
9+
- **What was wrong.** The sharing middleware's by-id write gate does not stop at record sharing's refusal: it asks the security service whether an app-authored row-level policy admits the row for that verb (`checkAuthoredRowWrite`), and on `admit` hands the row to the pre-image gate, which admits it. Explain's record-grained write verdict asked record sharing's per-record gate alone, so on a private object, for a row owned by someone else that only such a policy lets the caller write, the `PATCH` / `DELETE` landed while explain answered `record.visible: false`, decided by `sharing`.
10+
- **What explain answers now.** The same deferral, on the same condition: only when the per-record update or delete gate refuses the row, asked through the sharing service's own probe with the context that gate is asked with. A `transfer` is deferred as an `update`, since its door is the `PATCH` that writes `owner_id`. Only the literal `admit` lifts the refusal; `abstain`, any other answer and a failed probe leave it standing. The record verdict is then `visible: true`, `decidedBy: 'rls'`, the layer that last admitted the row; the `sharing` layer still reports `excluded`, and its detail names the deferral. A sharing service without the probe defers nothing, as before.
11+
- **What does not move.** No enforcement answer changes. The `sys_attachment` and `sys_comment` parent gates ask record sharing first and refuse on its `deny` without asking explain, so a row this deferral admits is still refused there at the record-sharing step.

‎packages/plugins/plugin-security/src/explain-enforce-parity.test.ts‎

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,15 +559,26 @@ const SHARING_READER = { userId: 'u_reader', positions: ['qa_pos'], permissions:
559559
const SHARING_WRITER = { userId: 'u_writer', positions: ['qa_pos'], permissions: ['qa_line_writer'], posture: 'MEMBER' };
560560
/** Read and write depth `own`: the control. */
561561
const SHARING_OWN = { userId: 'u_own', positions: ['qa_pos'], permissions: ['qa_line_own'], posture: 'MEMBER' };
562+
/**
563+
* Read depth `org`, write depth `own`, and an app-authored row-level `update`
564+
* and `delete` policy admitting every line not named `unnamed`
565+
* ({@link bootSharing}'s `authored` rows): record sharing refuses this writer
566+
* every line owned by someone else, and the by-id write gate defers that
567+
* refusal to the policy.
568+
*/
569+
const AUTHORED_WRITER = { userId: 'u_authored', positions: ['qa_pos'], permissions: ['qa_line_authored'], posture: 'MEMBER' };
562570

563571
/**
564572
* A `private`-OWD object with an `owner_id`, the real `SharingService` as the
565573
* kernel's `sharing` service and its middleware on the engine after this
566574
* plugin's, as the platform boots them. Row `l_other` is owned by nobody the
567575
* rig explains. `faultReadFilter` makes the sharing service's read filter
568-
* reject, as a share store that cannot be read does.
576+
* reject, as a share store that cannot be read does. `authored` adds
577+
* {@link AUTHORED_WRITER}'s set and three lines: `l_theirs` (owned by
578+
* someone else, admitted by the policy), `l_unnamed` (owned by someone else,
579+
* not admitted) and `l_mine` (owned by the writer).
569580
*/
570-
async function bootSharing(opts: { faultReadFilter?: boolean } = {}) {
581+
async function bootSharing(opts: { faultReadFilter?: boolean; authored?: boolean } = {}) {
571582
const LINE = `qa_parity_line_${next()}`;
572583
const engine = new ObjectQL();
573584
engine.registerDriver(
@@ -601,6 +612,13 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) {
601612
{ id: 'l_other', name: 'owned by someone else', owner_id: 'u_somebody' },
602613
{ id: 'l_own', name: 'owned by the control', owner_id: 'u_own' },
603614
], SYS);
615+
if (opts.authored) {
616+
await engine.insert(LINE, [
617+
{ id: 'l_theirs', name: 'theirs', owner_id: 'u_somebody' },
618+
{ id: 'l_unnamed', name: 'unnamed', owner_id: 'u_somebody' },
619+
{ id: 'l_mine', name: 'mine', owner_id: AUTHORED_WRITER.userId },
620+
], SYS);
621+
}
604622

605623
const sets = [
606624
MEMBER_DEFAULT,
@@ -613,6 +631,18 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) {
613631
name: 'qa_line_own',
614632
objects: { [LINE]: { allowRead: true, allowEdit: true, readScope: 'own', writeScope: 'own' } },
615633
}),
634+
...(opts.authored
635+
? [PermissionSetSchema.parse({
636+
name: 'qa_line_authored',
637+
objects: {
638+
[LINE]: { allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true, readScope: 'org', writeScope: 'own' },
639+
},
640+
rowLevelSecurity: [
641+
{ name: 'qa_line_named_updates', object: LINE, operation: 'update', using: "name != 'unnamed'" },
642+
{ name: 'qa_line_named_deletes', object: LINE, operation: 'delete', using: "name != 'unnamed'" },
643+
],
644+
})]
645+
: []),
616646
];
617647
let security: unknown;
618648
let sharing: SharingService | undefined;
@@ -645,11 +675,21 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) {
645675
engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' });
646676

647677
return {
648-
explain: (caller: object, operation: 'read' | 'update', recordId: string) =>
678+
explain: (caller: object, operation: 'read' | 'update' | 'delete' | 'transfer', recordId: string) =>
649679
explained(plugin.explainAccessForCaller({ object: LINE, operation, recordId }, { ...caller })),
650680
find: (caller: object, id: string) => rowsOf(engine.find(LINE, { where: { id }, context: { ...caller } } as never)),
651681
update: (caller: object, id: string) =>
652682
landed(engine.update(LINE, { name: 'renamed' }, { where: { id }, context: { ...caller } } as never)),
683+
remove: (caller: object, id: string) => landed(engine.delete(LINE, { where: { id }, context: { ...caller } } as never)),
684+
/** A transfer is a by-id update of `owner_id`, gated by the transfer grant. */
685+
transfer: (caller: object, id: string) =>
686+
landed(engine.update(LINE, { owner_id: 'u_recipient' }, { where: { id }, context: { ...caller } } as never)),
687+
/**
688+
* Record sharing's tri-state update verdict, asked as the `sys_attachment`
689+
* and `sys_comment` parent gates ask it before anything else: the caller's
690+
* envelope with no operation-private key.
691+
*/
692+
checkEdit: (caller: object, id: string) => sharing!.checkEdit(LINE, id, { ...caller } as never),
653693
teardown: async () => {
654694
try { await engine.destroy(); } catch { /* noop */ }
655695
releaseAsyncContexts(engine, plugin);
@@ -700,7 +740,7 @@ const withRls = (predicate: string, f: (rig: RlsRig) => Promise<RowFaces>, opts:
700740
try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; }
701741
};
702742
const withSharing = (
703-
opts: { faultReadFilter?: boolean },
743+
opts: { faultReadFilter?: boolean; authored?: boolean },
704744
f: (rig: Awaited<ReturnType<typeof bootSharing>>) => Promise<RowFaces>,
705745
): RowRun => async () => {
706746
const rig = await bootSharing(opts);
@@ -935,6 +975,49 @@ const TABLE: Row[] = [
935975
explain: await r.explain(SHARING_OWN, 'update', 'l_other'), enforce: await r.update(SHARING_OWN, 'l_other'),
936976
})),
937977
},
978+
// #24647: the by-id write gate defers record sharing's refusal to an
979+
// app-authored row-level write policy that admits the row (ADR-0105 D3).
980+
{
981+
card: '#24647', shape: 'private OWD, an `own` writer whose authored update policy admits a row owned by someone else, update',
982+
position: 'record.visible',
983+
enforced: 'admitted',
984+
run: withSharing({ authored: true }, async (r) => ({
985+
explain: await r.explain(AUTHORED_WRITER, 'update', 'l_theirs'), enforce: await r.update(AUTHORED_WRITER, 'l_theirs'),
986+
})),
987+
},
988+
{
989+
card: '#24647', shape: 'private OWD, an `own` writer whose authored delete policy admits a row owned by someone else, delete',
990+
position: 'record.visible',
991+
enforced: 'admitted',
992+
run: withSharing({ authored: true }, async (r) => ({
993+
explain: await r.explain(AUTHORED_WRITER, 'delete', 'l_theirs'), enforce: await r.remove(AUTHORED_WRITER, 'l_theirs'),
994+
})),
995+
},
996+
{
997+
card: '#24647 control', shape: 'private OWD, the same writer, a row owned by someone else that no authored policy admits, update',
998+
position: 'record.visible',
999+
enforced: REFUSED_DENIED,
1000+
run: withSharing({ authored: true }, async (r) => ({
1001+
explain: await r.explain(AUTHORED_WRITER, 'update', 'l_unnamed'), enforce: await r.update(AUTHORED_WRITER, 'l_unnamed'),
1002+
})),
1003+
},
1004+
{
1005+
card: '#24647 control', shape: 'private OWD, the same writer, a row owned by someone else that no authored policy admits, delete',
1006+
position: 'record.visible',
1007+
enforced: REFUSED_DENIED,
1008+
run: withSharing({ authored: true }, async (r) => ({
1009+
explain: await r.explain(AUTHORED_WRITER, 'delete', 'l_unnamed'), enforce: await r.remove(AUTHORED_WRITER, 'l_unnamed'),
1010+
})),
1011+
},
1012+
{
1013+
// A transfer's door is the by-id update of `owner_id`, deferred as an update.
1014+
card: '#24647', shape: 'private OWD, an `own` writer holding the transfer grant whose authored update policy admits a row owned by someone else, transfer',
1015+
position: 'record.visible',
1016+
enforced: 'admitted',
1017+
run: withSharing({ authored: true }, async (r) => ({
1018+
explain: await r.explain(AUTHORED_WRITER, 'transfer', 'l_theirs'), enforce: await r.transfer(AUTHORED_WRITER, 'l_theirs'),
1019+
})),
1020+
},
9381021
// #20002: a dependency enforcement shares with explain throws.
9391022
{
9401023
card: '#20002', shape: 'the sharing read filter throws, the caller\'s own row, read', position: 'record.visible',
@@ -999,3 +1082,65 @@ describe('security.explain answers what enforcement does — the enumeration', (
9991082
}, 60_000);
10001083
}
10011084
});
1085+
1086+
/**
1087+
* #24647's row, beyond the verdict the table compares: what the record story
1088+
* names, and what the `sys_attachment` and `sys_comment` parent gates answer.
1089+
*
1090+
* Both gates ask record sharing's `checkEdit` FIRST, with the caller's envelope
1091+
* and no operation-private key, refuse on its `deny` without asking anything
1092+
* further, and ask explain's record-grained `update` verdict only once that
1093+
* step admits (each gate's own parity table pins it: its `sharing deny` row
1094+
* refuses with explain never called, though the double would admit). So on a
1095+
* row the deferral admits, record sharing's own `deny` still decides both
1096+
* gates: explain admitting it cannot widen either one. That holds for today's
1097+
* gate composition only, and the case below says which card changes it.
1098+
*/
1099+
describe('an app-authored row-level write policy the by-id write gate defers to (ADR-0105 D3)', () => {
1100+
it('the update story names the deferral: record sharing excludes the row, row-level security admits it', async () => {
1101+
const rig = await bootSharing({ authored: true });
1102+
try {
1103+
const x = await rig.explain(AUTHORED_WRITER, 'update', 'l_theirs');
1104+
expect(x.kind, describeExplained(x)).toBe('decision');
1105+
if (x.kind !== 'decision') return;
1106+
expect(x.decision.record).toEqual({ recordId: 'l_theirs', visible: true, decidedBy: 'rls' });
1107+
const sharingRecord = x.decision.layers.find((l) => l.layer === 'sharing')?.record;
1108+
expect(sharingRecord?.outcome).toBe('excluded');
1109+
expect(sharingRecord?.detail).toMatch(/app-authored row-level update policy admits this record/);
1110+
expect(x.decision.layers.find((l) => l.layer === 'rls')?.record?.outcome).toBe('admitted');
1111+
} finally {
1112+
await rig.teardown();
1113+
}
1114+
}, 60_000);
1115+
1116+
it("the parent gates' first question, record sharing, still refuses the row explain now admits", async () => {
1117+
const rig = await bootSharing({ authored: true });
1118+
try {
1119+
// ⚠️ A sibling card flips this pin: #24646 (ruling B, comment 6279880675)
1120+
// drops both gates' own-depth record-sharing step and their master step,
1121+
// so they ask only explain's record-grained by-id `update` verdict. The
1122+
// gate then admits exactly what the parent's by-id update admits; for
1123+
// this scenario, with #24647's deferral, that is ADMITTED. Until then the
1124+
// gates' first question below is record sharing's, and it refuses.
1125+
expect(await rig.checkEdit(AUTHORED_WRITER, 'l_theirs')).toBe('deny');
1126+
const x = await rig.explain(AUTHORED_WRITER, 'update', 'l_theirs');
1127+
expect(x.kind === 'decision' ? x.decision.record?.visible : x.kind, describeExplained(x)).toBe(true);
1128+
expect(await rig.update(AUTHORED_WRITER, 'l_theirs')).toEqual({ kind: 'admitted' });
1129+
} finally {
1130+
await rig.teardown();
1131+
}
1132+
}, 60_000);
1133+
1134+
it('the positive control: a row record sharing admits is admitted by the first question, by explain and by the door', async () => {
1135+
const rig = await bootSharing({ authored: true });
1136+
try {
1137+
expect(await rig.checkEdit(AUTHORED_WRITER, 'l_mine')).toBe('allow');
1138+
const x = await rig.explain(AUTHORED_WRITER, 'update', 'l_mine');
1139+
expect(x.kind === 'decision' ? x.decision.record : x.kind, describeExplained(x))
1140+
.toEqual({ recordId: 'l_mine', visible: true, decidedBy: 'rls' });
1141+
expect(await rig.update(AUTHORED_WRITER, 'l_mine')).toEqual({ kind: 'admitted' });
1142+
} finally {
1143+
await rig.teardown();
1144+
}
1145+
}, 60_000);
1146+
});

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
} from '@objectstack/formula';
3737
import { ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec';
3838
import type {
39+
AuthoredRowWriteOperation,
40+
AuthoredRowWriteVerdict,
3941
ControlledByParentWriteDenialLeg,
4042
ControlledByParentWriteOutcome,
4143
ControlledByParentWriteUnresolvedReason,
@@ -380,6 +382,33 @@ export interface ExplainEngineDeps {
380382
* explanation for a `delete` must consult this rather than the update gate.
381383
*/
382384
canDeleteRecord?: (object: string, recordId: string, context: any) => Promise<boolean>;
385+
/**
386+
* [#5493 / ADR-0105 D3] The by-id write gate's DEFERRAL: whether an
387+
* APP-AUTHORED row-level policy admits the row for the refusing gate's verb
388+
* (`ISecurityService.checkAuthoredRowWrite`, reached through the sharing
389+
* service's own probe). The sharing middleware asks it only after its
390+
* per-record gate refused the row, and only the literal `admit` lifts that
391+
* refusal: the row then goes on to the pre-image gate, which is the
392+
* row-level security this report's `rls` layer explains.
393+
*
394+
* Asked on the same condition here: for a by-id `update` or `delete` whose
395+
* gate above ({@link canEditRecord} / {@link canDeleteRecord}) answered
396+
* `false`, with that gate's verb and the context being EXPLAINED, and for a
397+
* `transfer` as an `update` (its door is the by-id update of `owner_id`,
398+
* which the update gate judges). Every
399+
* other answer leaves the refusal standing, and so does a rejection: the
400+
* door's probe catches its own failure and keeps its refusal, so a failing
401+
* probe is not a failing request and is not reported as one.
402+
*
403+
* Optional: a deps bag wired without it reports the gate's refusal as the
404+
* write verdict, as before.
405+
*/
406+
probeAuthoredRowWrite?: (
407+
object: string,
408+
recordId: string,
409+
operation: AuthoredRowWriteOperation,
410+
context: any,
411+
) => Promise<AuthoredRowWriteVerdict | string>;
383412
/**
384413
* [ADR-0055] The master-detail write check's own answer for a by-id UPDATE
385414
* of `recordId` by `context`: `ISecurityService.checkControlledByParentWrite`,
@@ -1558,6 +1587,21 @@ async function applyRecordAttribution(
15581587
: undefined;
15591588
const writeGateFault = canEditOrFault === DEPENDENCY_FAULT;
15601589
const canEdit = canEditOrFault === DEPENDENCY_FAULT ? undefined : canEditOrFault;
1590+
// [#5493 / ADR-0105 D3] The by-id write gate DEFERS before it refuses: when
1591+
// its per-record gate refuses an `update` or a `delete`, the sharing
1592+
// middleware asks whether an app-authored row-level policy admits the row
1593+
// for that verb, and only `admit` lifts the refusal, handing the row to the
1594+
// pre-image gate (the `rls` layer below). Asked on that condition alone, so
1595+
// a row the gate admits, abstains on or faulted on is explained as before;
1596+
// every answer but `admit`, and a rejection, keeps the refusal, as at the door.
1597+
// A transfer's door is the by-id update of `owner_id`, so it is deferred as
1598+
// an update; restore and purge have no by-id door (the object gate refuses
1599+
// both), so nothing defers them.
1600+
const authoredDeferralVerb: AuthoredRowWriteOperation | undefined =
1601+
engineOp === 'update' || engineOp === 'transfer' ? 'update' : engineOp === 'delete' ? 'delete' : undefined;
1602+
const authoredRowWriteAdmits = canEdit === false && authoredDeferralVerb && deps.probeAuthoredRowWrite
1603+
? (await deps.probeAuthoredRowWrite(object, recordId, authoredDeferralVerb, context).catch(() => 'abstain')) === 'admit'
1604+
: false;
15611605
// [ADR-0055] A by-id write of a `controlled_by_parent` record (an update, a
15621606
// delete, a transfer: {@link MASTER_CHECKED_BY_ID_WRITES}) is judged by the
15631607
// master-detail write check (step 2.8), which the sharing gate above cannot
@@ -1697,7 +1741,11 @@ async function applyRecordAttribution(
16971741
? `The write gate admits this record via the Modify All Data bypass [${vamaSets.join(', ')}], ` +
16981742
'not ownership or a share (see the vama_bypass layer).'
16991743
: 'The sharing service grants write on this record (ownership or an edit/full share).')
1700-
: 'No ownership and no edit/full share grants write on this record.')
1744+
: 'No ownership and no edit/full share grants write on this record.' +
1745+
(authoredRowWriteAdmits
1746+
? ` The by-id ${engineOp} does not stop at that refusal: an app-authored row-level ${authoredDeferralVerb} ` +
1747+
'policy admits this record, so the write gate defers to row-level security (see the rls layer).'
1748+
: ''))
17011749
: depthAdmitsRow
17021750
? `${shareRules.length} share(s) attached; none grants the caller access to this record — read depth ` +
17031751
`'${depthScope}' already admits it, so sharing adds nothing here (see the depth layer).`
@@ -1865,7 +1913,7 @@ async function applyRecordAttribution(
18651913
const businessRowAdmits = isRead
18661914
? owd.effect !== 'private' || ownerIsMe || sharingOutcome === 'admitted' || depthAdmitsRow || vamaAdmitsRow
18671915
: canEdit !== undefined
1868-
? canEdit
1916+
? canEdit || authoredRowWriteAdmits
18691917
: owd.effect === 'public' || ownerIsMe || sharingOutcome === 'admitted' || vamaAdmitsRow;
18701918

18711919
// [#4647] Was the bypass DECISIVE? Only where the baseline and the concrete
@@ -1893,6 +1941,9 @@ async function applyRecordAttribution(
18931941
// the same layer.
18941942
else if (masterLegRefusal) { visible = false; decidedBy = 'sharing'; }
18951943
else if (!businessRowAdmits) { visible = false; decidedBy = owd.effect === 'private' ? 'sharing' : 'owd_baseline'; }
1944+
// [#5493 / ADR-0105 D3] A row the write gate refused and its deferral
1945+
// admitted was last admitted by the app-authored row-level policy.
1946+
else if (authoredRowWriteAdmits) { visible = true; decidedBy = 'rls'; }
18961947
else {
18971948
visible = true;
18981949
// `depth` ranks with `sharing`, the other layer that widens a private

0 commit comments

Comments
 (0)