Access rules (rules:)
Decide per collection which callers may read, create, update and delete documents.
Each collection of a realm with auth: enabled can have a rules: section in its collection.yaml, next to its schema.json. Rules decide, per operation, whether a caller may act on a whole document. There are no per-field permissions: data that needs different visibility belongs in a separate collection with its own rules.
$CONFIG_DIR/<realm>/<database>/<collection>/
schema.json
indexes.json # optional
collection.yaml # optional — its `rules:` section is the access rules
rules.test.yaml # optional — tests for the rules
The same file holds the collection’s other policy: files:, soft_delete: and on_owner_delete: (see the collection file). This page is about the rules: key.
Rules used to live in a rules.yaml of their own. A directory that still has one does not load: backd stops and names the file, so a forgotten rules.yaml can’t leave a collection silently without rules. To migrate, indent the file’s content by two spaces under a rules: key in collection.yaml and delete rules.yaml.
Example
# collection.yaml
rules:
# Anyone may read published posts; authors also see their drafts.
read: >
document.published == true
|| (user != nil && document._meta.owner == user.id)
# Signed-in users with a verified email may write posts.
create: user != nil && user.email_verified
# Authors may edit their own posts, but not move them to another status.
update: >
user != nil && document._meta.owner == user.id
&& !('status' in changed())
# Only admins may delete.
delete: hasRole(user, 'admin')
Operations
| Key | Controls |
|---|---|
read | Listing documents and fetching one |
create | POST |
update | PUT and PATCH |
delete | DELETE (and, in a collection with soft delete, the soft delete) |
restore | Soft-deleted collections only: reading the trash (deleted=only|include) and POST …/restore. Like read, it becomes a database filter, so it follows the same limits |
purge | Soft-deleted collections only: DELETE …?purge=true. Evaluated on the stored document, like delete |
write | Shorthand used for create, update and delete when they aren’t set; it doesn’t cover restore or purge |
Anything not allowed is denied: an operation without a rule (and no write to fall back on) is forbidden for users and anonymous callers. A collection without a rules: section is only reachable with API keys.
Each value is an expr expression that must be true to allow the operation.
What rules can use
| Name | Available on | Meaning |
|---|---|---|
user | all | The signed-in user: user.id, user.email, user.email_verified, user.roles. nil for anonymous callers |
document | read, update, delete | The stored document, including document.id and document._meta.* |
data | create, update | The document being written: the request body on create, the complete result on update |
now | all | The current time, only for comparing with a timestamp: document._meta.created_at, document._meta.updated_at, or a field the schema stores as a date |
hasRole(user, 'a', 'b', …) | all | True if the user has any of the roles; false for anonymous callers |
changed() | update | The top-level fields whose value differs between document and data |
Rules have no access to the request itself (headers, address) and can’t look up other documents. For shared documents, store member ids in an array and check user.id in document.members.
Anonymous callers
A request without credentials is anonymous: user is nil. So read: "true" makes a collection public, and user != nil means “any signed-in user”.
Every use of a user field must be guarded, so a rule can’t misbehave for anonymous callers:
read: user != nil && document._meta.owner == user.id # ok
read: document._meta.owner == user.id # startup error: add `user != nil &&`
read: user == nil || user.email_verified # ok: guarded by `user == nil ||`
read: document.published || hasRole(user, 'editor') # ok: hasRole needs no guard
Negations are literal: !hasRole(user, 'banned') is true for anonymous callers, because they have no roles.
Roles
Role names must be declared in the realm’s realm.yaml. Using an undeclared role, with hasRole or with 'x' in user.roles, stops startup.
Read rules are database filters
read rules also decide which documents a list returns, so they are applied inside the database query: pages, has_more and count only ever include readable documents. That’s why a read rule must be made of:
- comparisons between a document field and a value:
==,!=,<,<=,>,>=, with a field on one side; document.field in [...]andvalue in document.arrayField;- boolean fields on their own (
document.published); &&,||,!and parentheses.
Compared fields must be declared in schema.json with a type that isn’t array, and not sit inside an array. The database compares arrays element by element ({tags: "x"} matches any array containing "x"), which differs from what the rule says, so document.tags == 'x' stops startup. Write 'x' in document.tags instead: it means the same in the rule and in the database.
The value side can be anything that doesn’t depend on the document: literals, user fields, hasRole(...), now with arithmetic such as now - duration('24h'). It is computed once per request. Rules comparing two document fields, or calling functions on document fields, stop startup with an explanation.
Missing and null fields
In a read rule, a field that is missing or null:
- never equals, exceeds or is in anything:
document.n > 3,document.status == 'a'anddocument.status in ['a']don’t match it; - matches
== nil,!=and negations:document.status == nil,document.status != 'a',!(document.n > 3)andnot document.publishedall match it.
So not document.published includes documents without published. If that’s not what you want, write document.published == false.
These are the database’s semantics, and they apply to lists and to fetching one document alike. Tests compare the pushed-down filter with the rule itself on hundreds of documents, for every supported construct, to make sure the database never lists a document the rule doesn’t allow.
create, update and delete rules are evaluated in memory, so they can use any expr feature, such as len(data.title) < 100 or all(changed(), # in ['title', 'body']).
Rules can check the submitted data against the caller, for example to make sure a post is signed with the writer’s own email and never re-attributed:
create: user != nil && data.author.email == user.email
update: user != nil && document._meta.owner == user.id && !('author' in changed())
changed() compares top-level fields, so a change anywhere inside author (for example author.email) counts as author changing.
How rules are applied
In a realm with auth: enabled, every data request is decided like this:
| Caller | Rules |
|---|---|
| API key | Not applied: full access |
| Signed-in user (session token) | Applied, with user set |
| API key acting on behalf of a user | Applied, with user set to that user |
| No credentials | Applied, with user set to nil |
| Invalid, expired or revoked credentials | Refused with 401, never treated as anonymous |
For each operation:
- List (
GET /collection): only documents thereadrule allows are returned. The rule is combined with the client’swhere, solimit,skip,has_moreandcountonly ever see readable documents. - Fetch (
GET /collection/{id}): the samereadfilter is applied, so a list and a fetch never disagree. - Create: the
createrule is checked withdata, after schema validation. - Update (
PUT,PATCH): the document must be readable, then theupdaterule is checked with the storeddocumentand the complete newdata, after schema validation. - Delete: the document must be readable, then the
deleterule is checked. The delete only happens if the document hasn’t changed since it was checked.
Answers when a rule says no
| Situation | Status |
|---|---|
The caller can’t read the document (update, delete or fetch of a document the read rule hides) | 404 not_found, exactly as if it didn’t exist |
The rule is false for every document (for example read: user != nil for an anonymous caller), or the operation isn’t allowed | 401 unauthenticated for anonymous callers, who may be allowed once signed in; 403 forbidden for signed-in users |
No rule for the operation, or no rules: section | Same as a false rule |
So a document’s existence is only revealed to callers who could read it. A rule that fails while being evaluated (for example comparing a string with a number) denies.
At LOG_LEVEL=debug, every denial is logged ("msg":"access denied") with the collection, the operation and the reason, never the document’s contents.
Checks at startup
backd refuses to start, naming the file, when the rules: section:
- isn’t a mapping of the keys above to expressions, or has an unknown key;
- has a syntax error, or uses an unknown variable or
userfield; - uses a
documentordatafield that isn’t inschema.json; - uses
documenton create,dataon read or delete, orchanged()outside update (awriterule is checked for each operation it covers); - uses an undeclared role, or a role name that isn’t a string literal;
- uses a
userfield without a guard; - uses
nowother than to compare with a timestamp (a_metatimestamp or a field stored as a date), or compares a field stored as a date with text or auserfield; - has a
readrule that can’t be a database filter, or that compares an array field or a field without a declared type; - exists in a realm with
auth: disabled.
Files
Uploads and removals of file fields follow the update rule, downloads the read rule: see Rules for files.
Testing rules
backd rules test checks your rules without MongoDB and without starting backd. It reads a fixture, rules.test.yaml, next to each collection.yaml: named callers, stored documents, and for each caller which documents they may read, create, update, delete, restore and purge. It prints what fails and exits non-zero, so it fits a CI step before packaging. It needs only CONFIG_DIR.
# rules.test.yaml
now: 2026-10-04T12:00:00Z # optional: what `now` is in the rules (default 2026-01-01)
users: # callers; "anonymous" always exists
ada: { id: u1, email: ada@example.com } # email_verified defaults to true
bob: { id: u2, email: bob@example.com, roles: [staff] } # roles must be declared in realm.yaml
documents: # stored documents, by name
draft: { title: Draft, published: false, _meta: { owner: u1 } }
published: { title: Hello, published: true, _meta: { owner: u2 } }
tests:
- name: anyone reads published posts # optional
as: anonymous # a user above; default anonymous
read: { allow: [published], deny: [draft] }
create: [ { data: { title: Spam }, expect: deny } ]
delete: { deny: [published] }
- as: ada
update:
- { document: draft, patch: { published: true }, expect: allow } # a merge patch, as PATCH sends it
- { document: draft, data: { title: Whole, status: x }, expect: deny } # the complete new document, as PUT sends it
read,delete,restoreandpurgelist document names underallowanddeny(restoreandpurgeare for soft-deleting collections; a fixture document is in the trash when its_metahasdeleted_at, and a trashed document is seen through the read and restore rules both);createtakesdata(the body) withexpect: allowordeny;updatetakes adocumentand eitherpatchordata, withexpect.- The decisions are the server’s. A
readrule becomes the same database filter, evaluated the way MongoDB does (a missing field, an array,null);updateanddeletefirst need the stored document to be readable, so a document the caller can’t read is a denial there too (the server answers404); a rule that fails while being evaluated denies. A document or body that breaksschema.jsonis a mistake in the fixture, reported as such: the server would answer400before it asked the rules. - Stored documents get
_meta.owner: nullandcreated_atandupdated_atequal tonow, unless the fixture sets_meta(owner,created_by,updated_by,version,created_at,updated_at,deleted_at,deleted_by,purge_at). Theiridis their name, unless they have anid. - Output lists failures with what the rules decided and why;
--verbosealso lists what passes,--collection realm/database/collection(or a prefix) runs some collections, and collections with rules and no fixture are listed.--strictfails when there are any.
API keys bypass rules, so there is nothing to test for them; what the tests don’t cover is the HTTP layer (statuses, where filters combined with the rule), which the server’s own tests do.