Row access
Show each signed-in person only the rows that are theirs, decided on the server by a policy module the App never imports.
export default defineAppRowPolicy<Rows>({ appId: 'lintel', // … async resolveRows(principal, { db }) { // … if (principal.roles.some((role) => OFFICE.includes(role))) return all(true); if (principal.roles.includes('resident')) return household(db, principal.email); return all(false); },See it in the sample (examples/apps/lintel/policy.ts:89); household finds the one resident whose email is the account’s and returns their lease, charges, payments and repairs, and nothing else.
The App says auth: { rows: 'policy', … }, and the server loads the module
with --policy ./policy.ts.
principal.roles holds the roles the account was given, by name: a role that
extends another is not
also given the other, so test for each by name, as Lintel’s OFFICE list does.
(A view’s or page’s roles are different: they open to a role that extends one they name.)
What you’ll see
Section titled “What you’ll see”- A manager at two dealerships sees both dealerships’ leads and cars together, and no other dealer’s, in every list, record, count, search and export. A record outside the person’s rows answers as one that does not exist.
- An edit that would point a row at a parent the person cannot read is refused. A generated column is read like any other and written by nobody;
tablewalk checkwarns ([write-generated]) where a role’sfieldslists one to write. - A refused save says which rule refused it, in the answer and on the server’s terminal:
This account's role does not write "note" of "job".,The App's row policy does not let this account change this "job" record.,"total" is generated by the database and cannot be written.The answer is{ code, message, error, errors, outcome }, the code and message at the top as a command’s are.
When to use it, and when not
Section titled “When to use it, and when not”- Use a row policy when one organization’s people see different rows of the same tables: their own accounts, their region, their dealerships.
- Use a tenant App instead when each customer is a separate organization with its own members, roles and administration.
- Use roles alone when people differ only in which tables and columns they reach.
Options
Section titled “Options”| Part | What it does | Default | Status |
|---|---|---|---|
resolveRows(principal, { db }) |
Each resource’s rows for the verified account: a filter, true (all) or false (none) |
required | Stable |
db |
The App’s own pooled connections, read-only and typed by Rows, across all rows |
— | Stable |
authorizeDeletedRecord(principal, { table, key, row }) |
Whether this person may delete or restore this row | deletes refused | Stable |
resultReferences |
One extractor per signed-in command, naming the rows its result draws on | required with --commands |
Stable |
publicForms |
Enrolls a fixed public form under the policy | none | Stable |
A resource the policy leaves out, or answers false for, has no rows for that
person. A filter is ANDed with every query the person makes. The resolver
receives only the verified account id, the App id and the roles. Policy
changes need a restart.
What a policy scopes
Section titled “What a policy scopes”- Reads. Lists, records, related lists, searches, counts and exports. A path through a reference is scoped like one through a key.
- Writes. With
access: 'write'and rolewritegrants, Save, inline edits, fixed actions and New work on a writable SQLite or PostgreSQL source. A write may point a row only at a parent the person reads; only the references it sets are checked. A row the same write made inside the person’s rows counts as theirs for the rest of that write: a child rule that names its parents by key (request_id in [...]) admits a line on the new parent, every other condition of the rule still holds, and a rule offalsestays closed. - Commands.
--commandsworks beside--policy; a conflicting row the person cannot see is refused astemporarily_unavailable, never revealed. - Jobs and services. Jobs, timers, inbound endpoints, integrations and effects run as a service the policy’s
services: ['clock']names, and get the rowsresolveRowsgivesservice:clock(events and jobs). A command’s events are recorded under the policy too. - Mail. A notify rule’s
email: truemails a recipient only while the policy gives them that record, the same rows a session of theirs would get.send: { channel: 'email' }mails an address the record holds (email). - Refused. Raw SQL, MCP, the CLI’s export modes and the App API’s event feed. The server serves exactly one
--appwith a policy.
tablewalk check --app ./dealer --policy ./dealer/policy.tstablewalk --config server.json --app ./dealer --policy ./dealer/policy.tsCommon mistakes
Section titled “Common mistakes”- A person sees no rows at all → the policy left the resource out or answered
false; name it, withtrueor a filter. - The App will not start under its policy →
Policy startup error:is the policy module’s own fault, said with its cause. When the App or a connection refused, the line is the App’s:App error:, its cause, and the file and line that wrote it, as any App error is. The start is refused overresultReferences→ it names each command to add and any to leave out; add an extractor for each,() => []when its result names no other row, and none for apublic: truecommand. - A delete is refused → add
authorizeDeletedRecordto the policy; without it, deleting under a row policy is off. [role-cannot-read]warnings still apply → a policy narrows rows, never columns; narrow columns with the role’sfields.
Related
Section titled “Related”- Roles and field access: tables and columns, before rows.
- Tenant Apps: one App serving several organizations.
- Commands: conflicts a hidden row may cause.
- The authoring guide (
docs/authoring.md): the policy contract in full.