AshGrant.Evaluator (AshGrant v0.14.1)

Copy Markdown View Source

Permission evaluation with deny-wins semantics.

This module evaluates a list of permissions against a resource and action, implementing the deny-wins pattern where any deny rule takes precedence over allow rules. It is the core evaluation engine used by AshGrant.Check and AshGrant.FilterCheck.

Deny-Wins Pattern

The evaluation follows these rules:

  1. If ANY deny rule matches → access denied
  2. If NO deny rule matches AND at least one allow rule matches → access granted
  3. If no rules match → access denied

This is similar to Apache Shiro's authorization model and provides a secure default (deny by default) with the ability to revoke permissions at any level.

Why Deny-Wins?

The deny-wins pattern is useful for:

  • Revoking permissions: Easily revoke specific permissions from broad grants
  • Exception handling: "Allow all except X" patterns
  • Inheritance overrides: Child roles can restrict parent permissions
  • Security: Explicit denials cannot be accidentally overridden

Permission Input Formats

The evaluator accepts permissions in multiple formats:

  • Strings: "blog:*:read:always", "!blog:*:delete:always", "employee:*:read:always:sensitive" (5-part)
  • Permission structs: %AshGrant.Permission{...}
  • PermissionInput structs: %AshGrant.PermissionInput{string: "blog:*:read:always", ...}
  • Custom structs: Any struct implementing the AshGrant.Permissionable protocol

All formats are automatically normalized internally.

Examples

Basic Access Check

permissions = ["blog:*:read:always", "blog:*:write:own"]

Evaluator.has_access?(permissions, "blog", "read")   # true
Evaluator.has_access?(permissions, "blog", "write")  # true
Evaluator.has_access?(permissions, "blog", "delete") # false

Deny-Wins in Action

permissions = [
  "blog:*:*:always",           # Allow all blog actions
  "!blog:*:delete:always"      # Deny delete
]

Evaluator.has_access?(permissions, "blog", "read")   # true
Evaluator.has_access?(permissions, "blog", "update") # true
Evaluator.has_access?(permissions, "blog", "delete") # false (deny wins!)

Getting Scopes

permissions = [
  "blog:*:read:own",
  "blog:*:read:published",
  "blog:*:update:own"
]

Evaluator.get_scope(permissions, "blog", "read")
# => "own" (first matching)

Evaluator.get_all_scopes(permissions, "blog", "read")
# => ["own", "published"]

Instance Permissions

# Instance permission format: resource:instance_id:action:
permissions = ["feed:feed_abc123xyz789ab:read:", "feed:feed_abc123xyz789ab:write:"]

Evaluator.has_instance_access?(permissions, "feed_abc123xyz789ab", "read")
# => true

Instance Permissions with Scopes (ABAC)

Instance permissions can include scope conditions for attribute-based access:

# Instance permission with scope: resource:instance_id:action:scope
permissions = ["doc:doc_123:update:draft", "doc:doc_123:read:business_hours"]

# Check if access is granted
Evaluator.has_instance_access?(permissions, "doc_123", "update")
# => true

# Get the scope condition for further evaluation
Evaluator.get_instance_scope(permissions, "doc_123", "update")
# => "draft" (the application can then verify if the document is in draft status)

# Get all scopes for an action
Evaluator.get_all_instance_scopes(permissions, "doc_123", "read")
# => ["business_hours"]

Functions Overview

FunctionPurpose
has_access?/3Check if actor can perform action on resource type
has_instance_access?/3Check if actor can perform action on specific instance
get_scope/3Get first matching scope (for SimpleCheck)
get_all_scopes/3Get all matching scopes (for FilterCheck)
get_field_group/3Get first matching field group from 5-part permissions
get_all_field_groups/3Get all matching field groups (union for field access)
get_instance_scope/3Get scope from instance permission (for ABAC conditions)
get_all_instance_scopes/3Get all scopes from instance permissions
get_matching_instance_ids/3Get all instance IDs for a resource/action
find_matching/3Get all matching permissions (debug/introspection)
combine/1Merge multiple permission lists

Summary

Functions

Combines multiple permission lists with deny-wins semantics.

Finds all matching permissions (both allow and deny).

Gets all field groups from matching permissions.

Gets all scopes for matching instance permissions.

Gets all scopes for matching permissions.

Gets the field group from the first matching permission.

Gets the scope for a matching instance permission.

Gets all instance IDs that the user has permission to access.

Gets the scope for a matching permission.

Checks if the given permissions grant access to a resource and action.

Checks if the given permissions grant access to a specific resource instance.

Types

permissions()

@type permissions() :: [AshGrant.Permission.t() | String.t() | map()]

Functions

combine(permission_lists)

@spec combine([permissions()]) :: [AshGrant.Permission.t()]

Combines multiple permission lists with deny-wins semantics.

This is useful when permissions come from multiple sources (e.g., roles + instance permissions).

Examples

iex> role_perms = ["blog:*:read:always"]
iex> instance_perms = ["blog:blog_abc123xyz789ab:write:"]
iex> combined = AshGrant.Evaluator.combine([role_perms, instance_perms])
iex> AshGrant.Evaluator.has_access?(combined, "blog", "read")
true

find_matching(permissions, resource, action, action_type \\ nil)

@spec find_matching(permissions(), String.t(), String.t(), atom() | nil) :: [
  AshGrant.Permission.t()
]

Finds all matching permissions (both allow and deny).

Examples

iex> permissions = ["blog:*:*:always", "!blog:*:delete:always", "blog:*:read:published"]
iex> matching = AshGrant.Evaluator.find_matching(permissions, "blog", "read")
iex> length(matching)
2

get_all_field_groups(permissions, resource, action, action_type \\ nil)

@spec get_all_field_groups(permissions(), String.t(), String.t(), atom() | nil) :: [
  String.t()
]

Gets all field groups from matching permissions.

Returns a deduplicated list of field group names from all matching allow permissions. When an actor has multiple permissions with different field groups, these are merged as a union to determine the combined set of accessible fields.

Examples

iex> permissions = ["employee:*:read:always:sensitive", "employee:*:read:always:billing"]
iex> AshGrant.Evaluator.get_all_field_groups(permissions, "employee", "read")
["sensitive", "billing"]

iex> permissions = ["employee:*:read:always:sensitive", "!employee:*:read:always"]
iex> AshGrant.Evaluator.get_all_field_groups(permissions, "employee", "read")
[]

get_all_instance_scopes(permissions, instance_id, action)

@spec get_all_instance_scopes(permissions(), String.t(), String.t()) :: [String.t()]

Gets all scopes for matching instance permissions.

Returns a list of scopes from all matching allow permissions for the given instance. Useful when a user has multiple instance permissions with different scopes.

Examples

iex> permissions = ["doc:doc_123:read:draft", "doc:doc_123:read:internal"]
iex> AshGrant.Evaluator.get_all_instance_scopes(permissions, "doc_123", "read")
["draft", "internal"]

iex> permissions = ["doc:doc_123:*:always", "!doc:doc_123:delete:always"]
iex> AshGrant.Evaluator.get_all_instance_scopes(permissions, "doc_123", "delete")
[]

get_all_scopes(permissions, resource, action, action_type \\ nil)

@spec get_all_scopes(permissions(), String.t(), String.t(), atom() | nil) :: [
  String.t()
]

Gets all scopes for matching permissions.

Returns a list of scopes from all matching allow permissions. Useful when a user has multiple roles with different scopes.

Examples

iex> permissions = ["blog:*:read:own", "blog:*:read:published", "blog:*:read:always"]
iex> AshGrant.Evaluator.get_all_scopes(permissions, "blog", "read")
["own", "published", "always"]

get_field_group(permissions, resource, action, action_type \\ nil)

@spec get_field_group(permissions(), String.t(), String.t(), atom() | nil) ::
  String.t() | nil

Gets the field group from the first matching permission.

Returns the field_group string from the first matching allow permission. Returns nil if no matching permission, if denied, or if no field_group is set.

Examples

iex> permissions = ["employee:*:read:always:sensitive"]
iex> AshGrant.Evaluator.get_field_group(permissions, "employee", "read")
"sensitive"

