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):

POST /v1/collections
JSON
{
  "name": "documents",
  "read_rule": "scoped",
  "write_rule": "owner",
  "delete_rule": "owner",
  "owner_field": "org_id"
}
  • read_rule governs get and query. Default: authenticated.
  • write_rule governs both insert and update — there is no separate insert rule. Default: authenticated.
  • delete_rule governs delete and deleteWhere. 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

RuleKeys offA request passes when…
publicNo per-record restriction. The least restrictive tier; read-only.
authenticatedsessionThe caller is a signed-in user — any signed-in user.
ownercreated_byThe caller created the record.
scopedowner_fieldThe caller's owner_field value matches the record's.
conditionalrule_conditionsThe conditions evaluate true for this caller and record.
server_onlyNever — 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

A 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_modeall (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:

POST /v1/collections
JSON
{
  "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 of equals, not_equals, in, not_in.
  • field — the record field being compared.
  • source: "user" — compare against the caller's own value for that field. Omit source and supply value to 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

When writing under a 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

One lookup condition per rule. Lookups are evaluated on inserts; update and delete rules containing a lookup deny by default (fail closed), as do read rules — lookup-based read filtering is not yet supported. Unresolvable bindings (a missing field, a signed-out caller) also fail closed: when in doubt, the engine denies.

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:

CodeHTTPMeaning
record_not_found404A read was denied (or the record genuinely does not exist). Reads hide existence.
permission_denied403An identified caller is not allowed to write or delete this record.
unauthenticated401A write or delete was attempted with no user identity.

Reads fail closed and silent

A denied read returns 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.

Elevation never crosses projects

Bypassing rules is not the same as bypassing isolation. Every database operation is scoped to its project, so an elevated caller still only ever sees that project's data. Elevation removes the per-record rule check within a project; it can never reach another project's records.