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 ?? '—'}).
  • Defaults are safe on the client, permissive on the server. A component mounted without LiveView wiring (e.g. in a Storybook scenario) renders every field — the default prop is all-true. The server-side fallback in the domain module is also all-true, so entities without any declared policy behave as before.

Backward compatibility

Existing can_read :books, fn query, context -> ... end hooks and can_create / can_update / can_delete from Phase 2 continue to work — they run before the policy scope/allow gates, not instead of them. Either subsystem alone is enough; combining both produces strictly-more-restrictive access (intersection of both rule sets), which is the safe direction to fail.

Start with can_* when you only need row filtering; upgrade to policy when you need field masking or want the same rules enforced on your Svelte UI.