diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index 993a4bc..e6a5671 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -320,6 +320,22 @@ defmodule Textbin.Administration do end end + @doc """ + Makes a paste immediately inaccessible and records the moderation decision. + + Blob removal and hard deletion remain the responsibility of the retryable + expiration cleaner, so storage availability cannot roll back moderation. + """ + def delete_paste(scope, paste, reason, opts \\ []) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, paste_id} <- paste_id(paste) do + authority_transaction( + scope, + &delete_paste_in_transaction(&1, paste_id, reason, opts) + ) + end + end + defp authorize_account_deletion_in_transaction(scope, user_id) do with :ok <- lock_authority_changes(), %User{suspended_at: nil} = user <- lock_user(user_id), @@ -485,6 +501,30 @@ defmodule Textbin.Administration do end end + defp delete_paste_in_transaction(actor, paste_id, reason, opts) do + now = Paste.utc_now_ms() + + with %Paste{} = paste <- lock_active_paste(paste_id, now), + {:ok, expired_paste} <- + paste + |> Ecto.Changeset.change(expires_at: now) + |> Repo.update(), + :ok <- + record_paste_audit( + actor, + "platform.paste.deleted", + expired_paste, + reason, + %{"previous_expires_at" => encode_timestamp(paste.expires_at)}, + opts + ) do + {:ok, expired_paste} + else + nil -> {:error, :not_found} + error -> error + end + end + defp authority_transaction(%Scope{} = scope, callback) do Repo.transact(fn -> with :ok <- lock_authority_changes(), @@ -617,6 +657,23 @@ defmodule Textbin.Administration do |> audit_result() end + defp record_paste_audit(actor, action, target, reason, metadata, opts) do + %PlatformAuditEvent{} + |> PlatformAuditEvent.changeset(%{ + actor_kind: "user", + actor_user_id: actor.id, + actor_label: actor.email, + action: action, + target_type: "paste", + target_id: target.id, + reason: reason, + request_id: Keyword.get(opts, :request_id), + metadata: metadata + }) + |> Repo.insert() + |> audit_result() + end + defp audit_result({:ok, %PlatformAuditEvent{}}), do: :ok defp audit_result({:error, changeset}), do: {:error, changeset} @@ -637,6 +694,16 @@ defmodule Textbin.Administration do Repo.one(from user in User, where: user.email == ^email, lock: "FOR UPDATE") end + defp lock_active_paste(paste_id, now) do + Repo.one( + from paste in Paste, + where: + paste.id == ^paste_id and + (is_nil(paste.expires_at) or paste.expires_at > ^now), + lock: "FOR UPDATE" + ) + end + defp normalize_reason(reason) when is_binary(reason) do case String.trim(reason) do "" -> {:error, :reason_required} @@ -658,6 +725,20 @@ defmodule Textbin.Administration do defp user_id(_id), do: {:error, :not_found} + defp paste_id(%Paste{id: id}), do: paste_id(id) + + defp paste_id(id) when is_binary(id) do + case Ecto.UUID.cast(id) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + + defp paste_id(_id), do: {:error, :not_found} + + defp encode_timestamp(nil), do: nil + defp encode_timestamp(timestamp), do: DateTime.to_iso8601(timestamp) + defp distinct_users(id, id), do: {:error, :same_user} defp distinct_users(_target_id, _replacement_id), do: :ok diff --git a/lib/textbin_web/live/ui/admin_live.ex b/lib/textbin_web/live/ui/admin_live.ex index 2550356..ed5eb87 100644 --- a/lib/textbin_web/live/ui/admin_live.ex +++ b/lib/textbin_web/live/ui/admin_live.ex @@ -18,6 +18,8 @@ defmodule TextbinWeb.UI.AdminLive do socket |> assign(:page_title, "Platform administration") |> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup)) + |> assign(:moderation_form, to_form(%{"paste_id" => "", "reason" => ""}, as: :moderation)) + |> assign(:account_action_form, to_form(%{"reason" => ""}, as: :account_action)) |> assign(:lookup_performed?, false) |> assign(:lookup, empty_lookup()) |> stream_configure(:largest_pastes, dom_id: &"largest-paste-row-#{&1.row_key}")} @@ -71,6 +73,31 @@ defmodule TextbinWeb.UI.AdminLive do end end + def handle_event( + "moderate_paste", + %{"moderation" => %{"paste_id" => paste_id, "reason" => reason}}, + socket + ) do + socket.assigns.current_scope + |> Administration.delete_paste(paste_id, reason) + |> handle_mutation_result(socket, "Paste removed and queued for storage cleanup.") + end + + def handle_event( + "account_action", + %{ + "account_action" => %{ + "action" => action, + "target_id" => target_id, + "reason" => reason + } + }, + socket + ) do + result = account_action(action, socket.assigns.current_scope, target_id, reason) + handle_mutation_result(result, socket, account_action_message(action)) + end + def handle_event("load_more_audit", _params, %{assigns: %{audit_next_cursor: nil}} = socket), do: {:noreply, socket} @@ -99,6 +126,70 @@ defmodule TextbinWeb.UI.AdminLive do |> push_navigate(to: ~p"/") end + defp account_action("grant", scope, target_id, reason), + do: Administration.grant_platform_admin(scope, target_id, reason) + + defp account_action("revoke", scope, target_id, reason), + do: Administration.revoke_platform_admin(scope, target_id, reason) + + defp account_action("suspend", scope, target_id, reason), + do: Administration.suspend_user(scope, target_id, reason) + + defp account_action("restore", scope, target_id, reason), + do: Administration.restore_user(scope, target_id, reason) + + defp account_action(_action, _scope, _target_id, _reason), do: {:error, :not_found} + + defp account_action_message("grant"), do: "Platform administrator access granted." + defp account_action_message("revoke"), do: "Platform administrator access revoked." + defp account_action_message("suspend"), do: "Account suspended and active sessions revoked." + defp account_action_message("restore"), do: "Account restored. A new login is still required." + defp account_action_message(_action), do: "Account updated." + + defp handle_mutation_result({:ok, _result}, socket, message) do + {:noreply, + socket + |> clear_lookup() + |> put_flash(:info, message) + |> push_patch(to: ~p"/admin")} + end + + defp handle_mutation_result({:error, :forbidden}, socket, _message), + do: {:noreply, leave_admin(socket)} + + defp handle_mutation_result({:error, :reauthentication_required}, socket, _message) do + {:noreply, + socket + |> put_flash(:error, "Reauthenticate before performing this sensitive action.") + |> push_navigate(to: ~p"/users/log-in")} + end + + defp handle_mutation_result({:error, reason}, socket, _message) do + {:noreply, put_flash(socket, :error, mutation_error(reason))} + end + + defp mutation_error(:reason_required), do: "A reason is required." + defp mutation_error(:reason_too_long), do: "The reason must be at most 500 bytes." + defp mutation_error(:not_found), do: "The target is unavailable or has already been handled." + + defp mutation_error(:final_active_admin), + do: "The final active administrator cannot be removed." + + defp mutation_error(:self_suspension), do: "You cannot suspend your own account." + defp mutation_error(:already_suspended), do: "That account is already suspended." + defp mutation_error(:unconfirmed), do: "Only confirmed accounts can become administrators." + defp mutation_error(:suspended), do: "A suspended account cannot become an administrator." + defp mutation_error(:ineligible), do: "That account is not eligible for this action." + defp mutation_error(_reason), do: "The administrative action could not be completed." + + defp clear_lookup(socket) do + socket + |> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup)) + |> assign(:account_action_form, to_form(%{"reason" => ""}, as: :account_action)) + |> assign(:lookup_performed?, false) + |> assign(:lookup, empty_lookup()) + end + defp empty_lookup, do: %{user: nil, organization: nil, workspace: nil} defp largest_stream_entries(page) do @@ -130,12 +221,30 @@ defmodule TextbinWeb.UI.AdminLive do def status_class("Active"), do: "bg-success/10 text-success" def status_class(_status), do: "bg-warning/10 text-warning" + def account_action_options(_actor_id, %{suspended_at: %DateTime{}}), + do: [{"Restore account", "restore"}] + + def account_action_options(actor_id, %{id: actor_id, platform_role: "admin"}), + do: [{"Revoke platform administrator", "revoke"}] + + def account_action_options(_actor_id, %{platform_role: "admin"}), + do: [{"Revoke platform administrator", "revoke"}, {"Suspend account", "suspend"}] + + def account_action_options(_actor_id, %{ + kind: "registered", + confirmed_at: %DateTime{} + }), + do: [{"Grant platform administrator", "grant"}, {"Suspend account", "suspend"}] + + def account_action_options(_actor_id, _user), do: [{"Suspend account", "suspend"}] + def audit_title("platform.admin.bootstrap"), do: "Platform administrator bootstrapped" def audit_title("platform.admin.granted"), do: "Platform administrator granted" def audit_title("platform.admin.revoked"), do: "Platform administrator revoked" def audit_title("platform.account.suspended"), do: "Account suspended" def audit_title("platform.account.restored"), do: "Account restored" def audit_title("platform.admin.account_deleted"), do: "Administrator account deleted" + def audit_title("platform.paste.deleted"), do: "Paste administratively removed" def audit_title(action), do: action def page_params(kind, page, assigns) do diff --git a/lib/textbin_web/live/ui/admin_live/index.html.heex b/lib/textbin_web/live/ui/admin_live/index.html.heex index 70bda3a..9006e53 100644 --- a/lib/textbin_web/live/ui/admin_live/index.html.heex +++ b/lib/textbin_web/live/ui/admin_live/index.html.heex @@ -129,6 +129,43 @@ <.summary_stat label="Workspaces" value={@lookup.user.workspace_memberships} /> <.summary_stat label="Pastes" value={@lookup.user.pastes} /> + <.form + for={@account_action_form} + id="admin-account-action-form" + phx-submit="account_action" + class="mt-5 grid gap-3 border-t border-base-300 pt-5 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_auto] sm:items-end" + > + <.input + field={@account_action_form[:action]} + id="admin-account-action" + type="select" + label="Action" + options={account_action_options(@current_scope.user.id, @lookup.user)} + required + /> + <.input + field={@account_action_form[:reason]} + id="admin-account-action-reason" + type="text" + label="Reason" + maxlength="500" + required + /> + + +
+
+
+
+
+ <.icon name="hero-shield-exclamation" class="size-5" /> +
+

Moderation

+

Remove a paste

+

+ The paste becomes inaccessible immediately. Blob cleanup is retried independently if storage is unavailable. +

+
+ <.form + for={@moderation_form} + id="admin-paste-moderation-form" + phx-submit="moderate_paste" + class="grid content-start gap-4" + > + <.input + field={@moderation_form[:paste_id]} + id="admin-moderation-paste-id" + type="text" + label="Exact paste ID" + placeholder="Paste UUID" + autocomplete="off" + required + /> + <.input + field={@moderation_form[:reason]} + id="admin-moderation-reason" + type="textarea" + label="Moderation reason" + maxlength="500" + required + /> +
+

Recent reauthentication is required.

+ +
+ +
+
+
<.paste_panel id="recent-public-pastes" diff --git a/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs b/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs index 49d2a7e..0b81ebe 100644 --- a/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs +++ b/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs @@ -5,7 +5,12 @@ defmodule Textbin.Repo.Migrations.AddAdministrationPasteIndexes do def change do execute( - "UPDATE pastes SET size_bytes = octet_length(data) WHERE size_bytes IS NULL AND data IS NOT NULL", + """ + UPDATE pastes + SET size_bytes = octet_length(data), + sha256 = sha256(convert_to(data, 'UTF8')) + WHERE data IS NOT NULL AND (size_bytes IS NULL OR sha256 IS NULL) + """, "SELECT 1" ) diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 59ba895..77d5afd 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -145,7 +145,7 @@ CREATE TABLE public.platform_audit_events ( action character varying(255) NOT NULL, target_type character varying(255) NOT NULL, target_id uuid NOT NULL, - reason character varying(255) NOT NULL, + reason text NOT NULL, request_id character varying(255), metadata jsonb DEFAULT '{}'::jsonb NOT NULL, inserted_at timestamp without time zone NOT NULL, diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index 7c9d58c..aab4a5a 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -49,9 +49,9 @@ Add reasoned, reauthenticated mutations after authorization, auditing, and read-only inspection are in place. Report review starts after RFD 4 provides the report model. -- [ ] Administrative paste deletion makes content inaccessible before retryable +- [X] Administrative paste deletion makes content inaccessible before retryable storage cleanup and remains audited when storage is unavailable. -- [ ] Sensitive actions enforce the documented reason and recent-reauthentication +- [X] Sensitive actions enforce the documented reason and recent-reauthentication matrix. - [ ] Once the RFD 4 report model exists, platform administrators can page through the report queue and dismiss or resolve reports with a reason and audit event. diff --git a/test/textbin/administration/migration_test.exs b/test/textbin/administration/migration_test.exs index 11879d2..93e4617 100644 --- a/test/textbin/administration/migration_test.exs +++ b/test/textbin/administration/migration_test.exs @@ -2,6 +2,8 @@ defmodule Textbin.Administration.MigrationTest do use ExUnit.Case, async: false alias Textbin.MigrationRepo + alias Textbin.Pastes + alias Textbin.Pastes.Paste @foundation_version 20_260_822_090_000 @administration_indexes_version 20_260_826_090_000 @@ -32,10 +34,22 @@ defmodule Textbin.Administration.MigrationTest do paste_id = insert_legacy_inline_paste() Ecto.Migrator.run(MigrationRepo, migrations, :up, to: @administration_indexes_version) - assert %{rows: [[21]]} = - MigrationRepo.query!("SELECT size_bytes FROM pastes WHERE id = $1::uuid", [ - uuid(paste_id) - ]) + assert %{rows: [["legacy inline content", 21, sha256]]} = + MigrationRepo.query!( + "SELECT data, size_bytes, sha256 FROM pastes WHERE id = $1::uuid", + [ + uuid(paste_id) + ] + ) + + assert sha256 == :crypto.hash(:sha256, "legacy inline content") + + assert %Paste{data: "legacy inline content"} = + Pastes.load_data(%Paste{ + data: "legacy inline content", + size_bytes: 21, + sha256: sha256 + }) assert index_definition("pastes_admin_recent_visibility_index") =~ "(visibility, inserted_at DESC, id DESC)" @@ -48,6 +62,12 @@ defmodule Textbin.Administration.MigrationTest do assert apply(migration, :__migration__, [])[:disable_ddl_transaction] end + test "structure snapshot preserves the audit reason text column" do + structure = File.read!(Path.expand("../../../priv/repo/structure.sql", __DIR__)) + + assert structure =~ ~r/CREATE TABLE public\.platform_audit_events \(.+reason text NOT NULL/s + end + defp insert_legacy_inline_paste do user_id = Ecto.UUID.generate() organization_id = Ecto.UUID.generate() diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index 8442680..3dfcbb6 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -333,6 +333,87 @@ defmodule Textbin.AdministrationTest do end end + describe "administrative paste deletion" do + setup do + admin = admin_fixture() + owner = user_fixture() + owner_scope = user_scope_fixture(owner) + + %{admin: admin, scope: admin_scope(admin), owner_scope: owner_scope} + end + + test "expires and audits the paste before retryable storage cleanup", context do + original_storage = Application.fetch_env!(:textbin, Textbin.Storage) + + on_exit(fn -> Application.put_env(:textbin, Textbin.Storage, original_storage) end) + + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{ + data: String.duplicate("moderated", 1_024), + audience: "public" + }) + + Application.put_env(:textbin, Textbin.Storage, + adapter: Textbin.FailingDeleteStorage, + opts: [test_pid: self(), delegate: original_storage] + ) + + assert {:ok, %Paste{expires_at: %DateTime{}}} = + Administration.delete_paste(context.scope, paste.id, "malware distribution", + request_id: "request-456" + ) + + refute_received {:storage_delete_failed, _storage_key} + refute Pastes.get_shared_paste(nil, paste.id) + assert Repo.get(Paste, paste.id) + + assert %PlatformAuditEvent{ + action: "platform.paste.deleted", + target_type: "paste", + target_id: target_id, + reason: "malware distribution", + request_id: "request-456" + } = + Repo.one!( + from event in PlatformAuditEvent, + where: event.action == "platform.paste.deleted" + ) + + assert target_id == paste.id + assert Pastes.delete_expired_pastes(limit: 1) == 0 + assert_receive {:storage_delete_failed, storage_key} + assert storage_key == paste.storage_key + assert Repo.get(Paste, paste.id) + end + + test "requires a reason, recent reauthentication, and current authority", context do + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{data: "reported", audience: "public"}) + + assert {:error, :reason_required} = + Administration.delete_paste(context.scope, paste.id, " ") + + stale_scope = + Scope.for_user(%{ + context.admin + | authenticated_at: DateTime.add(DateTime.utc_now(:second), -21, :minute) + }) + + assert {:error, :reauthentication_required} = + Administration.delete_paste(stale_scope, paste.id, "policy violation") + + assert {:error, :forbidden} = + Administration.delete_paste(context.owner_scope, paste.id, "not authorized") + + assert Repo.get!(Paste, paste.id).expires_at == nil + + refute Repo.exists?( + from event in PlatformAuditEvent, + where: event.action == "platform.paste.deleted" + ) + end + end + describe "administration reads" do setup do admin = admin_fixture() diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs index d59a694..d9bb008 100644 --- a/test/textbin_web/live/ui/admin_live_test.exs +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -1,12 +1,16 @@ defmodule TextbinWeb.UI.AdminLiveTest do use TextbinWeb.ConnCase, async: false + import Ecto.Query import Phoenix.LiveViewTest import Textbin.AccountsFixtures alias Textbin.Accounts.Scope + alias Textbin.Accounts.User alias Textbin.Administration + alias Textbin.Administration.PlatformAuditEvent alias Textbin.Pastes + alias Textbin.Pastes.Paste alias Textbin.Repo alias TextbinWeb.ForbiddenError @@ -70,6 +74,101 @@ defmodule TextbinWeb.UI.AdminLiveTest do assert has_element?(view, "#admin-lookup-empty") end + test "performs reasoned account actions from an exact user lookup", %{conn: conn} do + target = user_fixture() + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + assert has_element?(view, "#admin-account-action-form") + + view + |> form("#admin-account-action-form", + account_action: %{ + action: "grant", + target_id: target.id, + reason: "incident response coverage" + } + ) + |> render_submit() + + assert_patch(view, ~p"/admin") + assert Repo.get!(User, target.id).platform_role == "admin" + refute has_element?(view, "#admin-user-result") + refute has_element?(view, "#admin-account-action-form") + end + + test "offers only eligible account actions", %{admin: admin, conn: conn} do + unconfirmed = unconfirmed_user_fixture() + assert {:ok, guest} = Textbin.Accounts.create_guest_user() + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-lookup-form", lookup: %{query: admin.email}) + |> render_submit() + + assert has_element?(view, "#admin-account-action option[value='revoke']") + refute has_element?(view, "#admin-account-action option[value='suspend']") + + for target <- [unconfirmed, guest] do + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + assert has_element?(view, "#admin-account-action option[value='suspend']") + refute has_element?(view, "#admin-account-action option[value='grant']") + end + end + + test "removes a paste immediately and exposes its audit event", %{conn: conn} do + owner = user_fixture() + + assert {:ok, paste} = + Pastes.create_paste(Scope.for_user(owner), %{ + data: "reported content", + audience: "public" + }) + + assert {:ok, view, _html} = live(conn, ~p"/admin") + assert has_element?(view, "#admin-paste-moderation-form") + + view + |> form("#admin-paste-moderation-form", + moderation: %{paste_id: paste.id, reason: "reported malware"} + ) + |> render_submit() + + assert_patch(view, ~p"/admin") + assert %Paste{expires_at: %DateTime{}} = Repo.get!(Paste, paste.id) + + assert Repo.exists?( + from event in PlatformAuditEvent, + where: + event.action == "platform.paste.deleted" and event.target_id == ^paste.id and + event.reason == "reported malware" + ) + end + + test "sensitive panel actions redirect stale sessions to reauthentication", %{conn: conn} do + token = get_session(conn, :user_token) + override_token_authenticated_at(token, DateTime.add(DateTime.utc_now(:second), -21, :minute)) + + owner = user_fixture() + assert {:ok, paste} = Pastes.create_paste(Scope.for_user(owner), %{data: "reported"}) + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-paste-moderation-form", + moderation: %{paste_id: paste.id, reason: "policy violation"} + ) + |> render_submit() + + assert_redirect(view, ~p"/users/log-in") + assert Repo.get!(Paste, paste.id).expires_at == nil + end + test "renders every protected largest-paste row without exposing capability IDs", %{conn: conn} do owner = user_fixture() scope = Scope.for_user(owner)