Merge branch 'main' into feature/371-groups-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:
commit
5df1da1573
9 changed files with 632 additions and 27 deletions
2
Justfile
2
Justfile
|
|
@ -41,7 +41,7 @@ audit:
|
||||||
mix deps.audit
|
mix deps.audit
|
||||||
mix hex.audit
|
mix hex.audit
|
||||||
|
|
||||||
test *args: install-dependencies start-database
|
test *args: install-dependencies
|
||||||
mix test {{args}}
|
mix test {{args}}
|
||||||
|
|
||||||
format:
|
format:
|
||||||
|
|
|
||||||
|
|
@ -501,9 +501,11 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
||||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
||||||
|
|
||||||
# CustomFieldValue: Can read/update custom field values of linked member
|
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
||||||
|
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
|
||||||
|
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
||||||
|
|
||||||
# CustomField: Can read all (needed for forms)
|
# CustomField: Can read all (needed for forms)
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||||
|
|
@ -678,7 +680,7 @@ Quick reference table showing what each permission set allows:
|
||||||
| **User** (all) | - | - | - | R, C, U, D |
|
| **User** (all) | - | - | - | R, C, U, D |
|
||||||
| **Member** (linked) | R, U | - | - | - |
|
| **Member** (linked) | R, U | - | - | - |
|
||||||
| **Member** (all) | - | R | R, C, U | R, C, U, D |
|
| **Member** (all) | - | R | R, C, U | R, C, U, D |
|
||||||
| **CustomFieldValue** (linked) | R, U | - | - | - |
|
| **CustomFieldValue** (linked) | R, U, C, D | - | - | - |
|
||||||
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
||||||
| **CustomField** (all) | R | R | R | R, C, U, D |
|
| **CustomField** (all) | R | R | R | R, C, U, D |
|
||||||
| **Role** (all) | - | - | - | R, C, U, D |
|
| **Role** (all) | - | - | - | R, C, U, D |
|
||||||
|
|
@ -1053,35 +1055,35 @@ end
|
||||||
|
|
||||||
### CustomFieldValue Resource Policies
|
### CustomFieldValue Resource Policies
|
||||||
|
|
||||||
**Location:** `lib/mv/membership/custom_field_value.ex`
|
**Location:** `lib/membership/custom_field_value.ex`
|
||||||
|
|
||||||
**Special Case:** Users can access custom field values of their linked member.
|
**Pattern:** Bypass for READ (list queries), CustomFieldValueCreateScope for create (no filter), HasPermission for read/update/destroy. Create uses a dedicated check because Ash cannot apply filters to create actions.
|
||||||
|
|
||||||
|
The bypass `action_type(:read)` is a production-side rule: reading own CFVs (where `member_id == actor.member_id`) is always allowed and overrides Permission-Sets; no further policies are needed for that. It applies to all read actions (get, list, load).
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
defmodule Mv.Membership.CustomFieldValue do
|
defmodule Mv.Membership.CustomFieldValue do
|
||||||
use Ash.Resource, ...
|
use Ash.Resource, ...
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
# SPECIAL CASE: Users can access custom field values of their linked member
|
# Bypass for READ (list queries; expr triggers auto_filter)
|
||||||
# Note: This uses member_id relationship (CustomFieldValue.member_id → Member.id → User.member_id)
|
bypass action_type(:read) do
|
||||||
policy action_type([:read, :update]) do
|
description "Users can read custom field values of their linked member"
|
||||||
description "Users can access custom field values of their linked member"
|
|
||||||
authorize_if expr(member_id == ^actor(:member_id))
|
authorize_if expr(member_id == ^actor(:member_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# GENERAL: Check permissions from role
|
# CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
# own_data -> create when member_id == actor.member_id; normal_user/admin -> create (scope :all)
|
||||||
description "Check permissions from user's role"
|
policy action_type(:create) do
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope
|
||||||
end
|
end
|
||||||
|
|
||||||
# DEFAULT: Forbid
|
# READ/UPDATE/DESTROY: HasPermission (scope :linked / :all)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :update, :destroy]) do
|
||||||
forbid_if always()
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
# DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
# ...
|
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1089,11 +1091,13 @@ end
|
||||||
|
|
||||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||||
|--------|----------|----------|------------|-------------|-------|
|
|--------|----------|----------|------------|-------------|-------|
|
||||||
| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ |
|
| Read linked | ✅ (bypass) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ |
|
||||||
| Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ |
|
| Update linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ |
|
||||||
|
| Create linked | ✅ (CustomFieldValueCreateScope) | ❌ | ✅ | ❌ | ✅ |
|
||||||
|
| Destroy linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ |
|
||||||
| Read all | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Read all | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Create | ❌ | ❌ | ✅ | ❌ | ✅ |
|
| Create all | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||||
| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ |
|
| Destroy all | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
### CustomField Resource Policies
|
### CustomField Resource Policies
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,10 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "custom_field_values"
|
table "custom_field_values"
|
||||||
|
|
@ -62,6 +65,36 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
end
|
end
|
||||||
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
|
attributes do
|
||||||
uuid_primary_key :id
|
uuid_primary_key :id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
||||||
|
@moduledoc """
|
||||||
|
Policy check for CustomFieldValue create actions only.
|
||||||
|
|
||||||
|
Use this for create instead of HasPermission because Ash cannot apply
|
||||||
|
filters to create actions ("Cannot use a filter to authorize a create").
|
||||||
|
This check performs the same scope logic as HasPermission for create
|
||||||
|
(PermissionSets + :linked/:all) but only implements strict_check, so it
|
||||||
|
never adds a filter.
|
||||||
|
|
||||||
|
## member_id source
|
||||||
|
|
||||||
|
The check reads `member_id` from the create changeset via
|
||||||
|
`Ash.Changeset.get_argument_or_attribute/2`, so it works when member_id
|
||||||
|
is set as an attribute or as an action argument. The CustomFieldValue
|
||||||
|
resource's default create action must accept and require `member_id`
|
||||||
|
(e.g. via `default_accept [:value, :member_id, :custom_field_id]`).
|
||||||
|
|
||||||
|
Used in CustomFieldValue policies:
|
||||||
|
policy action_type(:create) do
|
||||||
|
authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
use Ash.Policy.Check
|
||||||
|
alias Mv.Authorization.PermissionSets
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts),
|
||||||
|
do: "CustomFieldValue create allowed by permission set scope (:linked or :all)"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def strict_check(actor, authorizer, _opts) do
|
||||||
|
actor = ensure_role_loaded(actor)
|
||||||
|
|
||||||
|
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),
|
||||||
|
perm <- find_custom_field_value_create(permissions.resources) do
|
||||||
|
case perm do
|
||||||
|
nil -> {:ok, false}
|
||||||
|
%{scope: :all} -> {:ok, true}
|
||||||
|
%{scope: :linked} -> {:ok, member_id_matches?(authorizer, actor)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_ -> {:ok, false}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_custom_field_value_create(resources) do
|
||||||
|
Enum.find(resources, fn p ->
|
||||||
|
p.resource == "CustomFieldValue" and p.action == :create and p.granted
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_id_matches?(authorizer, actor) do
|
||||||
|
member_id = get_create_member_id(authorizer)
|
||||||
|
!is_nil(member_id) and member_id == actor.member_id
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_create_member_id(authorizer) do
|
||||||
|
changeset = authorizer.changeset || authorizer.subject
|
||||||
|
|
||||||
|
if changeset && function_exported?(Ash.Changeset, :get_argument_or_attribute, 2) do
|
||||||
|
Ash.Changeset.get_argument_or_attribute(changeset, :member_id)
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor)
|
||||||
|
end
|
||||||
|
|
@ -148,6 +148,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
else
|
else
|
||||||
# No record yet (e.g., read/list queries) - deny at strict_check level
|
# No record yet (e.g., read/list queries) - deny at strict_check level
|
||||||
# Resources must use expr-based bypass policies for list filtering
|
# 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}
|
{:ok, false}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -213,7 +214,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
|
|
||||||
{:filter, filter_expr} ->
|
{:filter, filter_expr} ->
|
||||||
# :linked or :own scope - apply filter
|
# :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
|
filter_expr
|
||||||
|
|
||||||
false ->
|
false ->
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,11 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
||||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
||||||
|
|
||||||
# CustomFieldValue: Can read/update custom field values of linked member
|
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
||||||
|
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
|
||||||
|
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
||||||
|
|
||||||
# CustomField: Can read all (needed for forms)
|
# CustomField: Can read all (needed for forms)
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||||
|
|
|
||||||
|
|
@ -348,7 +348,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
|
|
||||||
# Delete custom field value
|
# Delete custom field value
|
||||||
assert :ok = Ash.destroy(cfv)
|
assert :ok = Ash.destroy(cfv, actor: system_actor)
|
||||||
|
|
||||||
# Value should no longer be found
|
# Value should no longer be found
|
||||||
deleted_results =
|
deleted_results =
|
||||||
|
|
|
||||||
494
test/mv/membership/custom_field_value_policies_test.exs
Normal file
494
test/mv/membership/custom_field_value_policies_test.exs
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for CustomFieldValue resource authorization policies.
|
||||||
|
|
||||||
|
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
|
||||||
|
and verifies that policies correctly enforce access control based on
|
||||||
|
user roles and permission sets.
|
||||||
|
"""
|
||||||
|
# async: false because we need database commits to be visible across queries
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a role with a specific permission set
|
||||||
|
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||||
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
case Authorization.create_role(
|
||||||
|
%{
|
||||||
|
name: role_name,
|
||||||
|
description: "Test role for #{permission_set_name}",
|
||||||
|
permission_set_name: permission_set_name
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
) do
|
||||||
|
{:ok, role} -> role
|
||||||
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a user with a specific permission set
|
||||||
|
# Returns user with role preloaded (required for authorization)
|
||||||
|
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||||
|
role = create_role_with_permission_set(permission_set_name, actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||||
|
user_with_role
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_linked_member_for_user(user, actor) do
|
||||||
|
{:ok, member} =
|
||||||
|
Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Linked",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor, return_notifications?: false)
|
||||||
|
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|
||||||
|
|> Ash.update(actor: actor, domain: Mv.Accounts, return_notifications?: false)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_unlinked_member(actor) do
|
||||||
|
{:ok, member} =
|
||||||
|
Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Unlinked",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_custom_field(actor) do
|
||||||
|
{:ok, field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "test_field_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: :string
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
field
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_custom_field_value(member_id, custom_field_id, value, actor) do
|
||||||
|
{:ok, cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member_id,
|
||||||
|
custom_field_id: custom_field_id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => value}
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor, domain: Mv.Membership)
|
||||||
|
|
||||||
|
cfv
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "own_data permission set (Mitglied)" do
|
||||||
|
setup %{actor: actor} do
|
||||||
|
user = create_user_with_permission_set("own_data", actor)
|
||||||
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
custom_field = create_custom_field(actor)
|
||||||
|
|
||||||
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||||
|
|
||||||
|
cfv_unlinked =
|
||||||
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
||||||
|
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
|
%{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member,
|
||||||
|
custom_field: custom_field,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked,
|
||||||
|
actor: actor
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read custom field values of linked member", %{user: user, cfv_linked: cfv_linked} do
|
||||||
|
{:ok, cfv} =
|
||||||
|
Ash.get(CustomFieldValue, cfv_linked.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert cfv.id == cfv_linked.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can list custom field values returns only linked member's values", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked
|
||||||
|
} do
|
||||||
|
{:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert length(values) == 1
|
||||||
|
assert hd(values).id == cfv_linked.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update custom field value of linked member", %{user: user, cfv_linked: cfv_linked} do
|
||||||
|
{:ok, updated} =
|
||||||
|
cfv_linked
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "updated"}
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert %Ash.Union{value: "updated", type: :string} = updated.value
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create custom field value for linked member", %{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
# Create a second custom field via admin (own_data cannot create CustomField)
|
||||||
|
custom_field2 = create_custom_field(actor)
|
||||||
|
|
||||||
|
{:ok, cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: linked_member.id,
|
||||||
|
custom_field_id: custom_field2.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "new"}
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert cfv.member_id == linked_member.id
|
||||||
|
assert cfv.custom_field_id == custom_field2.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy custom field value of linked member", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
result = Ash.destroy(cfv_linked, actor: user, domain: Mv.Membership)
|
||||||
|
assert :ok = result
|
||||||
|
|
||||||
|
assert {:error, _} =
|
||||||
|
Ash.get(CustomFieldValue, cfv_linked.id, domain: Mv.Membership, actor: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot read custom field values of unlinked member", %{
|
||||||
|
user: user,
|
||||||
|
cfv_unlinked: cfv_unlinked
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Invalid, fn ->
|
||||||
|
Ash.get!(CustomFieldValue, cfv_unlinked.id, actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update custom field value of unlinked member", %{
|
||||||
|
user: user,
|
||||||
|
cfv_unlinked: cfv_unlinked
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
cfv_unlinked
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "hacked"}
|
||||||
|
})
|
||||||
|
|> Ash.update!(actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create custom field value for unlinked member", %{
|
||||||
|
user: user,
|
||||||
|
unlinked_member: unlinked_member,
|
||||||
|
custom_field: custom_field
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: unlinked_member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "forbidden"}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy custom field value of unlinked member", %{
|
||||||
|
user: user,
|
||||||
|
cfv_unlinked: cfv_unlinked
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(cfv_unlinked, actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
|
setup %{actor: actor} do
|
||||||
|
user = create_user_with_permission_set("read_only", actor)
|
||||||
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
custom_field = create_custom_field(actor)
|
||||||
|
|
||||||
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||||
|
|
||||||
|
cfv_unlinked =
|
||||||
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
||||||
|
%{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked,
|
||||||
|
custom_field: custom_field,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all custom field values", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked
|
||||||
|
} do
|
||||||
|
{:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
ids = Enum.map(values, & &1.id)
|
||||||
|
assert cfv_linked.id in ids
|
||||||
|
assert cfv_unlinked.id in ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read individual custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do
|
||||||
|
{:ok, cfv} =
|
||||||
|
Ash.get(CustomFieldValue, cfv_unlinked.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert cfv.id == cfv_unlinked.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create custom field value (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member,
|
||||||
|
custom_field: custom_field
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: linked_member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "forbidden"}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update custom field value (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
cfv_linked
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "hacked"}
|
||||||
|
})
|
||||||
|
|> Ash.update!(actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy custom field value (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(cfv_linked, actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
|
setup %{actor: actor} do
|
||||||
|
user = create_user_with_permission_set("normal_user", actor)
|
||||||
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
custom_field = create_custom_field(actor)
|
||||||
|
|
||||||
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||||
|
|
||||||
|
cfv_unlinked =
|
||||||
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
||||||
|
%{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked,
|
||||||
|
custom_field: custom_field,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member,
|
||||||
|
actor: actor
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all custom field values", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked
|
||||||
|
} do
|
||||||
|
{:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
ids = Enum.map(values, & &1.id)
|
||||||
|
assert cfv_linked.id in ids
|
||||||
|
assert cfv_unlinked.id in ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create custom field value", %{
|
||||||
|
user: user,
|
||||||
|
unlinked_member: unlinked_member,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
# normal_user cannot create CustomField; use actor (admin) to create it
|
||||||
|
custom_field = create_custom_field(actor)
|
||||||
|
|
||||||
|
{:ok, cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: unlinked_member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "new"}
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert cfv.member_id == unlinked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update any custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do
|
||||||
|
{:ok, updated} =
|
||||||
|
cfv_unlinked
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "updated"}
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert %Ash.Union{value: "updated", type: :string} = updated.value
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy any custom field value", %{
|
||||||
|
user: user,
|
||||||
|
cfv_unlinked: cfv_unlinked,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
:ok = Ash.destroy(cfv_unlinked, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert {:error, _} =
|
||||||
|
Ash.get(CustomFieldValue, cfv_unlinked.id, domain: Mv.Membership, actor: actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "admin permission set" do
|
||||||
|
setup %{actor: actor} do
|
||||||
|
user = create_user_with_permission_set("admin", actor)
|
||||||
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
custom_field = create_custom_field(actor)
|
||||||
|
|
||||||
|
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
|
||||||
|
|
||||||
|
cfv_unlinked =
|
||||||
|
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||||
|
|
||||||
|
%{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked,
|
||||||
|
custom_field: custom_field,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all custom field values", %{
|
||||||
|
user: user,
|
||||||
|
cfv_linked: cfv_linked,
|
||||||
|
cfv_unlinked: cfv_unlinked
|
||||||
|
} do
|
||||||
|
{:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
ids = Enum.map(values, & &1.id)
|
||||||
|
assert cfv_linked.id in ids
|
||||||
|
assert cfv_unlinked.id in ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do
|
||||||
|
custom_field = create_custom_field(user)
|
||||||
|
|
||||||
|
{:ok, cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: unlinked_member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "new"}
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert cfv.member_id == unlinked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update any custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do
|
||||||
|
{:ok, updated} =
|
||||||
|
cfv_unlinked
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "updated"}
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert %Ash.Union{value: "updated", type: :string} = updated.value
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy any custom field value", %{
|
||||||
|
user: user,
|
||||||
|
cfv_unlinked: cfv_unlinked,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
:ok = Ash.destroy(cfv_unlinked, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert {:error, _} =
|
||||||
|
Ash.get(CustomFieldValue, cfv_unlinked.id, domain: Mv.Membership, actor: actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -247,7 +247,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
|
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
|
||||||
assert member != nil
|
assert member != nil
|
||||||
|
|
||||||
{:ok, member_with_cf} = Ash.load(member, :custom_field_values)
|
{:ok, member_with_cf} = Ash.load(member, :custom_field_values, actor: actor)
|
||||||
assert length(member_with_cf.custom_field_values) == 1
|
assert length(member_with_cf.custom_field_values) == 1
|
||||||
cfv = List.first(member_with_cf.custom_field_values)
|
cfv = List.first(member_with_cf.custom_field_values)
|
||||||
assert cfv.custom_field_id == custom_field.id
|
assert cfv.custom_field_id == custom_field.id
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue