feat: implement authorization policies for Member resource
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
f7cda66598
commit
9d58c9d1ef
5 changed files with 169 additions and 17 deletions
|
|
@ -34,7 +34,8 @@ defmodule Mv.Membership.Member do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
|
@ -294,6 +295,40 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Authorization Policies
|
||||
# Order matters: Most specific policies first, then general permission check
|
||||
policies do
|
||||
# SYSTEM OPERATIONS: Allow operations without actor (seeds, tests, system jobs)
|
||||
# This must come first to allow database seeding and test fixtures
|
||||
# IMPORTANT: Use bypass so this short-circuits and doesn't require other policies
|
||||
bypass action_type([:create, :read, :update, :destroy]) do
|
||||
description "Allow system operations without actor (seeds, tests)"
|
||||
authorize_if Mv.Authorization.Checks.NoActor
|
||||
end
|
||||
|
||||
# SPECIAL CASE: Users can always READ their linked member
|
||||
# This allows users with ANY permission set to read their own linked member
|
||||
# Check using the inverse relationship: User.member_id → Member.id
|
||||
bypass action_type(:read) do
|
||||
description "Users can always read member linked to their account"
|
||||
authorize_if expr(id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# GENERAL: Check permissions from user's role
|
||||
# HasPermission handles update permissions correctly:
|
||||
# - :own_data → can update linked member (scope :linked)
|
||||
# - :read_only → cannot update any member (no update permission)
|
||||
# - :normal_user → can update all members (scope :all)
|
||||
# - :admin → can update all members (scope :all)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# DEFAULT: Forbid if no policy matched
|
||||
# Ash implicitly forbids if no policy authorized
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members list based on email match priority.
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
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) ->
|
||||
|
|
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
{:ok, false}
|
||||
|
||||
true ->
|
||||
strict_check_with_permissions(actor, resource, action)
|
||||
strict_check_with_permissions(actor, resource, action, record)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to reduce nesting depth
|
||||
defp strict_check_with_permissions(actor, resource, action) do
|
||||
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),
|
||||
|
|
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
actor,
|
||||
resource_name
|
||||
) do
|
||||
:authorized -> {:ok, true}
|
||||
{:filter, _} -> {:ok, :unknown}
|
||||
false -> {:ok, false}
|
||||
: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} ->
|
||||
|
|
@ -150,15 +157,60 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
end
|
||||
end
|
||||
|
||||
# Helper to extract action from authorizer
|
||||
# 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
|
||||
defp get_action_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{action: %{name: action}} -> action
|
||||
%{action: action} when is_atom(action) -> action
|
||||
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
# Fallback for older Ash versions or different subject shapes
|
||||
%{action: %{type: action_type}} when action_type in [:create, :read, :update, :destroy] ->
|
||||
action_type
|
||||
|
||||
_ ->
|
||||
nil
|
||||
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()
|
||||
|
|
@ -190,21 +242,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
|||
end
|
||||
|
||||
# Scope: linked - Filter based on user relationship (resource-specific!)
|
||||
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
|
||||
# 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" ->
|
||||
# Member has_one :user → filter by user.id == actor.id
|
||||
{:filter, expr(user.id == ^actor.id)}
|
||||
# User.member_id → Member.id (inverse relationship)
|
||||
# Filter: member.id == actor.member_id
|
||||
{:filter, expr(id == ^actor.member_id)}
|
||||
|
||||
"CustomFieldValue" ->
|
||||
# CustomFieldValue belongs_to :member → member has_one :user
|
||||
# Traverse: custom_field_value.member.user.id == actor.id
|
||||
{:filter, expr(member.user.id == ^actor.id)}
|
||||
# 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: try user relationship first, then user_id
|
||||
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
|
||||
# Fallback for other resources
|
||||
{:filter, expr(user_id == ^actor.id)}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
60
lib/mv/authorization/checks/no_actor.ex
Normal file
60
lib/mv/authorization/checks/no_actor.ex
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
defmodule Mv.Authorization.Checks.NoActor do
|
||||
@moduledoc """
|
||||
Custom Ash Policy Check that allows actions when no actor is present.
|
||||
|
||||
This is primarily used for:
|
||||
- Database seeding (priv/repo/seeds.exs)
|
||||
- Test fixtures that create data without authentication
|
||||
- Background jobs that operate on behalf of the system
|
||||
|
||||
## Security Note
|
||||
|
||||
This check should only be used for specific actions where system-level
|
||||
access is appropriate. It should always be combined with other policy
|
||||
checks that validate actor-based permissions when an actor IS present.
|
||||
|
||||
## Usage in Policies
|
||||
|
||||
policies do
|
||||
# Allow seeding and system operations
|
||||
policy action_type(:create) do
|
||||
authorize_if NoActor
|
||||
end
|
||||
|
||||
# Check permissions when actor is present
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
authorize_if HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
## Behavior
|
||||
|
||||
- Returns `{:ok, true}` when actor is nil (allows action)
|
||||
- Returns `{:ok, :unknown}` when actor is present (delegates to other policies)
|
||||
- `auto_filter` returns nil (no filtering needed)
|
||||
"""
|
||||
|
||||
use Ash.Policy.Check
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
"allows actions when no actor is present (for seeds and system operations)"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, _authorizer, _opts) do
|
||||
if is_nil(actor) do
|
||||
# No actor present - allow (for seeds, tests, system operations)
|
||||
{:ok, true}
|
||||
else
|
||||
# Actor present - let other policies decide
|
||||
{:ok, :unknown}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(_actor, _authorizer, _opts) do
|
||||
# No filtering needed - this check only validates presence/absence of actor
|
||||
nil
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue