Authorization

Copy Markdown

Incant is experimental; authorization APIs may still change before a first stable release.

Incant reuses the host Phoenix application's existing authentication. It does not generate a user model. The LiveView context exposes an actor, detected from assigns in this order:

:current_scope
:current_user
:current_admin
:actor
:user

Configure an explicit assign and policy on the admin root when needed:

defmodule MyApp.Admin do
  use Incant.Admin,
    policy: MyApp.Admin.Policy,
    actor_assign: :current_scope
end

For custom extraction, use actor: with a one-argument function or {Module, :function} callback that receives LiveView assigns:

use Incant.Admin,
  policy: MyApp.Admin.Policy,
  actor: {MyApp.Admin.Actor, :from_assigns}

Policies use a Bodyguard-compatible callback shape:

defmodule MyApp.Admin.Policy do
  use Incant.Policy

  def authorize(:view_admin, actor, context), do: allow_admin?(actor, context)
  def authorize(:view_resource, actor, %{resource: resource}), do: can_view?(actor, resource)
  def authorize(:view_dashboard, actor, %{dashboard: dashboard}), do: can_view?(actor, dashboard)
  def authorize(:view_row, actor, %{resource: resource, selected_row: row}), do: can_view_row?(actor, resource, row)
  def authorize(:create, actor, %{resource: resource, attrs: attrs}), do: can_create?(actor, resource, attrs)
  def authorize(:edit, actor, %{resource: resource, row: row, attrs: attrs}), do: can_edit?(actor, resource, row, attrs)
  def authorize(:run_action, actor, %{action: action, row: row}), do: can_run?(actor, action, row)
  def authorize(:run_bulk_action, actor, %{action: action, selected_ids: ids}), do: can_run_bulk?(actor, action, ids)
  def authorize(:run_page_action, actor, %{action: action}), do: can_run_page?(actor, action)
end

Return true/:ok to allow or false/:error/{:error, reason} to deny. Without a configured policy, Incant allows all actions.

See Architecture for the runtime authorization/data-loading flow.

Policies may also scope data:

def scope_query(actor, resource, queryable, context) do
  MyApp.Admin.Authorization.scope_query(actor, resource, queryable, context)
end

def scope_rows(actor, resource, rows, context) do
  Enum.filter(rows, &can_view_row?(actor, resource, &1))
end

To bridge Bodyguard, delegate from the Incant policy:

def authorize(action, actor, context) do
  Bodyguard.permit(MyApp.Admin.Authz, action, actor, context)
end

Phoenix 1.8 phx.gen.auth apps should usually set actor_assign: :current_scope and write policies against the generated scope struct.

Resources and dashboards can override the admin policy locally:

defmodule MyApp.Admin.Resources.Product do
  use Incant.Resource,
    schema: MyApp.Catalog.Product,
    repo: MyApp.Repo,
    policy: MyApp.Admin.Policies.Product
end

A local policy handles resource/dashboard actions and scoping for that surface; the admin policy remains the fallback.

A realistic account-scoped policy can use the Phoenix current_scope generated by phx.gen.auth:

defmodule MyApp.Admin.Policy do
  use Incant.Policy
  import Ecto.Query

  def authorize(:view_admin, %{user: user}, _context), do: user.role in [:admin, :operator]
  def authorize(:view_resource, _scope, _context), do: true
  def authorize(:view_dashboard, _scope, _context), do: true
  def authorize(:view_row, _scope, %{selected_row: nil}), do: {:error, :not_found}
  def authorize(:view_row, scope, %{selected_row: row}), do: row.account_id == scope.user.account_id
  def authorize(:create, %{user: user}, %{attrs: attrs}), do: user.role == :admin and attrs["account_id"] == to_string(user.account_id)
  def authorize(:edit, scope, %{row: row}), do: row.account_id == scope.user.account_id
  def authorize(:run_action, scope, %{row: row}), do: row.account_id == scope.user.account_id
  def authorize(:run_bulk_action, %{user: user}, _context), do: user.role == :admin
  def authorize(:run_page_action, %{user: user}, _context), do: user.role in [:admin, :operator]

  def scope_query(scope, _resource, queryable, _context) do
    where(queryable, [row], field(row, :account_id) == ^scope.user.account_id)
  end

  def scope_rows(scope, _resource, rows, _context) do
    Enum.filter(rows, &(&1.account_id == scope.user.account_id))
  end
end

For Bodyguard, keep Incant thin and delegate:

def authorize(action, actor, context) do
  Bodyguard.permit(MyApp.Admin.Authz, action, actor, context)
end