Fix unlink-by-omission: on_missing :ignore, test, doc, string-key
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- Member update_member: on_missing :unrelate → :ignore (no unlink when :user omitted) - Test: normal_user update linked member without :user keeps link - Doc: unlink only explicit (user: nil), admin-only; Actor.admin?(nil) note - Check: defense-in-depth for "user" string key
This commit is contained in:
parent
543fded102
commit
5194b20b5c
4 changed files with 46 additions and 19 deletions
|
|
@ -2052,7 +2052,7 @@ Users and Members are separate entities that can be linked. Special rules:
|
||||||
**Enforcement:**
|
**Enforcement:**
|
||||||
|
|
||||||
- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
|
- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
|
||||||
- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins (e.g. normal_user / Kassenwart) can still create and update members as long as they do not pass the `:user` argument.
|
- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only.
|
||||||
|
|
||||||
### Approach: Separate Ash Actions
|
### Approach: Separate Ash Actions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,15 +154,13 @@ defmodule Mv.Membership.Member do
|
||||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||||
|
|
||||||
# Manage the user relationship during member update
|
# Manage the user relationship during member update
|
||||||
|
# on_missing: :ignore so that omitting :user does NOT unlink (security: only admins may
|
||||||
|
# change the link; unlink is explicit via user: nil, forbidden for non-admins by policy).
|
||||||
change manage_relationship(:user, :user,
|
change manage_relationship(:user, :user,
|
||||||
# Look up existing user and relate to it
|
|
||||||
on_lookup: :relate,
|
on_lookup: :relate,
|
||||||
# Error if user doesn't exist in database
|
|
||||||
on_no_match: :error,
|
on_no_match: :error,
|
||||||
# Error if user is already linked to another member (prevents "stealing")
|
|
||||||
on_match: :error,
|
on_match: :error,
|
||||||
# If no user provided, remove existing relationship (allows user removal)
|
on_missing: :ignore
|
||||||
on_missing: :unrelate
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync member email to user when email changes (Member → User)
|
# Sync member email to user when email changes (Member → User)
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,16 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do
|
||||||
**is present** (key in arguments, regardless of value), only admins may perform the action.
|
**is present** (key in arguments, regardless of value), only admins may perform the action.
|
||||||
This covers:
|
This covers:
|
||||||
- **Linking:** `user: %{id: user_id}` → only admin
|
- **Linking:** `user: %{id: user_id}` → only admin
|
||||||
- **Unlinking:** `user: nil` or `user: %{}` on update_member triggers `on_missing: :unrelate` → only admin
|
- **Unlinking:** explicit `user: nil` or `user: %{}` on update_member → only admin
|
||||||
Non-admin users (e.g. normal_user / Kassenwart) can create and update members only when
|
Non-admin users can create and update members only when they do **not** pass the
|
||||||
they do **not** pass the `:user` argument at all.
|
`:user` argument; omitting `:user` leaves the relationship unchanged.
|
||||||
|
|
||||||
## Unlink via Member actions
|
## Unlink semantics (update_member)
|
||||||
|
|
||||||
Unlink is intended via Member update_member: when `:user` is not provided in params,
|
The Member resource uses `on_missing: :ignore` for the `:user` relationship on update.
|
||||||
manage_relationship uses `on_missing: :unrelate` and removes the link. Passing `user: nil`
|
So **omitting** `:user` from params does **not** change the link (no "unlink by omission").
|
||||||
or `user: %{}` explicitly is still "changing the link" and is forbidden for non-admins
|
Unlink is only possible by **explicitly** passing `:user` (e.g. `user: nil`), which this
|
||||||
(argument presence is checked, not value).
|
check forbids for non-admins. Admins may link or unlink via the `:user` argument.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do
|
||||||
|
|
||||||
- If the `:user` argument **key is not present** → does not forbid.
|
- If the `:user` argument **key is not present** → does not forbid.
|
||||||
- If `:user` is present (any value, including nil or %{}) and actor is not admin → forbids.
|
- If `:user` is present (any value, including nil or %{}) and actor is not admin → forbids.
|
||||||
- If actor is nil → treated as non-admin (forbid when :user present); no crash.
|
- If actor is nil → treated as non-admin (forbid when :user present). `Actor.admin?(nil)` is defined and returns false.
|
||||||
- If actor is admin (or system actor) → does not forbid.
|
- If actor is admin (or system actor) → does not forbid.
|
||||||
"""
|
"""
|
||||||
use Ash.Policy.Check
|
use Ash.Policy.Check
|
||||||
|
|
@ -42,7 +42,7 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def strict_check(actor, authorizer, _opts) do
|
def strict_check(actor, authorizer, _opts) do
|
||||||
# Defensive: nil actor → treat as non-admin (Actor.ensure_loaded(nil) and admin?(nil) are safe)
|
# Nil actor: treat as non-admin (Actor.admin?(nil) returns false; no crash)
|
||||||
actor = if is_nil(actor), do: nil, else: Actor.ensure_loaded(actor)
|
actor = if is_nil(actor), do: nil, else: Actor.ensure_loaded(actor)
|
||||||
|
|
||||||
if user_argument_present?(authorizer) and not Actor.admin?(actor) do
|
if user_argument_present?(authorizer) and not Actor.admin?(actor) do
|
||||||
|
|
@ -53,10 +53,10 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Forbid when :user was passed at all (link, unlink via nil/empty, or invalid value).
|
# Forbid when :user was passed at all (link, unlink via nil/empty, or invalid value).
|
||||||
# Check argument key presence, not value, to avoid bypass via user: nil or user: %{}.
|
# Check argument key presence (atom or string) for defense-in-depth.
|
||||||
defp user_argument_present?(authorizer) do
|
defp user_argument_present?(authorizer) do
|
||||||
args = get_arguments(authorizer)
|
args = get_arguments(authorizer) || %{}
|
||||||
Map.has_key?(args || %{}, :user)
|
Map.has_key?(args, :user) or Map.has_key?(args, "user")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_arguments(authorizer) do
|
defp get_arguments(authorizer) do
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,35 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
Membership.update_member(linked_member, %{user: nil}, actor: normal_user)
|
Membership.update_member(linked_member, %{user: nil}, actor: normal_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "normal_user update linked member without :user keeps link", %{
|
||||||
|
normal_user: normal_user,
|
||||||
|
admin: admin,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
# Admin links member to a user
|
||||||
|
link_target =
|
||||||
|
Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|
||||||
|
{:ok, linked_member} =
|
||||||
|
Membership.update_member(
|
||||||
|
unlinked_member,
|
||||||
|
%{user: %{id: link_target.id}},
|
||||||
|
actor: admin
|
||||||
|
)
|
||||||
|
|
||||||
|
# normal_user updates only first_name (no :user) – link must remain (on_missing: :ignore)
|
||||||
|
{:ok, updated} =
|
||||||
|
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: normal_user)
|
||||||
|
|
||||||
|
assert updated.first_name == "Updated"
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
|
||||||
|
|
||||||
|
assert user.member_id == updated.id
|
||||||
|
end
|
||||||
|
|
||||||
test "admin can create member with :user argument", %{admin: admin} do
|
test "admin can create member with :user argument", %{admin: admin} do
|
||||||
link_target =
|
link_target =
|
||||||
Mv.Fixtures.user_with_role_fixture("own_data")
|
Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue