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)* ")"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. ASCII letters match regardless of case. |
!~ | 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.
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. |
:lower | Lower-cases this operand’s value before comparing, so compare it with a lowercase value (title:lower = "hello"). |
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.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.
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.
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.
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.
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.
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”.