warden

Role- and permission-based authorization for Elixir, with Absinthe and Plug integrations. One authorizer module, two front ends:

Common guarantees:

GraphQL specifics:

See docs/DESIGN.md for the design and the implementation notes.

Warden is a community library. It is not affiliated with the absinthe-graphql or Plug projects.

Installation

def deps do
  [
    {:warden, "~> 0.1"},
    # Optional, each enables its layer:
    {:absinthe, "~> 1.12"},  # Warden.Absinthe
    {:plug, "~> 1.14"}       # Warden.Plug
  ]
end

absinthe and plug are optional dependencies. The core (Warden, Warden.Rule, Warden.Authorizer and the structs) works without either; Warden.Absinthe and Warden.Plug are compiled only when their dependency is present.

Choosing the authorizer

Configure a default, and pass authorizer: wherever you want a specific one. Both are first-class:

# config/config.exs: the default for every entry point
config :warden, authorizer: MyApp.Authz
Entry point Configured default Explicit
GraphQL schema use Warden.Absinthe use Warden.Absinthe, authorizer: MyApp.Authz
Plug plug Warden.Plug, rule: role(:admin) plug Warden.Plug, rule: role(:admin), authorizer: MyApp.Authz
Controller Warden.Plug.authorize(conn, rule, value: post) Warden.Plug.authorize(conn, rule, value: post, authorizer: MyApp.Authz)
Core Warden.actor(context), Warden.authorize(ctx, rule) Warden.actor(context, authorizer: MyApp.Authz), Warden.authorize(ctx, rule, authorizer: MyApp.Authz)

Resolution order, the same everywhere: the explicit authorizer: option, then the authorizer of the %Warden.Actor{} you pass (an explicit option that names a different module raises Warden.PolicyError), then the configured default. With none of them, Warden raises an ArgumentError that names the config line; for a schema or a plug that happens at compile time.

Several authorizers side by side (for example a separate admin API with its own role catalog) are just explicit options:

plug Warden.Plug, rule: role(:operator), authorizer: MyApp.AdminAuthz
Warden.authorize(context, role(:operator), authorizer: MyApp.AdminAuthz)

The default is read with Application.compile_env/2, because schemas and plugs resolve it at compile time. Set it in config/config.exs (or a per-env file), not in config/runtime.exs: a runtime value that differs from the compiled one makes the release refuse to boot.

The authorizer

The authorizer declares your catalogs and tells Warden who the actor is. The same module serves GraphQL, Plug and the core API.

defmodule MyApp.Authz do
  use Warden.Authorizer,
    roles: [:admin, :editor, :viewer],
    permissions: ["posts:read", "posts:write"],
    role_hierarchy: %{admin: [:editor], editor: [:viewer]},
    role_permissions: %{admin: :all, editor: ["posts:write"], viewer: ["posts:read"]},
    checks: [:self, :owner]

  @impl true
  def subject(context), do: context[:current_user]

  @impl true
  def roles(user, _context), do: user.roles

  # Optional, default [].
  @impl true
  def permissions(user, _context), do: user.extra_permissions

  # Required when `checks:` is not empty.
  @impl true
  def check(:self, %Warden.Check{actor: actor, value: user}), do: user.id == actor.subject.id
  def check(:owner, %Warden.Check{actor: actor, value: post}), do: post.author_id == actor.subject.id
end
Option Meaning
roles Role catalog (required).
permissions Permission catalog. nil (default) accepts any term.
role_hierarchy %{role => [implied roles]}. Validated: catalog roles only, no cycles.
role_permissions %{role => [permissions] | :all}. :all grants every permission.
checks Names handled by check/2.
expose_denial_reasons Add missing roles and permissions to the default error. Default false.
spec_compliant_errors Set to true if you run Absinthe with spec_compliant_errors: true.

The actor (%Warden.Actor{subject, roles, permissions, authorizer}) is built once per GraphQL execution, on every Warden.Plug check, and on every core call given a context map (never for a core call given an actor): roles are expanded through the hierarchy, role_permissions are merged with permissions/2. roles/2 and permissions/2 must return lists. Roles and permissions outside the catalog are dropped and logged, never granted. A nil or false subject is anonymous: no roles, no permissions, and roles/2 is not called. A subject of :error or {:error, _} (for example a failed token lookup) raises Warden.PolicyError: return nil for "no user".

Optional callbacks:

Core API

For controllers, contexts, jobs: any Elixir code.

import Warden.Rule

ctx = %{current_user: user}

# Each call with the configured default, then with an explicit authorizer.
actor = Warden.actor(ctx)
actor = Warden.actor(ctx, authorizer: MyApp.Authz)

:ok = Warden.authorize(ctx, check(:owner), value: post)
:ok = Warden.authorize(ctx, check(:owner), value: post, authorizer: MyApp.Authz)
# a denial: {:error, %Warden.Denial{kind: :call}}

Warden.allowed?(ctx, role(:admin))                                   # true / false
Warden.allowed?(ctx, role(:admin), authorizer: MyApp.Authz)

Warden.authorize!(ctx, check(:owner), value: post)                   # :ok or raises Warden.ForbiddenError
Warden.authorize!(ctx, check(:owner), value: post, authorizer: MyApp.Authz)

Warden.filter(ctx, check(:owner), posts)                             # the posts the actor may see
Warden.filter(ctx, check(:owner), posts, authorizer: MyApp.Authz)

# An actor built once per request works wherever a context map does, and keeps the
# authorizer it was built with; an explicit `authorizer:` must name that same module:
Warden.allowed?(actor, role(:admin))
Warden.allowed?(actor, role(:admin), authorizer: MyApp.Authz)

Plug

Needs the plug dependency.

import Warden.Rule

plug Warden.Plug, rule: role(:admin)                            # configured default
plug Warden.Plug, rule: role(:admin), authorizer: MyApp.Authz   # explicit
plug Warden.Plug, rule: authenticated(), on_deny: {MyAppWeb.Auth, :deny}
plug Warden.Plug, rule: authenticated(), on_deny: {MyAppWeb.Auth, :deny}, authorizer: MyApp.Authz

In a Phoenix controller

defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller

  import Warden.Rule

  # Route-level rules, checked before any action runs: configured default, then explicit.
  plug Warden.Plug, [rule: authenticated()] when action in [:index, :show]
  plug Warden.Plug, [rule: role(:editor), authorizer: MyApp.Authz] when action in [:new, :create]

  def show(conn, %{"id" => id}) do
    post = Posts.get!(id)

    rule = any_of([permission("posts:read"), check(:owner)])

    # Resource-level check with the configured default authorizer...
    result = Warden.Plug.authorize(conn, rule, value: post)
    # ...or with an explicit one:
    # result = Warden.Plug.authorize(conn, rule, value: post, authorizer: MyApp.Authz)

    case result do
      {:ok, conn} -> render(conn, :show, post: post)
      {:error, _denial} -> conn |> put_status(:not_found) |> put_view(MyAppWeb.ErrorHTML) |> render(:"404")
    end
  end

  def update(conn, %{"id" => id, "post" => params}) do
    post = Posts.get!(id)

    # Raises Warden.ForbiddenError (rendered as 403) when denied. Explicit authorizer;
    # without `authorizer:` the configured default is used:
    #   conn = Warden.Plug.authorize!(conn, check(:owner), value: post)
    conn = Warden.Plug.authorize!(conn, check(:owner), value: post, authorizer: MyApp.Authz)
    {:ok, post} = Posts.update(post, params)
    redirect(conn, to: ~p"/posts/#{post}")
  end
end

Warden.Plug.authorize/3 returns {:ok, conn} (the conn unchanged) or {:error, %Warden.Denial{}} and sends nothing; Warden.Plug.authorize!/3 returns the conn or raises Warden.ForbiddenError. Both take value: and authorizer:.

GraphQL

Declaring rules

defmodule MyApp.Schema do
  use Absinthe.Schema
  use Warden.Absinthe                            # configured default
  # use Warden.Absinthe, authorizer: MyApp.Authz # or explicit

  object :user do
    authorize any_of([role(:admin), check(:self)])     # object rule

    field :name, :string                               # no rule
    field :email, :string do
      authorize any_of([role(:admin), check(:self)])   # field rule
    end
  end

  input_object :post_filter do
    field :include_drafts, :boolean, default_value: false do
      authorize role(:editor)                          # input-field rule
    end
  end

  query do
    field :users, list_of(non_null(:user)) do
      authorize authenticated()
      arg :include_deleted, :boolean, default_value: false
      authorize_args include_deleted: role(:admin)     # argument rule
      resolve &Users.list/2
    end
  end
end

use Warden.Absinthe must come after use Absinthe.Schema. In type modules pulled in with import_types, add use Warden.Absinthe.Notation after use Absinthe.Schema.Notation.

Rules

Builder Passes when
public() always
authenticated() the actor has a subject
role(r) the actor has role r (after hierarchy expansion)
any_role(rs) the actor has at least one of rs
permission(p) the actor has permission p
all_permissions(ps) / any_permission(ps) all / at least one of ps
check(name) / check(name, opts) check/2 returns true
all_of(rules) / any_of(rules) all / at least one rule passes
deny() never

