Packages

Declarative, schema-first authorization middleware for Absinthe GraphQL. Auth rules live next to the field they protect.

Current section

Files

Jump to
absinthe_permission lib absinthe_permission.ex
Raw

lib/absinthe_permission.ex

defmodule AbsinthePermission do
@moduledoc """
Declarative, schema-first authorization for Absinthe GraphQL.
Auth rules live next to the field they protect. They are compiled to
introspectable data at schema-compile time, evaluated by middleware
at request time, and emit telemetry on every decision.
## At a glance
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"
authorize_owner :todo,
by: arg(:id),
if_owner: "edit_own_todo",
if_other: "edit_others_todo"
resolve &MyApp.Resolvers.update_todo/2
end
end
end
## Required Absinthe context
At request time the middleware expects two keys in the Absinthe
context:
* `:current_user` — any term; available as `current_user` /
`current_user(:field)` in conditions
* `:permissions` — a list of permission strings the caller holds
Set them in your Plug pipeline before Absinthe runs:
conn
|> Absinthe.Plug.put_options(
context: %{
current_user: user,
permissions: MyApp.Auth.permissions_for(user)
}
)
If the context is missing these keys, the middleware raises
`AbsinthePermission.MissingContextError` by default. Override with
the `:on_missing_context` option to `use AbsinthePermission`:
use AbsinthePermission, on_missing_context: :deny # or :allow
## Options for `use AbsinthePermission`
* `:on_missing_context` — `:raise` (default), `:deny`, or `:allow`.
Behaviour when `current_user`/`permissions` are absent from context.
## See also
* `AbsinthePermission.DSL` — the macros (`authorize`, `load`,
`authorize_owner`, `loader`, …)
* `AbsinthePermission.Rule` — the rule data structure
* `AbsinthePermission.Decision` — the result emitted on each check
* `mix help absinthe_permission.audit` — list every rule in a schema
"""
@doc """
Sets up a schema module to use AbsinthePermission.
Imports the DSL, registers the necessary module attributes, hooks
into Absinthe's middleware pipeline, and installs a
`@before_compile` callback that generates the rule lookup
functions.
## Options
* `:on_missing_context` — `:raise` | `:deny` | `:allow` (default `:raise`)
"""
defmacro __using__(opts \\ []) do
on_missing = Keyword.get(opts, :on_missing_context, :raise)
validate_on_missing!(on_missing)
quote do
Module.register_attribute(__MODULE__, :__ap_rules_pending__, accumulate: true)
Module.register_attribute(__MODULE__, :__ap_loads_pending__, accumulate: true)
Module.register_attribute(__MODULE__, :__ap_loaders__, accumulate: true)
Module.put_attribute(__MODULE__, :__ap_on_missing_context__, unquote(on_missing))
import AbsinthePermission.DSL
@before_compile AbsinthePermission.Compiler
def __absinthe_permission_on_missing_context__, do: unquote(on_missing)
# Override Absinthe's default `middleware/3` callback. Absinthe's
# `use Absinthe.Schema` already defined a default + `defoverridable`,
# so this `def` replaces it. Marked overridable again so end users can
# add their own middleware on top of ours.
def middleware(middleware, field, object) do
AbsinthePermission.Middleware.attach(middleware, field, object, __MODULE__)
end
defoverridable middleware: 3
end
end
defp validate_on_missing!(v) when v in [:raise, :deny, :allow], do: :ok
defp validate_on_missing!(other) do
raise ArgumentError,
"invalid :on_missing_context value #{inspect(other)}. " <>
"Valid: :raise, :deny, :allow"
end
# ---------------------------------------------------------------------------
# Public introspection API
# ---------------------------------------------------------------------------
@doc """
Return the list of rules attached to a field on a schema.
AbsinthePermission.rules_for(MyApp.Schema, :mutation, :update_todo)
Returns `[]` when the field has no rules. Useful in tests, audits,
and for AI agents inspecting the schema.
"""
@spec rules_for(module(), atom(), atom()) :: [AbsinthePermission.Rule.t()]
def rules_for(schema, type_id, field_id)
when is_atom(schema) and is_atom(type_id) and is_atom(field_id) do
schema.__absinthe_permission_rules__(type_id, field_id)
end
@doc """
Return all loads declared for a field on a schema.
AbsinthePermission.loads_for(MyApp.Schema, :mutation, :update_todo)
"""
@spec loads_for(module(), atom(), atom()) :: [AbsinthePermission.Load.t()]
def loads_for(schema, type_id, field_id)
when is_atom(schema) and is_atom(type_id) and is_atom(field_id) do
schema.__absinthe_permission_loads__(type_id, field_id)
end
@doc """
Look up a registered loader function.
AbsinthePermission.loader(MyApp.Schema, :todo)
#=> #Function<...>
"""
@spec loader(module(), atom()) :: (any(), map() -> any()) | nil
def loader(schema, name) when is_atom(schema) and is_atom(name) do
schema.__absinthe_permission_loader__(name)
end
@doc """
Return all rules in a schema, grouped by `{type_id, field_id}`.
This is what `mix absinthe_permission.audit` consumes.
"""
@spec all_rules(module()) :: %{
{atom(), atom()} => [AbsinthePermission.Rule.t()]
}
def all_rules(schema) when is_atom(schema) do
schema.__absinthe_permission_all_rules__()
end
end