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)* ")"
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. 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.

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.
:lowerLower-cases this operand’s value before comparing, so compare it with a lowercase value (title:lower = "hello").
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.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.

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.

@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 >= @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.

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.

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”.