Skip to content

Commit 42c81e5

Browse files
committed
wip(plugin-security): defer a transfer's record verdict as its door does, as an update; changeset
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49ce224 commit 42c81e5

3 files changed

Lines changed: 24 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` 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
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. 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1010,7 +1010,8 @@ const TABLE: Row[] = [
10101010
})),
10111011
},
10121012
{
1013-
card: '#24647 probe', shape: 'private OWD, the same writer, a row owned by someone else the policy admits, transfer',
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',
10141015
position: 'record.visible',
10151016
enforced: 'admitted',
10161017
run: withSharing({ authored: true }, async (r) => ({

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,9 @@ export interface ExplainEngineDeps {
393393
*
394394
* Asked on the same condition here: for a by-id `update` or `delete` whose
395395
* gate above ({@link canEditRecord} / {@link canDeleteRecord}) answered
396-
* `false`, with that gate's verb and the context being EXPLAINED. Every
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
397399
* other answer leaves the refusal standing, and so does a rejection: the
398400
* door's probe catches its own failure and keeps its refusal, so a failing
399401
* probe is not a failing request and is not reported as one.
@@ -1592,9 +1594,13 @@ async function applyRecordAttribution(
15921594
// pre-image gate (the `rls` layer below). Asked on that condition alone, so
15931595
// a row the gate admits, abstains on or faulted on is explained as before;
15941596
// every answer but `admit`, and a rejection, keeps the refusal, as at the door.
1595-
const authoredRowWriteAdmits = canEdit === false && (engineOp === 'update' || engineOp === 'delete')
1596-
&& deps.probeAuthoredRowWrite
1597-
? (await deps.probeAuthoredRowWrite(object, recordId, engineOp, context).catch(() => 'abstain')) === 'admit'
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'
15981604
: false;
15991605
// [ADR-0055] A by-id write of a `controlled_by_parent` record (an update, a
16001606
// delete, a transfer: {@link MASTER_CHECKED_BY_ID_WRITES}) is judged by the
@@ -1737,7 +1743,7 @@ async function applyRecordAttribution(
17371743
: 'The sharing service grants write on this record (ownership or an edit/full share).')
17381744
: 'No ownership and no edit/full share grants write on this record.' +
17391745
(authoredRowWriteAdmits
1740-
? ` The by-id ${engineOp} does not stop at that refusal: an app-authored row-level ${engineOp} ` +
1746+
? ` The by-id ${engineOp} does not stop at that refusal: an app-authored row-level ${authoredDeferralVerb} ` +
17411747
'policy admits this record, so the write gate defers to row-level security (see the rls layer).'
17421748
: ''))
17431749
: depthAdmitsRow

0 commit comments

Comments
 (0)