From 0360bff87f69c4575a29c6c9d404d5261547861a Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Wed, 26 Aug 2026 05:54:00 +0000 Subject: [PATCH 1/3] docs(rfd): phase administration delivery Amp-Thread-ID: https://ampcode.com/threads/T-01a03c9b-35c6-73ab-92fc-bde8c64ddff4 --- rfd/0001/IMPLEMENTATION.org | 52 ++++++++++++++++++++++++++++++------- rfd/0001/README.adoc | 18 +++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index 2668595..40093e7 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -2,19 +2,16 @@ Implements [[file:README.adoc][RFD 1: Administration]]. -- [ ] A non-admin cannot mount an admin route or obtain admin data by calling a - context function directly. -- [ ] Every context call reloads current platform authority, and a mounted admin - loses panel access promptly after revocation or suspension. +* Phase 1: Platform authority and account controls + +Establish the role, recovery path, and account invariants before exposing an +administration interface. + - [X] Organization owners and admins have no platform authority unless separately granted it. - [X] An operator can grant the first platform administrator from a release without manipulating the database manually, and can use the same audited mechanism for emergency recovery. -- [ ] Every privilege change, suspension, restoration, and administrative deletion - is audited. -- [ ] Administrative mutations and their audit events commit atomically, and the - runtime database path cannot update or delete platform audit events. - [X] Concurrent revocation, suspension, and account-deletion attempts cannot remove the final active platform administrator, while one transaction can grant a replacement and remove the previous administrator. @@ -22,13 +19,48 @@ Implements [[file:README.adoc][RFD 1: Administration]]. reauthentication and records a platform audit event. - [X] Suspension immediately invalidates browser sessions and API tokens without deleting memberships or content, and restoration does not revive old tokens. -- [ ] Administrative paste deletion makes content inaccessible before retryable - storage cleanup and remains audited when storage is unavailable. + +* Phase 2: Authorization and audit boundary + +Make context authorization and the immutable platform log the security boundary +for every transport. + +- [ ] A non-admin cannot mount an admin route or obtain admin data by calling a + context function directly. +- [ ] Every context call reloads current platform authority, and a mounted admin + loses panel access promptly after revocation or suspension. +- [ ] Every privilege change, suspension, restoration, and administrative deletion + is audited. +- [ ] Administrative mutations and their audit events commit atomically, and the + runtime database path cannot update or delete platform audit events. + +* Phase 3: Read-only administration views + +Ship useful installation, account, organization, workspace, paste-metadata, and +audit views without creating a privileged content-reading path. + - [ ] Admin list queries are paginated, scoped in SQL, and do not load paste bodies. - [ ] General recent-paste discovery exposes only public pastes, and metadata views do not create a privileged path to workspace-only or arbitrary unlisted content. + +* Phase 4: Moderation and report review + +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 + storage cleanup and remains audited when storage is unavailable. - [ ] 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. + +* Phase 5: End-to-end verification + +Each earlier phase ships with targeted tests; this phase closes cross-feature and +LiveView coverage gaps. + - [ ] Authorization, role-change invalidation, final-admin concurrency, suspension, deletion cleanup, reauthentication, and audit behavior have context and LiveView tests. diff --git a/rfd/0001/README.adoc b/rfd/0001/README.adoc index a468421..b1f1ba0 100644 --- a/rfd/0001/README.adoc +++ b/rfd/0001/README.adoc @@ -217,5 +217,23 @@ for the platform log. == Implementation +Delivery proceeds in dependency order: + +. *Platform authority and account controls* establish the role, release + bootstrap, final-active-admin invariant, and suspension behavior without + exposing an administration interface. +. *Authorization and audit boundary* enforce current authority in contexts and + routes, invalidate mounted access after authority changes, and make immutable, + transactional platform audit events cover every administrative mutation. +. *Read-only administration views* add bounded installation, account, + organization, workspace, paste-metadata, and audit views without adding a + privileged content-reading path. +. *Moderation and report review* add paste deletion and the documented reason and + reauthentication rules. The administration foundation and other moderation + actions can ship first; the report queue starts after RFD 4 supplies its report + model. +. *End-to-end verification* closes cross-feature context and LiveView coverage + after every earlier phase has shipped with its own targeted tests. + Progress against this proposal is tracked in the link:IMPLEMENTATION.org[RFD 1 implementation checklist]. From 49646ee11f1df17bd068f507769ff32d0caf2126 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Wed, 26 Aug 2026 06:02:58 +0000 Subject: [PATCH 2/3] feat(admin): enforce live platform authority Amp-Thread-ID: https://ampcode.com/threads/T-01a03c9b-35c6-73ab-92fc-bde8c64ddff4 --- lib/textbin/administration.ex | 39 ++++++++++++ lib/textbin_web/live/ui/admin_live.ex | 55 ++++++++++++++++ lib/textbin_web/router.ex | 1 + lib/textbin_web/user_auth.ex | 31 ++++++++- rfd/0001/IMPLEMENTATION.org | 8 +-- test/textbin/administration_test.exs | 28 ++++++++ test/textbin_web/live/ui/admin_live_test.exs | 67 ++++++++++++++++++++ 7 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 lib/textbin_web/live/ui/admin_live.ex create mode 100644 test/textbin_web/live/ui/admin_live_test.exs diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index d5eeb05..dc40273 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -27,6 +27,13 @@ defmodule Textbin.Administration do def authorize_platform_admin(_scope), do: {:error, :forbidden} + @doc "Subscribes the caller to authority changes for its current user." + def subscribe_to_platform_authority(%Scope{user: %User{id: user_id}}) do + Phoenix.PubSub.subscribe(Textbin.PubSub, platform_authority_topic(user_id)) + end + + def subscribe_to_platform_authority(_scope), do: {:error, :forbidden} + @doc false def authorize_account_deletion(%Scope{user: %User{id: user_id}} = scope) do case Repo.transact(fn -> authorize_account_deletion_in_transaction(scope, user_id) end) do @@ -77,6 +84,7 @@ defmodule Textbin.Administration do scope, &grant_platform_admin_in_transaction(&1, target_id, reason, opts) ) + |> notify_platform_authority_change() end end @@ -88,6 +96,7 @@ defmodule Textbin.Administration do scope, &revoke_platform_admin_in_transaction(&1, target_id, reason, opts) ) + |> notify_platform_authority_change() end end @@ -107,6 +116,7 @@ defmodule Textbin.Administration do opts ) ) + |> notify_platform_authority_change() end end @@ -120,6 +130,7 @@ defmodule Textbin.Administration do scope, &suspend_user_in_transaction(&1, target_id, reason, opts) ) + |> notify_platform_authority_change() end disconnect_suspended_sessions(result) @@ -130,6 +141,7 @@ defmodule Textbin.Administration do with {:ok, reason} <- normalize_reason(reason), {:ok, target_id} <- user_id(target) do authority_transaction(scope, &restore_user_in_transaction(&1, target_id, reason, opts)) + |> notify_platform_authority_change() end end @@ -486,4 +498,31 @@ defmodule Textbin.Administration do end defp disconnect_suspended_sessions(result), do: result + + defp notify_platform_authority_change({:ok, %User{id: user_id}} = result) do + broadcast_platform_authority_change(user_id) + result + end + + defp notify_platform_authority_change({:ok, %{revoked: %User{id: user_id}}} = result) do + broadcast_platform_authority_change(user_id) + result + end + + defp notify_platform_authority_change({:ok, {%User{id: user_id}, _tokens}} = result) do + broadcast_platform_authority_change(user_id) + result + end + + defp notify_platform_authority_change(result), do: result + + defp broadcast_platform_authority_change(user_id) do + Phoenix.PubSub.broadcast( + Textbin.PubSub, + platform_authority_topic(user_id), + :platform_authority_changed + ) + end + + defp platform_authority_topic(user_id), do: "platform_authority:#{user_id}" end diff --git a/lib/textbin_web/live/ui/admin_live.ex b/lib/textbin_web/live/ui/admin_live.ex new file mode 100644 index 0000000..060bdd8 --- /dev/null +++ b/lib/textbin_web/live/ui/admin_live.ex @@ -0,0 +1,55 @@ +defmodule TextbinWeb.UI.AdminLive do + use TextbinWeb, :live_view + + on_mount {TextbinWeb.UserAuth, :require_platform_admin} + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :page_title, "Administration")} + end + + @impl true + def render(assigns) do + ~H""" + +
+
+
+
+
+ <.icon name="hero-shield-check" class="size-6" /> +
+
+

+ Platform controls +

+

+ Administration +

+

+ This restricted area is authorized against current platform authority on every + mount and authority change. +

+
+
+
+ +
+
+ + <.icon name="hero-lock-closed" class="size-4" /> + +
+

Authorization boundary active

+

+ Operational views will be introduced in the next delivery phase. +

+
+
+
+
+
+
+ """ + end +end diff --git a/lib/textbin_web/router.ex b/lib/textbin_web/router.ex index cb04dc8..09fd58d 100644 --- a/lib/textbin_web/router.ex +++ b/lib/textbin_web/router.ex @@ -89,6 +89,7 @@ defmodule TextbinWeb.Router do live "/o/:organization_slug/settings", UI.OrganizationLive, :settings live "/w/:organization_slug/:workspace_slug/members", UI.WorkspaceLive, :members live "/w/:organization_slug/:workspace_slug/settings", UI.WorkspaceLive, :settings + live "/admin", UI.AdminLive, :index end post "/users/update-password", UserSessionController, :update_password diff --git a/lib/textbin_web/user_auth.ex b/lib/textbin_web/user_auth.ex index 58353f5..8eb8fd5 100644 --- a/lib/textbin_web/user_auth.ex +++ b/lib/textbin_web/user_auth.ex @@ -334,13 +334,42 @@ defmodule TextbinWeb.UserAuth do {:halt, socket} match?({:ok, _user}, Administration.authorize_platform_admin(socket.assigns.current_scope)) -> - {:cont, socket} + if Phoenix.LiveView.connected?(socket) do + :ok = Administration.subscribe_to_platform_authority(socket.assigns.current_scope) + + {:cont, + Phoenix.LiveView.attach_hook( + socket, + :platform_authority, + :handle_info, + &handle_platform_authority_change/2 + )} + else + {:cont, socket} + end true -> raise TextbinWeb.ForbiddenError end end + defp handle_platform_authority_change(:platform_authority_changed, socket) do + case Administration.authorize_platform_admin(socket.assigns.current_scope) do + {:ok, _user} -> + {:cont, socket} + + {:error, :forbidden} -> + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "Your platform access has changed.") + |> Phoenix.LiveView.push_navigate(to: ~p"/") + + {:halt, socket} + end + end + + defp handle_platform_authority_change(_message, socket), do: {:cont, socket} + defp mount_current_scope(socket, session) do Phoenix.Component.assign_new(socket, :current_scope, fn -> {user, _} = user_from_session(session) diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index 40093e7..7a26ad5 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -25,13 +25,13 @@ administration interface. Make context authorization and the immutable platform log the security boundary for every transport. -- [ ] A non-admin cannot mount an admin route or obtain admin data by calling a +- [X] A non-admin cannot mount an admin route or obtain admin data by calling a context function directly. -- [ ] Every context call reloads current platform authority, and a mounted admin +- [X] Every context call reloads current platform authority, and a mounted admin loses panel access promptly after revocation or suspension. -- [ ] Every privilege change, suspension, restoration, and administrative deletion +- [X] Every privilege change, suspension, restoration, and administrative deletion is audited. -- [ ] Administrative mutations and their audit events commit atomically, and the +- [X] Administrative mutations and their audit events commit atomically, and the runtime database path cannot update or delete platform audit events. * Phase 3: Read-only administration views diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index 4f5d335..5faea9e 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -133,6 +133,18 @@ defmodule Textbin.AdministrationTest do assert revoked.reason == "coverage ended" end + test "rolls back authority changes when their audit event is invalid", %{scope: scope} do + target = user_fixture() + + assert {:error, %Ecto.Changeset{}} = + Administration.grant_platform_admin(scope, target, "operations coverage", + request_id: String.duplicate("x", 256) + ) + + assert Repo.get!(User, target.id).platform_role == nil + refute Repo.exists?(from event in PlatformAuditEvent, where: event.target_id == ^target.id) + end + test "requires a reason and recent reauthentication", %{admin: admin} do target = user_fixture() @@ -219,6 +231,14 @@ defmodule Textbin.AdministrationTest do Administration.restore_user(scope, suspended, "appeal accepted") refute Repo.exists?(from token in UserToken, where: token.user_id == ^user.id) + + assert ["platform.account.suspended", "platform.account.restored"] == + Repo.all( + from event in PlatformAuditEvent, + where: event.target_id == ^user.id, + order_by: [asc: event.inserted_at], + select: event.action + ) end test "rejects self-suspension and suspending the final active admin", %{ @@ -284,6 +304,14 @@ defmodule Textbin.AdministrationTest do user = admin_fixture() event = Repo.one!(from event in PlatformAuditEvent, where: event.target_id == ^user.id) + assert_raise Postgrex.Error, ~r/platform audit events are append-only/, fn -> + Repo.transaction(fn -> + event + |> Ecto.Changeset.change(reason: "rewritten") + |> Repo.update!() + end) + end + assert_raise Postgrex.Error, ~r/platform audit events are append-only/, fn -> Repo.transaction(fn -> Repo.delete!(event) end) end diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs new file mode 100644 index 0000000..86a05de --- /dev/null +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -0,0 +1,67 @@ +defmodule TextbinWeb.UI.AdminLiveTest do + use TextbinWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Textbin.AccountsFixtures + + alias Textbin.Accounts.Scope + alias Textbin.Administration + alias Textbin.Repo + alias TextbinWeb.ForbiddenError + + test "requires authentication and current platform authority", %{conn: conn} do + assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/admin") + assert path == ~p"/users/log-in" + + user = user_fixture() + + assert_raise ForbiddenError, fn -> + live(log_in_user(conn, user), ~p"/admin") + end + + assert {:ok, :granted} = Administration.bootstrap_platform_admin(user.email) + + assert {:ok, view, _html} = live(log_in_user(conn, user), ~p"/admin") + assert has_element?(view, "#admin-page") + assert has_element?(view, "#admin-foundation-status") + end + + test "leaves the panel promptly when current authority is revoked", %{conn: conn} do + actor = platform_admin_fixture() + target = platform_admin_fixture() + target_conn = log_in_user(conn, target) + + assert {:ok, view, _html} = live(target_conn, ~p"/admin") + + assert {:ok, _target} = + Administration.revoke_platform_admin( + admin_scope(actor), + target, + "rotation complete" + ) + + assert_redirect(view, ~p"/") + end + + test "leaves the panel promptly when the administrator is suspended", %{conn: conn} do + actor = platform_admin_fixture() + target = platform_admin_fixture() + + assert {:ok, view, _html} = live(log_in_user(conn, target), ~p"/admin") + + assert {:ok, {_target, _tokens}} = + Administration.suspend_user(admin_scope(actor), target, "security response") + + assert_redirect(view, ~p"/") + end + + defp platform_admin_fixture do + user = user_fixture() + assert {:ok, :granted} = Administration.bootstrap_platform_admin(user.email) + Repo.reload!(user) + end + + defp admin_scope(user) do + Scope.for_user(%{user | authenticated_at: DateTime.utc_now(:second)}) + end +end From ec05798c8ecc75ba1571b50df1f82d7ca88811d8 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Wed, 26 Aug 2026 06:21:43 +0000 Subject: [PATCH 3/3] fix(admin): close authority invalidation race Amp-Thread-ID: https://ampcode.com/threads/T-01a03c9b-35c6-73ab-92fc-bde8c64ddff4 --- lib/textbin_web/user_auth.ex | 54 +++++++++++--------- rfd/0001/IMPLEMENTATION.org | 4 +- test/textbin/administration_test.exs | 31 +++++++++++ test/textbin_web/live/ui/admin_live_test.exs | 12 +++++ 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/lib/textbin_web/user_auth.ex b/lib/textbin_web/user_auth.ex index 8eb8fd5..4c25cdf 100644 --- a/lib/textbin_web/user_auth.ex +++ b/lib/textbin_web/user_auth.ex @@ -324,31 +324,39 @@ defmodule TextbinWeb.UserAuth do def on_mount(:require_platform_admin, _params, session, socket) do socket = mount_current_scope(socket, session) - cond do - !authenticated_scope?(socket.assigns.current_scope) -> - socket = - socket - |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") - |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + if authenticated_scope?(socket.assigns.current_scope) do + authorize_platform_admin_mount(socket) + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") - {:halt, socket} + {:halt, socket} + end + end - match?({:ok, _user}, Administration.authorize_platform_admin(socket.assigns.current_scope)) -> - if Phoenix.LiveView.connected?(socket) do - :ok = Administration.subscribe_to_platform_authority(socket.assigns.current_scope) - - {:cont, - Phoenix.LiveView.attach_hook( - socket, - :platform_authority, - :handle_info, - &handle_platform_authority_change/2 - )} - else - {:cont, socket} - end + defp authorize_platform_admin_mount(socket) do + connected? = Phoenix.LiveView.connected?(socket) - true -> + if connected? do + :ok = Administration.subscribe_to_platform_authority(socket.assigns.current_scope) + end + + case Administration.authorize_platform_admin(socket.assigns.current_scope) do + {:ok, _user} when connected? -> + {:cont, + Phoenix.LiveView.attach_hook( + socket, + :platform_authority, + :handle_info, + &handle_platform_authority_change/2 + )} + + {:ok, _user} -> + {:cont, socket} + + {:error, :forbidden} -> raise TextbinWeb.ForbiddenError end end @@ -362,7 +370,7 @@ defmodule TextbinWeb.UserAuth do socket = socket |> Phoenix.LiveView.put_flash(:error, "Your platform access has changed.") - |> Phoenix.LiveView.push_navigate(to: ~p"/") + |> Phoenix.LiveView.redirect(to: ~p"/") {:halt, socket} end diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index 7a26ad5..df79525 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -27,8 +27,8 @@ for every transport. - [X] A non-admin cannot mount an admin route or obtain admin data by calling a context function directly. -- [X] Every context call reloads current platform authority, and a mounted admin - loses panel access promptly after revocation or suspension. +- [X] Every administrative read and mutation reloads current platform authority, + and a mounted admin loses panel access promptly after revocation or suspension. - [X] Every privilege change, suspension, restoration, and administrative deletion is audited. - [X] Administrative mutations and their audit events commit atomically, and the diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index 5faea9e..b12322d 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -67,6 +67,37 @@ defmodule Textbin.AdministrationTest do assert {:error, :forbidden} = Administration.authorize_platform_admin(scope) end + test "queues an authority notification before follow-up authorization" do + actor = admin_fixture() + target = admin_fixture() + target_scope = admin_scope(target) + parent = self() + + watcher = + Task.async(fn -> + assert :ok = Administration.subscribe_to_platform_authority(target_scope) + send(parent, {:subscribed, self()}) + assert_receive :authorize + + authorization = Administration.authorize_platform_admin(target_scope) + assert_receive notification + {authorization, notification} + end) + + assert_receive {:subscribed, watcher_pid} + + assert {:ok, %User{platform_role: nil}} = + Administration.revoke_platform_admin( + admin_scope(actor), + target, + "subscription race" + ) + + send(watcher_pid, :authorize) + + assert {{:error, :forbidden}, :platform_authority_changed} = Task.await(watcher) + end + test "organization owners and admins have no platform authority" do owner = user_fixture() organization_admin = user_fixture() diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs index 86a05de..fe684cf 100644 --- a/test/textbin_web/live/ui/admin_live_test.exs +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -32,6 +32,7 @@ defmodule TextbinWeb.UI.AdminLiveTest do target_conn = log_in_user(conn, target) assert {:ok, view, _html} = live(target_conn, ~p"/admin") + monitor = monitor_proxy(view) assert {:ok, _target} = Administration.revoke_platform_admin( @@ -41,6 +42,7 @@ defmodule TextbinWeb.UI.AdminLiveTest do ) assert_redirect(view, ~p"/") + assert_full_redirect(monitor, ~p"/") end test "leaves the panel promptly when the administrator is suspended", %{conn: conn} do @@ -48,11 +50,21 @@ defmodule TextbinWeb.UI.AdminLiveTest do target = platform_admin_fixture() assert {:ok, view, _html} = live(log_in_user(conn, target), ~p"/admin") + monitor = monitor_proxy(view) assert {:ok, {_target, _tokens}} = Administration.suspend_user(admin_scope(actor), target, "security response") assert_redirect(view, ~p"/") + assert_full_redirect(monitor, ~p"/") + end + + defp monitor_proxy(%{proxy: {_ref, _topic, proxy_pid}}), + do: {proxy_pid, Process.monitor(proxy_pid)} + + defp assert_full_redirect({proxy_pid, monitor_ref}, path) do + assert_receive {:DOWN, ^monitor_ref, :process, ^proxy_pid, + {:shutdown, {:redirect, %{to: ^path}}}} end defp platform_admin_fixture do