LogoZonai
zonai.dev

Row Rules

Per-row authorization that runs after the row is fetched.

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#

MethodWhen it runsDefault
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 password column) are always redacted to '__REDACTED__' — the real submitted value is never put in after, 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.

  • canView still 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 canView refuses still fails a list with 403, 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.
  • expand doesn't apply the related table's scope. The related rows still go through that table's canView, 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.