AshGrant.Permission (AshGrant v0.18.0)

Copy Markdown View Source

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 all
  • instance_id - The specific resource ID or "*" for all instances
  • action - The action (e.g., "read", "update") or wildcard patterns
  • scope - The access scope (e.g., "always", "own") for filtering
  • field_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]
ComponentDescriptionValid Values
!Deny prefix (optional)! or omitted
resourceResource typeidentifier, *
instance_idResource instance or *prefixed_id, UUID, *
actionAction nameidentifier, *, read* (type wildcard)
scopeAccess scopeall, own, custom, or empty
field_groupColumn-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 action
  • read* - 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 blogs

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

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

When 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:actionresource:*:action:
  • Three-part: resource:action:scoperesource:*: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

Types

A syntax problem found by diagnostics/1.

t()

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

diagnostic()

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

t()

@type t() :: %AshGrant.Permission{
  action: String.t(),
  deny: boolean(),
  description: String.t() | nil,
  field_group: String.t() | nil,
  instance_id: String.t(),
  metadata: map() | nil,
  resource: String.t(),
  scope: String.t() | nil,
  source: String.t() | nil
}

Functions

deny?(permission)

@spec deny?(t()) :: boolean()

Checks if this is a deny rule.

diagnostics(permission_string)

@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. See matches_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_wildcard

delete 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"

from_input(input)

@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
}

instance_permission?(permission)

@spec instance_permission?(t()) :: boolean()

Checks if this is an instance-level permission.

An instance permission has a specific instance_id (not "*").

matches?(perm, resource, action)

@spec matches?(t(), String.t(), String.t()) :: boolean()

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")
true

A 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")
false

The deprecated "read*" spelling behaves the same way here:

iex> perm = AshGrant.Permission.parse!("blog:*:read*:always")
iex> AshGrant.Permission.matches?(perm, "blog", "read_published")
false

The 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

matches?(perm, resource, action, action_type)

@spec matches?(t(), String.t(), String.t(), atom() | nil) :: boolean()

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)
false

The deprecated "read*" spelling is equivalent:

iex> perm = AshGrant.Permission.parse!("blog:*:read*:always")
iex> AshGrant.Permission.matches?(perm, "blog", "list_published", :read)
true

matches_action?(pattern, action)

@spec matches_action?(String.t(), String.t()) :: boolean()

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

matches_action?(pattern, action, action_type)

@spec matches_action?(String.t(), String.t(), atom() | nil) :: boolean()

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.verify reports 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)
true

The deprecated "read*" spelling behaves identically:

iex> AshGrant.Permission.matches_action?("read*", "list_published", :read)
true
iex> AshGrant.Permission.matches_action?("update*", "publish", :update)
true

Neither 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

matches_instance?(perm, instance_id, action)

@spec matches_instance?(t(), String.t(), String.t()) :: boolean()

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")
true

A 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

matches_resource?(pattern, pattern)

@spec matches_resource?(String.t(), String.t()) :: boolean()

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

parse(permission_string)

@spec parse(String.t()) :: {:ok, t()} | {:error, String.t()}

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}}

parse!(permission_string)

@spec parse!(String.t()) :: t()

Parses a permission string, raising on error.

resource(permission)

@spec resource(t()) :: String.t()

Returns the resource type from this permission.

to_string(perm)

@spec to_string(t()) :: String.t()

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"