From a501b0fce532f06bf19c40b6d6fbd95f2fe6346e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 26 Jul 2026 16:13:42 -0600 Subject: [PATCH 1/2] docs: describe operation authorization and row filters --- reference/database/schema.md | 57 +++++++++++++++++++++++---------- release-notes/v5-lincoln/5.2.md | 6 +++- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/reference/database/schema.md b/reference/database/schema.md index 7ebaa1ea..924d71da 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -528,35 +528,58 @@ let results = Document.search( `vectorFilter` is available from the JavaScript API only (it cannot be expressed in a REST query string). The function receives the candidate record and must return a boolean — `true` to include the record in results, `false` to exclude it (it still routes traversal either way). It must be synchronous, side-effect free, and fast — it can run once per candidate record visited during traversal (verdicts are memoized per query). Records passed to it are frozen. -### Record-Level Access Control (Record-Scoped `allowRead`) +### Row-Level Access Control with Explicit Filters -Overriding `allowRead(user, target, context)` on a table resource makes it a **record-scoped** check: during query execution it is evaluated once per record with `this` bound to the record, so row-level logic reads naturally from `this`. For vector queries the check participates in the graph traversal, so a restricted user receives the k nearest records _they are allowed to see_ rather than "nearest k, minus redacted" (which under-fills results and reveals that nearby restricted records exist): +Use a `rowFilter` function on a search target or subscription request when access depends on each record. Attach the filter in an operation override after deciding that the request itself is allowed to proceed. This keeps authorization ahead of query planning and table scans while still filtering the returned or delivered rows: ```javascript export class Reports extends tables.Reports { - allowRead(user, target, context) { - // Compose the table/RBAC grant first, so losing the role's read denies (at request entry and - // when a live subscription is re-authorized). super.allowRead is safe to call at any scope. - if (!super.allowRead(user, target, context)) return false; - if (user.role.permission.super_user) return true; - // Collection scope — a whole-table subscribe or the subscription re-auth check — has no record - // loaded (`this.ownerId` is undefined). Return true to open the connection; rows are filtered - // per record during delivery / query execution. - if (this.ownerId == null) return true; - return this.ownerId === user.id; // per record + get(target) { + const context = this.getContext(); + if (target.isCollection) { + target.rowFilter = (record, liveContext) => record.ownerId === liveContext.user.id; + } else if (this.ownerId !== context.user.id) { + return new Response(null, { status: 404 }); + } + return super.get(target); } + + search(target) { + target.rowFilter = (record, context) => record.ownerId === context.user.id; + return super.search(target); + } + + subscribe(request) { + request.rowFilter = (record, context) => record.ownerId === context.user.id; + return super.subscribe(request); + } +} +``` + +`rowFilter` receives the candidate record and the live request or subscription context. It must be synchronous, side-effect free, and fast. Ordinary object and array records are passed as shallow read-only views. Throwing an error or returning a promise aborts a query; on a subscription it terminates the stream. The filter is enforced across normal index and range searches, OR queries, HNSW traversal, source-revalidated records, subscription snapshots, and live row events. + +For vector queries, `rowFilter` participates in HNSW traversal. A caller therefore receives the k nearest _matching_ records rather than "nearest k, minus filtered records." `rowFilter` can apply to every candidate visited; prefer indexed query conditions for predicates that can be expressed declaratively. + +`rowFilter` applies only to authoritative row events. Delete tombstones and published messages may not contain the complete current record. A subscription that needs those events can also provide an `eventFilter`: + +```javascript +subscribe(request) { + request.rowFilter = (record, context) => record.ownerId === context.user.id; + request.eventFilter = (event, context) => + event.type === 'put' || + event.type === 'invalidate' || + (event.type === 'delete' && String(event.id).startsWith(`${context.user.tenantId}:`)); + return super.subscribe(request); } ``` -How the one definition applies at each scope (when permission checking is active, e.g. any external request): +`eventFilter` receives a shallow read-only view of every non-control event and the live context. It has the same synchronous and fail-closed requirements as `rowFilter` and is composed with it for authoritative row events. When a subscription has a `rowFilter`, non-row events are withheld unless an `eventFilter` explicitly accepts them. Transaction and reload control events continue to pass so the subscription remains coherent. -- **Collection queries** (REST collection `GET`, `search()`, including vector sorts) — evaluated per record; rows failing the check are filtered out of results. The default (non-overridden) `allowRead` is a table-level RBAC check and continues to run once at request entry with no per-record cost. -- **Single-record `get(id)`** — evaluated at request entry with the record loaded (attribute reads like `this.ownerId` resolve against the record); a denied record returns a 403. -- **Subscriptions** (SSE, WebSocket, MQTT) — the entry check grants the connection at subscribe time (evaluated at collection scope — return the base grant to open), then delivery is filtered per event so a subscriber receives only the row-change events for records the check permits. A live subscription is periodically re-authorized by re-running this same `allowRead` against the current user, so revoking the role's read (or, for a connection-level override, its grant) tears the subscription down. Delete tombstones and published message payloads do not carry the full record, so an override keyed on row fields will deny those event types. +The legacy `allowRead`, `allowUpdate`, `allowCreate`, and `allowDelete` hooks are deprecated operation-level gates. When permission checking is active, Harper's standard instance flow evaluates the relevant hook once for the operation rather than once per row. Built-in table handlers with `loadAsInstance = false` likewise use one request/collection-scoped verdict; custom false-mode handlers remain responsible for authorization unless they delegate to those built-in handlers. -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. +Put application authorization and access-control decisions that need the complete target and context in operation overrides such as `get`, `put`, and `delete`. Collection operation overrides run before query planning or table scans and can attach a `rowFilter` or indexed conditions when an admitted request needs row-level narrowing. In the default single-record `get` flow, the record instance is loaded before the operation override runs, so its fields are available through `this`. ### Tuning Filtered Traversal diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 8cea990a..0afe11d0 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -12,7 +12,11 @@ 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 or JavaScript `vectorFilter` and `rowFilter` functions. `rowFilter` is an explicit query/subscription predicate that applications attach after admitting the operation; it also filters normal searches and subscription delivery. 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). + +## Authorization + +The legacy `allowRead`, `allowUpdate`, `allowCreate`, and `allowDelete` hooks remain one-time operation gates and are deprecated in favor of operation overrides, which receive the complete target and can access the request context through `getContext()`. This restores the pre-5.2 contract: collection admission occurs before query planning or a table scan starts. For permission-checked built-in table operations with `loadAsInstance = false`, query deletes call `allowDelete` once before scanning rather than using `allowRead`, and publishes call `allowCreate` once. Custom false-mode handlers remain responsible for authorization unless they delegate to the built-in handlers. ## Configuration From 7db0e12bc30e919b926362bbc8560a839f7ec682 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 26 Jul 2026 18:56:16 -0600 Subject: [PATCH 2/2] Address authorization documentation review --- reference/database/schema.md | 43 +++++++++++++++++++++------- reference/mcp/tool-metadata.md | 2 +- reference/mcp/tools-and-resources.md | 6 ++-- reference/resources/resource-api.md | 5 +++- release-notes/v5-lincoln/5.2.md | 2 +- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/reference/database/schema.md b/reference/database/schema.md index 924d71da..18036544 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -535,52 +535,73 @@ let results = Document.search( Use a `rowFilter` function on a search target or subscription request when access depends on each record. Attach the filter in an operation override after deciding that the request itself is allowed to proceed. This keeps authorization ahead of query planning and table scans while still filtering the returned or delivered rows: ```javascript +function canReadReport(record, context) { + const user = context.user; + if (user?.role?.permission?.super_user) return true; + return user?.username != null && record.ownerId != null && record.ownerId === user.username; +} + export class Reports extends tables.Reports { get(target) { const context = this.getContext(); if (target.isCollection) { - target.rowFilter = (record, liveContext) => record.ownerId === liveContext.user.id; - } else if (this.ownerId !== context.user.id) { + target.rowFilter = canReadReport; + } else if (!canReadReport(this, context)) { return new Response(null, { status: 404 }); } return super.get(target); } search(target) { - target.rowFilter = (record, context) => record.ownerId === context.user.id; + target.rowFilter = canReadReport; return super.search(target); } subscribe(request) { - request.rowFilter = (record, context) => record.ownerId === context.user.id; + request.rowFilter = canReadReport; return super.subscribe(request); } } ``` -`rowFilter` receives the candidate record and the live request or subscription context. It must be synchronous, side-effect free, and fast. Ordinary object and array records are passed as shallow read-only views. Throwing an error or returning a promise aborts a query; on a subscription it terminates the stream. The filter is enforced across normal index and range searches, OR queries, HNSW traversal, source-revalidated records, subscription snapshots, and live row events. +`rowFilter` is available only from the JavaScript API; clients cannot set it through REST or QUERY request data. It receives the candidate record and the live request or subscription context. It must be synchronous, side-effect free, and fast. Ordinary object and array records are passed as shallow read-only views. Throwing an error or returning a promise aborts a query; on a subscription it terminates the stream. The filter is enforced across normal index and range searches, OR queries, HNSW traversal, source-revalidated records, subscription snapshots, and live row events. + +`rowFilter` does not apply to a direct primary-key `get`. The example performs that check in `get()` after the default instance-loading flow has loaded the record. It therefore assumes the default `loadAsInstance` behavior. A false-mode handler must explicitly load any record data it needs before making the same decision. For vector queries, `rowFilter` participates in HNSW traversal. A caller therefore receives the k nearest _matching_ records rather than "nearest k, minus filtered records." `rowFilter` can apply to every candidate visited; prefer indexed query conditions for predicates that can be expressed declaratively. -`rowFilter` applies only to authoritative row events. Delete tombstones and published messages may not contain the complete current record. A subscription that needs those events can also provide an `eventFilter`: +`rowFilter` applies only when a non-raw `put` or `invalidate` event carries an authoritative row value. Delete tombstones, value-less invalidations, raw events, and published messages may not contain the complete current record. A subscription that needs such events can also provide an `eventFilter`: ```javascript subscribe(request) { - request.rowFilter = (record, context) => record.ownerId === context.user.id; - request.eventFilter = (event, context) => - event.type === 'put' || - event.type === 'invalidate' || - (event.type === 'delete' && String(event.id).startsWith(`${context.user.tenantId}:`)); + request.rowFilter = canReadReport; + request.eventFilter = (event, context) => { + const rowEvent = event.type === 'put' || event.type === 'invalidate'; + if (!request.rawEvents && rowEvent && event.value != null) return true; + const username = context.user?.username; + const ownerPrefix = username == null ? null : `${encodeURIComponent(username)}:`; + return ( + ownerPrefix != null && + (rowEvent || event.type === 'delete') && + String(event.id).startsWith(ownerPrefix) + ); + }; return super.subscribe(request); } ``` +This example assumes report IDs are created with the same `encodeURIComponent(username) + ':'` owner prefix, allowing value-less events to be authorized without loading the deleted or invalidated record. Encoding the owner segment prevents one username from being a raw string prefix of another owner's IDs. + `eventFilter` receives a shallow read-only view of every non-control event and the live context. It has the same synchronous and fail-closed requirements as `rowFilter` and is composed with it for authoritative row events. When a subscription has a `rowFilter`, non-row events are withheld unless an `eventFilter` explicitly accepts them. Transaction and reload control events continue to pass so the subscription remains coherent. +Live subscriptions are periodically re-authorized with a freshly loaded user. This recheck reruns the operation-level `allowRead` grant; it does not call a custom `subscribe()` override again. The `rowFilter` and `eventFilter` callbacks receive the refreshed context for subsequent events. If an application-specific connection grant must terminate an existing stream when revoked, keep that revocable grant in an `allowRead` override composed with `super.allowRead`; admission logic that runs only in `subscribe()` cannot provide that teardown behavior. + The legacy `allowRead`, `allowUpdate`, `allowCreate`, and `allowDelete` hooks are deprecated operation-level gates. When permission checking is active, Harper's standard instance flow evaluates the relevant hook once for the operation rather than once per row. Built-in table handlers with `loadAsInstance = false` likewise use one request/collection-scoped verdict; custom false-mode handlers remain responsible for authorization unless they delegate to those built-in handlers. Put application authorization and access-control decisions that need the complete target and context in operation overrides such as `get`, `put`, and `delete`. Collection operation overrides run before query planning or table scans and can attach a `rowFilter` or indexed conditions when an admitted request needs row-level narrowing. In the default single-record `get` flow, the record instance is loaded before the operation override runs, so its fields are available through `this`. +See the [`RequestTarget`](../resources/resource-api.md#requesttarget) and [`SubscriptionRequest`](../resources/resource-api.md#subscriptionrequest-options) references for the filter properties. + ### 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/reference/mcp/tool-metadata.md b/reference/mcp/tool-metadata.md index 0ee2dbb8..b4210521 100644 --- a/reference/mcp/tool-metadata.md +++ b/reference/mcp/tool-metadata.md @@ -83,7 +83,7 @@ Given a `Product` table with type-level and field-level docstrings: ```json { "name": "get_Product", - "description": "Product catalog row — what shows up in the storefront listing, search, and inventory feeds. One row per SKU.\n\nFetches a single Product record by sku. Runtime RBAC (allowGet) enforces per-record access at call time.", + "description": "Product catalog row — what shows up in the storefront listing, search, and inventory feeds. One row per SKU.\n\nFetches a single Product record by sku. Runtime RBAC (allowRead) enforces operation access at call time.", "inputSchema": { "type": "object", "properties": { diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 749f3032..36fa2268 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -106,9 +106,11 @@ class Orders extends Tables.orders { } ``` -The corresponding instance method runs through Harper's normal `transactional()` envelope, so per-record `allow*` predicates and audit logging behave the same way as regular verb dispatch. +The MCP transport audits the `tools/call`, but invokes the custom instance method directly. It does not open a Resource transaction or run an `allow*` gate automatically. -**Custom tools are exposed to any MCP session — including anonymous, unauthenticated ones.** Unlike the auto-generated verb tools (which are RBAC-filtered per user at `tools/list` time and enforce table permissions on call), the MCP layer performs no authentication or ACL check for a custom tool: it is listed to every session and its method executes even when no user is logged in (`context.user` may be empty). Access control is entirely the method's responsibility — to restrict a tool to authenticated users or specific roles, check `context.user` (or rely on the per-record `allow*` predicates its data access triggers) inside the method and throw when the caller doesn't qualify. +**Custom tools are exposed to any MCP session — including anonymous, unauthenticated ones.** Unlike the auto-generated verb tools (which are RBAC-filtered per user at `tools/list` time and enforce table permissions on call), the MCP layer performs no authentication or ACL check for a custom tool: it is listed to every session and its method executes even when no user is logged in (`context.user` may be empty). Access control is entirely the method's responsibility — to restrict a tool to authenticated users or specific roles, check `context.user` inside the method and throw when the caller doesn't qualify. + +A custom method must pass an armed context or target when delegating to a static Resource operation. The MCP-created instance context carries the authenticated user and a one-shot `authorize` flag, so the first call can use `Orders.get(target, this.getContext())`; that flag is consumed by the first static Resource operation. For every later delegated operation, use a fresh `RequestTarget`, set `target.checkPermission = true` so authorization derives from `context.user`, and pass `this.getContext()`. Never accept `checkPermission` from tool arguments or other client input. ### `exportTypes` gating diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 03841dde..03bf1bbd 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -230,6 +230,8 @@ All properties are optional: | `startTime` | Start from a past time (catch-up of historical messages). Cannot be used with `previousCount`. | | `previousCount` | Return the last N updates/messages. Cannot be used with `startTime`. | | `omitCurrent` | Do not send the current/retained record as the first update. | +| `rowFilter` | Synchronous JavaScript predicate applied to authoritative row values. | +| `eventFilter` | Synchronous JavaScript predicate for events that may not carry an authoritative row. | --- @@ -1072,7 +1074,8 @@ Properties: - `search` — The query/search string portion of the URL - `id` — Primary key derived from the path - `isCollection` — `true` when the request targets a collection -- `checkPermission` — Set to indicate authorization should be performed; has `action`, `resource`, and `user` sub-properties +- `rowFilter` — Synchronous JavaScript predicate applied to candidate records during search. It cannot be set by REST or QUERY request data. +- `checkPermission` — Framework-owned, one-shot request to run legacy operation authorization. Harper arms it for permission-checked dispatches. Trusted server-side code making a subsequent direct Resource call may set it to `true`, causing built-in table gates to derive permissions from `context.user`. Never accept or copy this field or a permission object from client data. Standard `URLSearchParams` methods are available: diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 0afe11d0..4eec9259 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -16,7 +16,7 @@ Vector searches combined with filters now evaluate the filter during HNSW graph ## Authorization -The legacy `allowRead`, `allowUpdate`, `allowCreate`, and `allowDelete` hooks remain one-time operation gates and are deprecated in favor of operation overrides, which receive the complete target and can access the request context through `getContext()`. This restores the pre-5.2 contract: collection admission occurs before query planning or a table scan starts. For permission-checked built-in table operations with `loadAsInstance = false`, query deletes call `allowDelete` once before scanning rather than using `allowRead`, and publishes call `allowCreate` once. Custom false-mode handlers remain responsible for authorization unless they delegate to the built-in handlers. +The legacy `allowRead`, `allowUpdate`, `allowCreate`, and `allowDelete` hooks remain one-time operation gates and are deprecated in favor of operation overrides, which receive the complete target and can access the request context through `getContext()`. This restores the v5.1 operation-scoped contract for standard instance-mode collection reads: admission occurs before query planning or scanning. It also closes legacy `loadAsInstance = false` gaps in built-in table handlers: search and subscribe use one `allowRead` verdict, array PUT uses one `allowUpdate`, query DELETE uses one `allowDelete`, and publish uses one `allowCreate`. Custom false-mode handlers remain responsible for authorization unless they delegate to the built-in handlers. See [Row-Level Access Control with Explicit Filters](/reference/v5/database/schema#row-level-access-control-with-explicit-filters) for intentional row and subscription-event narrowing. ## Configuration