AshGrant
Permission-based authorization extension for Ash Framework.
AshGrant connects three Ash-native concepts — resources, actions, and
expr() scopes — through a permission string ([!]resource:instance_id:action:scope[:field_group]).
Permissions resolve to native Ash filters and policy checks, with deny-wins semantics.
Authorization:
- Domain-level DSL — shared resolver and scopes inherited by all resources in a domain
- Scope DSL with
expr()— row-level filters, scope inheritance,^tenant()support - Field groups — column-level read access with inheritance and masking
- Instance permissions — per-record sharing with optional scope conditions
- Deny-wins evaluation — deny rules always override allows
UI Integration:
CanPerformcalculation — per-record boolean for UI visibility (compiles to SQL), with DSL sugar (can_perform_actions,can_perform)
Verification & Tooling:
explain/4— trace why authorization succeeded or failedIntrospect— query actor permissions, available actions at runtime- Policy testing — DSL and YAML-based config tests, no database required
AshGrant handles permission evaluation, not role management. Resolve roles to permission strings in your resolver.
Installation
Add ash_grant to your list of dependencies in mix.exs:
def deps do
[
{:ash_grant, "~> 0.13"}
]
endQuick Start
1. Add the Extension to Your Resource
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshGrant]
ash_grant do
# Resolver converts actor to permission strings
resolver fn actor, _context ->
case actor do
%{role: :admin} -> ["post:*:*:all"] # Full access
%{role: :editor} -> [
"post:*:read:all", # Read all posts
"post:*:create:all", # Create posts
"post:*:update:own" # Update own posts only
]
%{role: :viewer} -> ["post:*:read:published"] # Read published only
_ -> []
end
end
default_policies true # Auto-generates read/write policies
# Scopes define row-level filters (referenced by permission strings)
scope :all, true
scope :own, expr(author_id == ^actor(:id))
scope :published, expr(status == :published)
end
# ... attributes, actions, etc.
endHow it works:
-
Actor (
%{role: :editor, id: "user_123"}) is passed to the resolver -
Resolver returns permission strings like
"post:*:update:own" -
Permission
post:*:update:ownreferences scope:own -
Scope
:ownadds filterauthor_id == actor.idto queries
2. Use It
# Editor can read all posts
editor = %{id: "user_123", role: :editor}
Post |> Ash.read!(actor: editor)
# Editor can only update their own posts
Ash.update!(post, %{title: "New Title"}, actor: editor)
# => Succeeds if post.author_id == "user_123"
# => Fails if post.author_id != "user_123"
# Viewer can only read published posts
viewer = %{id: "user_456", role: :viewer}
Post |> Ash.read!(actor: viewer)
# => Returns only posts where status == :publishedGuides
- Getting Started — Module-based resolvers, explicit policies, domain-level DSL, resolver patterns
- Permissions — Permission format, wildcards, RBAC, instance permissions, instance_key, scope_through, deny-wins
- Scopes — Scope DSL, inheritance, combination rules, multi-tenancy, relational scopes, business examples
- Field-Level Permissions — Field groups, whitelist/blacklist modes, inheritance, masking
- Checks & Policies — Check types, CanPerform calculations, DSL configuration, default_policies
- Debugging & Introspection — explain/4, permission introspection, write: option, scope descriptions
- Policy Testing — DSL and YAML tests, mix tasks, export/import
Architecture
Ash Policy Check Ash Calculation
| |
+-------------+-------------+--------+ +---v-----------+
| | | | CanPerform |
+-----v-----+ +------v------+ | | (UI booleans) |
| Check | | FilterCheck | | +---+-----------+
| (writes) | | (reads) | | |
+-----+-----+ +------+------+ | |
| | | |
+-----------+---------------+-+------+------+
|
+-----------v-----------+
| PermissionResolver |
| (actor -> permissions)|
+-----------+-----------+
|
+-----------v-----------+
| Evaluator |
| (deny-wins matching) |
+-----------+-----------+
|
+-----------v-----------+
| Scope DSL / Field |
| Groups / Resolver |
+-----------------------+Disclosure
I've been a developer for about six years. I became interested in Elixir, Phoenix, and Ash a couple of years ago, but only started actually building with them about four months ago. This library was born out of my own needs, and honestly, my skills in this ecosystem aren't at the level where I'd normally attempt building something like this.
Most of AshGrant was developed through TDD with Claude Code—I described what I needed, Claude Code wrote the tests and implementation, and I reviewed the results. I treated it like any third-party library: if the tests pass and the code looks reasonable, I use it. I haven't read every line of code in detail, so I can't guarantee everything works perfectly.
I'm using this in production because I need it now, but please consider this more as a proof of concept—a proposal for how authorization could be handled in Ash. I'm sharing this publicly in hopes that it can be a starting point. If others find it useful and want to contribute, we could build something better together.
If you have suggestions or find issues, please feel free to open an issue or submit a PR—contributions are very welcome.
What made this possible is how exceptionally well-documented Elixir and Ash are. The clear abstractions—DSLs, Domains, Resources, Extensions—gave me a precise vocabulary to communicate my requirements to an LLM. These well-defined concepts provided both the courage to start and the foundation to actually ship something I use in production.
I'm deeply grateful to Zach for creating Ash Framework, the Ash Core Team, all the contributors, and the broader Elixir community. We have something special here.
License
MIT License - see LICENSE for details.