fix(moderation): enforce staff privilege boundaries
This commit is contained in:
parent
50be881601
commit
a753af688a
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user