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.
The block is plain Elixir
The body of policy :entity do … end is a normal Elixir block, so
for, if, helper function calls, and module-attribute splicing all
work — the compiler expands them before our macros run:
@admin_fields [:price, :cost_basis, :internal_notes]
policy :books do
scope fn q, actor ->
if actor.role == :admin, do: q, else: where(q, [b], b.published)
end
for f <- @admin_fields do
field f, visible: fn actor -> actor.role == :admin end
end
if Mix.env() == :dev do
allow :delete, fn _ -> true end
end
endMultiple policy :entity blocks are additive — useful for splitting
rules across files or wrapping one in an environment guard. Duplicate
rules within the same entity (two scopes, two field :xs, two
allow :creates) still raise at compile time, because silent
clause-ordering resolution is bewildering.
One limitation: field :x, @some_opts where @some_opts is a module
attribute reference raises a pointed error. The macro dispatches on
AST shape at compile time and can't see through the attribute. Either
inline the kw list or iterate with for over field names.
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.