Skip to content

Commit 04c56aa

Browse files
os-zhuangclaude
andauthored
feat(security): fail-closed authored-row-write verdict on ISecurityService (#5493 step 1) (#6841)
* feat(security): fail-closed authored-row-write verdict on ISecurityService (#5493 step 1) (#6735) `ISecurityService` gains an optional, verdict-shaped, by-id method `checkAuthoredRowWrite(object, recordId, operation, context)` returning `'admit' | 'abstain'`, plus `AuthoredRowWriteVerdict` / `AuthoredRowWriteOperation`. The question it answers is the one no existing surface could: does an APP-AUTHORED row-level policy admit this row for this write, on its own, with the platform's ownership floor taken out by PROVENANCE? Every other method reports the COMPOSED RLS verdict, and inside that composition sits the platform's own wildcard write floor (`created_by == current_user.id`, on the `member_default` baseline every authenticated member resolves). #5493 probe E-A measured the gap: a creator who is no longer the owner is admitted by the floor and refused by sharing with a byte-identical envelope, so a deferral keyed on the composed answer hands transferred records back to their former creators. Implementation reuses the #6684 provenance split — the SAME `computeLayeredRlsFilter` the middleware enforces with, driven by the SAME `dropPlatformOwnershipFloor` knob. No second RLS evaluator. A null Layer 1 is read as `abstain` (nothing authored is gating the write), and Layer 0 stays AND-ed in so the tenant wall holds. Fail-closed in the `abstain` direction throughout: the method is OPTIONAL, so a deployment without it behaves byte-for-byte as today; a principal-less or on-behalf-of context, an unresolvable probe and a thrown lookup all return `abstain`, and nothing throws outward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M * chore(spec): regenerate the contracts API-surface snapshot for the new verdict types (#6735) `check:api-surface` reported 0 breaking / 2 added — the expected additive product of declaring `AuthoredRowWriteVerdict` and `AuthoredRowWriteOperation` on the security-service contract. Regenerated with `pnpm --filter @objectstack/spec gen:api-surface`; the delta is exactly those two entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43a7a8d commit 04c56aa

6 files changed

Lines changed: 835 additions & 2 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
security: add a fail-closed authored-row-write verdict to `ISecurityService`
7+
8+
`ISecurityService` gains an optional, verdict-shaped, by-id method:
9+
10+
```ts
11+
checkAuthoredRowWrite?(
12+
object: string,
13+
recordId: string,
14+
operation: AuthoredRowWriteOperation, // 'update' | 'delete'
15+
context?: SecurityContext,
16+
): Promise< AuthoredRowWriteVerdict >; // 'admit' | 'abstain'
17+
```
18+
19+
It answers one question no existing surface could: does an **app-authored**
20+
row-level security policy admit this row for this write, on its own, with the
21+
platform's ownership floor taken out by provenance?
22+
23+
Every other method reports the **composed** RLS verdict, and sitting inside that
24+
composition is the platform's own wildcard write floor (`created_by ==
25+
current_user.id`, shipped on the `member_default` baseline every authenticated
26+
member resolves additively). So "the composed RLS admits this row" is true for
27+
the row's CREATOR whether or not any app policy mentions it — which makes it a
28+
measurably different question, not a cheaper spelling of the same one. A caller
29+
deferring to the composed answer would hand transferred records back to their
30+
former creators.
31+
32+
`admit` iff at least one applicable, non-floor policy matches the row for the
33+
operation. `abstain` in every other case — no authored policy, no match, an
34+
unreadable or cross-tenant row, a principal-less or on-behalf-of context, or any
35+
internal failure. The method never throws outward, and it is **optional**: a
36+
deployment whose security service omits it behaves byte-for-byte as before,
37+
because callers feature-detect and read absence as `abstain`.
38+
39+
`@objectstack/plugin-security` implements it on the registered `security`
40+
service, reading the verdict off the same layered RLS computation the middleware
41+
enforces with — no second RLS evaluator.

0 commit comments

Comments
 (0)