Caravela's policy block is the framework's core authorization
primitive. A single declaration compiles into three simultaneous
enforcement targets:
- Ecto WHERE clauses — row-level scope on every read.
- Field projection — invisible fields are redacted from every record before it leaves the context (JSON + GraphQL ride on this automatically).
- Typed Svelte
field_accessprop — the generated Svelte components gate columns/fields/inputs with{#if field_access.x}so the UI never renders controls the actor shouldn't see.
No three-way sync. No drift. The client could ignore the prop and the data still wouldn't be there — the server already stripped it.
DSL
defmodule MyApp.Domains.Library do
use Caravela.Domain, multi_tenant: true
version "v1"
import Ecto.Query, only: [where: 3]
entity :books do
field :title, :string, required: true
field :isbn, :string
field :published, :boolean, default: false
field :price, :decimal, precision: 10, scale: 2
field :internal_notes, :text
field :cost_basis, :decimal
field :author_email, :string
end
policy :books do
# Row-level: which records can this actor see?
scope fn query, actor ->
case actor.role do
:admin -> query
_ -> where(query, [b], b.published == true)
end
end
# Field-level: which fields can this actor see on accessible records?
field :price, visible: fn actor -> actor.role in [:admin, :editor] end
field :internal_notes, visible: fn actor -> actor.role == :admin end
field :cost_basis, visible: fn actor -> actor.role == :admin end
# Record-dependent rules — the field drops per row.
field :author_email, visible: fn actor, record ->
actor.role == :admin or actor.id == record.author_id
end
# Action-level: who can create/update/delete?
allow :create, fn actor -> actor.role in [:admin, :editor] end
allow :update, fn actor, record ->
actor.role == :admin or actor.id == record.author_id
end
allow :delete, fn actor -> actor.role == :admin end
end
endRules
scope fn query, actor -> query end— exactly one per entity. Applied to every read in the generated context.field :name, visible: fn actor -> bool end— arity 1. Constant per request. The Sveltefield_accessprop carries the resolved boolean.field :name, visible: fn actor, record -> bool end— arity 2. Record-dependent. The prop resolves to the sentinel'per_record'and each row is evaluated server-side; denied rows simply have the field set tonull.allow :create | :update | :delete, fn actor[, record] -> bool end— gates the context'screate_*/update_*/delete_*functions.
Actor lookup
The actor is context.current_user (or the string key
"current_user"). The authenticatable trait from
docs/auth.md sets it for you via on_mount hooks and the
fetch_current_user plug.
What the compiler generates
For each policy :entity block, the compiler emits three dispatch
functions on the domain module:
__caravela_policy_scope__/3—(entity, query, actor)__caravela_policy_field_visible__/3—(entity, field, actor)__caravela_policy_field_visible__/4—(entity, field, actor, record)__caravela_policy_allow__/3—(entity, action, actor)__caravela_policy_allow__/4—(entity, action, actor, record)
Each unpolicied entity falls through to a no-op default (query
unchanged, visibility true, allow true). Adding or removing a
policy never breaks existing call sites.
The generated context then exposes:
field_access(entity, context)— the typed map passed to Svelte via LiveSvelte.list_*/get_*— read paths apply the scope and the field projection before returning.create_*/update_*/delete_*— write paths hit both the existingcan_*check AND the newallowgate.
TypeScript + Svelte shape
Generated assets/svelte/[v<N>/]types/<context>.ts:
export interface BookFieldAccess {
title: true; // no rule → constant true
price: boolean; // arity-1 → boolean
internal_notes: boolean;
cost_basis: boolean;
author_email: 'per_record'; // arity-2 → per-row, field may be null
}Generated Svelte components accept it as a typed prop:
<script lang="ts">
import type { Book, BookFieldAccess, LiveHandle } from '../types/library';
let {
books = [],
field_access = { title: true, isbn: true, published: true, price: true, ... },
live
}: {
books?: Book[];
field_access?: BookFieldAccess;
live: LiveHandle;
} = $props();
</script>
<table>
<thead>
<tr>
<th>Title</th>
{#if field_access.price}<th>Price</th>{/if}
{#if field_access.internal_notes}<th>Notes</th>{/if}
</tr>
</thead>
<!-- ... -->
</table>Fields without a rule are rendered unconditionally; fields with a rule
are wrapped in {#if field_access.<name>}.
Security model
- The server is the authority. Field projection strips data
before it hits the wire. Row scoping limits what the DB ever
returns. A malicious client ignoring
field_accessstill cannot see redacted fields — the bytes were never sent. - Per-record rules evaluate per row. The TypeScript prop says
'per_record'so the client knows the field is sometimes present, sometimes null. Your Svelte code typically renders it with a fallback ({book.author_email ?? '—'}). - Deny-by-default. Domains default to
default_policy: :deny: any entity without a declaredpolicyblock has its scope filtered to zero rows, every field marked invisible, and every write denied. Forgetting to add a policy on a new entity can never silently leak data.
default_policy: :deny | :allow
# strict (default): entities without a policy block are fully denied
use Caravela.Domain
# explicit strict
use Caravela.Domain, default_policy: :deny
# opt out to permissive — entities without a policy block behave as
# if every field were visible and every action allowed
use Caravela.Domain, default_policy: :allowRegardless of the domain default, any entity that declares a policy
block is implicitly permissive for rule types it didn't declare.
Writing policy :books do field :price, visible: … end grants full
scope + allow access for :books — only :price is gated. This keeps
policy blocks additive: you can start with a single field rule and
grow into scope/allow over time without accidentally locking the
entity out.
The decision tree for any (entity, rule) pair:
┌─ entity has a declared rule for this exact (action|field)? ──┐
│ ├── yes → use the declared rule │
│ └── no → entity has any `policy` block? │
│ ├── yes → permissive (true / pass-through)│
│ └── no → domain-level `default_policy` │
└──────────────────────────────────────────────────────────────┘Authorization model
policy is the only authorization primitive. Earlier versions
shipped a separate can_read / can_create / can_update /
can_delete set of hooks — those were removed in 0.7.0 because they
ran alongside policy with no coordination (see the 0.7.0 changelog
entry). If you're migrating from an earlier release, move every
can_* rule into a policy :entity do … end block:
| Old | New |
|---|---|
can_read :books, fn q, ctx -> … end | policy :books do scope fn q, actor -> … end end |
can_create :books, fn ctx -> … end | allow :create, fn actor -> … end |
can_update :books, fn b, ctx -> … end | allow :update, fn actor, record -> … end |
can_delete :books, fn b, ctx -> … end | allow :delete, fn actor, record -> … end |
The rule function now receives the actor (context.current_user)
directly instead of the raw context map, so references to
context.current_user.role become just actor.role.