Mongo policies
A Mongo policy is the authorization layer for your collections — the equivalent of Postgres RLS, but written in TypeScript instead of SQL. Every /mongo/v1/* request runs through it. Until you deploy one, every Mongo request is denied (403 policy_denied) — Mongo is default-deny.
How it works
Your policy is a small Deno function. On each request the gateway (mongo-gate) verifies the apikey, decodes the user JWT if present, then calls your policy with a PolicyContextdescribing the operation. Your policy returns a decision; the gateway applies it and runs the Mongo operation. The policy never touches Mongo directly — it just decides — so it can't accidentally run a destructive query.
A policy can do three things:
- Allow or deny the operation outright.
- Scope it — layer a filter so a user only sees their own documents.
- Stamp fields — force server-set values like
user_idorcreated_atonto inserts/updates.
Writing a policy
Open Project → Mongo policyin the dashboard. The editor injects all types and helpers — no imports needed. A real policy switches on collection, operation, and role. Here's “users manage their own notes”:
Deno.serve(async (req: Request): Promise<Response> => {
const ctx: PolicyContext = await req.json();
const { operation, collection, user_id, role } = ctx;
// Your own backend (ich_admin_ key) has full access.
if (role === 'service_role') return policy.allow();
if (collection === 'notes') {
if (!user_id) return policy.deny('sign in required');
if (isReadOp(operation)) return policy.allow({ merge_filter: { user_id } });
if (isInsertOp(operation)) return policy.allow({ merge_doc: { user_id } });
if (isMutateOp(operation)) return policy.allow({ merge_filter: { user_id } });
}
return policy.deny(`no policy for ${operation} on ${collection}`);
});The PolicyContext (what you receive)
interface PolicyContext {
operation:
| 'find' | 'findOne' | 'count' | 'aggregate' // reads
| 'insertOne' | 'insertMany' // inserts
| 'updateOne' | 'updateMany' // updates
| 'deleteOne' | 'deleteMany'; // deletes
collection: string;
role: 'anon' | 'service_role'; // which apikey was used
user_id?: string; // sub from a verified Bearer JWT
user_email?: string;
claims?: Record<string, unknown>; // full JWT payload (your custom claims too)
ip?: string; user_agent?: string;
body: Record<string, unknown>; // the raw request body
}role is anon for the publishable key, service_role for the secret key. user_id/claims are set only when a valid Bearer JWT was supplied — always use these for identity, never values from body (the client controls those).
The decision (what you return)
Use the policy.allow() / policy.deny() helpers — they're type-checked so key typos are caught at edit time.
| Field | Effect |
|---|---|
| merge_filter | AND-ed into the client's filter. Scope reads/updates/deletes to the caller's rows. |
| merge_doc | Forced onto inserted/updated docs after user data — server-set fields. |
| replace_filter | Overrides the client's filter entirely (e.g. a fixed public slice). |
Type guards help you branch: isReadOp (find/findOne/count/aggregate), isInsertOp (insertOne/insertMany), isMutateOp (update*/delete*).
Patterns
// Public read, authenticated write
if (collection === 'posts') {
if (isReadOp(operation)) return policy.allow();
if (!user_id) return policy.deny('sign in to write');
if (isInsertOp(operation)) return policy.allow({ merge_doc: { author_id: user_id } });
if (isMutateOp(operation)) return policy.allow({ merge_filter: { author_id: user_id } });
}
// Per-organization scoping (org_id in JWT claims)
const orgId = ctx.claims?.org_id;
if (collection === 'projects' && orgId) {
if (isReadOp(operation)) return policy.allow({ merge_filter: { org_id: orgId } });
if (isInsertOp(operation)) return policy.allow({ merge_doc: { org_id: orgId } });
if (isMutateOp(operation)) return policy.allow({ merge_filter: { org_id: orgId } });
}
// Admin-only via a custom claim
if (collection === 'audit_log') {
return ctx.claims?.admin === true ? policy.allow() : policy.deny('admins only');
}
// Validate shape / cap batch size on insert
if (operation === 'insertMany' && (ctx.body.docs as unknown[])?.length > 50) {
return policy.deny('max 50 docs per insertMany');
}body for identity (the client controls it — use user_id/claims); relying on module-level variables (a fresh isolate per request resets them); long compute (per-plan timeout, 1s free → 30s business). For durable counters or rate limits, write to Mongo or Redis from inside the policy.What the gateway enforces regardless
Before your policy even runs, mongo-gate hard-blocks:
- Missing/invalid
apikey→401(your policy never sees it). - Banned operators (
$where,$function,$accumulator,$out,$merge, …) anywhere in the body →400. deleteMany({})with an empty filter → refused outright.
Deploying & breaking glass
Project → Mongo policy → Deploy saves the code to your project DB and restarts the functions container (~1s). To lock everyone out instantly during an incident, uncheck Enabled — the gateway then sees no policy and denies all Mongo requests until you re-enable a fixed version.