iex> permissions = ["employee:*:read:always"]
iex> AshGrant.Evaluator.get_field_group(permissions, "employee", "read")
nil

get_instance_scope(permissions, instance_id, action)

@spec get_instance_scope(permissions(), String.t(), String.t()) :: String.t() | nil

Gets the scope for a matching instance permission.

Returns the scope from the first matching allow permission for the given instance. Returns nil if no matching permission is found, if denied, or if the scope is empty.

This enables ABAC-style conditions on instance permissions, where the scope represents an authorization condition (e.g., "draft", "business_hours", "small_amount").

Examples

iex> permissions = ["doc:doc_123:update:draft"]
iex> AshGrant.Evaluator.get_instance_scope(permissions, "doc_123", "update")
"draft"

iex> permissions = ["doc:doc_123:read:"]
iex> AshGrant.Evaluator.get_instance_scope(permissions, "doc_123", "read")
nil

iex> permissions = ["doc:doc_123:*:always", "!doc:doc_123:delete:always"]
iex> AshGrant.Evaluator.get_instance_scope(permissions, "doc_123", "delete")
nil

get_matching_instance_ids(permissions, resource, action, action_type \\ nil)

@spec get_matching_instance_ids(permissions(), String.t(), String.t(), atom() | nil) ::
  [String.t()]

Gets all instance IDs that the user has permission to access.

Returns a list of instance IDs from all matching instance permissions (where instance_id != "*") for the given resource and action.

This is used by FilterCheck to build a WHERE id IN (...) filter for instance-based access control.

Examples

iex> permissions = ["shareddoc:doc_abc:read:", "shareddoc:doc_xyz:read:"]
iex> AshGrant.Evaluator.get_matching_instance_ids(permissions, "shareddoc", "read")
["doc_abc", "doc_xyz"]

iex> permissions = ["shareddoc:*:read:always", "otherdoc:doc_abc:read:"]
iex> AshGrant.Evaluator.get_matching_instance_ids(permissions, "shareddoc", "read")
[]

iex> permissions = ["shareddoc:doc_abc:read:", "!shareddoc:doc_abc:read:"]
iex> AshGrant.Evaluator.get_matching_instance_ids(permissions, "shareddoc", "read")
[]

get_scope(permissions, resource, action, action_type \\ nil)

@spec get_scope(permissions(), String.t(), String.t(), atom() | nil) ::
  String.t() | nil

Gets the scope for a matching permission.

Returns the scope from the first matching allow permission. Returns nil if no matching permission is found or if the match is a deny.

Examples

iex> permissions = ["blog:*:read:always", "blog:*:update:own"]
iex> AshGrant.Evaluator.get_scope(permissions, "blog", "read")
"always"
iex> AshGrant.Evaluator.get_scope(permissions, "blog", "update")
"own"
iex> AshGrant.Evaluator.get_scope(permissions, "blog", "delete")
nil

has_access?(permissions, resource, action, action_type \\ nil)

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

Checks if the given permissions grant access to a resource and action.

Implements deny-wins: if any deny rule matches, access is denied.

Examples

iex> permissions = ["blog:*:read:always", "blog:*:write:own"]
iex> AshGrant.Evaluator.has_access?(permissions, "blog", "read")
true

iex> permissions = ["blog:*:*:always", "!blog:*:delete:always"]
iex> AshGrant.Evaluator.has_access?(permissions, "blog", "delete")
false

has_instance_access?(permissions, instance_id, action)

@spec has_instance_access?(permissions(), String.t(), String.t()) :: boolean()

Checks if the given permissions grant access to a specific resource instance.

Instance permissions use the format resource:instance_id:action:scope where the scope can be empty (backward compatible) or contain a scope condition.

Examples

iex> permissions = ["feed:feed_abc123xyz789ab:read:", "feed:feed_abc123xyz789ab:write:"]
iex> AshGrant.Evaluator.has_instance_access?(permissions, "feed_abc123xyz789ab", "read")
true

iex> permissions = ["doc:doc_123:update:draft"]
iex> AshGrant.Evaluator.has_instance_access?(permissions, "doc_123", "update")
true