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:
- If ANY deny rule matches → access denied
- If NO deny rule matches AND at least one allow rule matches → access granted
- 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.Permissionableprotocol
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") # falseDeny-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")
# => trueInstance 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
| Function | Purpose |
|---|---|
has_access?/3 | Check if actor can perform action on resource type |
has_instance_access?/3 | Check if actor can perform action on specific instance |
get_scope/3 | Get first matching scope (for SimpleCheck) |
get_all_scopes/3 | Get all matching scopes (for FilterCheck) |
get_field_group/3 | Get first matching field group from 5-part permissions |
get_all_field_groups/3 | Get all matching field groups (union for field access) |
get_instance_scope/3 | Get scope from instance permission (for ABAC conditions) |
get_all_instance_scopes/3 | Get all scopes from instance permissions |
get_matching_instance_ids/3 | Get all instance IDs for a resource/action |
find_matching/3 | Get all matching permissions (debug/introspection) |
combine/1 | Merge 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
@type permissions() :: [AshGrant.Permission.t() | String.t() | map()]
Functions
@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
@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
@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")
[]
@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")
[]
@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"]
@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
@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
@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")
[]
@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
@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
@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