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.
Expression language
Section titled “Expression language”expr := operand op operand single comparison | expr "&&" expr logical AND | expr "||" expr logical OR | "(" expr ")" grouping
op := scalar | arrayscalar := "=" | "!=" | ">" | ">=" | "<" | "<=" | "~" | "!~"array := "?=" | "?!=" | "?>" | "?>=" | "?<" | "?<=" | "?~" | "?!~"
operand := literal | field | atRef | funcCall | macroliteral := "string" | 123 | true | false | nullfield := bareName ("." subPath)* (":" modifier)?atRef := "@request.auth." prop | "@request.method" | "@request.context" | ("@request.headers." | "@request.query." | "@request.body.") word | "@collection." name (":" alias)? "." path | datetimeMacromodifier := "isset" | "changed" | "length" | "each" | "lower"prop := id | email | type | collectionName | verified | anonymousfuncCall := "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.)
Comparison operators
Section titled “Comparison operators”| Op | Meaning |
|---|---|
= != > >= < <= | 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.
Array-prefixed operators
Section titled “Array-prefixed operators”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 meField modifiers (:foo)
Section titled “Field modifiers (:foo)”Trailing :modifier on a field reference reshapes the operand:
| Modifier | Notes |
|---|---|
:isset | True 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. |
:changed | On 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. |
:length | Element count for multi-fields (file/select arrays, JSON arrays). |
:each | Lifts 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. |
:lower | Lower-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. |
Group items
Section titled “Group items”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 2items.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 0items:length > 0 the list has itemsitems:any(sku = "A-1" && qty > 2) one item meets both conditionsitems:any(owner = @request.auth.id) one item belongs to the calleritems.sku ?= "A" && items.qty ?> 2can 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:anyare refused. items.qty > 2without?matches nothing: the list itself has noqty.- 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. :lengthon 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 asaddress.city = "Oslo". - An
encryptedgroup 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(...).
Request references
Section titled “Request references”| Reference | Notes |
|---|---|
@request.auth.id / .email / .type | Caller 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.collectionName | The auth collection the user signed in to, e.g. "users". |
@request.auth.verified | true when the user’s email is verified (read from the account on each request). |
@request.auth.anonymous | true 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. |
Cross-collection references
Section titled “Cross-collection references”@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.
Back-relations (<target>_via_<refField>)
Section titled “Back-relations (<target>_via_<refField>)”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 flaggedcomments_via_post.score:each >= 3 every comment scores 3 or moreEach 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.
Datetime macros
Section titled “Datetime macros”@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 >= @monthStartFilter functions
Section titled “Filter functions”| Function | Use |
|---|---|
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:
strftimewith 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').geoDistancewith 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.
How rules apply
Section titled “How rules apply”| Slot | When evaluated | Against |
|---|---|---|
list_rule | GET /api/v1/<col> | Compiled into SQL filter — applied as a WHERE so non-matching rows never load. |
view_rule | GET /api/v1/<col>/:id | The fetched record. |
create_rule | POST /api/v1/<col>; on auth collections, sign-up (see below) | The incoming body. |
update_rule | PATCH /api/v1/<col>/:id | The existing record (pre-update). |
delete_rule | DELETE /api/v1/<col>/:id | The 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.
Sign-up on auth collections
Section titled “Sign-up on auth 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-upcreate_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 roleNew 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.)
Update rules see the result of the change
Section titled “Update rules see the result of the change”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.
Examples
Section titled “Examples”Public read, admin write
Section titled “Public read, admin write”list_rule: null (public)view_rule: null (public)create_rule: "" (admin only — empty string)update_rule: ""delete_rule: ""Owner-only read
Section titled “Owner-only read”Records have an author relation field. Logged-in users see only their own:
list_rule: @request.auth.id = authorview_rule: @request.auth.id = authorThe list_rule becomes WHERE author = ? with the user’s id bound at query
time. Non-matching rows never reach the application.
Authenticated create, owner update/delete
Section titled “Authenticated create, owner update/delete”create_rule: @request.auth.id != "" && @request.auth.anonymous = falseupdate_rule: @request.auth.id = authordelete_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.
Published-or-mine
Section titled “Published-or-mine”Show published posts to everyone; let authors see their drafts too:
list_rule: published = true || @request.auth.id = authorAdmins only for everything
Section titled “Admins only for everything”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.*.
Tag-based ACL
Section titled “Tag-based ACL”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.)
Geo radius
Section titled “Geo radius”Locations have a point: geoPoint. Filter by distance:
?filter=geoDistance(point.lng, point.lat, -73.99, 40.74) < 5Field references
Section titled “Field references”A bare name in an expression refers to the record’s column. Special aliases:
id→ record idcreated/created_at→ created_at columnupdated/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).
Authoring rules in the admin
Section titled “Authoring rules in the admin”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.
Logging rule outcomes
Section titled “Logging rule outcomes”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, orfilter(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”.