AbsinthePermission

Hex.pmLicense

Declarative, schema-first authorization for Absinthe GraphQL.

Auth rules live next to the field they protect. They compile to introspectable data, evaluate via middleware, and emit telemetry on every decision.

field :update_todo, :todo do
  arg :id, :integer
  arg :state, :string

  authorize "edit_todos"
  authorize "close_todos", when: arg(:state) == "CLOSED"

  authorize_owner :todo,
    by: arg(:id),
    if_owner: "edit_own_todo",
    if_other: "edit_others_todo"

  resolve &MyApp.Resolvers.update_todo/2
end

That's it. No separate policy module to wire up; no string-encoded field paths; no closures hidden in module attributes; no surprise fail-open behaviour. Conditions are real Elixir, validated at mix compile, inspectable at runtime via AbsinthePermission.rules_for/3 or mix absinthe_permission.audit.

When to use this

If you instead prefer policy modules per resource (Bodyguard / Permit style), look at permit_absinthe. This library deliberately occupies the declarative-on-schema niche.

Installation

def deps do
  [
    {:absinthe_permission, "~> 1.0"}
  ]
end

Requires Elixir ~> 1.14 and Absinthe ~> 1.7.

Five-minute walkthrough

1. Wire up the schema

defmodule MyApp.Schema do
  use Absinthe.Schema
  use AbsinthePermission

  loaders do
    loader :todo, fn id, _ctx -> MyApp.Todos.get(id) end
  end

  query do
    field :todos, list_of(:todo) do
      authorize "view_todos"
      resolve &MyApp.Resolvers.list_todos/2
    end
  end

  mutation do
    field :update_todo, :todo do
      arg :id, :integer
      arg :state, :string

      authorize "edit_todos"
      authorize "close_todos", when: arg(:state) == "CLOSED"

      resolve &MyApp.Resolvers.update_todo/2
    end
  end
end

2. Populate the context

In your Plug pipeline (typically MyAppWeb.Context):

conn
|> Absinthe.Plug.put_options(
  context: %{
    current_user: user,
    permissions: MyApp.Auth.permissions_for(user)
  }
)

permissions is a list of binary permission strings. That's it.

3. (Optional) attach telemetry

:telemetry.attach(
  "ap-deny-logger",
  [:absinthe_permission, :decision, :deny],
  &MyApp.AuthLogger.handle/4,
  []
)

DSL reference

authorize/2

authorize "edit_todos"                              # always required
authorize ["admin", "support"]                       # any-of
authorize all: ["admin", "verified_2fa"]             # all-of

authorize "close_todos",  when: arg(:state) == "CLOSED"
authorize "high_prio",    when: arg(:priority) > 5
authorize "edit_own",     when: loaded(:todo).owner_id == current_user.id
authorize "edit_others",  unless: loaded(:todo).owner_id == current_user.id
authorize "view_emails",  on_deny: :null            # redact, return null
authorize "edit_todos",   error_message: "Only admins may edit todos."

# Escape hatch
authorize "complex", when: &MyApp.Auth.complex_check/1

Condition helpers (used inside when: / unless:)

arg(:name) a GraphQL argument
loaded(:name).field.path a field on a loaded record
current_user.id (or current_user(:id)) shorthand for context.current_user.id
context.path arbitrary context lookup

All native Elixir comparison operators work: ==, !=, >, >=, <, <=, in. Combine with and / or / not.

load/2

Resolves a record once before any rule on the field runs.

load :todo, by: arg(:id)
load :user, by: arg(:user_id), using: :user_loader

Loaders are registered with loader/2:

loaders do
  loader :todo, fn id, _ctx -> MyApp.Todos.get(id) end
  loader :user, &MyApp.Users.fetch/2
end

authorize_owner/2

Sugar for the most common pattern:

authorize_owner :todo,
  by:          arg(:id),
  owner_field: :owner_id,           # default
  user_field:  :id,                  # default
  if_owner:    "edit_own_todo",
  if_other:    "edit_others_todo"

Expands to one load plus two authorize rules.

Introspection

AbsinthePermission.rules_for(MyApp.Schema, :mutation, :update_todo)
AbsinthePermission.loads_for(MyApp.Schema, :mutation, :update_todo)
AbsinthePermission.loader(MyApp.Schema, :todo)
AbsinthePermission.all_rules(MyApp.Schema)

Or from the command line:

mix absinthe_permission.audit MyApp.Schema
mix absinthe_permission.audit MyApp.Schema --filter todo
mix absinthe_permission.audit MyApp.Schema --format json

Telemetry events

Event Metadata
[:absinthe_permission, :decision, :allow]%{schema, type, field, decision}
[:absinthe_permission, :decision, :deny]%{schema, type, field, decision}
[:absinthe_permission, :decision, :nullify]%{schema, type, field, decision}
[:absinthe_permission, :load, :stop]%{loader, name, found}
[:absinthe_permission, :load, :exception]%{loader, name, error}

The decision field is a t:AbsinthePermission.Decision.t/0 — useful for audit logs.

Configuration

use AbsinthePermission, on_missing_context: :raise   # default
use AbsinthePermission, on_missing_context: :deny    # return GraphQL error
use AbsinthePermission, on_missing_context: :allow   # treat as anonymous

For AI coding agents

This repo ships an AGENTS.md cookbook with verified patterns and a one-screen mental model. If you're an LLM working on an Absinthe project, start there.

License

MIT — see LICENSE.