AshGrant uses an Apache Shiro-inspired permission string format with deny-wins semantics.
Permission String Format
[!]resource:instance_id:action:scope[:field_group]| Component | Description | Examples |
|---|---|---|
! | Optional deny prefix | !blog:*:delete:always |
| resource | Resource type or * | blog, post, * |
| instance_id | Resource instance or * | *, post_abc123xyz789ab |
| action | Action name or wildcard | read, *, @read |
| scope | Access scope | all, own, published, or empty |
| field_group | Optional column-level group | public, sensitive, confidential |
The 5th part (field_group) is optional. When omitted (4-part format), all fields are visible.
When present, only fields in the named group (and its inherited parents) are accessible.
Wildcard Matching Rules
| Component | * (all) | @type (action type) | Exact match |
|---|---|---|---|
| resource | Yes | No | Yes |
| instance_id | Yes | No | Yes |
| action | Yes | Yes | Yes |
| scope | No | No | Yes |
Examples:
"*:*:read:always" # All resources, action NAMED read (exact name match)
"blog:*:@read:always" # All :read-TYPE actions on blog (type match)
"blog:*:read:always" # Only the action named "read" on blog (exact match)Action Type Wildcards vs Exact Action Names
AshGrant has two distinct action matching modes:
read(exact) — matches the action namedread, regardless of its action type@read(type wildcard) — matches any action whose action type is:read, regardless of its name
These are completely separate. A type wildcard never reads the action name: @read
matches a :read-type action called list_published, and does not match an
:update-type action called read_and_bump.
| Permission | Matches | Why |
|---|---|---|
post:*:@read:always | :list, :search, :get_by_id | All actions with type: :read |
post:*:@update:always | :publish, :approve, :archive | All actions with type: :update |
post:*:read:always | :read only | Exact action name match |
This means a permission like post:*:@read:always grants access to all read-type actions
on the resource, including custom ones like :search or :export if they are defined
with type: :read — and including read actions added later, with no seed change.
Valid types are Ash's action types: @action, @read, @create, @update, @destroy.
Anything else never matches — note that deletion is @destroy, not @delete.
Warning: Be careful in workflows where different read actions should have different access levels. For example, if
:listshows summaries but:readshows full details, using@readwould grant access to both. Use exact action names instead:post:*:list:alwaysandpost:*:read:own.
The deprecated read* spelling
Type wildcards were originally spelled read*, and that form still works. It is
deprecated and slated for removal in v1.0.0, because the trailing * reads like a
prefix glob and never was one — read* does not match read_all by name, and matches
:read-type actions with entirely unrelated names.
Migrate read* to @read. To find grants still using the old spelling, run
mix ash_grant.verify, or check your permission store directly:
MyApp.Role
|> MyApp.Repo.all()
|> Enum.flat_map(& &1.permissions)
|> Enum.flat_map(&AshGrant.Permission.diagnostics/1)Type wildcards never work on instance permissions
Instance matching has no action type available, so a type wildcard on an instance permission never matches anything — the grant is dead:
"post:post_abc123:@read:" # Dead — matches nothing, ever
"post:post_abc123:read:" # Use an exact action name
"post:post_abc123:*:" # ...or the catch-all wildcardAshGrant.Permission.diagnostics/1 flags these.
Type wildcards need the action type at check time
The same requirement applies at every check, not just for instance permissions: a type wildcard can only be evaluated when the caller supplies the Ash action type. Without it, the wildcard silently fails to match, and the result can be wrong rather than merely uninformed.
You almost never hit this, because the framework-generated policy checks
(AshGrant.Check, AshGrant.FilterCheck) always pass the action type. It matters only
when your own code calls AshGrant.Evaluator directly — a nav gate, a custom
authorization helper, a script:
# No action_type: "@read" cannot be evaluated, so it is skipped.
Evaluator.has_access?(["post:*:@read:always"], "post", "read") # => false (!)
# With the type, the wildcard is evaluated:
Evaluator.has_access?(["post:*:@read:always"], "post", "read", :read) # => true
# Best: Introspect.can?/4 takes the resource module and resolves the type for you:
AshGrant.Introspect.can?(MyApp.Blog.Post, :read, actor) # => {:allow, ...}That first false is indistinguishable from a real denial, which is what makes the bug
expensive: a grant sits in the database, looks correct, and the feature it gates quietly
disappears for exactly the actors it targets.
AshGrant detects this and signals it without changing the outcome, via
AshGrant.IndeterminateMatch:
| Mode | Behavior |
|---|---|
:off | do nothing |
:warn | emit a Logger.warning (deduplicated per process) — default |
:strict | raise AshGrant.IndeterminateMatch.IndeterminateMatchError |
config :ash_grant, indeterminate_type_wildcard: :strictIt reports only calls whose answer could actually be wrong: a skipped allow wildcard
that returned false, or a skipped deny wildcard that returned true. A defensive
wildcard-plus-literal grant pair still returns the right answer and stays silent — but
prefer fixing the call to pass the type, since the pair breaks silently if the literal is
ever removed.
Generic Actions
Generic actions (Ash actions with type: :action) are best authorized by their
specific action name. Each generic action is individually unique — one might send
email, another processes a payment — so blanket type-level access is rarely what you
want.
| Permission | Matches | Why |
|---|---|---|
service:*:ping:always | :ping only | Exact action name match |
service:*:check_status:always | :check_status only | Exact action name match |
service:*:*:always | All actions including generic | Universal wildcard |
service:*:@action:always | Every generic action | Type wildcard — see warning |
Warning:
@actionis a type wildcard like any other, soservice:*:@action:alwaysgrants every generic action on the resource — including ones added later. Because generic actions are unrelated to each other, this is almost never what you want: the grant that let someone:pingalso lets them:process_refund. Prefer exact action names for generic actions.
# Grant access to specific generic actions
["service:*:ping:always", "service:*:check_status:always"]
# Or use the universal wildcard for admin access
["service:*:*:always"]RBAC Permissions (instance_id = *)
"blog:*:read:always" # Read all blogs
"blog:*:read:published" # Read only published blogs
"blog:*:update:own" # Update own blogs only
"blog:*:*:always" # All actions on all blogs
"*:*:read:always" # Read all resources
"blog:*:@read:always" # All :read-TYPE actions
"!blog:*:delete:always" # DENY delete on all blogsInstance Permissions (specific instance_id)
For sharing specific resources (like Google Docs):
"blog:post_abc123xyz789ab:read:" # Read specific post
"blog:post_abc123xyz789ab:*:" # Full access to specific post
"!blog:post_abc123xyz789ab:delete:" # DENY delete on specific postInstance permissions have an empty scope (trailing colon) because the permission is already scoped to a specific instance.
Boundary note: Instance permissions match against the resource's own key field only (
:idby default, or the field specified byinstance_key). They do not automatically propagate to child resources. For example,"feed:feed_abc:read:"grants access to the Feed record itself, but not to Posts belonging to that feed. Usescope_throughto propagate parent instance permissions to child resources.
Instance Permissions with Scopes (ABAC)
Instance permissions can include scope conditions for attribute-based access control:
# Permission format: resource:instance_id:action:scope
"doc:doc_123:update:draft" # Can update doc_123 only when in draft status
"doc:doc_123:read:" # Can read doc_123 unconditionally (empty scope)Define the scope in your resource:
ash_grant do
resolver MyApp.PermissionResolver
scope :draft, expr(status == :draft)
scope :business_hours, expr(fragment("EXTRACT(HOUR FROM NOW()) BETWEEN 9 AND 17"))
endHow it works:
- For read actions:
filter_checkadds the scope filter to the query - For write actions:
checkevaluates the scope against the target record
# User has: "doc:doc_123:update:draft"
# This succeeds (doc is in draft)
Ash.update!(draft_doc, %{title: "New"}, actor: user)
# This fails (doc is published, not draft)
Ash.update!(published_doc, %{title: "New"}, actor: user)Instance permissions work with both:
- Read actions (
filter_check/1) - AddsWHERE id IN (instance_ids)filter - Write actions (
check/1) - Validates access to specific instance
Instance Permission Read Example
# Resolver returns instance permissions for shared documents
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
def resolve(%{shared_doc_ids: doc_ids}, _context) when is_list(doc_ids) do
# Generate instance permission for each shared document
Enum.map(doc_ids, fn doc_id ->
"document:#{doc_id}:read:"
end)
end
end
# User can only read documents explicitly shared with them
actor = %{id: "user-1", shared_doc_ids: ["doc_abc", "doc_xyz"]}
Document |> Ash.read!(actor: actor)
# => Returns only doc_abc and doc_xyzCombining RBAC and Instance Permissions
When an actor has both RBAC permissions (with scopes) and instance permissions, the filters are combined with OR logic. This means users can access:
- All records matching their RBAC scopes (e.g.,
:own,:published) - Plus specific instances from instance permissions
# Actor has:
# - "document:*:read:own" (RBAC — read own documents)
# - "document:doc_abc:read:" (Instance — read shared doc)
# - "document:doc_xyz:read:" (Instance — read shared doc)
# Result filter:
# (owner_id == actor.id) OR (id IN ["doc_abc", "doc_xyz"])
# The actor can see all their own documents AND the two shared onesThis is a common pattern for document sharing: users always see their own documents, plus any documents explicitly shared with them via instance permissions.
Instance Key
By default, instance permissions match against the :id (primary key) field.
Use instance_key to match against a different field:
ash_grant do
resolver MyApp.PermissionResolver
instance_key :feed_id # Match against feed_id instead of id
scope :always, true
endWith instance_key :feed_id, the permission "feed:feed_abc:read:" generates
WHERE feed_id IN ('feed_abc') instead of WHERE id IN ('feed_abc').
Scope Through (Parent-Child Propagation)
Use scope_through to propagate a parent resource's instance permissions to
child resources via a belongs_to relationship:
defmodule MyApp.Post do
use Ash.Resource, extensions: [AshGrant]
ash_grant do
resolver MyApp.PermissionResolver
default_policies true
scope :always, true
scope :own, expr(author_id == ^actor(:id))
# Posts inherit Feed's instance permissions via :feed relationship
scope_through :feed
end
relationships do
belongs_to :feed, MyApp.Feed
end
endWhen a user has "feed:feed_abc:read:", they can read all posts where
feed_id == "feed_abc". This works for reads (FilterCheck), writes (Check),
and CanPerform calculations. Parent instance filters are combined with RBAC
scopes using OR logic.
Options:
scope_through :feed— infer parent resource from relationshipscope_through :feed, actions: [:read, :update]— limit to specific actions
Legacy Format Support
For backward compatibility, shorter formats are supported but use with caution:
| Input | Parsed As | Notes |
|---|---|---|
"blog:read:always" | blog:*:read:always | Safe - 3rd part is clearly a scope |
"blog:read" | blog:*:read: | Safe - 2-part format |
"blog:post123:read" | blog:*:post123:read | Ambiguous! post123 becomes action |
Recommendation: Always use the full 4-part format to avoid ambiguity:
# RBAC permissions
"blog:*:read:always" # Explicit 4-part format (recommended)
"blog:read:always" # Legacy 3-part format (works but discouraged)
# Instance permissions
"blog:post123:read:" # Explicit instance permission (recommended)Deny-Wins Pattern
When both allow and deny rules match, deny always takes precedence:
permissions = [
"blog:*:*:always", # Allow all blog actions
"!blog:*:delete:always" # Deny delete
]
# Result:
# - blog:read -> allowed
# - blog:update -> allowed
# - blog:delete -> DENIED (deny wins)This pattern is useful for:
- Revoking specific permissions from broad grants
- Creating "except" rules (e.g., "all except delete")
- Implementing inheritance with overrides
Multiple Matching Grants (OR-Composition)
When an actor holds several allow permissions matching the same resource and action (e.g. from multiple roles, or a role plus an add-on bundle), their scopes OR-compose: access is granted when ANY grant's scope passes. Grants are purely additive — adding a narrower grant on top of a broader one never subtracts access. Deny rules are the only subtractive device.
permissions = [
"schedule:*:cancel:online_content", # narrow add-on bundle
"schedule:*:*:always" # blanket grant
]
# cancel on ANY schedule -> allowed (the :always grant passes)
# The narrow grant does not shadow the blanket one, and permission
# order never changes the outcome.This applies uniformly across every evaluation path (#123):
- Read (
AshGrant.FilterCheck) — matching grants' scope filters are OR-ed into a single query filter - Write / generic (
AshGrant.Check) — the action is authorized when any grant's scope passes (AshGrant.Evaluator.get_write_scopes/4) - UI (
AshGrant.Calculation.CanPerform) and policy tests (assert_can/3) — the same union
To restrict access, use a ! deny rule — never rely on one grant
"overriding" another.