AshGrant.Permission (AshGrant v0.14.1)

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, *, prefix*
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
"!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

Functions

Checks if this is a deny rule.

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

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.

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.

Examples

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

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

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.

When action_type is provided, prefix patterns like "read*" will also match actions whose Ash type equals the prefix (e.g., a :read-type action named list_published matches "read*").

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

matches_action?(pattern, action)

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

Checks if an action pattern matches an action name.

Supports wildcard matching with "*" and prefix matching with "prefix*".

Examples

iex> AshGrant.Permission.matches_action?("*", "read")
true
iex> AshGrant.Permission.matches_action?("read", "read")
true
iex> AshGrant.Permission.matches_action?("read*", "read_all")
false
iex> AshGrant.Permission.matches_action?("read", "write")
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.

When action_type is provided and the pattern is a type wildcard like "read*", the match succeeds if the action type matches the prefix. This allows "read*" to match :read-type actions like list_published or by_slug. Without action_type, type wildcards never match — use exact action names instead.

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_all", nil)
false
iex> AshGrant.Permission.matches_action?("update*", "publish", :update)
true
iex> AshGrant.Permission.matches_action?("read", "read", :read)
true

matches_instance?(perm, instance_id, action)

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

Checks if a permission matches a specific resource instance.

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

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"