Row rules make per-record decisions. They run after the table rule passes and receive the JWT plus a typed row — the stored row for
view, list, update and delete, or the payload for
create.
If canView returns false for any row in the result set, the entire request returns
403 Forbidden — no data is returned. The same applies to canUpdate and canDelete: if any matched row fails the check, the mutation is aborted and
403 is returned.
Every table needs a row rules file: with table rules but no row rules, every row-level check is denied.
canView also applies to each emission from /db/stream and /db/stream/list. Live queries are not a rules bypass. See Streaming.
Creating Row Rules#
Create <table>_row_rules.dart in rulesPath and extend RowRules:
import 'package:my_app/src/schemas/tasks.dart';
import 'package:zonai_schema/zonai_schema.dart';
TaskRowRules main() => TaskRowRules();
final class TaskRowRules extends RowRules<TaskTable, Task> {
TaskRowRules() : super(tasks);
@override
Future<bool> canView(Jwt? jwt, Task row) async => true;
@override
Future<bool> canUpdate(Jwt? jwt, Task before, Task after) async {
// Admins can always edit; owners can edit their own rows
if (jwt?.admin.isAdmin ?? false) return true;
return jwt?.userId == before.createdBy;
}
@override
Future<bool> canDelete(Jwt? jwt, Task row) async {
if (jwt?.admin.isAdmin ?? false) return true;
return jwt?.userId == row.createdBy;
}
}
Available Methods#
| Method | When it runs | Default |
|---|---|---|
canView(Jwt? jwt, T row) |
Before returning a row from view, list or a stream |
Any admin |
canUpdate(Jwt? jwt, T before, T after) |
Before an update, once per matched row |
Admin with canEdit |
canDelete(Jwt? jwt, T row) |
Before a delete, once per matched row |
Admin with canEdit |
canCreate(Jwt? jwt, T row) |
Before a create; row is the payload, not yet in the database |
Any admin |
row/before is the current state of the row from the database (or, for canCreate, the data being inserted, built with
Table.safeCreate). after is the row the pending update would produce, computed ahead of the write — exact for every update type, including JSON list/map column operations (add/remove/add_all/remove_all, nested map sets, merge patches).
Check ownership against before, never after. after is built from what the caller asked for, so an ownership test against it lets anyone claim a row by asserting they own it. Compare after with before only to gate the transition.
Two kinds of column can't reflect the true post-write value in after, so don't gate a rule on them:
- Server-managed columns (
createdAt/updatedAt/updatedWhen) report their pre-write value — the real value is wall-clock write time, which isn't known until the write actually happens. - Secret columns (e.g. a
passwordcolumn) are always redacted to'__REDACTED__'— the real submitted value is never put inafter, so it can't leak into rule code.
Gating the transition#
Because canUpdate sees both sides of the write, a rule can refuse a specific change rather than the whole update — for example, only an admin may reassign a row's owner:
@override
Future<bool> canUpdate(Jwt? jwt, Task before, Task after) async {
if (after.ownerId != before.ownerId) {
return jwt?.admin.isAdmin ?? false; // only an admin may reassign an owner
}
return jwt?.userId == before.ownerId;
}
Upgrading from a release where canUpdate took a single row: rename it to before
and add the after parameter. Behaviour is unchanged if you ignore after.
Row Rules vs. Table Rules#
Table rules run first. If the table rule denies, row rules never run and the request returns 403
before any SQL executes.
Row rules run once the rows involved have been read (for create, on the payload before the insert) — see
Request Pipeline. Semantics are per-row for list
and view — if any row fails canView, the whole request fails, unless a scope
keeps that row out of the read in the first place. Evaluation is batched (one rules call for the page), and tables that override
requiresPerRowCheck => false skip row-rule work after table access succeeds.
A count (/db/count, the total of /db/list, and the count stream) is one
COUNT over rows the caller may see. That needs one of: a scope,
requiresPerRowCheck => false, or an admin caller. Otherwise /db/count and the count stream answer
400 count_requires_view_scope, and /db/list returns its page with total
left out (null). Zonai won't count by reading every row. AuthRowRules declares a default scope (a user sees their own row), so user tables count out of the box.
For large result sets, keep row rules fast — avoid database queries inside them when possible.
Skipping per-row checks#
@override
bool get requiresPerRowCheck => false; // public table: table rules are enough
Default is true. Use false only when every row that passes table rules is always visible/mutable for that caller class.
Scoping reads#
viewScope states, as a filter, which rows a caller may see at all. Zonai ANDs it into the caller's
where on every read: GET /db, /db/list, /db/count, and the three streams.
@override
Future<Where?> viewScope(Jwt? jwt) async {
if (jwt == null || jwt.admin.isAdmin) return null; // no scope
return Eq('owner_id', jwt.userId.value);
}
@override
Future<bool> canView(Jwt? jwt, Task row) async =>
(jwt?.admin.isAdmin ?? false) || jwt?.userId.value == row.ownerId;
A row outside the scope is invisible. It is not returned and not counted, and it never causes a
403: a list returns the caller's own rows, and a GET /db for someone else's row is a
404. Without a scope, the same list 403s as soon as its page holds one row
canView refuses, so every client has to know to filter to its own rows.
-
canViewstill runs on every row inside the scope. The scope narrows what is checked; it never replaces the check. -
Keep the two consistent. A row the scope admits and
canViewrefuses still fails a list with403, and a count trusts the scope, so it would include that row. null, the default, means no scope.- Writes are not scoped. An update or delete is keyed to the rows its own rules authorized.
-
expanddoesn't apply the related table's scope. The related rows still go through that table'scanView, so nothing it refuses is returned.
Custom Operations#
Named operations that aren't create/update/delete/view/list/count — TableOperations.custom
— go through customOperations, keyed by the same operation name. An operation name that isn't a key in the map is denied, same as any unoverridden method above:
@override
Map<String, CustomRowOperationRule<Task>> get customOperations => {
'archive': (jwt, before, after) async =>
jwt?.admin.canEdit ?? false,
};
before/after work exactly like canUpdate's — after
is simulated from the operation's updates ahead of the write, so a rule can gate on the transition itself (e.g. only an admin may set
status to 'archived'). Table rules need a matching entry too — see Table Rules: Custom Operations.
Those updates are the ones on the request. If your custom() writes something the caller never sent — a server-computed column, a self-referencing
count = count + 1 — Zonai cannot see it, because custom() returns a whole query rather than a list of updates.
after then arrives as a copy of before, and a rule comparing the two refuses every call while both the rule and the SQL look correct. Declare those writes by overriding
customUpdates on the same
TableOperations; Zonai warns once per table/operation pair when a custom operation's row rule runs with nothing to simulate.