AshGrant uses an Apache Shiro-inspired permission string format with deny-wins semantics.

Permission String Format

[!]resource:instance_id:action:scope[:field_group]
ComponentDescriptionExamples
!Optional deny prefix!blog:*:delete:always
resourceResource type or *blog, post, *
instance_idResource instance or **, post_abc123xyz789ab
actionAction name or wildcardread, *, @read
scopeAccess scopeall, own, published, or empty
field_groupOptional column-level grouppublic, 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
resourceYesNoYes
instance_idYesNoYes
actionYesYesYes
scopeNoNoYes

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 named read, 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.

PermissionMatchesWhy
post:*:@read:always:list, :search, :get_by_idAll actions with type: :read
post:*:@update:always:publish, :approve, :archiveAll actions with type: :update
post:*:read:always:read onlyExact 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 :list shows summaries but :read shows full details, using @read would grant access to both. Use exact action names instead: post:*:list:always and post:*: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 wildcard

AshGrant.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:

ModeBehavior
:offdo nothing
:warnemit a Logger.warning (deduplicated per process) — default
:strictraise AshGrant.IndeterminateMatch.IndeterminateMatchError
config :ash_grant, indeterminate_type_wildcard: :strict

It 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.

PermissionMatchesWhy
service:*:ping:always:ping onlyExact action name match
service:*:check_status:always:check_status onlyExact action name match
service:*:*:alwaysAll actions including genericUniversal wildcard
service:*:@action:alwaysEvery generic actionType wildcard — see warning

Warning: @action is a type wildcard like any other, so service:*:@action:always grants 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 :ping also 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 blogs

Instance 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 post

Instance 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 (:id by default, or the field specified by instance_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. Use scope_through to 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"))
end

How it works:

  1. For read actions: filter_check adds the scope filter to the query
  2. For write actions: check evaluates 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) - Adds WHERE 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_xyz

Combining 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 ones

This 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
end

With 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
end

When 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 relationship
  • scope_through :feed, actions: [:read, :update] — limit to specific actions

Legacy Format Support

For backward compatibility, shorter formats are supported but use with caution:

InputParsed AsNotes
"blog:read:always"blog:*:read:alwaysSafe - 3rd part is clearly a scope
"blog:read"blog:*:read:Safe - 2-part format
"blog:post123:read"blog:*:post123:readAmbiguous! 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):

To restrict access, use a ! deny rule — never rely on one grant "overriding" another.