Skip to content

feat(security): fail-closed authored-row-write verdict on ISecurityService (#5493 step 1) - #6841

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6735-authored-row-write-verdict
Aug 9, 2026
Merged

feat(security): fail-closed authored-row-write verdict on ISecurityService (#5493 step 1)#6841
os-zhuang merged 2 commits into
mainfrom
claude/issue-6735-authored-row-write-verdict

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6735

Part of #5493 (maintainer ruling 2026-08-08, #5493 comment 5226377985: Q1 = A, Q2 = A1). This card is the head of the chain: it lands, then #5493 step 2 (plugin-sharing consumption) dispatches.

What this adds

ISecurityService gains an optional, verdict-shaped, by-id method, plus two named types:

export type AuthoredRowWriteVerdict = 'admit' | 'abstain';
export type AuthoredRowWriteOperation = 'update' | 'delete';

checkAuthoredRowWrite?(
  object: string,
  recordId: string,
  operation: AuthoredRowWriteOperation,
  context?: SecurityContext,
): Promise< AuthoredRowWriteVerdict >;

@objectstack/plugin-security implements it on the registered security service.

Why no existing surface could answer it

Every other method on this contract reports the composed RLS verdict, and sitting inside that composition is the platform's own wildcard write floor — owner_only_writes / owner_only_deletes, created_by == current_user.id, shipped on the member_default baseline every authenticated member resolves additively.

So "the composed RLS admits this row" is true for the row's creator whether or not any app policy mentions the row at all. That makes it a measurably different question, not a cheaper spelling of the same one — and #5493's probe E-A is where the difference costs something: a creator who is no longer the owner (a record transferred away from them) is admitted by the platform floor and refused by sharing with a byte-identical envelope. A deferral keyed on the composed answer would hand transferred records back to their former creators.

Separating the two requires policy provenance, which is deliberately private to plugin-security (platform-ownership-policies.ts, ADR-0105 D3 keying) — an authorable "this is a floor" flag would hand authors a switch that turns their own policy off. Hence a method on the service rather than a derivation consumers could do themselves.

No second RLS evaluator

The verdict is read off the same computeLayeredRlsFilter the middleware enforces with, driven by the same dropPlatformOwnershipFloor knob #6684 landed for the by-id write pre-image gate. Two consequences worth naming, both load-bearing and both pinned:

  • layer1 == null is read as abstain, never as "admitted." Layer 1 is null exactly when no authored predicate is gating the write: the applicable set was empty, or the ADR-0066 ① posture-gated superuser short-circuit skipped business RLS wholesale. A superuser bypass is not an authored admission, and reporting it as one would re-open E-A from the other side. (The field-existence net's deny sentinel is not null, so it flows through the probe and matches nothing — also abstain.)
  • Layer 0 (the tenant wall) stays AND-ed in. A row in another tenant is admitted by nothing, and dropping the wall here would make this the one surface in the plugin that answers across it.

Signature trade-offs (delegated to the implementer by the issue)

Optional (checkAuthoredRowWrite?) rather than required. The counter-precedent is real and I want it on the record: #6428 declared ISharingService.checkEdit / checkDelete as required members and had the consumer feature-detect at runtime, and all eleven existing ISecurityService methods are required. Three measurements moved me the other way:

  1. The ruled semantics names a missing method as a legitimate, handled state ("abstain in every other case — including a missing method"). A required declaration asserts the method is always there, which contradicts the clause it is supposed to implement. Optional makes the type system agree with the ruling.
  2. Required buys no consumer guarantee here anyway. This contract's own header already instructs consumers to feature-detect (typeof svc.getReadableFields === 'function'), and packages/rest/src/rest-server.ts types the whole service as Partial< ISecurityService >. So "required" is a promise the codebase already declines to rely on — and the repo carries a held type error in this very contract's test stub from exactly that pattern (test-typecheck-debt.json, 1 error on security-service.test.ts, tracked by @ts-expect-error 退役 pin 在 packages/spec 里是幽灵检查:tsconfig 把 **/*.test.ts 排除出唯一的 tsc --noEmit #5286).
  3. Optional prevents the mistake structurally. svc.checkAuthoredRowWrite(...) unguarded compiles under a required declaration and throws at runtime against any partial implementation; that throw would then have to be caught to reach abstain. Under an optional declaration it does not compile. There is a compile-time pin for this in security-service.test.ts.

Optional members are also an established convention on this repo's contracts for capability extensions (ai-service, analytics-service, auth-service, automation-service all use them).

Two states, not three. No deny. This surface is evidence, not a gate: the caller already holds a refusal and asks only whether a declared widener speaks for the row. "No evidence" and "evidence against" are the same instruction to that caller — keep refusing — so a third state would be one nobody could act on differently.

The fail direction is the inverse of SharingWriteVerdict's, deliberately. There a failed lookup must be deny, because abstain hands the decision on. Here the caller uses admit to widen, so the answer that changes nothing is abstain. Both are the same discipline (#6564: a failure is never a widening); read the fail direction off what the caller does with the verdict, never off the state's name. The contract says so explicitly.

Operation is the RLS write vocabulary (update / delete), not the engine verb list. A caller maps purge onto delete and transfer / restore onto update itself — the same mapping the engine's by-id write pre-image gate already applies before it collects policies. Keeping it on the caller's side means a new lifecycle verb cannot silently acquire a widening path here by being spelled into a wider union.

Reverse verification — the pin can go red

Per the dispatch: a pin that cannot fail is not a pin. Three probes, each restored afterwards (the restore was confirmed byte-identical by diffing the working patch before and after).

1. The E-A pin goes red on a naive implementation. Flipping dropPlatformOwnershipFloor to false — i.e. deferring to composed RLS with the floor included:

× ⭐ a creator-but-no-longer-owner whose authored policy does NOT match the row → abstain
  → expected 'admit' to be 'abstain' // Object.is equality
  Tests  1 failed | 12 passed (13)

2. Removing the provenance pre-check as well — the fully naive "defer to composed RLS" shape — takes both E-A pins red:

× ⭐ a creator-but-no-longer-owner whose authored policy does NOT match the row → abstain
× ⭐ a creator holding NO authored policy at all → abstain
  Tests  2 failed | 11 passed (13)

3. Downstream really reads the new .d.ts. Feeding a wrong-typed implementation ('allow') to the registration turned plugin-security's typecheck red against the freshly built spec dist, which is the check that the contract addition is not being read from a stale artefact:

src/security-plugin.ts(749,9): error TS2322: ... Type '"allow"' is not assignable to type 'AuthoredRowWriteVerdict'.

The E-A pin also carries its own control. TRANSFERRED_UPDATE_IS_ADMITTED_BY_THE_COMPOSED_PATH drives the real security middleware and proves the composed path admits the very row the verdict abstains on. Without it the abstain assertions would be refusing something nothing offers, which is no pin at all.

Tests

packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts (new, 13 cases) drives the real SecurityPlugin, the real RLS compiler and the real member_default seed — the floor has to be the shipped one, because the separation under test is provenance, not shape. Covers both E-A readings (creator with a non-matching authored policy; creator with no authored policy at all), the positive admit, the verb boundary, the tenant wall, the superuser short-circuit, and the fail-closed set (throwing probe, unknown record, unknown object, principal-less context, on-behalf-of context).

packages/spec/src/contracts/security-service.test.ts gains three compile-time/semantic pins: the closed verdict union, the closed operation union, optionality, and that absence and abstain are one instruction.

The new fake engine's update() / delete() open with assertEngineUpdateDispatch / assertEngineDeleteDispatch from @objectstack/metadata-core, copied from the pinned fake in row-write-widener-composition.test.ts.

Boundaries honoured

Relationship to the queued cards


Generated by Claude Code

claude added 2 commits August 8, 2026 18:15
…rvice (#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
…w 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
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 12:03am

Request Review

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec.

115 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security, @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 9, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 01:07
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 04c56aa Aug 9, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6735-authored-row-write-verdict branch August 9, 2026 01:22
os-zhuang pushed a commit that referenced this pull request Aug 9, 2026
…xport

`resolveI18nLabel (function)` on `./ui`. 0 breaking (nothing removed or
narrowed), 1 added — the delta `check:api-surface` asked for.

Regenerated only after a post-rebase `pnpm --filter @objectstack/spec build`:
run against the pre-rebase dist it also DROPPED `AuthoredRowWriteOperation` /
`AuthoredRowWriteVerdict` from `contracts.json`, which #6841 had added to the
source in the meantime — the AGENTS.md §9 stale-artefact trap, in mirror image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ISecurityService gains a fail-closed authored-row-write verdict (contract + plugin-security implementation) — #5493 ruling, step 1

2 participants