diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 232ba99..d6d91a6 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -39,7 +39,11 @@ defmodule Mv.Membership.CustomFieldValue 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 postgres do table "custom_field_values" @@ -62,6 +66,36 @@ defmodule Mv.Membership.CustomFieldValue do end end + # Authorization Policies + # Order matters: Most specific policies first, then general permission check + # Pattern aligns with User and Member resources (bypass for READ, HasPermission for update/destroy) + # Create uses CustomFieldValueCreateScope because Ash cannot apply filters to create actions. + policies do + # SPECIAL CASE: Users can READ custom field values of their linked member + # Bypass needed for list queries (expr triggers auto_filter in Ash) + bypass action_type(:read) do + description "Users can read custom field values of their linked member" + authorize_if expr(member_id == ^actor(:member_id)) + end + + # CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create) + # - :own_data -> create allowed when member_id == actor.member_id (scope :linked) + # - :read_only -> no create permission + # - :normal_user / :admin -> create allowed (scope :all) + policy action_type(:create) do + description "CustomFieldValue create allowed by permission set scope" + authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope + end + + # READ/UPDATE/DESTROY: HasPermission (scope :linked / :all) + policy action_type([:read, :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 authorized (fail-closed) + end + attributes do uuid_primary_key :id diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex index 1a478b8..1cf1e39 100644 --- a/lib/mv/authorization/checks/has_permission.ex +++ b/lib/mv/authorization/checks/has_permission.ex @@ -110,12 +110,12 @@ defmodule Mv.Authorization.Checks.HasPermission do {:ok, false} true -> - strict_check_with_permissions(actor, resource, action, record) + strict_check_with_permissions(actor, resource, action, record, authorizer) end end # Helper function to reduce nesting depth - defp strict_check_with_permissions(actor, resource, action, record) do + defp strict_check_with_permissions(actor, resource, action, record, _authorizer) do # Ensure role is loaded (fallback if on_mount didn't run) actor = ensure_role_loaded(actor) @@ -148,6 +148,7 @@ defmodule Mv.Authorization.Checks.HasPermission do else # No record yet (e.g., read/list queries) - deny at strict_check level # Resources must use expr-based bypass policies for list filtering + # Create: use a dedicated check that does not return a filter (e.g. CustomFieldValueCreateScope) {:ok, false} end @@ -213,7 +214,7 @@ defmodule Mv.Authorization.Checks.HasPermission do {:filter, filter_expr} -> # :linked or :own scope - apply filter - # filter_expr is a keyword list from expr(...), return it directly + # Create actions must not use HasPermission (use a dedicated check, e.g. CustomFieldValueCreateScope) filter_expr false ->