Skip to content

Authorization policies

A policy decides which documents a caller may touch. It is the layer that makes a multi-user app safe, and the one place worth reading carefully before you ship.

Postgres has row-level security; MongoDB does not. The naive workaround — fetch documents, then filter them in application code — breaks pagination (you cannot know how many rows survive without reading them all) and does not scale.

Groveback instead expresses a policy as a Mongo query fragment and injects it into the real query with $and:

effectiveFilter = { $and: [ userFilter, policyFilter ] }

Mongo enforces it natively, your indexes still apply, and limit/skip stay correct.

A policy is keyed by operation. The operations are read, list, create, update, delete, plus admin.

{
"read": { "filter": { "ownerId": "$auth.uid" } },
"list": { "filter": { "ownerId": "$auth.uid" } },
"create": { "validate": "document.title.size() > 0" },
"update": {
"filter": { "ownerId": "$auth.uid" },
"immutableFields": ["ownerId", "slug"],
"allowedWriteFields": ["title", "body"],
"deniedReadFields": ["internalNotes"]
},
"delete": { "filter": { "ownerId": "$auth.uid" } },
"admin": { "filter": {} }
}
Key Meaning
filter A Mongo query fragment, $and-ed into the query. {} means no restriction.
validate A CEL expression that must evaluate to true. For creates and updates.
immutableFields Fields a write may not change once set.
allowedWriteFields If present, an allowlist — anything else is rejected.
deniedReadFields Stripped from every read, including realtime events.

Inside a filter, four placeholders interpolate from the caller’s context:

Placeholder Value
$auth.uid The caller’s user id (or API key id).
$auth.role The resolved role.
$auth.pid The project id.
$auth.token.<claim> A custom JWT claim. Only token takes a sub-path.

An unresolvable placeholder denies the request. An anonymous caller hitting a rule with $auth.uid gets nothing, rather than matching documents whose ownerId happens to be null.

Two terms, borrowed from Firestore: resource is the existing document, request.resource is the incoming one.

Operation What happens
list / find The policy filter is $and-ed into the query. Pagination stays intact.
read / get Same injection. A miss is 404, not 403 — existence is never revealed.
create There is no existing resource, so validate runs against the incoming document, and ownerId is forced to auth.uid server-side.
update The filter is injected into the match, so only authorized documents are touched, and the payload is rejected if it violates the field rules.
delete The filter is injected into the match.

PUT (full replacement) never orphans a document: id, ownerId and createdAt are re-pinned from the existing document, so a PUT that omits ownerId keeps the original owner.

Field checks compare actual value diffs, so sending a field back unchanged is not a mutation and does not trip immutableFields. This matters when a client does read → edit → write with the whole object.

Update operators are checked too — $set, $inc and $push are all validated against the same field rules, so you cannot sneak past with $inc on an immutable counter.

For write conditions a filter cannot express, validate takes an expression in a hardened CEL subset — essentially what Firebase Security Rules use, with no dependencies and no prototype access.

{ "create": { "validate": "document.price > 0 && document.currency in ['USD','EUR']" } }

Limits worth knowing: at most 4096 characters, 1024 tokens, a parse depth of 64, and 10,000 evaluation steps.

A CEL error, or any result other than true, rejects the write.

This is the part to internalize:

  • No policy on a collection ⇒ the collection does not exist on the data plane for non-admin callers.
  • No rule for the operation and no admin rule ⇒ deny, even for a caller holding *. Policy shape gates admins too.
  • An unresolvable placeholder ⇒ deny.
  • A CEL error ⇒ deny.

The only way past a policy is the admin document browser (/api/v1/admin/collections/:name/documents), which is admin-gated, bypasses policy filters deliberately — and still runs schema validation on writes.

Per-owner private data. The default for user-owned records:

{
"read": { "filter": { "ownerId": "$auth.uid" } },
"list": { "filter": { "ownerId": "$auth.uid" } },
"create": {},
"update": { "filter": { "ownerId": "$auth.uid" }, "immutableFields": ["ownerId"] },
"delete": { "filter": { "ownerId": "$auth.uid" } }
}

Public read, owner write. A blog:

{
"read": { "filter": { "published": true } },
"list": { "filter": { "published": true } },
"create": {},
"update": { "filter": { "ownerId": "$auth.uid" } },
"delete": { "filter": { "ownerId": "$auth.uid" } }
}

Admin only. Right when a trusted server of yours is the only caller:

{ "admin": {} }

Multi-tenant by claim. When your users belong to organizations:

{
"read": { "filter": { "orgId": "$auth.token.org" } },
"list": { "filter": { "orgId": "$auth.token.org" } }
}

Deny all, which is what BYO-Mongo import writes as a starting point:

{ "read": { "filter": { "$expr": false } }, "list": { "filter": { "$expr": false } } }

An unknown rule key, a malformed placeholder, or a CEL expression that does not compile is rejected at PUT …/policy time — not silently at the first denial. A typo fails loudly, while you are looking at it.

Index every field a filter mentions. A policy filter on an unindexed ownerId turns every authorized list into a collection scan. This is the single most common performance mistake with policies.

Terminal window
curl -X POST "$URL/api/v1/admin/collections/posts/indexes" \
-H "authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"keys":{"ownerId":1}}'

Policies apply to realtime too. The read rule is re-evaluated against every Change Stream event before it is emitted. See realtime.

Through the API:

Terminal window
curl -X PUT "$URL/api/v1/admin/collections/posts/policy" \
-H "authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"rules":{"read":{"filter":{"ownerId":"$auth.uid"}}}}'

In the bundle, as the policy key of groveback/collections/posts.json. Or in the dashboard, whose collection wizard writes correct rules for you from a template — Public, Private (per owner), Admin-only writes or Custom. That template step is the part most worth doing in the UI.

Reading a policy back tells you where it came from:

{ "policy": { }, "source": "own" }

source is own (set on this project), inherited, or default (from the deployment’s groveback.policies.json file).