Skip to content

Record-scoped write authorization: per-record allowDelete on query deletes, per-element allow* on array puts#1842

Open
kriszyp wants to merge 12 commits into
mainfrom
kris/record-level-allow-write
Open

Record-scoped write authorization: per-record allowDelete on query deletes, per-element allow* on array puts#1842
kriszyp wants to merge 12 commits into
mainfrom
kris/record-level-allow-write

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 17, 2026

Copy link
Copy Markdown
Member

Write-side corollary to #1786 (Record-scoped allowRead): allowUpdate/allowCreate/allowDelete were enforced once per request on multi-record writes, so a record-scoped override was silently under-enforced on conditional deletes and array puts.

The model

When loadAsInstance !== false, this mirrors #1786's marker/deferral design with write-path semantics:

  • Query-shaped delete with an application-overridden allowDelete (marker isDefaultAllowDelete) defers its entry check and is enforced once per matching record — FILTER semantics: denied (or throwing/rejecting — fail closed) rows are skipped, permitted rows deleted; limit/offset count allowed rows, mirroring the read guard filtering before the query limit.
  • Array puts (REST collection PUT and direct instance put) with an overridden allowUpdate/allowCreate are authorized per element — create-vs-update chosen by that element's existence — with FAIL semantics: one denial throws AccessViolation, aborting the whole transaction (no partial batch).
  • this binding: mutating allow* hooks stay on the resource (no RecordObject delegate); per-record evaluation binds this to a per-row resource instance with the record loaded, so this.ownerId and super.allow* composition both work (the SWR-instance idiom from allowStaleWhileRevalidate is never consulted for query-driven revalidation #1578).
  • Async overrides participate: the write paths are async, so unlike the sync search traversal in Record-scoped allowRead: unified row-level read access control (#1422 gap 2) #1786, async allow* overrides are awaited per record (fail-closed).
  • Default allowCreate consistently means INSERT: record creation and record-targeted publish both check INSERT RBAC; publish creates an audit/message entry and does not update the record. Framework-default array puts still keep their single collection-entry check.
  • The deferred put check runs in the static put action against the resolved body, so a promise body can't bypass it and enforcement is framework-owned for every resource class. A subclass overriding delete() loses the enforcesCheckPermission marker → keeps today's entry check (warned once per class).

loadAsInstance === false

False mode retains the HarperDB 4.7 / Harper 5.1 request-scope behavior rather than adopting the per-record model above:

  • array PUT calls allowUpdate once with the original whole batch, then starts writes only after a truthy grant
  • query-shaped DELETE prepares the selecting projection ($id), calls allowRead once on the collection receiver, clears the framework permission marker, and scans without allowDelete or per-row rechecks
  • record-targeted publish calls allowCreate once because it creates an audit/message entry
  • false results, synchronous throws, and rejected promises fail closed before any write/event is staged

The loadAsInstance !== false behavior in this PR is unchanged by the compatibility follow-up.

Deliberate compatibility caveat: a false-mode array PUT uses one whole-batch allowUpdate, including all-insert and mixed insert/update batches. It does not apply instance mode's per-element create-vs-update distinction.

Deliberately out of scope: operations-API/SQL writes stay on operation-level RBAC (they never arm checkPermission; raw tables have no override surface).

Where to look

  • resources/Resource.ts — the instance-mode write-deferral gate in the authorize wrapper and the per-element checks in the static put action.
  • resources/Table.ts — record-scoped query delete/array put behavior plus the false-mode request gates.
  • resources/DatabaseTransaction.ts / LMDBTransaction.ts — aborted transactions are terminally poisoned: addWrite()/save()/commit() reject, while successfully completed transactions retain their existing post-commit behavior; abort cascades across the multi-database chain.

Validation

  • TypeScript build
  • oxlint on changed production/tests
  • Prettier and git diff --check
  • focused authorization suite: 30 passing
  • full core unit suite: 3,799 passing, 188 pending
  • full resource unit suite: 1,285 passing, 14 pending; one unrelated caching-count failure after concurrent suite runs passed immediately in isolation
  • prior integration suite: 1,552 passing, 0 assertion failures, 17 skipped; nonzero only because the optional real-Ollama fixture hit the existing Node ERR_IMPORT_ATTRIBUTE_MISSING startup issue and cancelled its six children

Review

Thorough review plus a final post-fix artifact pass found no remaining blockers or significant concerns. Review verified that the compatibility follow-up changes only the false-mode branch and caught the legacy ordering requirement that $id be visible to allowRead; that ordering is now covered by regression.

Remaining accepted limitations:

  • Commit-retry TOCTOU (ownership changes between authorization and a conflict-retried commit) is a pre-existing gap across all single-record auth — follow-up issue to file, not widened here.
  • Approximate-index (HNSW) conditional deletes filter after the bounded candidate set, and denied-heavy deletes with limit scan the full matching set — same class of limitation as the Record-scoped allowRead: unified row-level read access control (#1422 gap 2) #1786 read filter.

Docs: companion PR HarperFast/documentation#593 (extends the #1786 allowRead docs page with the write-side model).

Generated with GPT-5.6 Codex.

@kriszyp
kriszyp requested review from Ethan-Arrowood and heskew July 17, 2026 00:28

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces record-scoped write authorization as a write-side corollary to record-scoped read authorization. It defers permission checks for collection-shaped writes (array PUTs and query-shaped DELETEs) to evaluate them per-record or per-element when application-overridden allowDelete, allowUpdate, or allowCreate hooks are present. Query-shaped deletes now utilize filter semantics (skipping denied rows), while array PUTs enforce fail-fast semantics (aborting the transaction on any denial). The feedback recommends improving the thenable check in Resource.ts by verifying that then is a function rather than just checking its truthiness.

Comment thread resources/Resource.ts Outdated
Comment thread resources/Resource.ts
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found. New push since the last review resolves the merge conflict and adds the false-mode (loadAsInstance === false) compatibility fixes (query-delete auth via allowRead, array-put auth via a single allowUpdate, publish auth via allowCreate instead of allowDelete) — all match documented legacy behavior and are covered by new tests.

@heskew heskew left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both notes are specific to tables that override an allow* hook — the default path is correct and covered by t8–t14. An override re-routes authorization through the per-element resource, and that's where the two issues live. Left inline.

🤖 Draft prepared with Claude (cross-model review pipeline); reviewed and posted by @heskew.

Comment thread resources/Table.ts
Comment thread resources/Table.ts Outdated
Comment thread resources/Table.ts
@kriszyp
kriszyp force-pushed the kris/record-level-allow-write branch from 21eeb09 to 280b872 Compare July 22, 2026 00:01
@Ethan-Arrowood

Copy link
Copy Markdown
Member

Please resolve the merge conflict and then re-request reviews. Thanks!

sent with Claude Opus 4.8

kriszyp and others added 10 commits July 23, 2026 14:51
…letes, per-element allow* on array puts

Write-side corollary to #1786 (record-scoped allowRead), closing the gaps where
allowUpdate/allowCreate/allowDelete were enforced once per request on multi-record
writes:

- Query-shaped delete with an application-overridden allowDelete defers its entry
  check (gated on the framework delete's enforcesCheckPermission marker, so a
  subclass overriding delete() keeps the entry check) and is enforced once per
  matching record with FILTER semantics: denied/throwing rows are skipped;
  limit/offset count allowed rows. `this` is a per-row resource instance, so
  schema properties and super.allowDelete composition both work, and the
  target-supplied permission operand is preserved for the RBAC baseline.
- Array puts (static collection put and direct instance put) are authorized per
  element — create-vs-update chosen by that element's existence — with FAIL
  semantics: one denial throws AccessViolation, aborting the whole transaction.
  The deferred check runs in the static put action against the RESOLVED body, so
  a promise body can't bypass it and enforcement is framework-owned for every
  resource class.
- The write paths are async, so unlike the record-scoped allowRead, async
  overrides participate in per-record evaluation (awaited, fail-closed).
- transactional() argument parsing now carries the collection-ness it determined
  onto the fallback-constructed query, so programmatic `put(records, context)`
  resolves a collection resource like the equivalent REST target.

Operations-API/SQL writes deliberately keep operation-level RBAC only (they never
arm checkPermission; raw tables have no override surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… params in finally

The limit gate ran after _writeDelete, so limit=0 deleted one row (the default
query-delete path deletes zero). Check before writing. Also restore
select/limit/offset/checkPermission on the target in a finally, so the
per-record hooks (and an erroring search) see the original target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…able record-scoped allowDelete

Review finding (cross-model adjudication): the un-gated put deferral moved
default-table array-PUT create authorization from the collection-scope INSERT
operand to the per-element row-resource path (allowCreate delegating to
allowUpdate = UPDATE operand) — a reachable RBAC behavior change. Defer only
when allowUpdate/allowCreate is application-overridden, mirroring the delete
gate; default-RBAC tables keep today's single entry check exactly (test pins
the insert operand). Also warn once per class when a record-scoped allowDelete
coexists with an overridden delete() — that combination silently keeps the
collection-scope entry check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ture lint

CI exposed a latent bug the per-element path exercises: the static put loop's
async branch called put(element, request) — the context is not a
URLSearchParams, so put()'s legacy argument shift never engaged and the CONTEXT
was written as the record body whenever getResource resolved asynchronously
(cyclic object → encoder stack overflow). Both branches now pass the query,
matching the sync branch. Deterministic regression test forces the async
branch via a getResource override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t resources

Review comments: cover both legs of the overridden-delete() gate (fail-closed
entry denial with the custom delete never reached; collection-permissive
override → documented downgrade to entry-scope semantics), and detect async
element resources via typeof .then — a record attribute named `then` reads
through the resource attribute proxy, so truthiness alone could misroute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lain Resource keeps its entry check

Record scoping is only meaningful where records exist. The put deferral had
no table gate, so a plain Resource subclass overriding allowUpdate/allowCreate
on a collection array put would have its entry check rerouted into per-element
evaluation — N calls with each element instead of one call with the array, on
classes that define their own class/instance semantics. The delete branch was
behavior-safe (enforcesCheckPermission is table-only) but logged a misleading
record-scoping warning for plain Resource subclasses.

Both write branches now gate on a class-level supportsRecordScopedWriteAuth
marker (the write-side analogue of supportsRowLevelAllowRead), set only by
makeTable. Tests pin the plain-Resource contract: single entry check receiving
the whole array, per-element put dispatch unchanged, no deferral on delete.
Format Check failed on the new record-scoped-write-auth DESIGN.md row
(unformatted markdown table) — run prettier --write to fix.

The Next.js adapter integration jobs (Node 20/22/24) failed on a
pre-existing TS2345 in DatabaseTransaction.ts (commitResolution can be
Promise<number | void> on coordinated retry, recordCommitLatency only
accepted Promise<void>). Harper's own CI tolerates this with `npm run
build || true`, but the adapter workflow does not. Apply the same fix
already validated on #1890 (open, unmerged): widen the
parameter to Promise<unknown> since the function only reads settlement
timing and never touches the resolved value.

Integration Tests 3/6 (uWS HTTP) fails on an unrelated pre-existing flake
(Blob lifecycle drop_table "Invalid column family specified in write
batch") — confirmed present on main as of 2026-07-20, unaffected by this
branch's diff. Left as-is; expected to pass on rerun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/record-level-allow-write branch from 6d0d9bc to d5db732 Compare July 23, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants