Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions lib/textbin/administration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}

Expand All @@ -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}
Expand All @@ -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

Expand Down
109 changes: 109 additions & 0 deletions lib/textbin_web/live/ui/admin_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}")}
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions lib/textbin_web/live/ui/admin_live/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,43 @@
<.summary_stat label="Workspaces" value={@lookup.user.workspace_memberships} />
<.summary_stat label="Pastes" value={@lookup.user.pastes} />
</dl>
<.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
/>
<input
id="admin-account-action-target"
type="hidden"
name={@account_action_form[:target_id].name}
value={@lookup.user.id}
/>
<button
id="admin-account-action-submit"
type="submit"
phx-disable-with="Applying..."
class="btn btn-warning"
>
Apply action
</button>
</.form>
</article>

<article
Expand Down Expand Up @@ -172,6 +209,59 @@
</div>
</section>

<section
id="paste-moderation"
class="overflow-hidden rounded-2xl border border-base-300 bg-base-100 shadow-sm"
>
<div class="grid gap-6 p-5 sm:p-7 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div>
<div class="flex size-10 items-center justify-center rounded-xl bg-error/10 text-error">
<.icon name="hero-shield-exclamation" class="size-5" />
</div>
<p class="mt-4 text-xs font-bold uppercase tracking-[0.16em] text-error">Moderation</p>
<h2 class="mt-2 text-xl font-semibold text-base-content">Remove a paste</h2>
<p class="mt-2 text-sm leading-6 text-base-content/60">
The paste becomes inaccessible immediately. Blob cleanup is retried independently if storage is unavailable.
</p>
</div>
<.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
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs text-base-content/45">Recent reauthentication is required.</p>
<button
id="admin-paste-moderation-submit"
type="submit"
phx-disable-with="Removing..."
class="btn btn-error"
>
<.icon name="hero-trash" class="size-4" /> Remove paste
</button>
</div>
</.form>
</div>
</section>

<div class="grid gap-6 xl:grid-cols-2">
<.paste_panel
id="recent-public-pastes"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
2 changes: 1 addition & 1 deletion priv/repo/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions rfd/0001/IMPLEMENTATION.org
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading