Skip to content

Commit d2e2454

Browse files
committed
test(plugin-security): name the sibling card that flips the parent-gate pin; changeset names transfer
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42c81e5 commit d2e2454

2 files changed

Lines changed: 10 additions & 3 deletions

File tree

‎.changeset/24647-explain-authored-row-write.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
'@objectstack/plugin-security': patch
33
---
44

5-
fix(plugin-security): `security/explain`'s record-grained `update` and `delete` 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
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
66

77
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)
88

99
- **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. 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.
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.
1111
- **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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1093,7 +1093,8 @@ describe('security.explain answers what enforcement does — the enumeration', (
10931093
* step admits (each gate's own parity table pins it: its `sharing deny` row
10941094
* refuses with explain never called, though the double would admit). So on a
10951095
* row the deferral admits, record sharing's own `deny` still decides both
1096-
* gates: explain admitting it cannot widen either one.
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.
10971098
*/
10981099
describe('an app-authored row-level write policy the by-id write gate defers to (ADR-0105 D3)', () => {
10991100
it('the update story names the deferral: record sharing excludes the row, row-level security admits it', async () => {
@@ -1115,6 +1116,12 @@ describe('an app-authored row-level write policy the by-id write gate defers to
11151116
it("the parent gates' first question, record sharing, still refuses the row explain now admits", async () => {
11161117
const rig = await bootSharing({ authored: true });
11171118
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.
11181125
expect(await rig.checkEdit(AUTHORED_WRITER, 'l_theirs')).toBe('deny');
11191126
const x = await rig.explain(AUTHORED_WRITER, 'update', 'l_theirs');
11201127
expect(x.kind === 'decision' ? x.decision.record?.visible : x.kind, describeExplained(x)).toBe(true);

0 commit comments

Comments
 (0)