+
+
+
+
+ <.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)