fix(moderation): enforce staff privilege boundaries

This commit is contained in:
SimpleTest 2026-07-24 21:07:08 +03:00
parent 50be881601
commit a753af688a
5 changed files with 72 additions and 6 deletions

View File

@ -211,6 +211,9 @@ defmodule WhoNeedHelp.Accounts do
requested_status = attrs["moderation_status"] || attrs[:moderation_status] requested_status = attrs["moderation_status"] || attrs[:moderation_status]
cond do cond do
user.role in [:moderator, :admin] and not admin_authorized?(moderator) ->
{:error, :forbidden}
user.id == moderator.id and user.id == moderator.id and
requested_status in [:restricted, :suspended, "restricted", "suspended"] -> requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
{:error, :cannot_restrict_self} {:error, :cannot_restrict_self}

View File

@ -102,6 +102,15 @@ defmodule WhoNeedHelp.Accounts.User do
|> cast(attrs, [:moderation_status, :moderation_note]) |> cast(attrs, [:moderation_status, :moderation_note])
|> validate_required([:moderation_status]) |> validate_required([:moderation_status])
|> validate_length(:moderation_note, max: 1_000) |> validate_length(:moderation_note, max: 1_000)
|> require_moderation_note()
end
defp require_moderation_note(changeset) do
if get_field(changeset, :moderation_status) in [:restricted, :suspended] do
validate_required(changeset, :moderation_note)
else
changeset
end
end end
def role_changeset(user, attrs) do def role_changeset(user, attrs) do

View File

@ -316,6 +316,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp report_activity(_report), do: nil defp report_activity(_report), do: nil
defp can_moderate_account?(%{role: :admin}, _target), do: true
defp can_moderate_account?(%{role: :moderator}, %{role: :user}), do: true
defp can_moderate_account?(_actor, _target), do: false
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -685,6 +689,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
<td>{status_label(user.moderation_status)}</td> <td>{status_label(user.moderation_status)}</td>
<td> <td>
<.form <.form
:if={can_moderate_account?(@current_scope.user, user)}
for={user_form} for={user_form}
phx-submit="moderate-user" phx-submit="moderate-user"
phx-value-id={user.id} phx-value-id={user.id}
@ -705,6 +710,12 @@ defmodule WhoNeedHelpWeb.ModerationLive do
/> />
<.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button> <.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button>
</.form> </.form>
<p
:if={not can_moderate_account?(@current_scope.user, user)}
class="text-xs text-base-content/55"
>
{gettext("Administrator access is required to moderate staff accounts.")}
</p>
<.form <.form
:if={@current_scope.user.role == :admin} :if={@current_scope.user.role == :admin}
for={role_form} for={role_form}

View File

@ -452,7 +452,10 @@ defmodule WhoNeedHelp.TrustSafetyTest do
test "restricted accounts cannot perform trust-sensitive actions", context do test "restricted accounts cannot perform trust-sensitive actions", context do
context.helper context.helper
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted}) |> WhoNeedHelp.Accounts.User.moderation_changeset(%{
moderation_status: :restricted,
moderation_note: "Test restriction"
})
|> Repo.update!() |> Repo.update!()
assert {:error, :account_not_eligible} = assert {:error, :account_not_eligible} =
@ -587,7 +590,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
) )
end end
test "restricted moderators lose authorization and the last active admin cannot be suspended", test "restricted moderators lose authorization and moderators cannot suspend administrators",
context do context do
admin = admin =
user_fixture(display_name: "Administrator") user_fixture(display_name: "Administrator")
@ -609,7 +612,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
restricted = restricted =
moderator moderator
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{ |> WhoNeedHelp.Accounts.User.moderation_changeset(%{
"moderation_status" => "restricted" "moderation_status" => "restricted",
"moderation_note" => "Test moderator restriction"
}) })
|> Repo.update!() |> Repo.update!()
@ -634,7 +638,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert {:ok, suspended} = assert {:ok, suspended} =
Trust.moderate_user(user_scope_fixture(active_moderator), context.helper.id, %{ Trust.moderate_user(user_scope_fixture(active_moderator), context.helper.id, %{
"moderation_status" => "suspended" "moderation_status" => "suspended",
"moderation_note" => "Confirmed safety policy violation"
}) })
assert suspended.moderation_status == :suspended assert suspended.moderation_status == :suspended
@ -649,14 +654,50 @@ defmodule WhoNeedHelp.TrustSafetyTest do
active: true active: true
) )
assert {:error, :last_admin} = assert {:error, :forbidden} =
Trust.moderate_user(user_scope_fixture(active_moderator), admin.id, %{ Trust.moderate_user(user_scope_fixture(active_moderator), admin.id, %{
"moderation_status" => "suspended" "moderation_status" => "suspended",
"moderation_note" => "Attempted staff restriction"
}) })
assert Repo.get!(WhoNeedHelp.Accounts.User, admin.id).moderation_status == :active assert Repo.get!(WhoNeedHelp.Accounts.User, admin.id).moderation_status == :active
end end
test "moderators cannot restrict staff and restrictions require an internal note", context do
admin =
user_fixture(display_name: "Staff administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
moderator =
user_fixture(display_name: "Staff moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
assert {:error, :forbidden} =
Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{
"moderation_status" => "restricted",
"moderation_note" => "A moderator must not restrict an administrator"
})
assert {:error, %Ecto.Changeset{} = changeset} =
Trust.moderate_user(user_scope_fixture(admin), context.helper.id, %{
"moderation_status" => "restricted",
"moderation_note" => ""
})
assert "can't be blank" in errors_on(changeset).moderation_note
assert Repo.get!(WhoNeedHelp.Accounts.User, context.helper.id).moderation_status == :active
assert {:ok, restricted} =
Trust.moderate_user(user_scope_fixture(admin), moderator.id, %{
"moderation_status" => "restricted",
"moderation_note" => "Administrator-reviewed staff restriction"
})
assert restricted.moderation_status == :restricted
end
test "the first administrator bootstrap is one-time and audited", context do test "the first administrator bootstrap is one-time and audited", context do
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email) assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
assert admin.id == context.helper.id assert admin.id == context.helper.id

View File

@ -1139,6 +1139,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Accounts" assert html =~ "Accounts"
assert html =~ "Please review the matched conversation." assert html =~ "Please review the matched conversation."
assert html =~ "Bicycle repair" assert html =~ "Bicycle repair"
refute has_element?(view, "#moderation-user-status-#{user.id}")
assert html =~ "Administrator access is required to moderate staff accounts."
assert has_element?( assert has_element?(
view, view,