Skip to content

API rules

The expression language Vaultbase uses to gate the records API per collection.

Every base/auth collection has five rule slots: list_rule, view_rule, create_rule, update_rule, delete_rule. View collections have only list_rule and view_rule. Singleton collections use only view_rule (reading the record) and update_rule (changing it); their record is never listed, created or deleted, so the other three don’t apply. Each slot is one of:

  • null — public; anyone can perform this action.
  • "" (empty string) — admin only.
  • An expression evaluated against the record + auth context.

Admins always bypass expression rules.

A rule you leave out when creating a collection is stored as "", so a new collection is admin-only until you open it up. Set null explicitly (or pick Public on the API rules tab) for anything that should be public.

A rule that doesn’t parse is refused when you save it: the collection create or update answers 422, naming the rule. An update checks only the rules it sets.

expr := operand op operand single comparison
| expr "&&" expr logical AND
| expr "||" expr logical OR
| "(" expr ")" grouping
op := scalar | array
scalar := "=" | "!=" | ">" | ">=" | "<" | "<=" | "~" | "!~"
array := "?=" | "?!=" | "?>" | "?>=" | "?<" | "?<=" | "?~" | "?!~"
operand := literal | field | atRef | funcCall | macro
literal := "string" | 123 | true | false | null
field := bareName ("." subPath)* (":" modifier)?
atRef := "@request.auth." prop
| "@request.method" | "@request.context"
| ("@request.headers." | "@request.query." | "@request.body.") word
| "@collection." name (":" alias)? "." path
| datetimeMacro
modifier := "isset" | "changed" | "length" | "each" | "lower"
prop := id | email | type | collectionName | verified | anonymous
funcCall := "geoDistance(" lonA "," latA "," lonB "," latB ")"
| "strftime(" format "," time ("," modifier)* ")"

A string takes "…" or '…'. Inside one, a backslash before its quote (\" in "…", \' in '…') is that quote, and \\ is one backslash, so a value ending in a backslash is written "C:\\". A backslash before any other character is itself. (Before 0.35.1 \\ was two backslashes, and a value ending in \ escaped its closing quote.)

OpMeaning
= != > >= < <=Standard scalar compare. =/!= use loose equality with bool↔number coercion (SQL semantics).
~Substring match. A value from the request (@request.*) is matched as plain text; in a pattern you write yourself, % and _ are wildcards. An empty or missing right side matches nothing. Letters match regardless of case as the database folds them, in list queries, single-record checks and realtime alike: ASCII letters only on SQLite, every letter on Postgres.
!~NOT substring — the exact negation of ~, so an empty right side matches everything.

A missing value (a field that is null, a header or query param that wasn’t sent) compares as SQL’s NULL: every comparison with it is false, != included. So status != "secret" doesn’t match a record whose status is empty, and tags:length = 0 doesn’t match a record with no tags. To test for a missing value, compare with the literal null: status = null, status != null. List queries and single-record checks give the same answer.

A whole json or group field, or a multi-value field, compares as its stored JSON text (["a","b"]), in list queries and single-record checks alike.

Prefix any scalar op with ? to switch to match-any-element semantics on multi-value fields (multi-select, multi-file, JSON arrays):

status ?= "draft" at least one tag equals "draft"
tags ?~ "feature" at least one tag contains "feature"
contributors ?!= @request.auth.id at least one contributor isn't me

Trailing :modifier on a field reference reshapes the operand:

ModifierNotes
:issetTrue when the request submitted this key (independent of value). Works on @request.body.*, @request.query.* and @request.headers.* only; on a record field it does nothing.
:changedOn update — @request.body.<f>:changed is true when the body’s value differs from the existing record. Also true when the body leaves <f> out, so pair it with @request.body.<f>:isset = false ||. Works on @request.body.* only.
:lengthElement count for multi-fields (file/select arrays, JSON arrays).
:eachLifts the comparison so it must hold for every element of an array field.
:any(...)On a group (or a JSON list of objects): true when one item meets every condition inside.
:lowerLower-cases this operand’s value before comparing, so compare it with a lowercase value (title:lower = "hello"). As with ~, SQLite lower-cases ASCII letters only and Postgres every letter.

A group field holds a list of items. Name a subfield after the field (items.qty) and use the array operators and modifiers:

items.qty ?> 2 some item has qty over 2
items.sku ?= "A-1" some item's sku is "A-1"
items.qty:each > 0 at least one item, and every item's qty is over 0
items:length > 0 the list has items
items:any(sku = "A-1" && qty > 2) one item meets both conditions
items:any(owner = @request.auth.id) one item belongs to the caller
  • items.sku ?= "A" && items.qty ?> 2 can be met by two different items; :any(...) asks for one item that meets every condition inside it.
  • Inside :any(...), names are the item’s subfields. @request.*, the datetime macros, ? operators on a multi-select subfield and modifiers work; @collection, _via_ and a nested :any are refused.
  • items.qty > 2 without ? matches nothing: the list itself has no qty.
  • With :each, an item missing the value fails the condition.
  • Values compare by their JSON type: a number never equals text, and two values of one item compare with each other the same way (items:any(qty > stock)). Text orders by character code: "10" < "9" and "B" < "a". @request.query.* values are text, so they never equal a number subfield.
  • A multi-select subfield is a list inside each item: match it with items:any(tags ?= "a"); items.tags ?= "a" matches nothing.
  • :length on a subfield counts a list’s elements or a text’s characters, and is 0 for a number or a boolean.
  • A single-object group (multiple: false) is one object: filter it as address.city = "Oslo".
  • An encrypted group can’t be filtered.

The same syntax works on a json field that holds a list of objects, when the list is the field itself (lines:any(...)); on a list nested deeper in the JSON, use meta.lines:any(...).

ReferenceNotes
@request.auth.id / .email / .typeCaller identity. For an unauthenticated caller every comparison with these is false (= "" and != "" alike), so @request.auth.id != "" requires sign-in. type is "user" or "admin".
@request.auth.collectionNameThe auth collection the user signed in to, e.g. "users".
@request.auth.verifiedtrue when the user’s email is verified (read from the account on each request).
@request.auth.anonymoustrue for a guest session from /auth/:collection/anonymous.
@request.method"GET" / "POST" / etc.
@request.context"default" / "oauth2" / "otp" / "password" / "realtime" / "protectedFile".
@request.headers.<name>Header by lowercased name (- becomes _). authorization and cookie are scrubbed before rule eval.
@request.query.<name>URL query param, by its exact name.
@request.query.params.<name>On a view with parameters: the value its query used for :name, default and signed-in user’s id included. A literal params.<name> key in the URL isn’t read.
@request.body.<name>Submitted body field, by its exact name (case-sensitive, like the field) — useful for :isset/:changed. Available to create and update rules.
@collection.<name>.<field> lookup by relation
@collection.users:author.email alias the join (clarifies multi-hop joins)

@collection.* references are matched against the rows of that collection: a rule passes if some row makes it true, and every reference to the same collection (and alias) in the rule refers to that same row. So @collection.members.grp = grp && @collection.members.user = @request.auth.id needs one membership row with both the record’s group and the caller. Use an alias (@collection.members:m2.…) to match a second, independent row.

!= and !~ against an @collection.* operand mean no row matches: @collection.bans.user != @request.auth.id passes only if the caller isn’t banned. For a non-admin caller this needs the whole collection to be public (view_rule = null): if it has any view rule, some rows may be hidden, absence can’t be shown, and the comparison is false — make a ban list public, or check it from a hook.

Cross-collection operands are expensive, so they’re capped: a rule may join at most 3 collections/aliases, and a ?filter= sent by a non-admin client may use only one @collection.* or _via_ operand in total, counting those in the view rules it inherits from the collections it references (more answers 400). The collection’s own view_rule is inherited into the join — only rows the caller may view count, so you can’t escape access control by joining sideways. A non-admin’s ?filter= also inherits the joined collection’s list_rule: a filter answers yes/no about the joined rows, so a collection that can be read by id but not listed (invite codes, a users list restricted to self) must not be searchable through another one. Rules you write are not affected; they inherit only the view rule. A join reads only the rows its rule admits, so a comparison never runs on a hidden row: a value the caller can’t see can’t change the answer, or turn it into an error.

Reference the records that point at this record:

list_rule: comments_via_post:length > 0

…matches posts with at least one comment whose post relation references the row. :length counts them, up to 1,000, and is 0 for a record with none, so comments_via_post:length = 0 finds posts without comments (on Postgres since 0.37.0; before, it matched nothing there).

A field of those records compares with the ? operators (some record matches) and :each (there are some, and every one matches), since 0.37.0:

comments_via_post.status ?= "flagged" some comment is flagged
comments_via_post.score:each >= 3 every comment scores 3 or more

Each value is compared with the field’s own type, so ?= "u1" doesn’t match u12. With the back-relation on the right of a comparison, only ?= and ?!= are accepted.

A plain comparison (=, !=, <, >, = null) on a back-relation is refused, since it doesn’t say whether some or every record should match: use ?=, :each or :length (since 0.37.0; before, it never matched on SQLite and failed on Postgres). ~ and !~ match the values as text.

@now, @yesterday, @tomorrow, @todayStart, @todayEnd, @monthStart, @monthEnd, @yearStart, @yearEnd, plus @second, @minute, @hour, @day, @weekday, @month, @year for the current time’s components. The datetime macros are Unix seconds, the format date and autodate fields are stored in, so they compare directly with those fields. Create and update rules see a date field as it will be stored, in Unix seconds, even when the request sends it as an ISO string. The same holds for a date subfield of a group, and for sign-up on an auth collection. A color value is seen in its stored form too (lowercase #rrggbb), so @request.body.accent = "#00ff00" matches a request that sends #0F0. A rule written against raw ISO strings or uppercase hex doesn’t match.

list_rule: published_at <= @now && published_at >= @monthStart
FunctionUse
geoDistance(lonA, latA, lonB, latB)Distance in km between two points; pair with comparisons for radius search.
strftime(format, time, mods…)SQLite-compatible date formatting in filters.

List rules run as SQL. Single-record checks (view, update, delete, file downloads, realtime events) run in the server process, and give the same answer as the SQL for the forms both support:

  • strftime with the codes %Y %m %d %H %M %S %j (UTC), and a time that is a stored date with the 'unixepoch' modifier, 'now', or a literal timestamp ('2026-01-31', '2026-01-31 12:00:00').
  • geoDistance with four numeric arguments.

Anything else (another code, another modifier, a missing argument) makes the comparison false, so the check denies. A single-record check never allows what the list query would hide.

The same applies wherever the in-process check can’t be sure of the database’s answer. On Postgres, case-insensitive matches follow the database’s locale; a letter it might fold differently (outside common scripts, or under a Turkish ICU locale) makes the comparison false. A JSON number matched with ~ or !~ on Postgres is compared as written, which the check can’t see, so it denies too. An object or array read out of JSON matches ~ as Postgres prints it.

SlotWhen evaluatedAgainst
list_ruleGET /api/v1/<col>Compiled into SQL filter — applied as a WHERE so non-matching rows never load.
view_ruleGET /api/v1/<col>/:idThe fetched record.
create_rulePOST /api/v1/<col>; on auth collections, sign-up (see below)The incoming body.
update_rulePATCH /api/v1/<col>/:idThe existing record (pre-update).
delete_ruleDELETE /api/v1/<col>/:idThe existing record.

Failure → 403 Forbidden.

A non-admin gets 404 instead, as for a missing collection, when the collection’s list_rule, view_rule and create_rule are all "" and the rule for the request is "" as well. A singleton whose view_rule and update_rule are "", or a view whose list_rule and view_rule are, answers 404 to every non-admin request. A non-admin’s ?filter= naming such a collection through @collection or _via_ answers 400. See Hidden collections.

On an auth collection, create_rule decides who may open an account: /auth/<col>/register, /auth/<col>/anonymous, /auth/<col>/promote (guest → real account) and OAuth2 sign-up. The rule sees the sign-up fields as @request.body (the password is never exposed to it).

create_rule: null open sign-up
create_rule: "" no self-registration (admin-created or invited accounts only)
create_rule: @request.body.role:isset = false open sign-up, but callers can't choose their role

New auth collections default to "", so turn sign-up on explicitly. (Before v0.18.0 sign-up ignored this rule; upgrading set existing auth collections’ "" to null once so their sign-up kept working.)

An update_rule has to pass against the record as it is now and as it would be after the update (the stored record with the request body merged in). Without the second check, owner = @request.auth.id would let an owner set owner to someone else in the same request — the rule passes on the current row. Fields absent from the body keep their stored value, so partial updates work as before.

list_rule: null (public)
view_rule: null (public)
create_rule: "" (admin only — empty string)
update_rule: ""
delete_rule: ""

Records have an author relation field. Logged-in users see only their own:

list_rule: @request.auth.id = author
view_rule: @request.auth.id = author

The list_rule becomes WHERE author = ? with the user’s id bound at query time. Non-matching rows never reach the application.

create_rule: @request.auth.id != "" && @request.auth.anonymous = false
update_rule: @request.auth.id = author
delete_rule: @request.auth.id = author

@request.auth.id != "" alone is true for any signed-in caller: guest (anonymous) sessions and users of every auth collection. Add @request.auth.anonymous = false, @request.auth.collectionName = "users" or @request.auth.verified = true as the collection needs.

Show published posts to everyone; let authors see their drafts too:

list_rule: published = true || @request.auth.id = author
list_rule: ""
view_rule: ""
create_rule: ""
update_rule: ""
delete_rule: ""

Block writes that mutate a forbidden field

Section titled “Block writes that mutate a forbidden field”
update_rule: @request.body.role:isset = false || @request.auth.type = "admin"

Non-admins cannot send a role in the body. Admins can.

Restrict updates to fields that didn’t change

Section titled “Restrict updates to fields that didn’t change”
update_rule: @request.body.status:isset = false || @request.body.status:changed = false || @request.auth.type = "admin"

Non-admins can update other fields, and can send status unchanged, but can’t change it. status:changed on the bare field doesn’t work: :changed applies only to @request.body.*.

Posts have tags: select[]. Show posts visible to any of the user’s tag list:

list_rule: tags ?= @request.auth.id

(?= matches when at least one element of tags equals the user id.)

Locations have a point: geoPoint. Filter by distance:

?filter=geoDistance(point.lng, point.lat, -73.99, 40.74) < 5

A bare name in an expression refers to the record’s column. Special aliases:

  • id → record id
  • created / created_at → created_at column
  • updated / updated_at → updated_at column

An unknown field is an error: a ?filter= that uses one answers 400, and a list rule that uses one denies (403).

The schema editor includes a typed autocomplete for rules — start typing @request. and the popup lists auth.id, auth.email, auth.type. Bare names autocomplete against the collection’s actual fields. Operators are suggested on Tab / Enter.

Every records-API request records which rules evaluated and what they returned. The admin Logs page surfaces:

  • Rule name (list_rule, view_rule, etc.) and collection
  • Expression text (or (public) / (admin only))
  • Outcome: allow, deny, or filter (list rule applied as SQL filter)
  • Reason: public, admin only, admin bypass, rule passed, rule failed, applied as SQL filter

Invaluable for debugging “why am I getting 403 on this collection”.