Ash Framework: Authorization and Monoids
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:
- An identity, the neutral starting point
- 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.