-
Notifications
You must be signed in to change notification settings - Fork 9
docs: record-scoped write authorization (allowDelete/allowUpdate/allowCreate) (5.2) #593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
93ae49d
351085a
f5c0ddf
4693838
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -558,6 +558,119 @@ 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`) | ||||||
|
|
||||||
| <VersionBadge version="v5.2.0" /> | ||||||
|
|
||||||
| 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; | ||||||
| } | ||||||
|
kriszyp marked this conversation as resolved.
|
||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| 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). | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defaults aren't fully unchanged: the create-within-record permission moved from
Suggested change
|
||||||
|
|
||||||
| 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. | ||||||
|
kriszyp marked this conversation as resolved.
|
||||||
|
|
||||||
| 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`. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: this clarification documents a side effect of the RBAC-bypass gap without closing it This paragraph explains that skipping Suggested fix: add —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The allow* methods are intended to programmatically provide "grants" to users otherwise not allowed (hence "allow"), not restricting users who already have permission (and could otherwise access the content through other means). |
||||||
|
|
||||||
| ### Upgrading Existing `allow*` Overrides | ||||||
|
|
||||||
| <VersionBadge version="v5.2.0" /> | ||||||
|
|
||||||
| 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 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. | ||||||
|
|
||||||
| ### 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: | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -12,7 +12,33 @@ 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Framework defaults are unchanged" isn't quite right — the create-within-record RBAC operand changed (see #1842). Suggested rewording that keeps the record-scoping point but adds the operand change:
Suggested change
|
||||||
|
|
||||||
| ### 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 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*` | ||||||
|
|
||||||
| 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 | ||||||
|
|
||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.