Ash Framework: Why Authorization gets Messy
Where is the mess?
In the last post, Authorization and Monoids, we looked at predicate monoids—p_any
, p_all
—as tools for combining checks in predictable ways.
Monoids are:
- associative: grouping doesn’t affect the result
- identity element: a neutral element that leaves other elements unchanged
- closed under the operation: combining any two elements always produces another of the same kind
Predicate monoids also have a bias, which gives us short-circuiting. And they’re commutative, so the order in which we apply them does not matter. We can reorder, extract, or combine predicates and be confident we’re not affecting behavior.
Let’s return to this quote:
It’s possible to encode complicated logic, but it’s also pretty easy to make a mess.
Again: why?
Let’s look at Ash’s auth logic through the lens of predicate monoids.
Bypass
We’ll start with the bypass
clauses. These look like a predicate monoid based on logical OR
.
So this:
bypass actor_attribute_equals(:role, :owner) do
authorize_if always()
end
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
Is logically the same as this (reordered):
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
bypass actor_attribute_equals(:role, :owner) do
authorize_if always()
end
The OR
monoid has identity false
and short-circuits on the first true
result.
Also, because this is a monoid, we have an identity case:
# No bypass logic
No bypass
clauses evaluate to false
(identity of OR
).
Within Policy Blocks
Ash uses predicate monoids inside a policy
block.
Authorize
The authorize_if
clauses combine using logical OR
.
So this:
policy action(:update) do
authorize_if actor_attribute_equals(:role, :admin)
authorize_if actor_attribute_equals(:role, :editor)
end
Is the same as this:
policy action(:update) do
authorize_if actor_attribute_equals(:role, :editor)
authorize_if actor_attribute_equals(:role, :admin)
end
Order doesn’t matter, we still get the same answer.
Ash also supports forbid_if
, which follows the same pattern.
Forbid
forbid_if
clauses are also combined using the OR
monoid.
So this:
policy action(:update) do
forbid_if exp(published == true)
forbid_if exp(editing == true)
end
Is the same as this:
policy action(:update) do
forbid_if exp(editing == true)
forbid_if exp(published == true)
end
Again: order doesn’t matter, and it’s OR
, so we short-circuit at the first true
.
Between Policy Blocks
The book states:
Standard policies are
AND
-ed into the expression, so all need to be authorized for the action to be authorized.
And
With standard policies defined using
policy
, all applicable policies for an action must apply… It’s not that one policy takes precedence; it’s that both policies apply and have to pass.
But then it warns:
Changing the order of policies within a resource can drastically affect the result.
This is the puzzle. If we cannot change the order then we are no longer in the world of monoid predicate composition. There is something different going on. It turns out that between policy blocks we have a kind of lexicographic ordering.
Let’s say we want to order users by name:
- Start with a rule for the last name
- If the result is greater or less, STOP and report results
- If equal, we continue to the next rule, in this case compare first names.
- Continue until we run out of rules, if they all return equal, the result is not orderable
When implemented monoidally, lexicographic ordering is associative and has an identity (:unknown
- continue to next). This is how Ash combines policy blocks.
policy action(:update) do
authorize_if actor_attribute_equals(:role, :admin)
end
policy action(:update) do
authorize_if actor_attribute_equals(:role, :editor)
end
Here, the first policy will always return the Yes/No decision, so the second will never run. This will only allow admins. If we change the order:
policy action(:update) do
authorize_if actor_attribute_equals(:role, :editor)
end
policy action(:update) do
authorize_if actor_attribute_equals(:role, :admin)
end
Now only editors will be allowed to take the action, and it will never check for admins. It’s like switching the order in which you sort users: last name then first name versus first name then last name. Changing the order changes the logic.
Like lexicographic order, if the first policy returns :unknown
(like last names being equal), the second policy will run:
policy action(:update) do
# (This policy returns :unknown)
end
policy action(:update) do
authorize_if actor_attribute_equals(:role, :editor)
end
And if we reorder:
policy action(:update) do
authorize_if actor_attribute_equals(:role, :editor)
end
policy action(:update) do
# (This policy returns :unknown)
end
Because the first policy is NOT :unknown
, the second policy is short-circuited.
Back to Bypass
This also means we were wrong about bypass
blocks. They do not use OR
monoids but instead follow lexicographic ordering. However, they only produce ALLOW
or :unknown
and never DENY
. It’s this limited result that allows them to be freely reordered.
The bypass
and policy
logic exist in the same composition pipeline, but they effectively operate under different monoidal rules:
- Between bypass:
OR
monoid (commutative) - Between policies: lexicographic (non-commutative)
When the book suggests “keep all bypass policies at the start”, they are helping us avoid the pitfalls of mixing rules.
Combine authorize and forbid within a Policy
I would expect Ash to combine the result of the authorize_if
group with the negation of the forbid_if
statements using AND
.
policy action(:update) do
authorize_if actor_attribute_equals(:role, :admin)
authorize_if actor_attribute_equals(:role, :editor)
forbid_if exp(published == true)
forbid_if exp(editing == true)
end
This allows the action if the actor is either an admin OR editor, AND the article is neither published nor editing.
These predicate monoids are associative and commutative so logical order doesn’t matter.
This means even this interleaved version would work the same:
policy action(:update) do
forbid_if exp(editing == true)
authorize_if actor_attribute_equals(:role, :editor)
forbid_if exp(published == true)
authorize_if actor_attribute_equals(:role, :admin)
end
However, the book states:
If the order of the checks in our policy was reversed as it is here… Then the logic is actually a bit different.”
Wait, what?
This means within the policy, the forbid_if
and authorize_if
blocks are combined using a lexicographic monoid.
The Mess
For me, the “mess” is the cognitive switching cost between the monoids.
- “Wait, does order matter here?”
- “Am I in the OR part or the lexicographic part?”
- “If I add this check, how does it combine with the others?”
The Trade-Off
Ash prioritizes readable, English-like syntax, making it easier to implement complex logic without having to learn abstract structures. That design choice lowers the barrier to entry and allows developers to express logic in clear, declarative terms.
The tradeoff is that it becomes easy to skip understanding how the logic is actually composed. This can lead to situations where the rules appear straightforward but produce unexpected results.
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.