Access Rules
Access rules decide who can read, write, and delete the records in a collection. They're evaluated on every direct SDK call, per record — so a single query can never hand back rows the caller wasn't allowed to see.
Three rules per collection
Every collection carries three independent rules — one each for reading, writing, and deleting. You set them when you create the collection (and can change them later):
{
"name": "documents",
"read_rule": "scoped",
"write_rule": "owner",
"delete_rule": "owner",
"owner_field": "org_id"
}read_rulegovernsgetandquery. Default:authenticated.write_rulegoverns bothinsertandupdate— there is no separate insert rule. Default:authenticated.delete_rulegovernsdeleteanddeleteWhere. Default:owner.
A write or delete rule can never be public — writing always requires an identified user. public is a read-only tier.
The five rule types
| Rule | Keys off | A request passes when… |
|---|---|---|
| public | — | No per-record restriction. The least restrictive tier; read-only. |
| authenticated | session | The caller is a signed-in user — any signed-in user. |
| owner | created_by | The caller created the record. |
| scoped | owner_field | The caller's owner_field value matches the record's. |
| conditional | rule_conditions | The conditions evaluate true for this caller and record. |
| server_only | — | Never — for direct SDK callers. Only elevated callers (Cloud Functions, dashboard) can touch the collection. |
owner and scoped look similar but answer different questions. Owner is about authorship — it keys off created_by, the user who created the record. Scoped is about tenancy — it keys off a field you name (owner_field, e.g. org_id or team_id), so an entire team or organization shares access regardless of who created each record.
When the record field and the caller's identity don't share a name, bind them explicitly: owner_field: "user_id=$caller.id" matches the record's user_id against the signed-in user's id — the same idea as Firebase's request.auth.uid or Supabase's auth.uid(). A plain field name keeps the same-name convention. The caller's context contains id, email, full_name, plus any keys from the user's metadata.
Scoped and conditional need their inputs
scoped rule requires owner_field to be set on the collection, and a conditional rule requires at least one entry in rule_conditions — both are enforced at creation. Without them the rule would silently match every record, so Koolbase rejects the collection instead.Conditional rules
A conditional rule is a list of comparisons combined by rule_mode — all (every condition must hold) or any (at least one). Each condition compares a record field to either a literal value or the caller's own value for that field:
{
"name": "tasks",
"read_rule": "conditional",
"rule_mode": "all",
"rule_conditions": [
{ "type": "equals", "field": "org_id", "source": "user" },
{ "type": "in", "field": "status", "value": ["open", "in_progress"] }
]
}type— one ofequals,not_equals,in,not_in.field— the record field being compared.source: "user"— compare against the caller's own value for that field. Omitsourceand supplyvalueto compare against a fixed literal.
The example reads as: a caller may read a task only if its org_id equals the caller's org_id and its status is open or in progress.
Condition quick reference
// Compare record field to the caller's same-named field
{ "type": "equals", "field": "org_id", "source": "user" }
// Compare record field to a literal value
{ "type": "equals", "field": "status", "value": "active" }
// Check record field against a list
{ "type": "in", "field": "role", "value": ["admin", "manager"] }Auto-injection on scoped writes
scoped rule, the server automatically injects the owner_field value from the caller's context into the record data — you don't need to set it manually.Lookup conditions
Two condition types check whether a record exists in another collection before allowing a write: exists and not_exists. They express relationships that field comparisons can't — allow-lists, membership checks, and blocking.
A lookup names a target collection and a where map. Each where value is a literal, or a binding: $record.<field> reads the incoming record, $caller.id (or any context key) reads the signed-in user.
The canonical example — server-enforced user blocking. Messages carry a recipient_id; a write is rejected when the recipient has blocked the sender:
// messages write rule: conditional
[{
"type": "not_exists",
"collection": "blocks",
"where": {
"user_id": "$record.recipient_id",
"blocked_user_id": "$caller.id"
}
}]Reading: the insert passes only when no blocks record exists where user_id equals the message's recipient and blocked_user_id equals the sender. A blocked sender receives permission_denied — the message never lands.
v1 scope and semantics
How rules apply, per operation
Reads
query applies the rule as a filter — owner and scoped reads automatically narrow results to the caller's own records, and conditional reads drop rows that don't match. A get by id runs the same check on the one record; if it fails, you get a record_not_found — identical to a record that truly doesn't exist. Reads never reveal that a record they can't see exists.
Writes
Every write requires an identified user. scoped and conditional writes are validated against the data being written, so you can't create or update a record into a scope that isn't yours. An update is additionally checked after the change is applied: it can't reassign owner_field to another tenant, and a conditional record must still satisfy the rule once patched. A record can't be edited out of your reach.
Deletes
Single deletes run the delete rule on the target record. deleteWhere applies the same rule as a scope on the bulk operation — an owner or scoped bulk delete only ever touches the caller's own records.
When a request is denied
Denials are deliberate and consistent across reads, writes, and deletes:
| Code | HTTP | Meaning |
|---|---|---|
| record_not_found | 404 | A read was denied (or the record genuinely does not exist). Reads hide existence. |
| permission_denied | 403 | An identified caller is not allowed to write or delete this record. |
| unauthenticated | 401 | A write or delete was attempted with no user identity. |
Reads fail closed and silent
record_not_found, never permission_denied — so a caller can't probe for the existence of records they aren't allowed to see. Writes and deletes do distinguish 403 from 401, because the caller already named the record by id. See the error reference for the full envelope.Elevated access
Access rules govern the direct client SDK. Two callers are elevated and bypass them: your Cloud Functions (which run with a trusted system identity) and the dashboard back-office. Inside a Function, the database is unrestricted — authorization there is your responsibility, via ctx.auth — the same model as Firebase Admin.