# Permit.Absinthe

Authorization-aware GraphQL with Absinthe and Permit for Elixir.

[![Contact Us](https://img.shields.io/badge/Contact%20Us-%23F36D2E?style=for-the-badge&logo=maildotru&logoColor=white&labelColor=F36D2E)](https://curiosum.com/contact) [![Visit Curiosum](https://img.shields.io/badge/Visit%20Curiosum-%236819E6?style=for-the-badge&logo=elixir&logoColor=white&labelColor=6819E6)](https://curiosum.com/services/elixir-software-development) [![License: MIT](https://img.shields.io/badge/License-MIT-1D0642?style=for-the-badge&logo=open-source-initiative&logoColor=white&labelColor=1D0642)]()

Permit.Absinthe provides integration between the Permit authorization library and Absinthe GraphQL for Elixir.

Hex version badgeVersion badgeActions StatusLicense badge

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

How it works

Permit.Absinthe applies Permit rules using three pieces of information on each field resolution.

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
&bull; Query resolver + custom processing<br>&bull; 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
end

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

Customisation 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.

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

  # ...
end

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

Load & 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
  end

Dataloader 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:

  1. Keep the standard Absinthe dataloader plugin in your schema.
  2. Add permit metadata to the type/field so Permit knows what resource/action to authorize (defaulting to :read).
  3. Use resolve &authorized_dataloader/3 for associations you want to batch-load safely.

Schema setup:

def plugins do
  [Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()]
end

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

Default 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

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

Contact

License

This project is licensed under the MIT License - see the LICENSE file for details.