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: `id == actor.member_id` (User.member_id → Member.id, inverse relationship) - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id) ## 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) record = get_record_from_authorizer(authorizer) cond do is_nil(actor) -> log_auth_failure(actor, resource, action, "no actor") {:ok, false} is_nil(action) -> log_auth_failure( actor, resource, action, "authorizer subject shape unsupported (no action)" ) {:ok, false} true -> strict_check_with_permissions(actor, resource, action, record) end end # Helper function to reduce nesting depth defp strict_check_with_permissions(actor, resource, action, record) do 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, filter_expr} -> # For strict_check on single records, evaluate the filter against the record evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name) 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 @impl true def auto_filter(actor, authorizer, _opts) do resource = authorizer.resource action = get_action_from_authorizer(authorizer) cond do is_nil(actor) -> # No actor - deny access (fail-closed) # Return filter that never matches (id IN [] = never matches) deny_filter() is_nil(action) -> # Cannot determine action - deny access (fail-closed) deny_filter() true -> auto_filter_with_permissions(actor, resource, action) end end # Helper function to reduce nesting depth defp auto_filter_with_permissions(actor, resource, action) do 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 -> # :all scope - allow all records (no filter) # Return empty keyword list (no filtering) [] {:filter, filter_expr} -> # :linked or :own scope - apply filter # filter_expr is a keyword list from expr(...), return it directly filter_expr false -> # No permission - deny access (fail-closed) deny_filter() end else _ -> # Error case (no role, invalid permission set, etc.) - deny access (fail-closed) deny_filter() end end # Helper function to return a filter that never matches (deny all records) # Used when authorization should be denied (fail-closed) # # Using `expr(false)` avoids depending on the primary key being named `:id`. # This is more robust than [id: {:in, []}] which assumes the primary key is `:id`. defp deny_filter do expr(false) end # Helper to extract action type from authorizer # CRITICAL: Must use action_type, not action.name! # Action types: :create, :read, :update, :destroy # Action names: :create_member, :update_member, etc. # PermissionSets uses action types, not action names # # Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context) defp get_action_from_authorizer(authorizer) do # Primary: Use authorizer.action.type (stable API) case Map.get(authorizer, :action) do %{type: action_type} when action_type in [:create, :read, :update, :destroy] -> action_type _ -> # Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts) case Map.get(authorizer, :subject) do %{action_type: action_type} when action_type in [:create, :read, :update, :destroy] -> action_type %{action: %{type: action_type}} when action_type in [:create, :read, :update, :destroy] -> action_type _ -> nil end end end # Helper to extract record from authorizer for strict_check defp get_record_from_authorizer(authorizer) do case authorizer.subject do %{data: data} when not is_nil(data) -> data _ -> nil end end # Evaluate filter expression for strict_check on single records # For :linked scope with Member resource: id == actor.member_id defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do case {resource_name, record} do {"Member", %{id: member_id}} when not is_nil(member_id) -> # Check if this member's ID matches the actor's member_id if member_id == actor.member_id do {:ok, true} else {:ok, false} end {"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) -> # Check if this CFV's member_id matches the actor's member_id if cfv_member_id == actor.member_id do {:ok, true} else {:ok, false} end _ -> # For other cases or when record is not available, return :unknown # This will cause Ash to use auto_filter instead {:ok, :unknown} 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_name_for_logging) 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_name_for_logging, 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 relationship (resource-specific!) # IMPORTANT: Understand the relationship direction! # - User belongs_to :member (User.member_id → Member.id) # - Member has_one :user (inverse, no FK on Member) defp apply_scope(:linked, actor, resource_name) do case resource_name do "Member" -> # User.member_id → Member.id (inverse relationship) # Filter: member.id == actor.member_id {:filter, expr(id == ^actor.member_id)} "CustomFieldValue" -> # CustomFieldValue.member_id → Member.id → User.member_id # Filter: custom_field_value.member_id == actor.member_id {:filter, expr(member_id == ^actor.member_id)} _ -> # Fallback for other resources {:filter, expr(user_id == ^actor.id)} end end # Log authorization failures for debugging (lazy evaluation) defp log_auth_failure(actor, resource, action, reason) do Logger.debug(fn -> actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" resource_name = get_resource_name_for_logging(resource) """ Authorization failed: Actor: #{actor_id} Resource: #{resource_name} Action: #{inspect(action)} Reason: #{reason} """ end) 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