Permission struct with parsing and matching capabilities.
This module provides the core permission representation for AshGrant. Permissions follow an Apache Shiro-inspired string format that handles both role-based (RBAC) and instance-level access, with an optional field group for column-level restrictions.
Permission Struct
A permission consists of:
resource- The resource type (e.g., "blog", "post") or"*"for allinstance_id- The specific resource ID or"*"for all instancesaction- The action (e.g., "read", "update") or wildcard patternsscope- The access scope (e.g., "always", "own") for filteringfield_group- Optional field group for column-level access (e.g., "sensitive")deny- Whether this is a deny rule (takes precedence over allow)
Permission Format
[!]resource:instance_id:action:scope[:field_group]| Component | Description | Valid Values |
|---|---|---|
! | Deny prefix (optional) | ! or omitted |
| resource | Resource type | identifier, * |
| instance_id | Resource instance or * | prefixed_id, UUID, * |
| action | Action name | identifier, *, read* (type wildcard) |
| scope | Access scope | all, own, custom, or empty |
| field_group | Column-level group (optional) | public, sensitive, custom |
Wildcard Patterns
Resource wildcards:
*- Matches any resource type
Instance wildcards:
*- Matches any instance (RBAC-style permission)post_abc123xyz789ab- Matches specific instance only
Action wildcards:
*- Matches any actionread*- Matches any action whose type is:read(requires action_type)
Examples
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
"*:*:*:always" # Full access to everything
"blog:*:@read:always" # All :read-TYPE actions (by type, never by name)
"!blog:*:delete:always" # DENY delete on all blogsInstance Permissions (specific instance_id)
For sharing specific resource instances (like Google Docs sharing):
"blog:post_abc123xyz789ab:read:" # Read specific post (no conditions)
"blog:post_abc123xyz789ab:*:" # Full access to specific post
"!blog:post_abc123xyz789ab:delete:" # DENY delete on specific postInstance Permissions with Scopes (ABAC)
Instance permissions can also include scopes for attribute-based conditions:
"doc:doc_123:update:draft" # Update only when document is in draft
"doc:doc_123:read:business_hours" # Read only during business hours
"invoice:inv_456:approve:small_amount" # Approve only if amount is small
"project:proj_789:admin:owner" # Admin access only when ownerWhen a scope is provided on an instance permission, it acts as an authorization condition that must be satisfied. Empty scopes (trailing colon) mean "no conditions" and are backward compatible with earlier versions.
Backward Compatibility
The parser also accepts shorter formats for convenience:
- Two-part:
resource:action→resource:*:action: - Three-part:
resource:action:scope→resource:*:action:scope
Usage
# Parse from string (new format)
{:ok, perm} = AshGrant.Permission.parse("blog:*:read:always")
# Legacy format also works
{:ok, perm} = AshGrant.Permission.parse("blog:read:always")
# Parse with error on failure
perm = AshGrant.Permission.parse!("blog:*:read:always")
# Check if permission matches for RBAC
AshGrant.Permission.matches?(perm, "blog", "read")
# => true
# Check instance permissions
inst_perm = AshGrant.Permission.parse!("blog:post_abc123:read:")
AshGrant.Permission.matches_instance?(inst_perm, "post_abc123", "read")
# => true
# Convert back to string
AshGrant.Permission.to_string(perm)
# => "blog:*:read:always"
Summary
Functions
Checks if this is a deny rule.
Reports syntax problems in a permission, for offline auditing.
Creates a Permission struct from a PermissionInput, preserving metadata.
Checks if this is an instance-level permission.
Checks if a permission matches a resource and action.
Checks if a permission matches a resource, action, and optional Ash action type.
Checks if an action pattern matches an action name.
Checks if an action pattern matches an action name, with optional Ash action type.
Checks if a permission matches a specific resource instance.
Checks if a resource pattern matches a resource name.
Parses a permission string into a Permission struct.
Parses a permission string, raising on error.
Returns the resource type from this permission.
Converts a Permission struct back to string format.
Types
@type diagnostic() :: %{ code: :deprecated_type_wildcard | :dead_instance_type_wildcard | :unknown_action_type, permission: String.t(), message: String.t(), suggestion: String.t() | nil }
A syntax problem found by diagnostics/1.
suggestion is a corrected permission string when one can be inferred, otherwise nil.
Functions
Checks if this is a deny rule.
@spec diagnostics(t() | String.t()) :: [diagnostic()]
Reports syntax problems in a permission, for offline auditing.
Permission strings are runtime data — they live in your roles table, your seeds, or
wherever your AshGrant.PermissionResolver reads from. AshGrant cannot reach that
store, so it exposes this check instead: run it over your own permission data to find
grants that need migrating.
MyApp.Role
|> MyApp.Repo.all()
|> Enum.flat_map(& &1.permissions)
|> Enum.flat_map(&AshGrant.Permission.diagnostics/1)mix ash_grant.verify runs this automatically over the permissions its policy tests
resolve. This never changes authorization behavior: a permission with diagnostics
still evaluates exactly as it always did.
Unparseable strings return [] — malformed input is parse/1's business, not this
function's.
Diagnostic codes
:deprecated_type_wildcard— the"read*"spelling. Prefer"@read"; the trailing-*form is slated for removal in v1.0.0.:dead_instance_type_wildcard— a type wildcard on an instance permission. Instance matching has no action type available, so the grant never matches anything. Seematches_instance?/3.:unknown_action_type— a type wildcard naming something that is not an Ash action type (:action,:read,:create,:update,:destroy), so it never matches."delete*"is the common case — Ash calls that type:destroy.
Examples
A well-formed grant reports nothing:
iex> AshGrant.Permission.diagnostics("blog:*:read:always")
[]
iex> AshGrant.Permission.diagnostics("blog:*:@read:always")
[]The deprecated spelling suggests its replacement:
iex> [d] = AshGrant.Permission.diagnostics("blog:*:read*:always")
iex> {d.code, d.suggestion}
{:deprecated_type_wildcard, "blog:*:@read:always"}A type wildcard on an instance permission is dead, in either spelling:
iex> [d] = AshGrant.Permission.diagnostics("blog:post_abc123:@read:")
iex> d.code
:dead_instance_type_wildcarddelete is not an Ash action type, so this grant never matches:
iex> codes = "blog:*:delete*:always" |> AshGrant.Permission.diagnostics() |> Enum.map(& &1.code)
iex> Enum.sort(codes)
[:deprecated_type_wildcard, :unknown_action_type]
iex> [_, unknown] = "blog:*:delete*:always" |> AshGrant.Permission.diagnostics() |> Enum.sort_by(& &1.code)
iex> unknown.suggestion
"blog:*:@destroy:always"
@spec from_input(AshGrant.PermissionInput.t()) :: t()
Creates a Permission struct from a PermissionInput, preserving metadata.
This function parses the permission string from the input and copies over the metadata fields (description, source, metadata).
Examples
iex> input = %AshGrant.PermissionInput{
...> string: "blog:*:read:always",
...> description: "Read all blogs",
...> source: "editor_role"
...> }
iex> AshGrant.Permission.from_input(input)
%AshGrant.Permission{
resource: "blog",
instance_id: "*",
action: "read",
scope: "always",
deny: false,
description: "Read all blogs",
source: "editor_role",
metadata: nil
}
Checks if this is an instance-level permission.
An instance permission has a specific instance_id (not "*").
Checks if a permission matches a resource and action.
This only matches RBAC-style permissions (where instance_id is "*").
For instance-level matching, use matches_instance?/3.
Does not consider scope - that's handled by the ScopeResolver.
This arity passes no action_type, so type wildcards ("@read", or the deprecated
"read*") never match here — see matches?/4 to use them.
Examples
iex> perm = AshGrant.Permission.parse!("blog:*:read:always")
iex> AshGrant.Permission.matches?(perm, "blog", "read")
trueA type wildcard needs an action type, and this arity has none — so it matches
nothing, regardless of the action name. This is false because no type was
supplied, not because "read_published" failed a name comparison:
iex> perm = AshGrant.Permission.parse!("blog:*:@read:always")
iex> AshGrant.Permission.matches?(perm, "blog", "read_published")
falseThe deprecated "read*" spelling behaves the same way here:
iex> perm = AshGrant.Permission.parse!("blog:*:read*:always")
iex> AshGrant.Permission.matches?(perm, "blog", "read_published")
falseThe bare "*" wildcard is a separate rule and is unaffected by action_type:
iex> perm = AshGrant.Permission.parse!("blog:*:*:always")
iex> AshGrant.Permission.matches?(perm, "blog", "delete")
true
Checks if a permission matches a resource, action, and optional Ash action type.
Type wildcards like "@read" match on the Ash action type alone. A :read-type
action named list_published matches "@read" because of its type — never because
of its name. When action_type is nil, type wildcards match nothing; pass a type
to use them. See matches_action?/3 for the full rules, including the deprecated
"read*" spelling.
Examples
iex> perm = AshGrant.Permission.parse!("blog:*:@read:always")
iex> AshGrant.Permission.matches?(perm, "blog", "list_published", :read)
true
iex> perm = AshGrant.Permission.parse!("blog:*:@read:always")
iex> AshGrant.Permission.matches?(perm, "blog", "list_published", :update)
falseThe deprecated "read*" spelling is equivalent:
iex> perm = AshGrant.Permission.parse!("blog:*:read*:always")
iex> AshGrant.Permission.matches?(perm, "blog", "list_published", :read)
true
Checks if an action pattern matches an action name.
Supports the catch-all wildcard "*" and exact action names.
Type wildcards ("read*") never match through this arity. They compare against an
Ash action type, and no type is available here — use matches_action?/3 and pass
an action_type for those.
Examples
iex> AshGrant.Permission.matches_action?("*", "read")
true
iex> AshGrant.Permission.matches_action?("read", "read")
true
iex> AshGrant.Permission.matches_action?("read", "write")
false"read*" is a type wildcard, not a prefix glob — it never reads the action name.
So it does not match "read_all" despite the shared prefix, and without an
action_type it matches nothing at all, not even "read" itself:
iex> AshGrant.Permission.matches_action?("read*", "read_all")
false
iex> AshGrant.Permission.matches_action?("read*", "read")
false
Checks if an action pattern matches an action name, with optional Ash action type.
Type wildcards
A type wildcard matches on the Ash action type and never looks at the action name. Two spellings are equivalent:
"@read"— preferred. Cannot be misread as a glob."read*"— deprecated, removal planned for v1.0.0. The trailing*looks like a prefix glob but never was one. Still fully supported;mix ash_grant.verifyreports grants that use it.
Because only the type is compared, a :read-type action named list_published
matches "@read", while an :update-type action named read_and_bump does not.
Without an action_type, both forms match nothing.
The catch-all "*" is a separate rule: it matches any action and ignores
action_type entirely.
Examples
iex> AshGrant.Permission.matches_action?("*", "anything", :read)
true
iex> AshGrant.Permission.matches_action?("@read", "list_published", :read)
true
iex> AshGrant.Permission.matches_action?("@read", "list_published", :update)
false
iex> AshGrant.Permission.matches_action?("read", "read", :read)
trueThe deprecated "read*" spelling behaves identically:
iex> AshGrant.Permission.matches_action?("read*", "list_published", :read)
true
iex> AshGrant.Permission.matches_action?("update*", "publish", :update)
trueNeither form matches without an action_type:
iex> AshGrant.Permission.matches_action?("@read", "read_all", nil)
false
iex> AshGrant.Permission.matches_action?("read*", "read_all", nil)
false
Checks if a permission matches a specific resource instance.
Instance matching never has an Ash action type available, so type wildcards
("read*") can never match an instance permission — such a grant is dead: it
matches nothing, ever. Use "*" or an exact action name instead.
Examples
iex> perm = AshGrant.Permission.parse!("blog:post_abc123xyz789ab:read:")
iex> AshGrant.Permission.matches_instance?(perm, "post_abc123xyz789ab", "read")
true
iex> perm = AshGrant.Permission.parse!("blog:post_abc123xyz789ab:*:")
iex> AshGrant.Permission.matches_instance?(perm, "post_abc123xyz789ab", "write")
trueA type wildcard on an instance permission is always false — this grant can never
authorize anything:
iex> perm = AshGrant.Permission.parse!("blog:post_abc123xyz789ab:read*:")
iex> AshGrant.Permission.matches_instance?(perm, "post_abc123xyz789ab", "list")
false
Checks if a resource pattern matches a resource name.
Supports wildcard matching with "*".
Examples
iex> AshGrant.Permission.matches_resource?("*", "blog")
true
iex> AshGrant.Permission.matches_resource?("blog", "blog")
true
iex> AshGrant.Permission.matches_resource?("blog", "post")
false
Parses a permission string into a Permission struct.
Supports 4-part, 5-part (with field_group), and legacy formats.
Formats
"resource:instance_id:action:scope" # 4-part
"resource:instance_id:action:scope:field_group" # 5-part (with field group)
"resource:action:scope" # Legacy 3-part → resource:*:action:scope
"resource:action" # Legacy 2-part → resource:*:action:Examples
iex> AshGrant.Permission.parse("blog:*:read:always")
{:ok, %AshGrant.Permission{resource: "blog", instance_id: "*", action: "read", scope: "always", deny: false}}
iex> AshGrant.Permission.parse("employee:*:read:always:sensitive")
{:ok, %AshGrant.Permission{resource: "employee", instance_id: "*", action: "read", scope: "always", field_group: "sensitive", deny: false}}
iex> AshGrant.Permission.parse("!blog:*:delete:always")
{:ok, %AshGrant.Permission{resource: "blog", instance_id: "*", action: "delete", scope: "always", deny: true}}
iex> AshGrant.Permission.parse("blog:post_abc123xyz789ab:read:")
{:ok, %AshGrant.Permission{resource: "blog", instance_id: "post_abc123xyz789ab", action: "read", scope: nil, deny: false}}
Parses a permission string, raising on error.
Returns the resource type from this permission.
Converts a Permission struct back to string format.
Produces a 4-part string, or 5-part when field_group is set.
Examples
iex> perm = %AshGrant.Permission{resource: "blog", instance_id: "*", action: "read", scope: "always"}
iex> AshGrant.Permission.to_string(perm)
"blog:*:read:always"
iex> perm = %AshGrant.Permission{resource: "employee", instance_id: "*", action: "read", scope: "always", field_group: "sensitive"}
iex> AshGrant.Permission.to_string(perm)
"employee:*:read:always:sensitive"
iex> perm = %AshGrant.Permission{resource: "blog", instance_id: "*", action: "delete", scope: "always", deny: true}
iex> AshGrant.Permission.to_string(perm)
"!blog:*:delete:always"