From 93ae49d4781445c8584f9a2894d70232328f0b64 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 16 Jul 2026 18:43:07 -0600 Subject: [PATCH 1/4] docs: record-scoped write authorization (allowDelete/allowUpdate/allowCreate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to HarperFast/harper#1842 — extends the record-scoped allowRead section with the write-side model: per-record allowDelete on conditional deletes (filter semantics), per-element allowUpdate/allowCreate on array PUTs (atomic fail semantics), this = per-row resource, async overrides supported, defaults unchanged; plus the overridden-delete() caveat. 5.2 release note included. Co-Authored-By: Claude Fable 5 --- reference/database/schema.md | 35 +++++++++++++++++++++++++++++++++ release-notes/v5-lincoln/5.2.md | 4 ++++ 2 files changed, 39 insertions(+) diff --git a/reference/database/schema.md b/reference/database/schema.md index 7ebaa1ea..8490b62b 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -558,6 +558,41 @@ How the one definition applies at each scope (when permission checking is active Constraints: the check must be synchronous, side-effect free, and fast — it can run once per candidate record visited during traversal (verdicts are memoized per query). `this` is the frozen record during per-record evaluation. A thrown exception denies that record (fail closed). On caching tables the check is enforced against the record actually returned, after any source revalidation. +### Record-Level Access Control for Writes (Record-Scoped `allowDelete` / `allowUpdate` / `allowCreate`) + + + +The write-side authorization hooks get the same record-scoped treatment when overridden. Unlike `allowRead`, they stay on the resource (records do not expose them): during per-record evaluation `this` is a per-row resource instance with the record loaded, so schema attributes read naturally (`this.ownerId`) and `super.allow*(...)` composes the role/RBAC baseline. Because the write paths are asynchronous, `async` overrides participate too (they are awaited, fail-closed). + +```javascript +export class Reports extends tables.Reports { + allowDelete(user, target, context) { + // Compose the table/RBAC grant first, so losing the role's delete denies. + if (!super.allowDelete(user, target, context)) return false; + if (user.role.permission.super_user) return true; + return this.ownerId === user.id; // per matching record + } + allowUpdate(user, updates, context) { + // `this` = the EXISTING record's resource; `updates` = the incoming data. + return this.ownerId === user.id; + } + allowCreate(user, record, context) { + // No existing record on a create — the incoming record is the parameter. + return record.ownerId === user.id; + } +} +``` + +How the overridden hooks apply (when permission checking is active, e.g. any external request): + +- **Conditional (query-shaped) `DELETE`** — an overridden `allowDelete` is evaluated once per matching record, with **filter semantics**: rows it denies (including by throwing or rejecting — fail closed) are skipped and the permitted rows are deleted, returning success rather than a blanket 403. `limit`/`offset` count _allowed_ rows, so denied rows don't consume pagination slots. +- **Array `PUT` into a collection** — with an overridden `allowUpdate`/`allowCreate`, each element is authorized individually: an element whose record already exists is checked with `allowUpdate` (`this` = the existing record's resource), a new element with `allowCreate`. Any denial fails the **whole request** with a 403 and aborts the transaction — no partial batch is committed (each element was an explicit write target, unlike a query's incidental matches). +- **Single-record writes** (`PUT`/`PATCH`/`DELETE` on an id) — unchanged: evaluated at request entry with the record loaded, so the same override works there too. + +The default (non-overridden) hooks are table-level RBAC checks and keep their single request-entry evaluation with no per-record cost — including the collection-scope insert permission governing array `PUT`s. Operations API and SQL writes are governed by role permissions only; the `allow*` hooks apply to the Resource APIs (REST and JavaScript). + +One caveat: a class that overrides `delete()` itself replaces the framework's record-scoped delete path — its query-shaped deletes keep the request-entry check (a warning is logged when this combination is detected), and the custom `delete()` is responsible for any row-level enforcement. + ### Tuning Filtered Traversal Filtered traversal is bounded by a visit budget of `ef * filterExpansion` nodes (`filterExpansion` defaults to 24). If the budget is exhausted before the result list fills — which happens when the filter matches only a tiny fraction of records — the search returns the matches found so far rather than erroring. Both knobs can be set per query: diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 8cea990a..b94ea368 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -14,6 +14,10 @@ All patch release notes for 5.2.x are available on the [releases page](https://g Vector searches combined with filters now evaluate the filter during HNSW graph traversal, so the query keeps exploring until it has enough matching nearest neighbors instead of post-filtering a fixed candidate set (which under-filled results under selective filters). Filters can come from query conditions, a JS-API `vectorFilter` function, or a record-scoped `allowRead` override — overriding `allowRead` on a table now makes it a row-level access check, evaluated per record with `this` bound to the record (closing the gap where a collection scan could return rows a single-record GET would deny). With it, a restricted user's vector search returns the k nearest records they are allowed to see. Very selective conditions automatically use an exact scan instead of graph traversal, and a `filterExpansion` visit budget bounds traversal cost. See [Vector Indexing](/reference/v5/database/schema#vector-indexing). +### Record-Scoped Write Authorization (`allowDelete` / `allowUpdate` / `allowCreate`) + +The write-side authorization hooks are now record-scoped when overridden, matching the record-scoped `allowRead` model. A conditional `DELETE` evaluates an overridden `allowDelete` once per matching record with filter semantics — permitted rows are deleted, denied rows are skipped — and an array `PUT` authorizes each element individually (`allowUpdate` for existing records, `allowCreate` for new ones), failing the whole request atomically on any denial. During per-record evaluation `this` is a per-row resource with the record loaded, so row-level checks like `this.ownerId === user.id` and `super.allow*` composition work naturally; `async` overrides are supported on these paths. Non-overridden (default RBAC) hooks keep their single request-entry check with no per-record cost. See [Record-Level Access Control for Writes](/reference/v5/database/schema#record-level-access-control-for-writes-record-scoped-allowdelete--allowupdate--allowcreate). + ## Configuration ### Replicated `set_configuration` From 351085aef96c0c3aed57bddd433d40f525c36f0f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 16 Jul 2026 21:13:57 -0600 Subject: [PATCH 2/4] docs: upgrade guidance for record-scoped allow* overrides Adds the back-compat guidance the record-scoped allow* docs were missing: what changes for an override that already exists, rather than only how the new model works. Grounded in a scan of 46 HarperFast app/template repos with allow* overrides. The three changes that alter behavior without raising an error: - a denial can become a filtered result rather than a 403 (the "special-case a role, else delegate to super" pattern is most affected) - for allowRead, `async` decides the enforcement model (traversal cannot await, so async overrides keep the single entry check) - array PUT now consults allowUpdate/allowCreate overrides that a collection-scope insert check previously bypassed Also documents that `context` is the same instance across per-record evaluations, so per-request work can be memoized on it. --- reference/database/schema.md | 76 +++++++++++++++++++++++++++++++++ release-notes/v5-lincoln/5.2.md | 12 ++++++ 2 files changed, 88 insertions(+) diff --git a/reference/database/schema.md b/reference/database/schema.md index 8490b62b..650f0763 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -593,6 +593,82 @@ The default (non-overridden) hooks are table-level RBAC checks and keep their si One caveat: a class that overrides `delete()` itself replaces the framework's record-scoped delete path — its query-shaped deletes keep the request-entry check (a warning is logged when this combination is detected), and the custom `delete()` is responsible for any row-level enforcement. +### Upgrading Existing `allow*` Overrides + + + +Record scoping changes how an existing override behaves, so an application moving to 5.2 should review any `allow*` hook it defines. The change is deliberately narrow and most applications need no change at all — but a few of the cases below alter behavior _silently_ rather than raising an error, so they are worth an explicit look. + +#### Does this affect my application? + +Only if a class that **extends a table** (`tables.MyTable` or `databases.myDb.MyTable`) overrides an `allow*` hook. Two common situations are unaffected: + +- **An `allow*` override on a plain `Resource` subclass.** A custom endpoint that reads or writes tables internally (`tables.MyTable.get(...)`, `.put(...)`, `.search(...)`) rather than extending one is not affected — record scoping applies to a table's own query and write paths, which a plain `Resource` never enters. This is the most common shape by far, so most applications with `allow*` overrides are untouched. +- **A table that does not override the hook.** Defaults keep their single request-entry RBAC check, unchanged and with no per-record cost. + +If you do override a hook on a table, two questions decide whether anything changes: does the override consult the record (via `this`), and does anything call that table with a collection read, an array `PUT`, or a conditional `DELETE`? + +#### A denial can become a filtered result instead of an error + +This is the change most likely to surprise, because the request still succeeds. Where an overridden hook previously produced one verdict for the whole request, a denial returned `403`. Under record scoping the verdict is per record, so denied rows are simply _omitted_: + +- A collection read whose overridden `allowRead` denies every record now returns `200` with an empty (or shorter) array instead of `403`. +- A conditional `DELETE` whose overridden `allowDelete` denies every match now returns success having deleted nothing, instead of `403`. + +The verdicts themselves are unchanged — no one gains access — but callers, tests, or monitoring that assert on a `403` will see a success response instead. The pattern most affected delegates the general case to the base class: + +```javascript +export class KVData extends tables.KVData { + allowRead(user, target, context) { + if (user?.role?.role === 'auditor') return true; + return super.allowRead(user, target, context); // ← RBAC denials now filter, not 403 + } +} +``` + +Here the override exists to special-case one role, but its presence makes the hook record-scoped for _every_ request, so an ordinary user without table read permission receives an empty `200` rather than a `403`. If you depend on the error, check the permission before the query (or at a `Resource` endpoint in front of it) rather than relying on the hook's response shape. + +#### For `allowRead`, `async` decides the enforcement model + +Per-record read evaluation happens during query traversal, which cannot await. So an `allowRead` declared `async` **cannot** be record-scoped: it keeps the single request-entry check at collection scope (and logs a warning once per class). A synchronous override with identical logic _is_ record-scoped. + +Adding or removing `async` on an `allowRead` therefore silently switches it between "one verdict for the request, `403` on denial" and "one verdict per record, denied rows filtered". Make the override synchronous if you want row-level filtering; keep it `async` only if it must make a whole-request decision. This applies to reads alone — the write paths are asynchronous, so `async` `allowUpdate`/`allowCreate`/`allowDelete` overrides participate in per-record evaluation normally. + +#### Array `PUT` consults hooks that previously never ran + +Before 5.2, an array `PUT` into a collection was authorized by a single collection-scope check against the insert permission. In 5.2, if the table overrides `allowUpdate` **or** `allowCreate`, every element is checked individually — existing records through `allowUpdate`, new records through `allowCreate`. + +Two consequences: an override that was never consulted on this path may now run for the first time, and overriding only one hook of the pair leaves the other at its RBAC default. A table that overrides `allowUpdate` alone will authorize new elements with the _default_ `allowCreate`. Review both hooks together, and note that any single denial fails the whole request with a `403` and commits no part of the batch. + +#### A record-independent override still runs per record + +An override that consults only `user` and `context` and never reads `this` returns the same verdict for every record. It stays correct, but it gains nothing from record scoping while still being called once per row — and it is subject to the filtered-result change above. To get actual row-level filtering, the override must consult the record through `this`. + +#### Per-record cost, and caching per-request work + +An overridden hook runs once per candidate record, so expensive work inside it (a lookup, a fetch) now repeats per row. The `context` object is the **same instance** across every per-record evaluation of a request, which makes it the right place to memoize per-request work: + +```javascript +const ROLE_CACHE = Symbol('roleCache'); + +export class Reports extends tables.Reports { + allowRead(user, target, context) { + let role = context[ROLE_CACHE]; + if (role === undefined) role = context[ROLE_CACHE] = lookUpRole(user); // once per request + return role.canSee(this.ownerId); + } +} +``` + +#### Checklist + +1. List the classes that extend a table _and_ override an `allow*` hook. Anything extending a plain `Resource` can be skipped. +2. For each, decide whether the override is meant to be a **row-level** decision (consults `this`) or a **whole-request** one. Make `allowRead` synchronous for the former; leave it `async` for the latter. +3. Check whether any caller does a collection read, an array `PUT`, or a conditional `DELETE` against that table — including the automatic REST routes an `@export`ed table exposes, not just your own code paths. +4. Update anything asserting on a `403` from those paths, which may now be a filtered `200`. +5. If you override `allowUpdate` or `allowCreate`, review the pair together. +6. Move expensive per-request work in a hook onto `context`, as above. + ### Tuning Filtered Traversal Filtered traversal is bounded by a visit budget of `ef * filterExpansion` nodes (`filterExpansion` defaults to 24). If the budget is exhausted before the result list fills — which happens when the filter matches only a tiny fraction of records — the search returns the matches found so far rather than erroring. Both knobs can be set per query: diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index b94ea368..6a2ec257 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -18,6 +18,18 @@ Vector searches combined with filters now evaluate the filter during HNSW graph The write-side authorization hooks are now record-scoped when overridden, matching the record-scoped `allowRead` model. A conditional `DELETE` evaluates an overridden `allowDelete` once per matching record with filter semantics — permitted rows are deleted, denied rows are skipped — and an array `PUT` authorizes each element individually (`allowUpdate` for existing records, `allowCreate` for new ones), failing the whole request atomically on any denial. During per-record evaluation `this` is a per-row resource with the record loaded, so row-level checks like `this.ownerId === user.id` and `super.allow*` composition work naturally; `async` overrides are supported on these paths. Non-overridden (default RBAC) hooks keep their single request-entry check with no per-record cost. See [Record-Level Access Control for Writes](/reference/v5/database/schema#record-level-access-control-for-writes-record-scoped-allowdelete--allowupdate--allowcreate). +### Upgrading Applications That Override `allow*` + +Record scoping (both the read and write hooks above) changes the behavior of `allow*` overrides that already exist, so applications upgrading to 5.2 should review any hook they define. Most are unaffected — the change reaches only classes that **extend a table** and override a hook, so the very common pattern of an `allow*` override on a plain `Resource` subclass that reads and writes tables internally is untouched, as is any table left on the default RBAC hooks. + +Three behavior changes are worth checking explicitly, because they do not raise an error: + +- **A denial can become a filtered result rather than a `403`.** A per-record verdict omits denied rows instead of rejecting the request, so a collection read or conditional `DELETE` that previously returned `403` may now return a successful response with fewer (or zero) rows. No one gains access, but callers and tests asserting on the error see a success. This most often shows up in overrides that special-case one role and delegate the rest to `super.allowRead(...)`. +- **For `allowRead`, `async` decides the model.** Per-record read evaluation happens during query traversal, which cannot await, so an `async allowRead` keeps its single request-entry check (and logs a warning) while a synchronous one becomes record-scoped. Adding or removing `async` silently switches enforcement between the two. Write hooks are unaffected by this — their paths are asynchronous, so `async` overrides are record-scoped normally. +- **Array `PUT` now consults hooks that previously never ran.** These writes used to be authorized by a single collection-scope insert check; now each element is checked individually, so an overridden `allowUpdate`/`allowCreate` may run on this path for the first time — and overriding only one hook of the pair leaves the other at its RBAC default. + +See [Upgrading Existing `allow*` Overrides](/reference/v5/database/schema#upgrading-existing-allow-overrides) for the full checklist, including per-record cost and how to memoize per-request work on `context`. + ## Configuration ### Replicated `set_configuration` From f5c0ddfc0e15c4d518b422eade47e7322201ce24 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 17 Jul 2026 05:20:39 -0600 Subject: [PATCH 3/4] docs: proper release-note entry for record-scoped allowRead; group the allow* change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowRead change was only mentioned inside the Filtered Vector Search paragraph, where nobody scanning 5.2 for authorization changes would find it — and the upgrade section referred to 'the read and write hooks above' when the read hook had no entry above. The read, write, and upgrade notes now live under one Record-Scoped Authorization section; the vector-search paragraph links to it instead of carrying the exposition. --- release-notes/v5-lincoln/5.2.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 6a2ec257..c248dfcc 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -12,11 +12,21 @@ All patch release notes for 5.2.x are available on the [releases page](https://g ### Filtered Vector Search (Predicate-Aware HNSW Traversal) -Vector searches combined with filters now evaluate the filter during HNSW graph traversal, so the query keeps exploring until it has enough matching nearest neighbors instead of post-filtering a fixed candidate set (which under-filled results under selective filters). Filters can come from query conditions, a JS-API `vectorFilter` function, or a record-scoped `allowRead` override — overriding `allowRead` on a table now makes it a row-level access check, evaluated per record with `this` bound to the record (closing the gap where a collection scan could return rows a single-record GET would deny). With it, a restricted user's vector search returns the k nearest records they are allowed to see. Very selective conditions automatically use an exact scan instead of graph traversal, and a `filterExpansion` visit budget bounds traversal cost. See [Vector Indexing](/reference/v5/database/schema#vector-indexing). +Vector searches combined with filters now evaluate the filter during HNSW graph traversal, so the query keeps exploring until it has enough matching nearest neighbors instead of post-filtering a fixed candidate set (which under-filled results under selective filters). Filters can come from query conditions, a JS-API `vectorFilter` function, or a record-scoped `allowRead` override (see [Record-Scoped Authorization](#record-scoped-authorization) below) — with it, a restricted user's vector search returns the k nearest records _they are allowed to see_ rather than "nearest k, minus redacted". Very selective conditions automatically use an exact scan instead of graph traversal, and a `filterExpansion` visit budget bounds traversal cost. See [Vector Indexing](/reference/v5/database/schema#vector-indexing). + +## Record-Scoped Authorization + +The `allow*` authorization hooks on tables are now **record-scoped when overridden**: instead of a single collection-scope verdict per request, an application-overridden hook is evaluated once per record, with access to that record's fields. Framework defaults are unchanged — a table that does not override a hook keeps its single request-entry RBAC check with no per-record cost — and the change applies only to classes that extend a table; a plain `Resource` subclass defines its own semantics and keeps the single entry check. + +### Row-Level Read Access Control (`allowRead`) + +Overriding `allowRead` on a table now makes it a row-level access check. During collection queries — including scans, searches, and vector traversals — it is evaluated once per candidate record with `this` bound to the (frozen) record, so checks like `return this.ownerId === user.id` read naturally; rows it denies are filtered out of the results. This closes the gap where a collection scan could return rows a single-record `GET` would deny. Single-record `get(id)` keeps its request-entry evaluation with the record loaded (a denied record returns a `403`), and subscriptions grant the connection at subscribe time, then filter delivery per event so a subscriber receives only the row changes the check permits — with live subscriptions periodically re-authorized so a revoked grant tears the subscription down. + +Per-record evaluation requires a synchronous override (query traversal cannot await): an `async allowRead` keeps the single request-entry check and logs a warning. Verdicts are memoized per query, and a thrown exception denies the record (fail closed). See [Record-Level Access Control](/reference/v5/database/schema#record-level-access-control-record-scoped-allowread). ### Record-Scoped Write Authorization (`allowDelete` / `allowUpdate` / `allowCreate`) -The write-side authorization hooks are now record-scoped when overridden, matching the record-scoped `allowRead` model. A conditional `DELETE` evaluates an overridden `allowDelete` once per matching record with filter semantics — permitted rows are deleted, denied rows are skipped — and an array `PUT` authorizes each element individually (`allowUpdate` for existing records, `allowCreate` for new ones), failing the whole request atomically on any denial. During per-record evaluation `this` is a per-row resource with the record loaded, so row-level checks like `this.ownerId === user.id` and `super.allow*` composition work naturally; `async` overrides are supported on these paths. Non-overridden (default RBAC) hooks keep their single request-entry check with no per-record cost. See [Record-Level Access Control for Writes](/reference/v5/database/schema#record-level-access-control-for-writes-record-scoped-allowdelete--allowupdate--allowcreate). +The write-side authorization hooks get the same treatment when overridden. A conditional `DELETE` evaluates an overridden `allowDelete` once per matching record with filter semantics — permitted rows are deleted, denied rows are skipped — and an array `PUT` authorizes each element individually (`allowUpdate` for existing records, `allowCreate` for new ones), failing the whole request atomically on any denial. During per-record evaluation `this` is a per-row resource with the record loaded, so row-level checks like `this.ownerId === user.id` and `super.allow*` composition work naturally; unlike `allowRead`, the write paths are asynchronous, so `async` overrides participate in per-record evaluation too. See [Record-Level Access Control for Writes](/reference/v5/database/schema#record-level-access-control-for-writes-record-scoped-allowdelete--allowupdate--allowcreate). ### Upgrading Applications That Override `allow*` From 46938388f8a9237f4f8ba9cda38890261caa2df6 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 19 Jul 2026 22:44:33 -0600 Subject: [PATCH 4/4] docs: address PR #593 review feedback - Scope the upgrade-checklist 403->200 note to collection reads and conditional DELETE; array PUT denials still return 403 (the change there is only that the hook may now run at all). - Clarify that `user` is undefined (not an object with a falsy id) for an unauthenticated caller, and how the allowDelete vs allowUpdate/allowCreate examples differ in how they fail closed for that case. Co-Authored-By: Claude Sonnet 5 --- reference/database/schema.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/reference/database/schema.md b/reference/database/schema.md index 650f0763..5855077e 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -593,6 +593,8 @@ The default (non-overridden) hooks are table-level RBAC checks and keep their si One caveat: a class that overrides `delete()` itself replaces the framework's record-scoped delete path — its query-shaped deletes keep the request-entry check (a warning is logged when this combination is detected), and the custom `delete()` is responsible for any row-level enforcement. +For an unauthenticated caller, `user` itself is `undefined` — not an object with a falsy `id`. Leading with `super.allow*(user, ...)`, as the `allowDelete` example does, denies before any `user.*` access runs, since the default checks are written with `user?.`. An override that skips that call and dereferences `user.id` directly, as the `allowUpdate`/`allowCreate` examples do, will throw for an unauthenticated caller instead — which still fails closed (denies), just via an exception rather than an explicit `false`. + ### Upgrading Existing `allow*` Overrides @@ -665,7 +667,7 @@ export class Reports extends tables.Reports { 1. List the classes that extend a table _and_ override an `allow*` hook. Anything extending a plain `Resource` can be skipped. 2. For each, decide whether the override is meant to be a **row-level** decision (consults `this`) or a **whole-request** one. Make `allowRead` synchronous for the former; leave it `async` for the latter. 3. Check whether any caller does a collection read, an array `PUT`, or a conditional `DELETE` against that table — including the automatic REST routes an `@export`ed table exposes, not just your own code paths. -4. Update anything asserting on a `403` from those paths, which may now be a filtered `200`. +4. Update anything asserting on a `403` from a collection read or conditional `DELETE`, which may now be a filtered `200`. Array `PUT` denials still return `403` — the change there is that the hook may now run for the first time (item 3). 5. If you override `allowUpdate` or `allowCreate`, review the pair together. 6. Move expensive per-request work in a hook onto `context`, as above.