Authorization logic gets messy fast.

The Ash Framework book warns:

“It’s possible to encode complicated logic, but it’s also pretty easy to make a mess.”

That’s catnip for me.

Why is it so easy to make a mess in authorization code? Are better patterns possible? Or is the problem itself just messy?

As someone who leans functional, I tend to see authorization as a problem of combining predicates, which is a job for the monoid.

The Basics

A monoid has two parts:

  1. An identity, the neutral starting point
  2. A combining operation, a way to reduce many into one

Addition is a monoid:

  • Identity: 0
  • Operation: +
Funx.Math.sum([1, 2, 3, 4])
# 10

Monoids handle empty input by returning the identity:

Funx.Math.sum([])
# 0

A monoid is closed under its operation. Combining elements produces another element of the same type:

a = Funx.Math.sum([1, 2])     # 3
b = Funx.Math.sum([4, 5])     # 9
Funx.Math.sum([a, b])         # 12

This makes them easy to reuse and compose.

Predicate Monoids

Let’s switch to a domain example. Here are some basic predicates:

defmodule AlbumPolicy do
  def is_admin?(actor, _album), do: actor.role == :admin
  def is_editor?(actor, _album), do: actor.role == :editor
  def is_creator?(actor, album), do: album.created_by_id == actor.id
end

The predicate monoid has a bias, a preferred outcome that determines what happens when no check gives a clear result. The bias enables short-circuiting; when a check counters the bias, evaluation stops.

Funx includes two predicate monoids: p_any/1 and p_all/1.

any returns true if any check passes

  • Identity is a function that returns false
  • Operation is or
  • Bias is false
  • Stops early when a check returns true
can_manage? = p_any([&AlbumPolicy.is_admin?/2, &AlbumPolicy.is_editor?/2])
can_manage?.(actor, album)

If the list is empty, it returns the identity:

p_any([]).(actor, album)
# false

all returns true only if all checks pass

  • Identity is a function that returns true
  • Operation is and
  • Bias is true
  • Stops early when a check returns false
editor_and_creator? = p_all([&AlbumPolicy.is_editor?/2, &AlbumPolicy.is_creator?/2])
editor_and_creator?.(actor, album)

Again, the empty case returns the identity:

p_all([]).(actor, album)
# true

Composing predicates

Predicate monoids work just like any other function. You can nest them, pass them around, and reuse them.

editor_and_creator? = p_all([
  &AlbumPolicy.is_editor?/2,
  &AlbumPolicy.is_creator?/2
])

can_manage? = p_any([
  &AlbumPolicy.is_admin?/2,
  editor_and_creator?
])

can_manage?.(actor, album)

Order Affects Speed, Not Result

Changing the order returns the same result:

p_any([&is_admin?/1, &is_author?/1, &is_moderator?/1])
p_any([&is_moderator?/1, &is_admin?/1, &is_author?/1])
# same result

Ideally predicates are fast and pure but if one is impure or computationally heavy, evaluation order matters. We want slow logic to run only after quick checks fail to decide the outcome.

For instance, if our is_creator?/2 required database access (impure):

def is_creator?(actor, album_id) do
  album = Repo.get!(Album, album_id)
  album.created_by_id == actor.id
end

Now order matters:

# Better: fast check first
p_any([&AlbumPolicy.is_admin?/2, &AlbumPolicy.is_creator?/2])

# Worse: slow check first
p_any([&AlbumPolicy.is_creator?/2, &AlbumPolicy.is_admin?/2])

Deferred checks

The Ash Framework is solving this defer problem with something they call a filter check:

“If a policy check would have different answers depending on the record being checked…we say this is a filter check. If it depends only on the actor or a static value like always(), then we say it’s a simple check.”

Simple checks evaluate immediately. Filter checks are expensive, so Ash defers them with the value :unknown.

Extending predicates with :unknown is called Kleene logic, and there are monoids for combining these values.

kleene_all

Same structure as p_all: short-circuit on false, identity is true.

kleene_all([true, true, true, true])
# true

kleene_all([true, :unknown, false, true])
# short-circuits on false

kleene_all([true, :unknown, true, true])
# evaluates all checks and returns :unknown

kleene_any

Same structure as p_any: short-circuit on true, identity is false.

kleene_any([false, false, false, false])
# false

kleene_any([false, :unknown, true, false])
# short-circuits on true

kleene_any([false, :unknown, false, false])
# evaluates all checks and returns :unknown

Monoids Lose Detail

kleene_any([false, :unknown, :unknown])
# :unknown

A monoid reduces many results into one, so when multiple checks return :unknown, the fold gives a single :unknown, losing which check or checks produced it.

In Ash, :unknown signals that slower effectful code should run, such as checking the database. Fast checks run first, and only if needed does the system fall back to the slower ones.

Where This Leads

Monoids give us a predictable way to combine logic. But once we introduce impure or compute-heavy predicates, evaluation order starts to matter.

Ash addresses this with evaluation layers, each using a different monoid, along with a defer strategy for expensive or unknown checks.

The “mess” we’ve been talking about lives in that coordination.

Next: how Ash’s evaluation layers work, why different monoids appear at each level, and how mixing authorize_if and forbid_if makes order affect correctness, not just performance.

This is part of a series on the Ash Framework book. Previous: The Coordination Problem.

Resources

Ash Framework

Learn how to use Ash Framework to build robust, maintainable Elixir applications. This book covers the principles and practices of domain-driven design with Ash's declarative approach.

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.

Funx - Functional Programming for Elixir

A library of functional programming abstractions for Elixir, including monads, monoids, Eq, Ord, and more. Built as an ecosystem where learning is the priority from the start.