refactor: apply review notes
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-02-20 18:24:20 +01:00
parent bc9ea818eb
commit b41f005d9e
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
9 changed files with 110 additions and 92 deletions

View file

@ -12,16 +12,25 @@ defmodule Mv.Membership.JoinRequest do
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
alias Ash.Policy.Check.Builtins, as: AshBuiltins
postgres do
table "join_requests"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
defaults [:destroy]
# Admin: list and get by id (used with HasPermission)
read :admin_read do
description "List and get JoinRequests; requires permission (e.g. admin / normal_user)"
primary? true
end
create :create do
primary? true
accept [
:email,
:confirmation_token_hash,
@ -38,15 +47,9 @@ defmodule Mv.Membership.JoinRequest do
create :confirm do
description "Public action: create JoinRequest after confirmation link click (actor: nil)"
accept [
:email,
:confirmation_token_hash,
:status,
:submitted_at,
:source,
:schema_version,
:payload
]
accept [:email, :confirmation_token_hash, :payload]
change Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata
end
update :update do
@ -58,12 +61,11 @@ defmodule Mv.Membership.JoinRequest do
policies do
policy action(:confirm) do
description "Allow public confirmation (actor nil) for join flow"
authorize_if Ash.Policy.Check.Builtins.actor_absent()
authorize_if AshBuiltins.actor_absent()
end
policy action_type(:read) do
description "Allow read when actor nil (success page) or when user has permission"
authorize_if Ash.Policy.Check.Builtins.actor_absent()
policy action(:admin_read) do
description "List/get JoinRequests only with permission (admin, later normal_user)"
authorize_if Mv.Authorization.Checks.HasPermission
end

View file

@ -0,0 +1,18 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata do
@moduledoc """
Ash Change that sets server-side metadata for the public :confirm action.
Client may only send :email, :confirmation_token_hash, :payload (concept §2.3.2).
This change sets: status, submitted_at, source, schema_version so they cannot be forged.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, "submitted")
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:source, "public_join")
|> Ash.Changeset.force_change_attribute(:schema_version, 1)
end
end

View file

@ -80,30 +80,30 @@ defmodule Mv.Membership do
end
resource Mv.Membership.JoinRequest do
define :list_join_requests, action: :read
define :get_join_request, action: :read, get_by: [:id]
define :list_join_requests, action: :admin_read
define :get_join_request, action: :admin_read, get_by: [:id]
define :update_join_request, action: :update
define :destroy_join_request, action: :destroy
end
end
# Idempotent confirm: implemented in code so duplicate token returns {:ok, existing} (concept §2.3.2)
# Idempotent confirm: duplicate token hits unique constraint -> return {:ok, nil} (no public read)
@doc """
Creates a JoinRequest after confirmation link click (public action with actor: nil).
Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists,
returns `{:ok, existing}` instead of creating a duplicate (per concept §2.3.2).
returns `{:ok, nil}` (no record returned; no public read for security).
"""
def confirm_join_request(attrs, opts \\ []) do
hash = attrs[:confirmation_token_hash] || attrs["confirmation_token_hash"]
case do_confirm_join_request(attrs, opts) do
{:ok, request} ->
{:ok, request}
if hash do
case get_join_request_by_confirmation_token_hash!(hash, opts) do
nil -> do_confirm_join_request(attrs, opts)
existing -> {:ok, existing}
end
else
do_confirm_join_request(attrs, opts)
{:error, %Ash.Error.Invalid{errors: errors}} = error ->
if unique_confirmation_token_violation?(errors), do: {:ok, nil}, else: error
other ->
other
end
end
@ -113,17 +113,12 @@ defmodule Mv.Membership do
|> Ash.create(Keyword.put(opts, :domain, __MODULE__))
end
defp get_join_request_by_confirmation_token_hash!(hash, opts) do
opts = Keyword.put(opts, :domain, __MODULE__)
Mv.Membership.JoinRequest
|> Ash.Query.filter(confirmation_token_hash == ^hash)
|> Ash.read_one(opts)
|> case do
{:ok, %Mv.Membership.JoinRequest{} = existing} -> existing
{:ok, nil} -> nil
_ -> nil
end
defp unique_confirmation_token_violation?(errors) do
Enum.any?(errors, fn err ->
Map.get(err, :field) == :confirmation_token_hash or
((pv = Map.get(err, :private_vars)) &&
(is_list(pv) and Keyword.get(pv, :constraint_type) == :unique))
end)
end
# Singleton pattern: Get the single settings record

View file

@ -269,6 +269,7 @@ defmodule Mv.Authorization.PermissionSets do
perm_all("Role") ++
perm_all("Group") ++
member_group_perms ++
perm_all("JoinRequest") ++
perm_all("MembershipFeeType") ++
perm_all("MembershipFeeCycle"),
pages: [