Policies — one declaration, three enforced layers

Copy Markdown View Source

Caravela's policy block is the framework's core authorization primitive. A single declaration compiles into three simultaneous enforcement targets:

  1. Ecto WHERE clauses — row-level scope on every read.
  2. Field projection — invisible fields are redacted from every record before it leaves the context (JSON + GraphQL ride on this automatically).
  3. Typed Svelte field_access prop — 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
end

Rules

  • 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 Svelte field_access prop 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 to null.
  • allow :create | :update | :delete, fn actor[, record] -> bool end — gates the context's create_* / 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 existing can_* check AND the new allow gate.

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_access still 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 declared policy block 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: :allow

Regardless 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:

OldNew
can_read :books, fn q, ctx -> … endpolicy :books do scope fn q, actor -> … end end
can_create :books, fn ctx -> … endallow :create, fn actor -> … end
can_update :books, fn b, ctx -> … endallow :update, fn actor, record -> … end
can_delete :books, fn b, ctx -> … endallow :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.