AGENTS.md — absinthe_permission cookbook for AI coding agents

Copy Markdown View Source

This file is the canonical "what does this library do, and what's the shortest path to do X" reference. It exists for AI coding agents (and fast-skim humans). Everything below is verified against the test suite and produces a working schema if pasted in.

Mental model in five lines

  1. Auth rules live next to the field they protect, via macros.
  2. use AbsinthePermission in the schema turns those macros on and wires up middleware.
  3. Each authorize "perm" becomes a %Rule{} stored on the schema at compile time.
  4. The middleware evaluates the rules at request time and emits telemetry on every decision.
  5. Conditions like arg(:state) == "CLOSED" are compiled to data ({:cmp, [:eq, {:arg, :state}, {:literal, "CLOSED"}]}), not closures — they are introspectable via AbsinthePermission.rules_for/3.

Setup pattern

defmodule MyApp.Schema do
  use Absinthe.Schema
  use AbsinthePermission                   # default: raise on missing context

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

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

The Plug pipeline must populate current_user and permissions:

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

Rule shapes

WantWrite
Always requiredauthorize "perm"
Any of Nauthorize ["a", "b"]
All of Nauthorize all: ["a", "b"]
Conditional on argauthorize "p", when: arg(:state) == "CLOSED"
Numeric comparisonauthorize "p", when: arg(:n) > 5
Inverse conditionauthorize "p", unless: arg(:flag)
List membershipauthorize "p", when: arg(:role) in ["admin", "support"]
Custom errorauthorize "p", error_message: "Admins only."
Field redactionauthorize "p", on_deny: :null

Conditions cookbook

Inside when: / unless::

arg(:state) == "CLOSED"                     # GraphQL argument
arg(:priority) > 5                           # any comparison
arg(:role) in ["admin", "support"]           # list membership
loaded(:todo).owner_id == current_user.id    # remote record + user context
current_user.role == "admin"                 # context shorthand
context.tenant_id == arg(:tenant_id)         # arbitrary context path
arg(:state) == "CLOSED" and arg(:flag)       # boolean composition

Escape hatch when macros can't express it:

authorize "perm", when: &MyAuth.complex_check/1
# or:
authorize "perm", when: fn %{args: a, context: c, loaded: l} -> ... end

The function receives %{args: map, context: map, loaded: map} and must return a truthy/falsy value.

Loading data before rules

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

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

  authorize "edit_own", when: loaded(:todo).owner_id == current_user.id
end

loads resolve once, before any rule evaluates, so multiple rules share the loaded data.

Owner-or-others sugar

The 80 % case as a one-liner:

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

  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"
end

Expands to a load plus two authorize rules.

Field-level redaction

Returns null instead of denying the operation. Use for sensitive leaf fields like :email:

object :user do
  field :id, :integer
  field :name, :string
  field :email, :string do
    authorize "view_emails", on_deny: :null
  end
end

Introspection (use this when you change anything)

AbsinthePermission.rules_for(MyApp.Schema, :mutation, :update_todo)
#=> [%AbsinthePermission.Rule{permission: {:any, ["edit_todos"]}, ...}, ...]

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

CLI for surveying every rule in a schema:

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

Telemetry events

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

Attach in your application supervision tree:

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

Compile-time guarantees

The DSL fails at mix compile (not at request time) for:

  • Unknown identifier in a condition (when: foo(:bar) where foo is not arg/loaded/current_user/context)
  • on_deny: value other than :error | :null | :filter

  • authorize_owner missing :if_owner or :if_other
  • :when and :unless set on the same rule
  • authorize / load called outside a schema field/object body
  • Permission spec containing non-string values

Configuring missing-context behaviour

use AbsinthePermission, on_missing_context: :raise   # default
use AbsinthePermission, on_missing_context: :deny    # return GraphQL error
use AbsinthePermission, on_missing_context: :allow   # bypass auth (anon mode)

:raise raises AbsinthePermission.MissingContextError. :deny returns an Unauthorized: missing context GraphQL error. :allow treats requests with no current_user/permissions as fully permitted — choose only when you understand the implications.

Safety properties (verified by the test suite)

  • Fail-loud on missing context — by default, requests without current_user / permissions raise. No silent fall-through.
  • No String.to_atom on user input — permissions are kept as binaries throughout the evaluation pipeline.
  • AND-semantics across rules — every fired rule must pass. Multiple authorize lines compose conjunctively; use any: [...] on a single rule for OR.
  • Pure data conditions — every condition (other than the :fun escape hatch) is plain tuples, serialisable, hashable, and printable.

File map (for navigation)

PathWhat lives there
lib/absinthe_permission.exPublic API, __using__ macro
lib/absinthe_permission/dsl.exDSL macros (authorize, load, loader, …)
lib/absinthe_permission/compiler.exCondition AST → data, scope detection, before-compile
lib/absinthe_permission/evaluator.exPure evaluation of rules and conditions
lib/absinthe_permission/middleware.exAbsinthe middleware integration
lib/absinthe_permission/rule.ex%Rule{} struct + permission normalization
lib/absinthe_permission/load.ex%Load{} struct
lib/absinthe_permission/decision.ex%Decision{} struct (telemetry payload)
lib/absinthe_permission/condition.exCondition grammar + formatter
lib/absinthe_permission/error.exCustom exception structs
lib/mix/tasks/absinthe_permission.audit.exThe audit task
test/absinthe_permission/integration_test.exsEnd-to-end with a real schema
test/absinthe_permission/evaluator_test.exsPure-function unit tests
test/support/test_schema.exWorked schema demonstrating every feature

Don't do this

  • authorize :symbol — permissions must be binaries. The compile error is helpful but you can save the round trip.
  • Module.put_attribute(:foo, :bar, ...) directly inside a field — use authorize / load. The library's introspection won't see your raw attribute.
  • current_user_id (the old DSL atom shorthand from v0.1) — use current_user.id or current_user(:id) instead.
  • String.to_atom/1 on permission names — use binaries everywhere.

Migration from v0.1.x

v1.0.0 is a complete rewrite. The old DSL (pre_op_policies, post_op_policies, remote_context, user_context, value-first {value, op} tuples) is removed. Mechanical translation:

v0.1v1.0
meta(required_permission: "p")authorize "p"
meta(pre_op_policies: [[state: "X", required_permission: "p"]])authorize "p", when: arg(:state) == "X"
remote_context: [config: [fetcher_key: :db, …], fields: […], required_permission: "p"]load :name, by: arg(:id) + authorize "p", when: loaded(:name).x == y
meta(post_op_policies: [[required_permission: "p"]]) (on a field)authorize "p", on_deny: :null
Value-first {:current_user_id, :neq}current_user.id != ...