Custom Policy Check - HasPermission closes #343 #344

Merged
moritz merged 3 commits from feature/343_haspermission into main 2026-01-08 18:05:16 +01:00
Showing only changes of commit 288002f404 - Show all commits

View file

@ -0,0 +1,203 @@
defmodule Mv.Authorization.Checks.HasPermission do
@moduledoc """
Custom Ash Policy Check that evaluates permissions from the PermissionSets module.
This check:
1. Reads the actor's role and permission_set_name
2. Looks up permissions from PermissionSets.get_permissions/1
3. Finds matching permission for current resource + action
4. Applies scope filter (:own, :linked, :all)
## Usage in Ash Resource
policies do
policy action_type(:read) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
## Scope Behavior
- **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user_id == actor.id
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
## Error Handling
Returns `false` for:
- Missing actor
- Actor without role
- Invalid permission_set_name
- No matching permission found
All errors result in Forbidden (policy fails).
## Examples
# In a resource policy
policies do
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
"""
use Ash.Policy.Check
require Ash.Query
import Ash.Expr
alias Mv.Authorization.PermissionSets
require Logger
@impl true
def describe(_opts) do
"checks if actor has permission via their role's permission set"
end
@impl true
def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
# Explicit nil check first (fail fast, clear error message)
if is_nil(actor) do
log_auth_failure(actor, resource, action, "no actor")
{:ok, false}
else
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
resource_name <- get_resource_name(resource) do
case check_permission(permissions.resources, resource_name, action, actor, resource_name) do
:authorized -> {:ok, true}
{:filter, _} -> {:ok, :unknown}
false -> {:ok, false}
end
else
%{role: nil} ->
log_auth_failure(actor, resource, action, "no role assigned")
{:ok, false}
%{role: %{permission_set_name: nil}} ->
log_auth_failure(actor, resource, action, "role has no permission_set_name")
{:ok, false}
{:error, :invalid_permission_set} ->
log_auth_failure(actor, resource, action, "invalid permission_set_name")
{:ok, false}
_ ->
log_auth_failure(actor, resource, action, "missing data")
{:ok, false}
end
end
end
@impl true
def auto_filter(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
# Explicit nil check first
if is_nil(actor) do
nil
else
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
resource_name <- get_resource_name(resource) do
case check_permission(permissions.resources, resource_name, action, actor, resource_name) do
:authorized -> nil
{:filter, filter_expr} -> filter_expr
false -> nil
end
else
_ -> nil
end
end
end
# Helper to extract action from authorizer
defp get_action_from_authorizer(authorizer) do
case authorizer.subject do
%{action: %{name: action}} -> action
%{action: action} when is_atom(action) -> action
_ -> nil
end
end
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
defp get_resource_name(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
# Find matching permission and apply scope
defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do
case Enum.find(resource_perms, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end) do
nil ->
log_auth_failure(actor, resource_module_name, action, "no matching permission found")
false
perm ->
apply_scope(perm.scope, actor, resource_name)
end
end
# Scope: all - No filtering, access to all records
defp apply_scope(:all, _actor, _resource) do
:authorized
end
# Scope: own - Filter to records where record.id == actor.id
# Used for User resource (users can access their own user record)
defp apply_scope(:own, actor, _resource) do
{:filter, expr(id == ^actor.id)}
end
# Scope: linked - Filter based on user_id relationship (resource-specific!)
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# Member.user_id == actor.id (direct relationship)
{:filter, expr(user_id == ^actor.id)}
"CustomFieldValue" ->
# CustomFieldValue.member.user_id == actor.id (traverse through member!)
{:filter, expr(member.user_id == ^actor.id)}
_ ->
# Fallback for other resources: try direct user_id
{:filter, expr(user_id == ^actor.id)}
end
end
# Log authorization failures for debugging
defp log_auth_failure(actor, resource, action, reason) do
actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
resource_name = get_resource_name_for_logging(resource)
Logger.debug("""
Authorization failed:
Actor: #{actor_id}
Resource: #{resource_name}
Action: #{action}
Reason: #{reason}
""")
end
# Helper to extract resource name for logging (handles both atoms and strings)
defp get_resource_name_for_logging(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
defp get_resource_name_for_logging(resource) when is_binary(resource) do
resource
end
defp get_resource_name_for_logging(_resource) do
"unknown"
end
end