Rules

A rule is a filter expression that can reference the caller. It's compiled into the SQL that reads or writes the rows, so rows a user can't see never leave SQLite.

Where rules go

Each collection has five rules, one per action. A missing rule means superusers only; "" means anyone.

migrations/V1__init.json
"rules": {
  "list":   "@request.auth.id != null",
  "view":   "@request.auth.id != null",
  "create": "author = @request.auth.id",
  "update": "author = @request.auth.id",
  "delete": "author = @request.auth.id || @request.auth.staff = true"
}

Fields can have two rules of their own: visible (who can read it) and writable (who can set it). Both default to the collection’s rules.

migrations/V1__init.json
{ "name": "mark", "type": "number",
  "visible":  "@request.auth.id = pupil || @request.auth.staff = true",
  "writable": "@request.auth.roles ~ \"examiner\"" }

The language

Same syntax as filters:

Compare= != > >= < <=
Contains / doesn’t contain~ !~
Combine&& || and parentheses
Values"text", numbers, true, false, null
The recordfield names: author, org, status
The caller@request.auth.…
Time@now, @days_ago.30, @years_ago.13

The caller

  • @request.auth.id, collection, email and superuser come from the token and are free.
  • Any other field of the caller’s record works too: @request.auth.staff, @request.auth.org. The record is loaded by id, in the same transaction, only when a rule uses it.
  • @request.auth.roles is a set: @request.auth.roles ~ "admin" tests membership, so it never matches “superadmin”.
  • For anonymous callers all of these are null, so a members-only rule just evaluates to false, never an error.
@request.auth.staff = true && org = @request.auth.org && id != @request.auth.id

Staff can delete users in their own organisation, but not themselves.

How they are enforced

  • Lists filter. Callers get only rows they may see, with correct totals.
  • View, update and delete return 404 when denied, so rules can’t be used to probe whether a row exists.
  • Writes are checked twice: before the change and again inside the transaction after it. A change that would put the row out of the writer’s own reach is rolled back.
  • Caller-only conditions are resolved before the query. @request.auth.roles ~ "admin" becomes a constant; conditions on the record go into the WHERE clause.
  • Field rules work the same way: a field the caller can never see isn’t selected; one that depends on the row is nulled per row, in SQL.

Checking permissions up front

GET /api/acl returns, for the signed-in user, what each collection allows: allow, deny, or conditional (only for some rows, e.g. their own). Use it to show only buttons that will work. It evaluates the same rules, so it can’t disagree with enforcement.