Authorization-aware GraphQL with Absinthe and Permit for Elixir.
[](https://curiosum.com/contact) [](https://curiosum.com/services/elixir-software-development) []()Permit.Absinthe provides integration between the Permit authorization library and Absinthe GraphQL for Elixir.
Installation
If available in Hex, the package can be installed
by adding permit_absinthe to your list of dependencies in mix.exs:
def deps do
[
{:permit_absinthe, "~> 0.2.0"}
]
end
Permit.Absinthe depends on :permit and permit_ecto, as well as on Absinthe and Dataloader. It does not depend on Absinthe.Plug except for running tests.
Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/permit_absinthe.
Features
- Map GraphQL types to Permit resource modules (Ecto schemas)
- Automatically check permissions for queries and mutations
- Resolvers for automatic resource loading and authorization
How it works
Permit.Absinthe applies Permit rules using three pieces of information on each field resolution.
-
The subject is who performs the action (usually
resolution.context[:current_user]). -
The action is what is being attempted (for example
:read,:create,:update). - The resource is the schema/module (or loaded struct) the action targets.
If can(subject) |> action?(resource) is allowed, the resolver continues; otherwise it returns an authorization error (or your custom handler result).
Ecto is the source of truth for matching permission conditions against data, and it constructs queries based on defined permissions to fetch records or lists of records. See Permit.Ecto docs for more on this.
Usage
Which integration should I pick?
Here's a quick table outlining which feature you should pick when plugging in Permit authorization into a specific Absinthe use case.
| If you need... | Use | Why |
|---|---|---|
| Simple query resolver | resolve &load_and_authorize/2 | Lowest boilerplate for standard resource reads |
| • Query resolver + custom processing<br>• Mutation | Permit.Absinthe.Middleware | Keeps full resolver control while reusing Permit loading/authorization |
| Association loading | resolve &authorized_dataloader/3 | Preserves Dataloader batching and enforces Permit rules |
| Authorization declared explicitly in schema directives | directives: [:load_and_authorize] | Makes authorization behavior more visible at schema level |
| Non-standard authorization or loading flow | Vanilla Permit calls inside your resolver | Maximum flexibility for advanced/custom cases |
Map GraphQL types to Permit resources
In your Absinthe schema, define metadata that maps your GraphQL types to Ecto schemas:
use Permit.Absinthe, authorization_module: MyApp.Authorization
object :post do
permit schema: MyApp.Blog.Post
field :id, :id
field :title, :string
field :content, :string
endAdd field-specific actions
By default, queries map to the :read action while mutations require explicit actions. You can specify these using the permit macro (or meta likewise):
field :unpublished_posts, list_of(:post) do
permit action: :view_unpublished
resolve &PostResolver.unpublished_posts/3
end
field :create_post, :post do
permit action: :create
resolve &PostResolver.create_post/3
endCustomisation options
The permit macro supports a set of options that let you customize how Permit Absinthe loads data, scopes Ecto queries, and formats success/errors.
:fetch_subject: function to fetch the “subject” (usually current user) from the resolution context (defaults toresolution.context[:current_user]).:base_query: function to build a custom Ecto base query before Permit scoping is applied (useful for soft deletes / tenancy / additional filters).:finalize_query: function to post-process the Ecto query after Permit scoping is applied (useful for sorting / pagination).:handle_unauthorized: function called when authorization fails or when the subject is missing; should return{:error, message}(or{:ok, value}if you intentionally want to return a safe fallback).:handle_not_found: function called when the resource cannot be found; should return{:error, message}.:unauthorized_message: custom string message used on unauthorized access (only when:handle_unauthorizedis not set).:loader: function to load data from a custom source (external API, cache, etc.) instead of the default Ecto/Dataloader-based loading.:wrap_authorized: function called on successful authorization; should return{:ok, value}(or{:error, message}) to reshape/redact the returned data.
Important caveat about callbacks
Permit Absinthe captures callback functions passed to permit (for example permit loader: &external_notes_loader/1) as AST at compile time to avoid Absinthe boilerplate. References, function calls, and aliases are supported, but functions defined in your schema module must be public (use def, not defp).
Using authorization resolvers
Permit.Absinthe provides resolver functions to load and authorize resources automatically:
defmodule MyApp.Schema do
use Absinthe.Schema
use Permit.Absinthe, authorization_module: MyApp.Authorization
object :post do
permit schema: MyApp.Blog.Post
field :id, :id
field :title, :string
field :content, :string
end
query do
field :post, :post do
permit action: :read
arg :id, non_null(:id)
resolve &load_and_authorize/2
end
field :posts, list_of(:post) do
permit action: :read
resolve &load_and_authorize/2
end
end
# ...
endCustom id field names and parameters can be specified:
field :post_by_slug, :post do
arg :slug, non_null(:string)
permit action: :read, id_param_name: :slug, id_struct_field_name: :slug
resolve &load_and_authorize/2
endLoad & authorize using Absinthe Middleware
In mutations, or when custom and more complex resolution logic is required, the Permit.Absinthe.Middleware can be used, preloading the resource (or list of resources) into context, which then can be consumed in a custom Absinthe resolver function.
query do
@desc "Get all articles"
field :articles, list_of(:article) do
permit action: :read
middleware Permit.Absinthe.Middleware
resolve(fn _parent, _args, %{context: context} = _resolution ->
# ...
{:ok, context.loaded_resources}
end)
# This would be equivalent:
#
# resolve &load_and_authorize/2
end
@desc "Get a specific article by ID"
field :article, :article do
permit action: :read
middleware Permit.Absinthe.Middleware
arg :id, non_null(:id)
resolve(fn _parent, _args, %{context: context} = _resolution ->
{:ok, context.loaded_resource}
end)
# This would be equivalent:
#
# resolve &load_and_authorize/2
end
end
mutation do
@desc "Update an article"
field :update_article, :article do
permit action: :update
arg(:id, non_null(:id))
arg(:name, non_null(:string))
arg(:content, non_null(:string))
middleware Permit.Absinthe.Middleware
resolve(fn _, %{name: name, content: content}, %{context: context} ->
case Blog.Content.update_article(context.loaded_resource, %{name: name, content: content}) do
{:ok, article} ->
{:ok, article}
{:error, changeset} ->
{:error, "Could not update article: #{inspect(changeset)}"}
end
end)
end
endDataloader integration
If you already use Absinthe + Dataloader, you usually get efficient batch loading but still need to ensure each loaded record is filtered through your authorization rules. permit_absinthe solves that by providing authorized_dataloader/3: a resolver that keeps Dataloader batching and applies Permit authorization in the same flow.
To set it up:
- Keep the standard Absinthe dataloader plugin in your schema.
-
Add
permitmetadata to the type/field so Permit knows what resource/action to authorize (defaulting to:read). -
Use
resolve &authorized_dataloader/3for associations you want to batch-load safely.
Schema setup:
def plugins do
[Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()]
endField setup:
object :item do
permit schema: MyApp.Item
field :subitems, list_of(:subitem) do
permit action: :read
resolve &authorized_dataloader/3
end
end
Compared to Permit.Absinthe v0.1, this means the removal of Permit.Absinthe.Middleware.DataloaderSetup altogether. The resolver function takes care of creating and managing necessary dataloader source structures in the Absinthe resolution.
Authorizing with GraphQL directives
Permit.Absinthe provides the :load_and_authorize directive to automatically load and authorize resources in your GraphQL fields.
The most reliable way to add Permit directives to your schema is using the prototype schema:
defmodule MyAppWeb.Schema do
use Absinthe.Schema
@prototype_schema Permit.Absinthe.Schema.Prototype
# Your schema definition...
query do
field :items, list_of(:item), directives: [:load_and_authorize] do
permit(action: :read)
resolve(fn _, %{context: %{loaded_resources: items}} ->
{:ok, items}
end)
end
end
end
The :load_and_authorize directive works with both single resources and lists of resources, ensuring that only accessible items are returned to the client based on the permission rules defined in your authorization module.
Custom resolvers with vanilla Permit authorization
For more complex authorization scenarios, you can implement custom resolvers using vanilla Permit syntax:
defmodule MyApp.Resolvers.Post do
import MyApp.Authorization
def create_post(_, args, %{context: %{current_user: user}} = _resolution) do
if can(user) |> create?(MyApp.Post) do
{:ok, MyApp.Blog.create_post(args)}
else
{:error, "Unauthorized"}
end
end
endDefault behaviour (quick reference)
| Area | Default |
|---|---|
| Action selection |
Queries default to :read; mutations must set permit action: ... explicitly. |
| Subject lookup |
Uses resolution.context[:current_user] unless you provide fetch_subject. |
| Resource mapping |
Uses the schema set via permit schema: ... on the GraphQL type. |
| Single-resource miss |
Returns {:error, "Not found"} when the record does not exist. |
| Unauthorized access |
Returns {:error, "Unauthorized"} by default (or :unauthorized_message / handle_unauthorized if configured). |
| Error customization | handle_unauthorized and handle_not_found override default error tuples. |
Community & support
- Issues: GitHub Issues
- Elixir Slack: Join us in #permit on Elixir Slack
- Blog: Permit-related content in the Curiosum Blog
Contributing
We welcome contributions! Please see the main Permit Contributing Guide for details.
Feel free to submit bugfix requests and feature ideas in Permit.Absinthe's GitHub Issues and create pull requests, whereas the best place to discuss development ideas and questions is the Permit channel in the Elixir Slack.
Development setup
-
Clone the repository and install dependencies with
mix deps.getnormally -
Tools such as Credo and Dialyzer should be run with
MIX_ENV=test -
The test suite requires Postgres to run and configured as in
config/test.exs
Contact
- Library maintainer: Michał Buszkiewicz
- Curiosum - Elixir development team behind Permit
License
This project is licensed under the MIT License - see the LICENSE file for details.