User Resource Policies closes #363 #364
2 changed files with 72 additions and 5 deletions
|
|
@ -5,9 +5,8 @@ defmodule Mv.Accounts.User do
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Accounts,
|
domain: Mv.Accounts,
|
||||||
data_layer: AshPostgres.DataLayer,
|
data_layer: AshPostgres.DataLayer,
|
||||||
extensions: [AshAuthentication]
|
extensions: [AshAuthentication],
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
# authorizers: [Ash.Policy.Authorizer]
|
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "users"
|
table "users"
|
||||||
|
|
@ -267,6 +266,51 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Authorization Policies
|
||||||
|
# Order matters: Most specific policies first, then general permission check
|
||||||
|
policies do
|
||||||
|
# ASHAUTHENTICATION BYPASS: Allow authentication actions (registration, login)
|
||||||
|
# These actions are called internally by AshAuthentication and need to bypass
|
||||||
|
# normal authorization policies. This must come FIRST because User is an
|
||||||
|
# authentication resource and authentication flows should have priority.
|
||||||
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
|
description "Allow AshAuthentication internal operations (registration, login)"
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
|
||||||
|
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
|
||||||
|
# In test: All operations allowed (for test fixtures)
|
||||||
|
# In production/dev: ALL operations denied without actor (fail-closed for security)
|
||||||
|
# NoActor.check uses compile-time environment detection to prevent security issues
|
||||||
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
|
description "Allow system operations without actor (test environment only)"
|
||||||
|
authorize_if Mv.Authorization.Checks.NoActor
|
||||||
|
end
|
||||||
|
|
||||||
|
# SPECIAL CASE: Users can always READ their own account
|
||||||
|
# This allows users with ANY permission set to read their own user record
|
||||||
|
# Uses bypass with expr filter to enable auto_filter behavior for reads/lists
|
||||||
|
# (consistent with Member "always read linked member" pattern)
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# GENERAL: Check permissions from user's role
|
||||||
|
# HasPermission handles permissions correctly:
|
||||||
|
# - :own_data → can update own user (scope :own)
|
||||||
|
# - :read_only → can update own user (scope :own)
|
||||||
|
# - :normal_user → can update own user (scope :own)
|
||||||
|
# - :admin → can read/create/update/destroy all users (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: Ash implicitly forbids if no policy authorizes
|
||||||
|
# No explicit forbid needed, as Ash's default behavior is fail-closed
|
||||||
|
end
|
||||||
|
|
||||||
# Global validations - applied to all relevant actions
|
# Global validations - applied to all relevant actions
|
||||||
validations do
|
validations do
|
||||||
# Password strength policy: minimum 8 characters for all password-related actions
|
# Password strength policy: minimum 8 characters for all password-related actions
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,25 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
resource_name
|
resource_name
|
||||||
) do
|
) do
|
||||||
:authorized ->
|
:authorized ->
|
||||||
|
# For :all scope, authorize directly
|
||||||
{:ok, true}
|
{:ok, true}
|
||||||
|
|
||||||
{:filter, filter_expr} ->
|
{:filter, filter_expr} ->
|
||||||
# For strict_check on single records, evaluate the filter against the record
|
# For :own/:linked scope:
|
||||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
# - With a record, evaluate filter against record for strict authorization
|
||||||
|
# - Without a record (queries/lists), return false
|
||||||
|
#
|
||||||
|
# NOTE: Returning false here forces the use of expr-based bypass policies.
|
||||||
|
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
|
||||||
|
# when strict_check returns :unknown. Instead, resources should use bypass policies
|
||||||
|
# with expr() directly for filter-based authorization (see User resource).
|
||||||
|
if record do
|
||||||
|
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||||
|
else
|
||||||
|
# No record yet (e.g., read/list queries) - deny at strict_check level
|
||||||
|
# Resources must use expr-based bypass policies for list filtering
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
|
||||||
false ->
|
false ->
|
||||||
{:ok, false}
|
{:ok, false}
|
||||||
|
|
@ -224,9 +238,18 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Evaluate filter expression for strict_check on single records
|
# Evaluate filter expression for strict_check on single records
|
||||||
|
# For :own scope with User resource: id == actor.id
|
||||||
# For :linked scope with Member resource: id == actor.member_id
|
# For :linked scope with Member resource: id == actor.member_id
|
||||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||||
case {resource_name, record} do
|
case {resource_name, record} do
|
||||||
|
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
||||||
|
# Check if this user's ID matches the actor's ID (scope :own)
|
||||||
|
if user_id == actor.id do
|
||||||
|
{:ok, true}
|
||||||
|
else
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
|
||||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||||
# Check if this member's ID matches the actor's member_id
|
# Check if this member's ID matches the actor's member_id
|
||||||
if member_id == actor.member_id do
|
if member_id == actor.member_id do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue