ichibaseichibase

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_id or created_at onto 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.

FieldEffect
merge_filterAND-ed into the client's filter. Scope reads/updates/deletes to the caller's rows.
merge_docForced onto inserted/updated docs after user data — server-set fields.
replace_filterOverrides 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');
}
Avoid: trusting 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 apikey401 (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.

Mongo also supports AFTER triggers (collection policies) that fire on successful writes for side effects — see Table triggers for the parallel concept.