There is no negation on purpose. Rules are plain terms evaluated at compile time; build them from literals, module attributes or compile-time expressions.

Where rules apply

SDL schemas

Use Warden.Absinthe.Hydrate.meta/2 in hydrate/2:

def hydrate(%Absinthe.Blueprint.Schema.FieldDefinition{identifier: :email}, [%{identifier: :user} | _]) do
  [Warden.Absinthe.Hydrate.meta(rule: role(:admin))]
end

On an argument node (InputValueDefinition of a field), rule: is an argument rule.

Absinthe's {:meta, ...} hydration replaces the node's meta. Warden installs a hydrator that keeps its own keys across a later {:meta, ...} and fails compilation when two hydrations set a Warden key to different values. You can also pass your other meta as the second argument: Warden.Absinthe.Hydrate.meta([rule: role(:admin)], cache: false).

Schema options

Option Default Meaning
authorizer configured default Your Warden.Authorizer module: this option, else config :warden, authorizer: ....
strict_mode false false, :root or :all; see below.
default_object_rule nil Rule for object types without their own rule (root and introspection types excluded).
introspection public() Rule that guards __schema and __type on every object Absinthe adds them to (any type named Query or RootQueryType, not only the root). __typename is guarded only by root object rules.
list_policy :null_item Default list policy.

default_object_rule: public() does nothing except hide that a type has no rule. Do not use it to silence strict mode.

strict_mode

Rules are optional by default: a field or type without a rule is allowed.

Value Compile-time requirement
false none
:root every field of the query, mutation and subscription types declares a rule (public() counts)
:all every field of every object type declares a rule (an interface field rule counts); introspection excluded

A missing rule is a CompileError that names every offending field. Introspection fields are recognized by their GraphQL name (__typename, __schema, __type), so a field whose identifier starts with __ is an ordinary field.

Denial handling

On a denial Warden calls deny/1 on your authorizer with a %Warden.Denial{}: kind (:field | :object | :argument | :subscribe | :introspection), rule, failed_rule, missing_roles, missing_permissions, actor, path, field, parent_type, type, value_index, argument and list_policy.

Return Effect
{:error, term} the field becomes null with this error (string, map or keyword list)
:null the field becomes null with no error
{:replace, value} the field resolves to value (kind: :field only); object rules still apply to it

The default handler:

Return an error for object denials from your own deny/1 if you want them reported. Any other return raises Warden.PolicyError.

A null in a non-null position folds to the nearest nullable parent, as the GraphQL spec requires. For :null on a non-null field, Absinthe adds its own "Cannot return null for non-nullable field" error. Absinthe does not fold a non-null field of an object that sits two or more list levels deep ([[T]]); Warden folds those itself when it nulled the field.

When a field is denied, the rest of its middleware does not run either.

List policies

Set per field with list_policy :filter (or the schema-wide list_policy: option):

Policy Denied item Item type non-null
:null_item (default) becomes null (with an error at the item index if the handler returns one) folds to the list field
:filter removed, no error removed
:fail_list the whole list field is denied same

With :filter, a handler that returns {:error, term} keeps the item as null with that error, because Absinthe cannot report an error for an item that is not in the list. Error paths use the index in the returned list (filtered items do not count), so they never point at another item or reveal the original position. With nested lists whose innermost items are non-null ([[T!]]), Absinthe nulls the whole field when any inner list becomes null; Warden keeps every item error on the field.

Subscriptions

The context of a subscription is the context at subscribe time. A revoked role or permission applies on the next event only if roles/2 and permissions/2 read current data, not a user struct stored in the context.

Security model and limits

REST and the core API

GraphQL

Warden enforces rules on what GraphQL execution returns and on what reaches resolvers. It does not protect against the following:

Development

mise exec -- mix test

test/standalone/no_optional_deps_test.exs builds throwaway Mix projects that depend on Warden by path (without the optional dependencies, with only plug, with only absinthe, and with only the configured default authorizer), compiles them with warnings as errors and runs Warden in them. It is excluded by default:

mise exec -- mix test --only no_optional_deps

The property tests run 500 cases by default; set WARDEN_PROPERTY_RUNS for more.

Static checks (Dialyzer must report 0 warnings):

mise exec -- mix compile --warnings-as-errors --force
mise exec -- mix format --check-formatted
mise exec -- mix dialyzer
MIX_ENV=test mise exec -- mix dialyzer   # also covers test/support, like ElixirLS

Contributing

See CONTRIBUTING.md for setup, the checks CI runs, and the commit message format (Conventional Commits).