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. Evaluation is
batched (one rules call for the page), and tables that override requiresPerRowCheck => false
skip row-rule work after table access succeeds.
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.
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.