Authorization
Authentication tells you who the caller is. Authorization decides what they can do. Maravilla evaluates policies you attach to your resources every time user code touches KV, the database, a realtime channel, or a media room.
// In your Maravilla admin console, on the "documents" resource, set a policy:
// auth.user_id == node.owner || auth.is_admin
// That's it — the rule runs on every kv.get / kv.put / db.find / etc.
// scoped to that resource.
How it works
There are three layers. Most apps only think about the second one.
- Tenant isolation — automatic. Data from one project is never visible to another, period. You don’t configure this.
- Per-resource policies — you define rules for each resource. Maravilla evaluates them at every read/write. This is what you’ll write 90% of the time.
- App-level checks — you can also ask “would this be allowed?” from inside your code with
platform.auth.can(...).
Policies are opt-in. A resource with no policy only gets Layer 1 (tenant isolation). Attach a policy when you want per-user access control.
Two ways to configure: UI or code
You can set everything up from the admin UI, or declare it in a maravilla.config.{ts,yaml,json} at the root of your project and let the deploy do it for you. Most teams end up with both — UI for quick iteration, config file for PR review and environment parity.
Config file wins for anything it declares. Anything it omits, the DB keeps. That lets you adopt incrementally: drop in just resources today, layer in groups, branding, etc. as you need them.
Supported out of the box:
| Framework | Adapter | How it picks up the config |
|---|---|---|
| SvelteKit | @maravilla-labs/adapter-sveltekit | Automatic on vite build |
| React Router 7 | @maravilla-labs/adapter-react-router | Automatic on vite build |
| Nuxt / SolidStart / TanStack Start | @maravilla-labs/preset-nitro | Automatic on nitro build |
If you’re on one of these, you already have @maravilla-labs/platform as a dep — the defineConfig helper lives at the /config subpath:
// maravilla.config.ts — at your project root
import { defineConfig } from '@maravilla-labs/platform/config';
export default defineConfig({
auth: {
resources: [
{
name: 'todos',
title: 'Todos',
actions: ['read', 'write', 'delete'],
policy: 'auth.user_id == node.owner || auth.is_admin',
},
],
groups: [
{ name: 'moderators',
permissions: [{ resource_name: 'todos', actions: ['read', 'delete'] }] },
],
relations: [
{ relation_name: 'STEWARDS', title: 'Stewards', category: 'family',
implies_stewardship: true, bidirectional: false },
],
registration: {
fields: [
{ key: 'email', label: 'Email', field_type: 'email',
required: true, show_on_register: true },
{ key: 'display_name', label: 'Display name', field_type: 'text',
required: true, show_on_register: true },
],
},
oauth: {
google: {
enabled: true,
client_id: '1234567890.apps.googleusercontent.com',
client_secret: { env: 'GOOGLE_CLIENT_SECRET' }, // resolved server-side
scopes: ['openid', 'email', 'profile'],
},
},
security: {
password_policy: { min_length: 12, require_uppercase: true,
require_number: true, require_special: false },
session: { access_token_ttl_secs: 900, refresh_token_ttl_secs: 2592000,
max_sessions_per_user: 5, require_email_verification: true },
},
branding: { app_name: 'HoneyBee', primary_color: '#f59e0b', layout: 'centered' },
},
});
Every section is optional. Declare what you want to own in the repo; leave the rest in the UI. Runtime data (user-to-group memberships, circles, stewardship overrides) stays in the UI — it’s tied to individual users, not something you version in your repo.
OAuth secrets. Accept either "${env.VAR_NAME}" (string shorthand) or { env: "VAR_NAME" } (object). Resolved from the tenant’s environment at reconcile time — never plaintext in your repo.
What happens on deploy
- You run
npm run build(or your framework’s build command). The adapter findsmaravilla.config.*, validates it, and bakes theauthblock into the framework’s outputmanifest.json(typicallybuild/manifest.json; older projects may use.maravilla/manifest.json). - Your deploy pipeline ships the manifest. Delivery reads the
authblock on the first request to the new deployment. - Declared items are upserted by natural key (resource name, group name, relation name). Singleton sections (registration fields, security, branding) replace the current config only for the fields you declared.
- Anything the admin UI created but your config doesn’t mention is kept, never auto-deleted. The deploy log lists it under
authz.config.driftso you can reconcile manually. - Malformed policy expressions fail the deploy with a line/column error before anything ships.
YAML — same schema, no TS types:
# maravilla.config.yaml
auth:
resources:
- name: todos
title: Todos
actions: [read, write, delete]
policy: auth.user_id == node.owner || auth.is_admin
Sync without a full deploy
While iterating, a full build-and-deploy cycle is slow. The CLI has two subcommands that go straight to the admin API:
# Show what's different between your maravilla.config.* and the project
maravilla auth diff --project <project-id>
# Apply it (same logic as a deploy's reconcile)
maravilla auth sync --project <project-id>
diffexits non-zero when there are differences — wire it into CI to gate PRs.syncis safe to re-run — it’s an upsert, not a replace. Drift (admin-UI-only items) is reported, not deleted.- Both read your built
manifest.json(defaultbuild/manifest.json, fallback.maravilla/manifest.json) — so run your build first. Keeps the CLI thin; it never has to re-parse TypeScript configs. - Requires
maravilla loginfirst.
Defining a resource (UI)
Resources live in your project’s Auth Settings → Resources tab.
- Click Add Resource.
- Title: a human name like “Documents” or “Messages”.
- Slug: used in code. Typically the KV namespace or DB collection name (e.g.
documents). - Service type (optional): which platform service this resource gates —
kv,database,realtime,media,vector,storage,queue,push,workflow,transforms. When set, the editor offers per-service action presets and the reconciler validates that the policy only references legalnode.*fields for that service. Omit for legacy / cross-service resources. - Actions: the operations you care about —
read,write,delete, etc. - Policy (optional): a REL expression (see below). Leave empty to skip policy checks.
Save. The policy is live the moment you hit save — existing deployments pick it up on their next request.
Why the type matters
Without a type, a KV namespace called todos and a DB collection also called todos will silently share a policy — the policy can disambiguate by checking node.namespace vs node.collection, but that’s a footgun. Setting type: 'kv' (or 'database') makes the binding explicit, lets the UI offer service-correct action presets and policy snippets, and lets the reconciler reject obvious mismatches before they ship.
Writing a policy
Policies are boolean expressions. true → allow; anything else → deny. Maravilla makes two things available:
| Variable | What it is |
|---|---|
auth.user_id | The caller’s user id ("" if no one’s signed in) |
auth.email | The caller’s email |
auth.is_admin | true if the caller has the admin flag |
auth.roles | Array of role names on the current project |
auth.groups | Array of group names the caller belongs to |
auth.circles | Array of circle ids the caller belongs to |
node.* | Resource-shaped data for this specific operation (see below) |
Examples
# Anyone signed in can read, only admins can write
auth.user_id != "" && (node.action == "read" || auth.is_admin)
# Only the owner of the row, or someone in the "editors" group
auth.user_id == node.owner || auth.groups.contains("editors")
# Public read for published items, owner-only writes
(node.action == "read" && node.status == "published") || auth.user_id == node.owner
# Admins from any project, plus users whose email ends in your domain
auth.is_admin || auth.email.endsWith("@acme.com")
Operators & methods
- Comparison:
==,!=,>,<,>=,<= - Logic:
&&,||,! - Null-safe chaining:
node.meta.published == truereturnsfalse(not an error) ifnode.metais missing. - On strings:
.contains(s),.startsWith(s),.endsWith(s) - On arrays:
.contains(x) - On paths:
.descendantOf("/content/blog")
What’s in node?
It depends on which op fired. For all of them, node.action is a string like "read" / "write" / "delete" / "list".
| Service | What node looks like |
|---|---|
| KV | { namespace, key, action, value, value_new? } (list ops: { namespace, prefix, limit, action: "list" }) |
| Database | { collection, filter, action, document?, update? } |
| Realtime | { channel, action: "publish" | "subscribe" | "presence:join" | …} |
| Media | { room, role, action: "join" | "create" | "record:start" | …} |
| Vector | { collection, action: "index:read" | "index:admin" } |
| Storage | { bucket, key, action: "get" | "put" | "delete" | "list" | "upload-url" | …} |
| Queue | { queue, action: "enqueue" } |
| Push | { action: "subscribe" | "send" | "schedule" | …, target?, payload? } |
| Workflow | { workflow, input?, action: "start" | "send-event" } |
| Transforms | { bucket, src_key, action: "transcode" | "thumbnail" | …} |
Reference the fields you care about. Policies that ignore node still work — they just act as tenant-wide rules.
Pre-fetched value/document
For ops where the platform can resolve the record before evaluating the policy, it does — and exposes the result as node.value (KV) or node.document (Database). This lets you write the natural rule:
auth.user_id == node.value.owner || node.value.public == true
Without this, on a KV get, node.value would be undefined and node.value.owner would never match — your policy would silently fall through to the rest of the expression. The pre-fetch makes ownership clauses fire correctly.
| Op | Pre-fetched? | Field exposed |
|---|---|---|
kv.get | yes (the read itself) | node.value |
kv.put | yes when policy attached (extra read) | node.value (existing), node.value_new (incoming) |
kv.delete | yes when policy attached (extra read) | node.value |
kv.list | no — list has no per-record context | — |
db.findOne | yes (the read itself) | node.document |
db.find | no — list has no per-record context | use read_filter instead, see below |
db.insertOne | n/a — record doesn’t exist yet | node.document (incoming) |
db.updateOne | yes when policy attached (extra read) | node.document (existing), node.update (delta) |
db.deleteOne | yes when policy attached (extra read) | node.document |
The “extra read when policy attached” cases pay nothing when the resource has no policy — the pre-fetch is gated on a single SELECT for policy presence.
Scoping reads with read_filter
A REL policy is a per-record predicate — it answers “should this caller see this row?” That works fine for findOne and single-key get, where the platform fetches the record and runs the predicate. But for find and list returning many rows, evaluating a free-form predicate per row would be expensive. Maravilla takes a different route: declare a filter the runtime ANDs into the caller’s query before it runs.
{
name: 'documents',
type: 'database',
actions: ['read', 'write', 'delete'],
policy: 'auth.user_id == node.document.owner || node.document.public == true',
read_filter: '{"$or":[{"owner":"$auth.user_id"},{"public":true}]}',
}
What that does:
- A caller who runs
db.find('documents', { status: 'draft' })is silently rewritten to:{ $and: [ { status: 'draft' }, { $or: [ { owner: 'u1' }, { public: true } ] } ] } - The caller can’t see rows they’re not allowed to see — the filter rewrite happens server-side; their query has to AND with it.
policystill gates per-record reads (e.g.findOne) and writes —read_filteronly adds the bulk-read scoping.
Allowed $auth.X placeholders. The runtime substitutes these from the caller’s identity at request time:
| Placeholder | Resolves to |
|---|---|
$auth.user_id | string |
$auth.email | string |
$auth.is_admin | boolean |
$auth.roles | array of strings |
Any other $auth.X reference fails validation when you save the resource.
When to use which. Use read_filter when the caller can run unbounded queries against a collection. Use policy (with node.document / node.value) when single-record access decisions need richer logic than a relational predicate can express. Most apps that share data across users will want both.
Identity in your code
Policies need to know who is making the call. Three ways to bind identity:
// 1. Sign someone in — this binds identity implicitly.
await platform.auth.login({ email, password });
// 2. You already have a JWT from a session cookie or Authorization header.
await platform.auth.setCurrentUser(token);
// 3. To clear (log out).
await platform.auth.setCurrentUser(null);
platform.auth.validate(token) is pure — it verifies a token and returns the user, but does not change who policies see as the caller. Use setCurrentUser when you want the identity to apply.
// Read who policies will see right now
const caller = platform.auth.getCurrentUser();
// { user_id, email, is_admin, roles, is_anonymous }
Scope: identity binding lasts for the duration of one inbound HTTP request. Two concurrent requests on your deployment see two separate identities — they never bleed into each other.
Checking permission in code
// Can the current caller delete this document?
const ok = await platform.auth.can("delete", "documents", {
owner: doc.owner,
status: doc.status
});
if (!ok) {
return new Response("Forbidden", { status: 403 });
}
platform.auth.can(action, resourceId, node) runs the same policy the platform itself would run. Returns a plain boolean — no exceptions.
When you’d reach for this: the platform pre-fetches the record on kv.get / db.findOne and on policy-attached writes, so most rules fire correctly without you doing anything. But for richer per-record decisions — e.g. a custom service handler that already loaded a document for unrelated reasons, or a cross-resource check (“can this caller see this other record?”) — call auth.can(...) with the resolved data and the policy fires against your node. Same engine, you provide the shape.
How denials surface
When a policy denies a direct KV/DB/Realtime/Media call, the op throws. Catch it like any other platform error:
try {
await platform.KV.documents.put(id, doc);
} catch (e) {
if (String(e).includes("authz denied")) {
return new Response("Forbidden", { status: 403 });
}
throw e;
}
Prefer platform.auth.can(...) when you want a boolean you can branch on cleanly.
Advanced: graph relationships
For scenarios like “guardian can read ward’s data” or “manager can update reports from their team”, policies can traverse your configured relationships:
# Allow if the caller is a steward of whoever owns the resource
node.owner RELATES auth.user_id VIA "STEWARDS" DEPTH 1..2
Set up stewardship in Auth Settings → Stewardship. The VIA name matches the relation type you configured. Keep DEPTH small — deep traversal is slow.
Escape hatch: turning policies off
Sometimes you have code paths (admin jobs, first-run seeders) that need to operate without policy checks. Inside your app code:
platform.policy.setEnabled(false);
// ...trusted work here — Layer 2 is bypassed...
platform.policy.setEnabled(true);
Important:
- This only disables per-resource policies (Layer 2). Tenant isolation (Layer 1) is always enforced — you still can’t cross tenants.
- It’s scoped to the current request. A new request starts with policies re-enabled.
- Every flip is logged server-side with the caller’s identity so bypasses are auditable.
- Don’t pass user input into this. It’s for your code, not theirs.
Groups
Groups are named sets of users managed in Auth Settings → Groups. Add users to a group from the Users tab. Groups become available to policies as auth.groups.
auth.groups.contains("moderators") || auth.user_id == node.owner
Groups can also have resource-level permissions set directly in the admin UI, no policy needed — useful for broad roles like “all editors can write all documents”.
Bad policies don’t ship
When you save a malformed policy, Maravilla rejects it with a line/column error. Your deployment never sees a broken expression.
A policy that evaluates to an error at runtime (missing field on a null, type mismatch, …) is treated as a deny — never as an allow. If your policies start denying unexpectedly, check the request logs for the parse or eval error.
Quick reference
Inside your app code:
// Identity
await platform.auth.login({ email, password }); // implicit bind
await platform.auth.setCurrentUser(token); // explicit bind
await platform.auth.setCurrentUser(null); // clear
platform.auth.getCurrentUser(); // snapshot
// Checks
await platform.auth.can(action, resourceId, node); // boolean
// Per-request policy toggle
platform.policy.setEnabled(false); // Layer 2 off
platform.policy.isEnabled(); // current state
In your project config:
// maravilla.config.ts
import { defineConfig } from '@maravilla-labs/platform/config';
export default defineConfig({
auth: {
resources: [ /* name, title, actions, policy */ ],
groups: [ /* name, description, permissions */ ],
relations: [ /* relation_name, title, implies_stewardship, ... */ ],
registration: { fields: [ /* ... */ ] },
oauth: { google: { client_id, client_secret: { env: 'X' }, ... } },
security: { password_policy, session },
branding: { app_name, primary_color, layout, ... },
},
});
From the CLI:
maravilla login # once per machine
maravilla auth diff --project <project-id> # preview, CI-safe
maravilla auth sync --project <project-id> # apply
Next steps
- Authentication — Sign users in to populate
auth.* - KV Store — Policies run on every
kv.get/put/delete/list - Database — Policies run on every
db.find/insert/update/delete - Realtime — Policies gate
publish,subscribe, and presence - Media — Policies decide who gets a LiveKit join token