From c89b31b3f76f41dddb6d77dceff1065d6e920567 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 09:51:01 -0700 Subject: [PATCH 1/7] docs(rfd): resolve administration design decisions --- rfd/0001/IMPLEMENTATION.org | 24 +++++- rfd/0001/README.adoc | 161 +++++++++++++++++++++++++++++++----- 2 files changed, 160 insertions(+), 25 deletions(-) diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index cc62001..d7d08db 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -4,13 +4,29 @@ 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. - [ ] Organization owners and admins have no platform authority unless separately granted it. - [ ] An operator can grant the first platform administrator from a release without - manipulating the database manually. + manipulating the database manually, and can use the same audited mechanism for + emergency recovery. - [ ] Every privilege change, suspension, restoration, and administrative deletion is audited. -- [ ] Concurrent attempts cannot remove the final platform administrator. +- [ ] Administrative mutations and their audit events commit atomically, and the + runtime database path cannot update or delete platform audit events. +- [ ] 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. +- [ ] 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. - [ ] Admin list queries are paginated, scoped in SQL, and do not load paste bodies. -- [ ] Authorization, final-admin concurrency, reauthentication, and audit behavior - have context and LiveView tests. +- [ ] General recent-paste discovery exposes only public pastes, and metadata views + do not create a privileged path to workspace-only or arbitrary unlisted content. +- [ ] Sensitive actions enforce the documented reason and recent-reauthentication + matrix. +- [ ] 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 0b1448e..d64284d 100644 --- a/rfd/0001/README.adoc +++ b/rfd/0001/README.adoc @@ -22,9 +22,17 @@ Textbin adds a platform-level `admin` role. It is independent of organization must not appear as a workspace collaborator. * Ordinary users have no platform role. Absence of the role is the default. -Platform authority is stored on the user account or in a dedicated platform -role relation with a database constraint over supported values. Authorization -must not depend on email addresses, configuration allowlists, or UI state. +Platform authority is stored as a nullable `platform_role` on the user account. +The database permits only the `admin` value; `NULL` represents an ordinary +user. Registration, profile, and other user-controlled changesets never cast +this field. A dedicated relation can replace the field if Textbin later gains +multiple platform roles or delegated permissions. + +An active platform administrator is confirmed, not suspended, and has the +`admin` platform role. Every authority-changing operation preserves at least +one active platform administrator. Authorization must not depend on email +addresses, configuration allowlists, UI state, or authority copied into a +long-lived session. == Authentication and authorization @@ -34,14 +42,26 @@ The admin LiveViews belong inside the existing requires a current user. A platform-admin `on_mount` hook then rejects non-admins before mounting the page. -Context modules repeat the platform-admin check for every read and mutation so -they remain safe when called by a controller, release task, or future API. A -denied browser request behaves as not found unless showing an explicit forbidden -response is operationally useful; APIs return `403` after authentication. +The scope's user ID establishes caller identity but a user struct loaded into a +session is not proof of current authority. Context modules query current account +and platform-role state for every administrative read and mutation so they +remain safe when called by a connected LiveView, controller, release task, or +future API. Role changes notify mounted admin LiveViews so they can leave the +panel promptly; context authorization remains the security boundary if a +notification is delayed or missed. + +Unauthenticated browser requests redirect to login. An authenticated non-admin +receives `403 Forbidden`, while an absent target receives `404 Not Found`. APIs +return `401` without valid authentication and `403` when an authenticated caller +lacks platform authority. Contexts represent those cases distinctly so each +transport can map them without weakening authorization. -Destructive and privilege-changing actions require recent reauthentication. Administrative sessions use the normal session lifetime; there is no separate -permanent admin session or impersonation feature. +permanent admin session or impersonation feature. The existing twenty-minute +sudo window and supported password or magic-link login establish recent +reauthentication. The first version does not require MFA. Adding MFA is a +separate authentication change rather than a panel-specific password +requirement. == Initial administrator bootstrap @@ -50,39 +70,138 @@ platform-admin role to an existing, confirmed user identified by normalized email. Operators invoke it through the release binary. It must: . fail if the user does not exist or is not confirmed; +. fail if the selected user is suspended; . lock and update the selected account transactionally; . report whether authority was granted or already present; and . append an immutable platform audit event naming the actor as the bootstrap mechanism. The function does not accept or print passwords. Registration and confirmation -remain the normal account-creation path. Revoking the final platform admin is -rejected unless a replacement is granted in the same operation. +remain the normal account-creation path. The function remains available when +administrators already exist so an operator with release access can recover +administrative access without manipulating the database manually. It does not +silently restore a suspended account. + +Grant, revoke, suspend, restore, and account-deletion operations serialize on a +single PostgreSQL transaction-level advisory lock before evaluating the active +administrator invariant. Revoking, suspending, or deleting the final active +administrator is rejected unless an eligible replacement is granted in the +same transactional transfer operation. Self-suspension is not available in the +panel. == Panel scope The first admin panel provides: -* installation totals and recent operational failures; +* installation totals that can be answered efficiently from PostgreSQL; * user lookup by exact email or ID and account status; * organization and workspace lookup with membership summaries; -* recent and largest paste metadata, without rendering content by default; +* recent public paste metadata and largest paste metadata, without selecting or + rendering paste bodies; * abuse reports and their resolution state; * administrative paste deletion; * account suspension and restoration; and * platform administrator grant and revocation. -Viewing paste content, impersonating users, editing user content, changing -organization ownership, and reading bearer tokens are not part of the first -version. +The panel does not provide a privileged content-reading path. Administrators can +read public content normally and can inspect an unlisted paste when a report or +exact-ID lookup gives them its capability ID. General recent-paste discovery is +limited to public pastes. Largest-paste results may include body-free metadata +for every audience but do not expose an unlisted paste ID. Workspace-only +content remains governed by workspace membership. + +Impersonating users, editing user content, changing organization ownership, and +reading bearer tokens are not part of the first version. General request and +application failures belong in structured logs and metrics defined by RFD 4; +the panel does not become a log viewer. A future panel may show bounded, durable +maintenance state such as failed blob cleanup attempts. + +== Administrative actions + +Administrative paste deletion requires a reason and recent reauthentication. +In one database transaction, the context reauthorizes the actor, locks the +paste, marks it expired, and appends its platform audit event. Expiration makes +the paste inaccessible immediately. Existing retryable cleanup then removes its +inline or external data and hard-deletes the row. A storage failure does not +roll back the moderation decision or its audit record; the target ID remains in +the platform log after cleanup. + +Suspension requires a reason and recent reauthentication. It records +`suspended_at`, revokes every session and API token, and disconnects active +sessions. Authentication queries reject suspended users, including password and +magic-link login. Suspension preserves content, organization and workspace +memberships, and the platform role. Restoration also requires a reason, recent +reauthentication, and an audit event, but does not restore old tokens; the user +must log in again. + +Platform-role grants and revocations require a reason and recent +reauthentication. A grant target must be registered, confirmed, and not +suspended. Dismissing or resolving an abuse report requires a reason and an +audit event but does not alone require recent reauthentication. If resolution +deletes a paste or suspends an account, the corresponding action retains its +reauthentication requirement. + +[cols="2,1,1,1",options="header"] +|=== +| Action +| Reason +| Recent reauthentication +| Audit event + +| Read panel data or the platform audit log +| No +| No +| No + +| Dismiss or resolve a report without another mutation +| Yes +| No +| Yes + +| Delete a paste +| Yes +| Yes +| Yes + +| Suspend or restore an account +| Yes +| Yes +| Yes + +| Grant, revoke, or transfer the platform-admin role +| Yes +| Yes +| Yes + +| Grant the role through the release bootstrap +| Fixed mechanism reason +| Not applicable +| Yes +|=== + +RFD 4 owns report submission, the report schema and state machine, reason +categories, rate limits, safety metrics, and the public reporting experience. +This RFD owns platform authorization, the administrator review queue, and the +authorized moderation actions. The administration foundation can ship before +the RFD 4 report model; the report queue depends on that model. == Audit requirements -Every administrative mutation records an append-only platform audit event with -the actor, action, target type and ID, timestamp, request ID when available, and -non-secret structured metadata. Bootstrap events identify the release command -rather than inventing a user actor. Audit records never contain paste content, -passwords, session tokens, API tokens, or storage credentials. +Every administrative mutation records a separate, append-only platform audit +event in the same database transaction as the durable mutation. An event stores +the actor kind, nullable actor user ID, immutable actor label, action, target +type and ID, timestamp, reason, request ID when available from trusted server +context, and non-secret structured metadata such as previous and new state. +Bootstrap events use a `bootstrap` actor kind and identify the release mechanism +rather than inventing a user actor. Database constraints enforce valid user and +bootstrap actor combinations. + +Textbin's runtime database path rejects updates and deletes of platform audit +events. This makes events immutable through the application without claiming +to prevent a self-hosting database owner from changing their own database. +Audit records never contain paste content, passwords, session tokens, API +tokens, or storage credentials. They remain cursor-paginated with a bounded +page size. Only platform administrators can read platform audit events. Organization audit events remain governed by organization authorization and are not a substitute From a0fb34c051f574f0759af1dc3bf46f51a5f5ef3e Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 10:02:17 -0700 Subject: [PATCH 2/7] docs(rfd): publish administration proposal --- rfd/0001/IMPLEMENTATION.org | 2 ++ rfd/0001/README.adoc | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index d7d08db..e3e30f9 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -18,6 +18,8 @@ Implements [[file:README.adoc][RFD 1: Administration]]. - [ ] 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. +- [ ] Deleting an account that carries a platform role requires recent + reauthentication and records a platform audit event. - [ ] 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 diff --git a/rfd/0001/README.adoc b/rfd/0001/README.adoc index d64284d..a468421 100644 --- a/rfd/0001/README.adoc +++ b/rfd/0001/README.adoc @@ -1,5 +1,5 @@ :authors: Darwin D Wu -:state: discussion +:state: published :discussion: https://github.com/chaba-dev/textbin/pull/54 :labels: process, software, security @@ -139,7 +139,10 @@ reauthentication. A grant target must be registered, confirmed, and not suspended. Dismissing or resolving an abuse report requires a reason and an audit event but does not alone require recent reauthentication. If resolution deletes a paste or suspends an account, the corresponding action retains its -reauthentication requirement. +reauthentication requirement. Deleting an account that carries a platform role +uses the fixed reason `account_deleted`, requires recent reauthentication, and +records a platform audit event in addition to enforcing the active-admin +invariant. [cols="2,1,1,1",options="header"] |=== @@ -173,6 +176,11 @@ reauthentication requirement. | Yes | Yes +| Delete an account carrying a platform role +| Fixed `account_deleted` reason +| Yes +| Yes + | Grant the role through the release bootstrap | Fixed mechanism reason | Not applicable From 8dc3feb4cf33a11cec0d8c5f2e3c58039cce9e62 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 10:21:34 -0700 Subject: [PATCH 3/7] feat: establish platform administration foundation --- lib/textbin/accounts.ex | 56 ++- lib/textbin/accounts/user.ex | 2 + lib/textbin/accounts/user_token.ex | 3 + lib/textbin/administration.ex | 447 ++++++++++++++++++ .../administration/platform_audit_event.ex | 55 +++ lib/textbin/organizations.ex | 16 +- lib/textbin/release.ex | 5 + lib/textbin_web/forbidden_error.ex | 3 + lib/textbin_web/user_auth.ex | 42 +- ...0822090000_add_platform_administration.exs | 63 +++ priv/repo/structure.sql | 93 +++- rel/overlays/bin/grant-platform-admin | 12 + rfd/0001/IMPLEMENTATION.org | 10 +- .../administration/concurrency_test.exs | 131 +++++ test/textbin/administration_test.exs | 298 ++++++++++++ test/textbin_web/user_auth_test.exs | 52 +- 16 files changed, 1253 insertions(+), 35 deletions(-) create mode 100644 lib/textbin/administration.ex create mode 100644 lib/textbin/administration/platform_audit_event.ex create mode 100644 lib/textbin_web/forbidden_error.ex create mode 100644 priv/repo/migrations/20260822090000_add_platform_administration.exs create mode 100755 rel/overlays/bin/grant-platform-admin create mode 100644 test/textbin/administration/concurrency_test.exs create mode 100644 test/textbin/administration_test.exs diff --git a/lib/textbin/accounts.ex b/lib/textbin/accounts.ex index d34e724..4697e97 100644 --- a/lib/textbin/accounts.ex +++ b/lib/textbin/accounts.ex @@ -42,7 +42,8 @@ defmodule Textbin.Accounts do def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do user = Repo.get_by(User, email: email) - if User.valid_password?(user, password), do: user + + if user && is_nil(user.suspended_at) && User.valid_password?(user, password), do: user end @doc """ @@ -246,9 +247,13 @@ defmodule Textbin.Accounts do Generates a session token. """ def generate_user_session_token(user) do - {token, user_token} = UserToken.build_session_token(user) - Repo.insert!(user_token) - token + if active_user?(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + else + raise ArgumentError, "cannot create a session for a suspended user" + end end @doc """ @@ -341,9 +346,13 @@ defmodule Textbin.Accounts do """ def deliver_login_instructions(%User{} = user, magic_link_url_fun) when is_function(magic_link_url_fun, 1) do - {encoded_token, user_token} = UserToken.build_email_token(user, "login") - Repo.insert!(user_token) - UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) + if active_user?(user) do + {encoded_token, user_token} = UserToken.build_email_token(user, "login") + Repo.insert!(user_token) + UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) + else + {:error, :suspended} + end end @doc """ @@ -354,6 +363,21 @@ defmodule Textbin.Accounts do :ok end + @doc "Disconnects sockets authenticated by the supplied session tokens." + def disconnect_sessions(tokens) do + Enum.each(tokens, fn %{token: token} -> + topic = user_session_topic(token) + + Phoenix.PubSub.broadcast( + Textbin.PubSub, + topic, + %Phoenix.Socket.Broadcast{topic: topic, event: "disconnect", payload: %{}} + ) + end) + end + + def user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}" + ## API tokens @doc """ @@ -375,12 +399,16 @@ defmodule Textbin.Accounts do The raw token is returned once alongside the stored token record. """ def create_user_api_token(%User{} = user, attrs \\ %{}) do - name = api_token_name(attrs) - {token, user_token} = UserToken.build_api_token(user, name) + if active_user?(user) do + name = api_token_name(attrs) + {token, user_token} = UserToken.build_api_token(user, name) - case Repo.insert(user_token) do - {:ok, user_token} -> {:ok, {token, user_token}} - {:error, changeset} -> {:error, changeset} + case Repo.insert(user_token) do + {:ok, user_token} -> {:ok, {token, user_token}} + {:error, changeset} -> {:error, changeset} + end + else + {:error, :suspended} end end @@ -444,6 +472,10 @@ defmodule Textbin.Accounts do end end + defp active_user?(%User{id: user_id}) do + Repo.exists?(from user in User, where: user.id == ^user_id and is_nil(user.suspended_at)) + end + ## Token helper defp update_user_and_delete_all_tokens(changeset) do diff --git a/lib/textbin/accounts/user.ex b/lib/textbin/accounts/user.ex index 20fa237..464a43c 100644 --- a/lib/textbin/accounts/user.ex +++ b/lib/textbin/accounts/user.ex @@ -10,6 +10,8 @@ defmodule Textbin.Accounts.User do schema "users" do field :email, :string field :kind, :string, default: "registered" + field :platform_role, :string + field :suspended_at, :utc_datetime field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true field :confirmed_at, :utc_datetime diff --git a/lib/textbin/accounts/user_token.ex b/lib/textbin/accounts/user_token.ex index b6d5406..e1ccb07 100644 --- a/lib/textbin/accounts/user_token.ex +++ b/lib/textbin/accounts/user_token.ex @@ -66,6 +66,7 @@ defmodule Textbin.Accounts.UserToken do from token in by_token_and_context_query(token, "session"), join: user in assoc(token, :user), where: token.inserted_at > ago(@session_validity_in_days, "day"), + where: is_nil(user.suspended_at), select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at} {:ok, query} @@ -138,6 +139,7 @@ defmodule Textbin.Accounts.UserToken do join: user in assoc(token, :user), where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), where: token.sent_to == user.email, + where: is_nil(user.suspended_at), select: {user, token} {:ok, query} @@ -184,6 +186,7 @@ defmodule Textbin.Accounts.UserToken do query = from token in by_token_and_context_query(hashed_token, @api_token_context), join: user in assoc(token, :user), + where: is_nil(user.suspended_at), select: {user, token} {:ok, query} diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex new file mode 100644 index 0000000..b882bc6 --- /dev/null +++ b/lib/textbin/administration.ex @@ -0,0 +1,447 @@ +defmodule Textbin.Administration do + @moduledoc """ + Installation-wide authorization and audited platform authority changes. + + Caller identity comes from `Scope`, but every operation reloads authority from + the database. Privilege and suspension changes share one transaction lock so + concurrent operations cannot remove the final active administrator. + """ + + import Ecto.Query, warn: false + + alias Textbin.Accounts + alias Textbin.Accounts.{Scope, User, UserToken} + alias Textbin.Administration.PlatformAuditEvent + alias Textbin.Repo + + @platform_admin_role "admin" + @authority_lock_key 8_174_021_483_001 + + @doc "Returns the current user when the scope has active platform authority." + def authorize_platform_admin(%Scope{user: %User{id: user_id}}) do + case Repo.get(User, user_id) do + %User{} = user -> authorize_active_admin(user) + nil -> {:error, :forbidden} + end + end + + def authorize_platform_admin(_scope), do: {:error, :forbidden} + + @doc false + def authorize_account_deletion(%Scope{user: %User{id: user_id}} = scope) do + case Repo.transact(fn -> + with :ok <- lock_authority_changes(), + %User{suspended_at: nil} = user <- lock_user(user_id), + :ok <- require_account_deletion_reauthentication(scope, user), + :ok <- preserve_active_admin(user.id, user.platform_role) do + {:ok, :authorized} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end) do + {:ok, :authorized} -> :ok + error -> error + end + end + + def authorize_account_deletion(_scope), do: {:error, :not_found} + + @doc false + def lock_platform_authority_changes, do: lock_authority_changes() + + @doc false + def record_platform_account_deletion(%User{platform_role: nil}, _authenticated_at), do: :ok + + def record_platform_account_deletion(%User{} = user, authenticated_at) do + if Repo.in_transaction?() do + scope = Scope.for_user(%{user | authenticated_at: authenticated_at}) + + with :ok <- require_recent_reauthentication(scope), + :ok <- preserve_active_admin(user.id, user.platform_role) do + record_user_audit( + user, + "platform.admin.account_deleted", + user, + "account_deleted", + %{"previous_role" => user.platform_role}, + [] + ) + end + else + raise "platform account deletion audit requires a database transaction" + end + end + + @doc "Bootstraps or recovers platform authority through an audited release RPC." + def bootstrap_platform_admin(email) when is_binary(email) do + email = email |> String.trim() |> String.downcase() + + Repo.transact(fn -> + with :ok <- lock_authority_changes(), + %User{} = user <- lock_user_by_email(email), + :ok <- eligible_admin_target(user) do + result = + if user.platform_role == @platform_admin_role, do: :already_present, else: :granted + + with {:ok, user} <- put_platform_role(user, @platform_admin_role), + :ok <- + record_bootstrap_audit(user, result) do + {:ok, result} + end + else + nil -> {:error, :not_found} + error -> error + end + end) + end + + @doc "Grants platform authority to an eligible user." + def grant_platform_admin(scope, target, reason, opts \\ []) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target) do + authority_transaction(scope, fn actor -> + with %User{} = target <- lock_user(target_id), + :ok <- eligible_admin_target(target) do + if target.platform_role == @platform_admin_role do + {:ok, :already_present} + else + with {:ok, target} <- put_platform_role(target, @platform_admin_role), + :ok <- + record_user_audit( + actor, + "platform.admin.granted", + target, + reason, + %{"previous_role" => nil, "new_role" => @platform_admin_role}, + opts + ) do + {:ok, target} + end + end + else + nil -> {:error, :not_found} + error -> error + end + end) + end + end + + @doc "Revokes platform authority while preserving one active administrator." + def revoke_platform_admin(scope, target, reason, opts \\ []) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target) do + authority_transaction(scope, fn actor -> + with %User{platform_role: @platform_admin_role} = target <- lock_user(target_id), + :ok <- preserve_active_admin(target.id, target.platform_role), + {:ok, target} <- put_platform_role(target, nil), + :ok <- + record_user_audit( + actor, + "platform.admin.revoked", + target, + reason, + %{"previous_role" => @platform_admin_role, "new_role" => nil}, + opts + ) do + {:ok, target} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end) + end + end + + @doc "Grants a replacement and revokes an administrator in one transaction." + def transfer_platform_admin(scope, target, replacement, reason, opts \\ []) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target), + {:ok, replacement_id} <- user_id(replacement), + :ok <- distinct_users(target_id, replacement_id) do + authority_transaction(scope, fn actor -> + with %User{platform_role: @platform_admin_role} = target <- lock_user(target_id), + %User{} = replacement <- lock_user(replacement_id), + :ok <- eligible_admin_target(replacement), + {:ok, replacement} <- maybe_grant_replacement(actor, replacement, reason, opts), + {:ok, target} <- put_platform_role(target, nil), + :ok <- + record_user_audit( + actor, + "platform.admin.revoked", + target, + reason, + %{ + "previous_role" => @platform_admin_role, + "new_role" => nil, + "replacement_user_id" => replacement.id + }, + opts + ) do + {:ok, %{revoked: target, replacement: replacement}} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end) + end + end + + @doc "Suspends an account and revokes all of its authentication tokens." + def suspend_user(scope, target, reason, opts \\ []) do + result = + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target), + :ok <- not_self(scope, target_id) do + authority_transaction(scope, fn actor -> + with %User{} = target <- lock_user(target_id), + :ok <- not_suspended(target), + :ok <- preserve_active_admin(target.id, target.platform_role), + tokens <- Repo.all_by(UserToken, user_id: target.id), + {:ok, target} <- + target + |> Ecto.Changeset.change(suspended_at: DateTime.utc_now(:second)) + |> Repo.update(), + {_count, nil} <- + Repo.delete_all(from token in UserToken, where: token.user_id == ^target.id), + :ok <- + record_user_audit( + actor, + "platform.account.suspended", + target, + reason, + %{}, + opts + ) do + {:ok, {target, tokens}} + else + nil -> {:error, :not_found} + error -> error + end + end) + end + + disconnect_suspended_sessions(result) + end + + @doc "Restores a suspended account without restoring its revoked tokens." + def restore_user(scope, target, reason, opts \\ []) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target) do + authority_transaction(scope, fn actor -> + with %User{suspended_at: %DateTime{}} = target <- lock_user(target_id), + {:ok, target} <- + target + |> Ecto.Changeset.change(suspended_at: nil) + |> Repo.update(), + :ok <- + record_user_audit( + actor, + "platform.account.restored", + target, + reason, + %{}, + opts + ) do + {:ok, target} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end) + end + end + + defp authority_transaction(%Scope{} = scope, callback) do + Repo.transact(fn -> + with :ok <- lock_authority_changes(), + {:ok, actor} <- lock_platform_admin(scope), + :ok <- require_recent_reauthentication(scope) do + callback.(actor) + end + end) + end + + defp authority_transaction(_scope, _callback), do: {:error, :forbidden} + + defp lock_platform_admin(%Scope{user: %User{id: user_id}}) do + case lock_user(user_id) do + %User{} = user -> authorize_active_admin(user) + nil -> {:error, :forbidden} + end + end + + defp authorize_active_admin( + %User{ + platform_role: @platform_admin_role, + confirmed_at: %DateTime{}, + suspended_at: nil + } = user + ), + do: {:ok, user} + + defp authorize_active_admin(_user), do: {:error, :forbidden} + + defp require_recent_reauthentication(%Scope{user: %User{} = user}) do + if Accounts.sudo_mode?(user), do: :ok, else: {:error, :reauthentication_required} + end + + defp require_account_deletion_reauthentication(_scope, %User{platform_role: nil}), do: :ok + + defp require_account_deletion_reauthentication(scope, %User{}), + do: require_recent_reauthentication(scope) + + defp eligible_admin_target(%User{ + kind: "registered", + confirmed_at: %DateTime{}, + suspended_at: nil + }), + do: :ok + + defp eligible_admin_target(%User{kind: kind}) when kind != "registered", + do: {:error, :ineligible} + + defp eligible_admin_target(%User{confirmed_at: nil}), do: {:error, :unconfirmed} + defp eligible_admin_target(%User{suspended_at: %DateTime{}}), do: {:error, :suspended} + defp eligible_admin_target(_user), do: {:error, :ineligible} + + defp preserve_active_admin(target_id, @platform_admin_role) do + active_admins = + Repo.aggregate( + from(user in User, + where: + user.platform_role == @platform_admin_role and not is_nil(user.confirmed_at) and + is_nil(user.suspended_at) and user.id != ^target_id + ), + :count + ) + + if active_admins > 0, do: :ok, else: {:error, :final_active_admin} + end + + defp preserve_active_admin(_target_id, _role), do: :ok + + defp maybe_grant_replacement( + _actor, + %User{platform_role: @platform_admin_role} = user, + _reason, + _opts + ), + do: {:ok, user} + + defp maybe_grant_replacement(actor, user, reason, opts) do + with {:ok, user} <- put_platform_role(user, @platform_admin_role), + :ok <- + record_user_audit( + actor, + "platform.admin.granted", + user, + reason, + %{"previous_role" => nil, "new_role" => @platform_admin_role}, + opts + ) do + {:ok, user} + end + end + + defp put_platform_role(%User{platform_role: role} = user, role), do: {:ok, user} + + defp put_platform_role(user, role) do + user + |> Ecto.Changeset.change(platform_role: role) + |> Repo.update() + end + + defp record_bootstrap_audit(user, result) do + %PlatformAuditEvent{} + |> PlatformAuditEvent.changeset(%{ + actor_kind: "bootstrap", + actor_label: "release_rpc", + action: "platform.admin.bootstrap", + target_type: "user", + target_id: user.id, + reason: "bootstrap", + metadata: %{"result" => Atom.to_string(result)} + }) + |> Repo.insert() + |> audit_result() + end + + defp record_user_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: "user", + 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} + + defp lock_authority_changes do + if Repo.in_transaction?() do + Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [@authority_lock_key]) + :ok + else + raise "platform authority lock requires a database transaction" + end + end + + defp lock_user(user_id) do + Repo.one(from user in User, where: user.id == ^user_id, lock: "FOR UPDATE") + end + + defp lock_user_by_email(email) do + Repo.one(from user in User, where: user.email == ^email, lock: "FOR UPDATE") + end + + defp normalize_reason(reason) when is_binary(reason) do + case String.trim(reason) do + "" -> {:error, :reason_required} + reason when byte_size(reason) <= 500 -> {:ok, reason} + _reason -> {:error, :reason_too_long} + end + end + + defp normalize_reason(_reason), do: {:error, :reason_required} + + defp user_id(%User{id: id}), do: user_id(id) + + defp user_id(id) when is_binary(id) do + case Ecto.UUID.cast(id) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + + defp user_id(_id), do: {:error, :not_found} + + defp distinct_users(id, id), do: {:error, :same_user} + defp distinct_users(_target_id, _replacement_id), do: :ok + + defp not_self(%Scope{user: %User{id: id}}, id), do: {:error, :self_suspension} + defp not_self(_scope, _target_id), do: :ok + + defp not_suspended(%User{suspended_at: nil}), do: :ok + defp not_suspended(%User{}), do: {:error, :already_suspended} + + defp disconnect_suspended_sessions({:ok, {_user, tokens}} = result) do + Accounts.disconnect_sessions(tokens) + result + end + + defp disconnect_suspended_sessions(result), do: result +end diff --git a/lib/textbin/administration/platform_audit_event.ex b/lib/textbin/administration/platform_audit_event.ex new file mode 100644 index 0000000..84b2db3 --- /dev/null +++ b/lib/textbin/administration/platform_audit_event.ex @@ -0,0 +1,55 @@ +defmodule Textbin.Administration.PlatformAuditEvent do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "platform_audit_events" do + field :actor_kind, :string + field :actor_user_id, :binary_id + field :actor_label, :string + field :action, :string + field :target_type, :string + field :target_id, :binary_id + field :reason, :string + field :request_id, :string + field :metadata, :map, default: %{} + timestamps(updated_at: false, type: :utc_datetime_usec) + end + + def changeset(event, attrs) do + event + |> cast(attrs, [ + :actor_kind, + :actor_user_id, + :actor_label, + :action, + :target_type, + :target_id, + :reason, + :request_id, + :metadata + ]) + |> validate_required([ + :actor_kind, + :actor_label, + :action, + :target_type, + :target_id, + :reason, + :metadata + ]) + |> validate_inclusion(:actor_kind, ["user", "bootstrap"]) + |> validate_length(:actor_label, max: 160) + |> validate_length(:action, max: 100) + |> validate_length(:target_type, max: 100) + |> validate_length(:reason, max: 500) + |> validate_length(:request_id, max: 255) + |> check_constraint(:actor_kind, + name: :platform_audit_events_actor_must_be_valid, + message: "does not match the actor user" + ) + end +end diff --git a/lib/textbin/organizations.ex b/lib/textbin/organizations.ex index c897aef..b65a873 100644 --- a/lib/textbin/organizations.ex +++ b/lib/textbin/organizations.ex @@ -10,6 +10,7 @@ defmodule Textbin.Organizations do import Ecto.Query, warn: false alias Textbin.Accounts.{Scope, User} + alias Textbin.Administration alias Textbin.Organizations.{AuditEvent, Organization, OrganizationMembership, Policy} alias Textbin.Organizations.{Workspace, WorkspaceMembership} alias Textbin.Pastes @@ -693,13 +694,14 @@ defmodule Textbin.Organizations do def recover_workspace_access(_, _), do: {:error, :not_found} - def delete_account(%Scope{user: %User{id: user_id}}) do + def delete_account(%Scope{user: %User{id: user_id, authenticated_at: authenticated_at}} = scope) do ensure_transaction_owner!() - with {:ok, personal_workspace_ids} <- + with :ok <- Administration.authorize_account_deletion(scope), + {:ok, personal_workspace_ids} <- Repo.transact(fn -> prepare_account_deletion(user_id) end), :ok <- delete_workspace_pastes(personal_workspace_ids) do - Repo.transact(fn -> finalize_account_deletion(user_id) end) + Repo.transact(fn -> finalize_account_deletion(user_id, authenticated_at) end) end end @@ -1565,10 +1567,12 @@ defmodule Textbin.Organizations do end) end - defp finalize_account_deletion(user_id) do - with {:ok, user, organizations, workspaces} <- lock_account_state(user_id), + defp finalize_account_deletion(user_id, authenticated_at) do + with :ok <- Administration.lock_platform_authority_changes(), + {:ok, user, organizations, workspaces} <- lock_account_state(user_id), :ok <- require_transferred_team_ownership(user.id, organizations, workspaces), - :ok <- record_account_deletion_audits(user, organizations) do + :ok <- record_account_deletion_audits(user, organizations), + :ok <- Administration.record_platform_account_deletion(user, authenticated_at) do Repo.delete(user) end end diff --git a/lib/textbin/release.ex b/lib/textbin/release.ex index 2b3dd10..4688105 100644 --- a/lib/textbin/release.ex +++ b/lib/textbin/release.ex @@ -14,6 +14,11 @@ defmodule Textbin.Release do end end + @doc "Grants platform administration authority to a confirmed existing user." + def grant_platform_admin(email) when is_binary(email) do + Textbin.Administration.bootstrap_platform_admin(email) + end + defp repos do Application.fetch_env!(@app, :ecto_repos) end diff --git a/lib/textbin_web/forbidden_error.ex b/lib/textbin_web/forbidden_error.ex new file mode 100644 index 0000000..12ac41a --- /dev/null +++ b/lib/textbin_web/forbidden_error.ex @@ -0,0 +1,3 @@ +defmodule TextbinWeb.ForbiddenError do + defexception message: "forbidden", plug_status: 403 +end diff --git a/lib/textbin_web/user_auth.ex b/lib/textbin_web/user_auth.ex index 75e30e7..58353f5 100644 --- a/lib/textbin_web/user_auth.ex +++ b/lib/textbin_web/user_auth.ex @@ -10,6 +10,7 @@ defmodule TextbinWeb.UserAuth do alias Textbin.Accounts alias Textbin.Accounts.{Scope, User} + alias Textbin.Administration # Make the remember me cookie valid for 14 days. This should match # the session validity setting in UserToken. @@ -258,19 +259,13 @@ defmodule TextbinWeb.UserAuth do defp put_token_in_session(conn, token) do conn |> put_session(:user_token, token) - |> put_session(:live_socket_id, user_session_topic(token)) + |> put_session(:live_socket_id, Accounts.user_session_topic(token)) end @doc """ Disconnects existing sockets for the given tokens. """ - def disconnect_sessions(tokens) do - Enum.each(tokens, fn %{token: token} -> - TextbinWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{}) - end) - end - - defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}" + defdelegate disconnect_sessions(tokens), to: Accounts @doc """ Handles mounting and authenticating the current_scope in LiveViews. @@ -286,6 +281,9 @@ defmodule TextbinWeb.UserAuth do on user_token. Redirects to login page if there's no logged user. + * `:require_platform_admin` - Requires a freshly authorized platform + administrator after applying normal authenticated-user behavior. + ## Examples Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate @@ -323,6 +321,26 @@ defmodule TextbinWeb.UserAuth do end end + 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") + + {:halt, socket} + + match?({:ok, _user}, Administration.authorize_platform_admin(socket.assigns.current_scope)) -> + {:cont, socket} + + true -> + raise TextbinWeb.ForbiddenError + end + end + defp mount_current_scope(socket, session) do Phoenix.Component.assign_new(socket, :current_scope, fn -> {user, _} = user_from_session(session) @@ -375,6 +393,14 @@ defmodule TextbinWeb.UserAuth do end end + @doc "Requires fresh platform administration authority." + def require_platform_admin(conn, _opts) do + case Administration.authorize_platform_admin(conn.assigns.current_scope) do + {:ok, _user} -> conn + {:error, :forbidden} -> raise TextbinWeb.ForbiddenError + end + end + defp authenticated_scope?(%Scope{user: %Accounts.User{} = user}) do not Accounts.User.guest?(user) end diff --git a/priv/repo/migrations/20260822090000_add_platform_administration.exs b/priv/repo/migrations/20260822090000_add_platform_administration.exs new file mode 100644 index 0000000..08a5ce1 --- /dev/null +++ b/priv/repo/migrations/20260822090000_add_platform_administration.exs @@ -0,0 +1,63 @@ +defmodule Textbin.Repo.Migrations.AddPlatformAdministration do + use Ecto.Migration + + def change do + alter table(:users) do + add :platform_role, :string + add :suspended_at, :utc_datetime + end + + create constraint(:users, :users_platform_role_must_be_supported, + check: "platform_role IS NULL OR platform_role = 'admin'" + ) + + create index(:users, [:platform_role], where: "platform_role IS NOT NULL") + create index(:users, [:suspended_at], where: "suspended_at IS NOT NULL") + + create table(:platform_audit_events, primary_key: false) do + add :id, :binary_id, primary_key: true + add :actor_kind, :string, null: false + add :actor_user_id, :binary_id + add :actor_label, :string, null: false + add :action, :string, null: false + add :target_type, :string, null: false + add :target_id, :binary_id, null: false + add :reason, :text, null: false + add :request_id, :string + add :metadata, :map, null: false, default: %{} + timestamps(updated_at: false, type: :utc_datetime_usec) + end + + create constraint(:platform_audit_events, :platform_audit_events_actor_must_be_valid, + check: """ + (actor_kind = 'user' AND actor_user_id IS NOT NULL) OR + (actor_kind = 'bootstrap' AND actor_user_id IS NULL) + """ + ) + + create index(:platform_audit_events, [:inserted_at, :id]) + create index(:platform_audit_events, [:actor_user_id]) + create index(:platform_audit_events, [:target_type, :target_id]) + + execute( + """ + CREATE FUNCTION prevent_platform_audit_event_changes() + RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'platform audit events are append-only'; + END; + $$ LANGUAGE plpgsql + """, + "DROP FUNCTION prevent_platform_audit_event_changes()" + ) + + execute( + """ + CREATE TRIGGER platform_audit_events_are_append_only + BEFORE UPDATE OR DELETE ON platform_audit_events + FOR EACH ROW EXECUTE FUNCTION prevent_platform_audit_event_changes() + """, + "DROP TRIGGER platform_audit_events_are_append_only ON platform_audit_events" + ) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 20cb528..26fb9f9 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict BUtNx9FO4R4TMffHZBZ7wa2crgdiJE761OheRYvpLlLJeBdLxhSGMd2MCJQoevK +\restrict 6gtWhRfmKpVJkaDO90gYdQMd8oshwcJAhZ5etVA7wO4LV6cIsOJfb8kQjX2kxyN -- Dumped from database version 17.10 -- Dumped by pg_dump version 17.10 @@ -33,6 +33,19 @@ CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; COMMENT ON EXTENSION citext IS 'data type for case-insensitive character strings'; +-- +-- Name: prevent_platform_audit_event_changes(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.prevent_platform_audit_event_changes() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + RAISE EXCEPTION 'platform audit events are append-only'; +END; +$$; + + SET default_tablespace = ''; SET default_table_access_method = heap; @@ -120,6 +133,26 @@ CREATE TABLE public.pending_uploads ( ); +-- +-- Name: platform_audit_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.platform_audit_events ( + id uuid NOT NULL, + actor_kind character varying(255) NOT NULL, + actor_user_id uuid, + actor_label character varying(255) NOT NULL, + action character varying(255) NOT NULL, + target_type character varying(255) NOT NULL, + target_id uuid 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, + CONSTRAINT platform_audit_events_actor_must_be_valid CHECK (((((actor_kind)::text = 'user'::text) AND (actor_user_id IS NOT NULL)) OR (((actor_kind)::text = 'bootstrap'::text) AND (actor_user_id IS NULL)))) +); + + -- -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - -- @@ -142,7 +175,10 @@ CREATE TABLE public.users ( inserted_at timestamp(0) without time zone NOT NULL, updated_at timestamp(0) without time zone NOT NULL, default_paste_ttl character varying(255) DEFAULT 'never'::character varying NOT NULL, - kind character varying(255) DEFAULT 'registered'::character varying NOT NULL + kind character varying(255) DEFAULT 'registered'::character varying NOT NULL, + platform_role character varying(255), + suspended_at timestamp(0) without time zone, + CONSTRAINT users_platform_role_must_be_supported CHECK (((platform_role IS NULL) OR ((platform_role)::text = 'admin'::text))) ); @@ -242,6 +278,14 @@ ALTER TABLE ONLY public.pending_uploads ADD CONSTRAINT pending_uploads_pkey PRIMARY KEY (storage_key); +-- +-- Name: platform_audit_events platform_audit_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.platform_audit_events + ADD CONSTRAINT platform_audit_events_pkey PRIMARY KEY (id); + + -- -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -380,6 +424,27 @@ CREATE INDEX pastes_workspace_id_inserted_at_index ON public.pastes USING btree CREATE INDEX pending_uploads_inserted_at_index ON public.pending_uploads USING btree (inserted_at); +-- +-- Name: platform_audit_events_actor_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX platform_audit_events_actor_user_id_index ON public.platform_audit_events USING btree (actor_user_id); + + +-- +-- Name: platform_audit_events_inserted_at_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX platform_audit_events_inserted_at_id_index ON public.platform_audit_events USING btree (inserted_at, id); + + +-- +-- Name: platform_audit_events_target_type_target_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX platform_audit_events_target_type_target_id_index ON public.platform_audit_events USING btree (target_type, target_id); + + -- -- Name: users_email_index; Type: INDEX; Schema: public; Owner: - -- @@ -394,6 +459,20 @@ CREATE UNIQUE INDEX users_email_index ON public.users USING btree (email); CREATE INDEX users_kind_index ON public.users USING btree (kind); +-- +-- Name: users_platform_role_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_platform_role_index ON public.users USING btree (platform_role) WHERE (platform_role IS NOT NULL); + + +-- +-- Name: users_suspended_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_suspended_at_index ON public.users USING btree (suspended_at) WHERE (suspended_at IS NOT NULL); + + -- -- Name: users_tokens_context_token_index; Type: INDEX; Schema: public; Owner: - -- @@ -450,6 +529,13 @@ CREATE UNIQUE INDEX workspaces_one_default_per_organization ON public.workspaces CREATE UNIQUE INDEX workspaces_organization_id_slug_index ON public.workspaces USING btree (organization_id, slug); +-- +-- Name: platform_audit_events platform_audit_events_are_append_only; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER platform_audit_events_are_append_only BEFORE DELETE OR UPDATE ON public.platform_audit_events FOR EACH ROW EXECUTE FUNCTION public.prevent_platform_audit_event_changes(); + + -- -- Name: organization_memberships organization_memberships_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -542,7 +628,7 @@ ALTER TABLE ONLY public.workspaces -- PostgreSQL database dump complete -- -\unrestrict BUtNx9FO4R4TMffHZBZ7wa2crgdiJE761OheRYvpLlLJeBdLxhSGMd2MCJQoevK +\unrestrict 6gtWhRfmKpVJkaDO90gYdQMd8oshwcJAhZ5etVA7wO4LV6cIsOJfb8kQjX2kxyN INSERT INTO public."schema_migrations" (version) VALUES (20260706061942); INSERT INTO public."schema_migrations" (version) VALUES (20260709081001); @@ -564,3 +650,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260813230000); INSERT INTO public."schema_migrations" (version) VALUES (20260814080000); INSERT INTO public."schema_migrations" (version) VALUES (20260814081000); INSERT INTO public."schema_migrations" (version) VALUES (20260814082000); +INSERT INTO public."schema_migrations" (version) VALUES (20260822090000); diff --git a/rel/overlays/bin/grant-platform-admin b/rel/overlays/bin/grant-platform-admin new file mode 100755 index 0000000..854c30a --- /dev/null +++ b/rel/overlays/bin/grant-platform-admin @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: $0 EMAIL" >&2 + exit 64 +fi + +export TEXTBIN_PLATFORM_ADMIN_EMAIL="$1" + +exec "$(dirname "$0")/textbin" rpc \ + 'Textbin.Release.grant_platform_admin(System.fetch_env!("TEXTBIN_PLATFORM_ADMIN_EMAIL"))' diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index e3e30f9..2668595 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -6,21 +6,21 @@ Implements [[file:README.adoc][RFD 1: Administration]]. context function directly. - [ ] Every context call reloads current platform authority, and a mounted admin loses panel access promptly after revocation or suspension. -- [ ] Organization owners and admins have no platform authority unless separately +- [X] Organization owners and admins have no platform authority unless separately granted it. -- [ ] An operator can grant the first platform administrator from a release without +- [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. -- [ ] Concurrent revocation, suspension, and account-deletion attempts cannot +- [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. -- [ ] Deleting an account that carries a platform role requires recent +- [X] Deleting an account that carries a platform role requires recent reauthentication and records a platform audit event. -- [ ] Suspension immediately invalidates browser sessions and API tokens without +- [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. diff --git a/test/textbin/administration/concurrency_test.exs b/test/textbin/administration/concurrency_test.exs new file mode 100644 index 0000000..b329d1a --- /dev/null +++ b/test/textbin/administration/concurrency_test.exs @@ -0,0 +1,131 @@ +defmodule Textbin.Administration.ConcurrencyTest do + use ExUnit.Case, async: false + + alias Ecto.Adapters.SQL.Sandbox + alias Textbin.Accounts.Scope + alias Textbin.Accounts.User + alias Textbin.Administration + alias Textbin.Repo + + import Ecto.Query + import Textbin.AccountsFixtures + + @authority_lock_key 8_174_021_483_001 + + setup do + :ok = Sandbox.checkout(Repo, sandbox: false) + Repo.query!("TRUNCATE platform_audit_events") + {:ok, cleanup} = Agent.start(fn -> [] end) + + on_exit(fn -> + user_ids = Agent.get(cleanup, & &1) + :ok = Sandbox.checkout(Repo, sandbox: false) + + try do + Repo.query!("TRUNCATE platform_audit_events") + Repo.delete_all(from user in User, where: user.id in ^user_ids) + after + Sandbox.checkin(Repo) + Agent.stop(cleanup) + end + end) + + %{cleanup: cleanup} + end + + test "concurrent administrators cannot revoke one another and remove all authority", %{ + cleanup: cleanup + } do + admin_a = tracked_admin_fixture(cleanup) + admin_b = tracked_admin_fixture(cleanup) + + results = + race([ + fn -> + Administration.revoke_platform_admin(admin_scope(admin_a), admin_b, "race") + end, + fn -> + Administration.revoke_platform_admin(admin_scope(admin_b), admin_a, "race") + end + ]) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &match?({:error, :forbidden}, &1)) == 1 + + assert Repo.aggregate( + from(user in User, + where: + user.platform_role == "admin" and not is_nil(user.confirmed_at) and + is_nil(user.suspended_at) + ), + :count + ) == 1 + end + + defp race(functions) do + parent = self() + + tasks = + Enum.map(functions, fn function -> + Task.async(fn -> + :ok = Sandbox.checkout(Repo, sandbox: false) + + try do + %{rows: [[backend_pid]]} = Repo.query!("SELECT pg_backend_pid()") + send(parent, {:ready, self(), backend_pid}) + receive do: (:go -> function.()) + after + Sandbox.checkin(Repo) + end + end) + end) + + workers = + Enum.map(tasks, fn _task -> + assert_receive {:ready, task_pid, backend_pid}, 5_000 + {task_pid, backend_pid} + end) + + Repo.transaction(fn -> + Repo.query!("SELECT pg_advisory_xact_lock($1)", [@authority_lock_key]) + Enum.each(workers, fn {task_pid, _backend_pid} -> send(task_pid, :go) end) + await_lock_waits!(Enum.map(workers, &elem(&1, 1)), 5_000) + end) + + Task.await_many(tasks, 10_000) + end + + defp await_lock_waits!(backend_pids, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await_lock_waits!(backend_pids, deadline) + end + + defp do_await_lock_waits!(backend_pids, deadline) do + %{rows: rows} = + Repo.query!( + "SELECT pid FROM pg_stat_activity WHERE pid = ANY($1) AND wait_event_type = 'Lock'", + [backend_pids] + ) + + if MapSet.new(rows, fn [pid] -> pid end) == MapSet.new(backend_pids) do + :ok + else + if System.monotonic_time(:millisecond) < deadline do + do_await_lock_waits!(backend_pids, deadline) + else + flunk("workers did not block on the platform authority lock") + end + end + end + + defp tracked_admin_fixture(cleanup) do + user = user_fixture() + Agent.update(cleanup, &[user.id | &1]) + assert {:ok, _result} = Administration.bootstrap_platform_admin(user.email) + Repo.get!(User, user.id) + end + + defp admin_scope(user) do + Scope.for_user(%{user | authenticated_at: DateTime.utc_now(:second)}) + end +end diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs new file mode 100644 index 0000000..c9af48e --- /dev/null +++ b/test/textbin/administration_test.exs @@ -0,0 +1,298 @@ +defmodule Textbin.AdministrationTest do + use Textbin.DataCase, async: false + + alias Textbin.Accounts + alias Textbin.Accounts.{Scope, User, UserToken} + alias Textbin.Administration + alias Textbin.Administration.PlatformAuditEvent + alias Textbin.Release + + import Textbin.AccountsFixtures + + describe "bootstrap_platform_admin/1" do + test "grants a confirmed user idempotently and audits every invocation" do + user = user_fixture() + + assert {:ok, :granted} = + Release.grant_platform_admin(String.upcase(user.email)) + + assert Repo.get!(User, user.id).platform_role == "admin" + + assert {:ok, :already_present} = + Administration.bootstrap_platform_admin(user.email) + + events = + Repo.all( + from event in PlatformAuditEvent, + where: event.target_id == ^user.id, + order_by: [asc: event.inserted_at] + ) + + assert Enum.map(events, & &1.actor_kind) == ["bootstrap", "bootstrap"] + assert Enum.map(events, & &1.actor_label) == ["release_rpc", "release_rpc"] + assert Enum.map(events, & &1.metadata["result"]) == ["granted", "already_present"] + end + + test "rejects missing, unconfirmed, guest, and suspended users" do + unconfirmed = unconfirmed_user_fixture() + guest = guest_user() + suspended = user_fixture() + + Repo.update_all(from(user in User, where: user.id == ^suspended.id), + set: [suspended_at: DateTime.utc_now(:second)] + ) + + assert {:error, :not_found} = + Administration.bootstrap_platform_admin("missing@example.com") + + assert {:error, :unconfirmed} = + Administration.bootstrap_platform_admin(unconfirmed.email) + + assert {:error, :ineligible} = Administration.bootstrap_platform_admin(guest.email) + assert {:error, :suspended} = Administration.bootstrap_platform_admin(suspended.email) + end + end + + describe "platform authorization" do + test "reloads authority instead of trusting the scope user" do + admin = admin_fixture() + scope = admin_scope(admin) + + assert {:ok, %User{id: id}} = Administration.authorize_platform_admin(scope) + assert id == admin.id + + Repo.update_all(from(user in User, where: user.id == ^admin.id), set: [platform_role: nil]) + + assert {:error, :forbidden} = Administration.authorize_platform_admin(scope) + end + + test "organization roles and stale platform fields grant no authority" do + user = user_fixture() + stale = %{user | platform_role: "admin"} + + assert {:error, :forbidden} = + Administration.authorize_platform_admin(Scope.for_user(stale)) + + assert {:error, :forbidden} = + Administration.grant_platform_admin( + Scope.for_user(stale), + user_fixture(), + "not authorized" + ) + end + end + + describe "platform authority changes" do + setup do + admin = admin_fixture() + %{admin: admin, scope: admin_scope(admin)} + end + + test "grants and revokes authority with reasons and request IDs", %{scope: scope} do + target = user_fixture() + + assert {:ok, %User{platform_role: "admin"}} = + Administration.grant_platform_admin(scope, target, "operations coverage", + request_id: "request-123" + ) + + assert {:ok, %User{platform_role: nil}} = + Administration.revoke_platform_admin(scope, target, "coverage ended") + + [revoked, granted] = + Repo.all( + from event in PlatformAuditEvent, + where: event.action != "platform.admin.bootstrap", + order_by: [desc: event.inserted_at] + ) + + assert granted.action == "platform.admin.granted" + assert granted.reason == "operations coverage" + assert granted.request_id == "request-123" + assert revoked.action == "platform.admin.revoked" + assert revoked.reason == "coverage ended" + end + + test "requires a reason and recent reauthentication", %{admin: admin} do + target = user_fixture() + + assert {:error, :reason_required} = + Administration.grant_platform_admin(admin_scope(admin), target, " ") + + stale_scope = + Scope.for_user(%{ + admin + | authenticated_at: DateTime.add(DateTime.utc_now(:second), -21, :minute) + }) + + assert {:error, :reauthentication_required} = + Administration.grant_platform_admin(stale_scope, target, "needed") + end + + test "rejects ineligible grant targets", %{scope: scope} do + unconfirmed = unconfirmed_user_fixture() + guest = guest_user() + + assert {:error, :unconfirmed} = + Administration.grant_platform_admin(scope, unconfirmed, "needed") + + assert {:error, :ineligible} = + Administration.grant_platform_admin(scope, guest, "needed") + end + + test "protects the final active administrator", %{admin: admin, scope: scope} do + assert {:error, :final_active_admin} = + Administration.revoke_platform_admin(scope, admin, "leaving") + + assert Repo.get!(User, admin.id).platform_role == "admin" + end + + test "transfers the final authority atomically", %{admin: admin, scope: scope} do + replacement = user_fixture() + + assert {:ok, %{revoked: revoked, replacement: promoted}} = + Administration.transfer_platform_admin( + scope, + admin, + replacement, + "rotation" + ) + + assert revoked.platform_role == nil + assert promoted.platform_role == "admin" + assert {:error, :forbidden} = Administration.authorize_platform_admin(scope) + assert {:ok, _user} = Administration.authorize_platform_admin(admin_scope(promoted)) + end + end + + describe "account suspension" do + setup do + admin = admin_fixture() + %{admin: admin, scope: admin_scope(admin)} + end + + test "revokes every token and blocks all authentication", %{scope: scope} do + user = user_fixture() |> set_password() + session_token = Accounts.generate_user_session_token(user) + {:ok, {api_token, _record}} = Accounts.create_user_api_token(user, %{"name" => "CLI"}) + {magic_token, _hashed_token} = generate_user_magic_link_token(user) + Phoenix.PubSub.subscribe(Textbin.PubSub, Accounts.user_session_topic(session_token)) + + assert {:ok, {%User{suspended_at: %DateTime{}} = suspended, tokens}} = + Administration.suspend_user(scope, user, "abuse") + + assert length(tokens) == 3 + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect"} + refute Repo.exists?(from token in UserToken, where: token.user_id == ^user.id) + refute Accounts.get_user_by_session_token(session_token) + refute Accounts.get_user_and_api_token(api_token) + refute Accounts.get_user_by_magic_link_token(magic_token) + refute Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + + assert_raise ArgumentError, fn -> Accounts.generate_user_session_token(user) end + assert {:error, :suspended} = Accounts.create_user_api_token(user) + + assert {:error, :suspended} = + Accounts.deliver_login_instructions(user, fn token -> token end) + + assert {:ok, %User{suspended_at: nil}} = + Administration.restore_user(scope, suspended, "appeal accepted") + + refute Repo.exists?(from token in UserToken, where: token.user_id == ^user.id) + end + + test "rejects self-suspension and suspending the final active admin", %{ + admin: admin, + scope: scope + } do + assert {:error, :self_suspension} = + Administration.suspend_user(scope, admin, "self") + + second_admin = admin_fixture() + + assert {:ok, {_suspended, _tokens}} = + Administration.suspend_user(scope, second_admin, "removed") + + second_scope = admin_scope(second_admin) + assert {:error, :forbidden} = Administration.authorize_platform_admin(second_scope) + end + end + + describe "platform administrator account deletion" do + test "protects the final active administrator before deleting account data" do + admin = admin_fixture() + + assert {:error, :final_active_admin} = Accounts.delete_user(admin_scope(admin)) + assert Repo.get!(User, admin.id) + end + + test "requires reauthentication and audits an allowed deletion" do + admin = admin_fixture() + _replacement = admin_fixture() + + stale_scope = + Scope.for_user(%{ + admin + | authenticated_at: DateTime.add(DateTime.utc_now(:second), -21, :minute) + }) + + assert {:error, :reauthentication_required} = Accounts.delete_user(stale_scope) + assert Repo.get!(User, admin.id) + + assert {:ok, %User{id: deleted_id}} = Accounts.delete_user(admin_scope(admin)) + assert deleted_id == admin.id + refute Repo.get(User, admin.id) + + assert %PlatformAuditEvent{ + actor_kind: "user", + actor_user_id: actor_id, + action: "platform.admin.account_deleted", + target_id: target_id, + reason: "account_deleted" + } = + Repo.one!( + from event in PlatformAuditEvent, + where: event.action == "platform.admin.account_deleted" + ) + + assert actor_id == admin.id + assert target_id == admin.id + end + end + + test "platform audit events reject updates and deletes" 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 -> Repo.delete!(event) end) + end + end + + test "registration cannot assign platform authority or suspension" do + assert {:ok, user} = + Accounts.register_user(%{ + email: unique_user_email(), + platform_role: "admin", + suspended_at: DateTime.utc_now(:second) + }) + + assert user.platform_role == nil + assert user.suspended_at == nil + end + + defp admin_fixture do + user = user_fixture() + assert {:ok, _result} = Administration.bootstrap_platform_admin(user.email) + Repo.get!(User, user.id) + end + + defp admin_scope(user) do + Scope.for_user(%{user | authenticated_at: DateTime.utc_now(:second)}) + end + + defp guest_user do + assert {:ok, user} = Accounts.create_guest_user() + user + end +end diff --git a/test/textbin_web/user_auth_test.exs b/test/textbin_web/user_auth_test.exs index 89f1f45..a12e728 100644 --- a/test/textbin_web/user_auth_test.exs +++ b/test/textbin_web/user_auth_test.exs @@ -3,7 +3,10 @@ defmodule TextbinWeb.UserAuthTest do alias Phoenix.LiveView alias Textbin.Accounts - alias Textbin.Accounts.Scope + alias Textbin.Accounts.{Scope, User} + alias Textbin.Administration + alias Textbin.Repo + alias TextbinWeb.ForbiddenError alias TextbinWeb.UserAuth import Textbin.AccountsFixtures @@ -367,6 +370,53 @@ defmodule TextbinWeb.UserAuthTest do end end + describe "platform administration authorization" do + test "allows an administrator and reloads current authority", %{conn: conn, user: user} do + assert {:ok, :granted} = Administration.bootstrap_platform_admin(user.email) + user = Repo.get!(User, user.id) + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:cont, _socket} = + UserAuth.on_mount(:require_platform_admin, %{}, session, %LiveView.Socket{}) + + Repo.update_all(User, set: [platform_role: nil]) + + assert_raise ForbiddenError, fn -> + UserAuth.on_mount(:require_platform_admin, %{}, session, %LiveView.Socket{}) + end + end + + test "returns forbidden for an authenticated non-admin", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert_raise ForbiddenError, fn -> + UserAuth.on_mount(:require_platform_admin, %{}, session, %LiveView.Socket{}) + end + + conn = assign(conn, :current_scope, Scope.for_user(user)) + assert_raise ForbiddenError, fn -> UserAuth.require_platform_admin(conn, []) end + end + + test "redirects an unauthenticated LiveView to login", %{conn: conn} do + socket = %LiveView.Socket{ + endpoint: TextbinWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + assert {:halt, updated_socket} = + UserAuth.on_mount( + :require_platform_admin, + %{}, + get_session(conn), + socket + ) + + assert updated_socket.assigns.current_scope == nil + end + end + describe "require_authenticated_user/2" do setup %{conn: conn} do %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} From b1423dd66f20bad473613330d0f0916d718d2e8f Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 10:46:34 -0700 Subject: [PATCH 4/7] fix: preserve constant-time password authentication --- lib/textbin/accounts.ex | 3 ++- test/textbin/accounts_test.exs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/textbin/accounts.ex b/lib/textbin/accounts.ex index 4697e97..6f4e8bc 100644 --- a/lib/textbin/accounts.ex +++ b/lib/textbin/accounts.ex @@ -42,8 +42,9 @@ defmodule Textbin.Accounts do def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do user = Repo.get_by(User, email: email) + valid_password? = User.valid_password?(user, password) - if user && is_nil(user.suspended_at) && User.valid_password?(user, password), do: user + if valid_password? && match?(%User{suspended_at: nil}, user), do: user end @doc """ diff --git a/test/textbin/accounts_test.exs b/test/textbin/accounts_test.exs index b6ab1c1..9287979 100644 --- a/test/textbin/accounts_test.exs +++ b/test/textbin/accounts_test.exs @@ -38,6 +38,16 @@ defmodule Textbin.AccountsTest do assert %User{id: ^id} = Accounts.get_user_by_email_and_password(user.email, valid_user_password()) end + + test "does not return a suspended user with a valid password" do + user = user_fixture() |> set_password() + + Repo.update_all(from(candidate in User, where: candidate.id == ^user.id), + set: [suspended_at: DateTime.utc_now(:second)] + ) + + refute Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end end describe "get_user!/1" do From d467a4f5198c88f98e26062efb4aa244e377a3ef Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 10:46:55 -0700 Subject: [PATCH 5/7] fix: close administration concurrency gaps --- lib/textbin/accounts.ex | 67 ++++++--- .../administration/concurrency_test.exs | 138 ++++++++++++++++-- 2 files changed, 172 insertions(+), 33 deletions(-) diff --git a/lib/textbin/accounts.ex b/lib/textbin/accounts.ex index 6f4e8bc..2ec85fb 100644 --- a/lib/textbin/accounts.ex +++ b/lib/textbin/accounts.ex @@ -247,13 +247,18 @@ defmodule Textbin.Accounts do @doc """ Generates a session token. """ - def generate_user_session_token(user) do - if active_user?(user) do - {token, user_token} = UserToken.build_session_token(user) - Repo.insert!(user_token) - token - else - raise ArgumentError, "cannot create a session for a suspended user" + def generate_user_session_token(%User{} = user) do + result = + insert_token_for_active_user(user.id, fn current_user -> + UserToken.build_session_token(%{ + current_user + | authenticated_at: user.authenticated_at + }) + end) + + case result do + {:ok, {token, _user_token, _current_user}} -> token + {:error, :suspended} -> raise ArgumentError, "cannot create a session for a suspended user" end end @@ -347,12 +352,15 @@ defmodule Textbin.Accounts do """ def deliver_login_instructions(%User{} = user, magic_link_url_fun) when is_function(magic_link_url_fun, 1) do - if active_user?(user) do - {encoded_token, user_token} = UserToken.build_email_token(user, "login") - Repo.insert!(user_token) - UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) - else - {:error, :suspended} + case insert_token_for_active_user(user.id, &UserToken.build_email_token(&1, "login")) do + {:ok, {encoded_token, _user_token, current_user}} -> + UserNotifier.deliver_login_instructions( + current_user, + magic_link_url_fun.(encoded_token) + ) + + {:error, :suspended} = error -> + error end end @@ -400,16 +408,11 @@ defmodule Textbin.Accounts do The raw token is returned once alongside the stored token record. """ def create_user_api_token(%User{} = user, attrs \\ %{}) do - if active_user?(user) do - name = api_token_name(attrs) - {token, user_token} = UserToken.build_api_token(user, name) + name = api_token_name(attrs) - case Repo.insert(user_token) do - {:ok, user_token} -> {:ok, {token, user_token}} - {:error, changeset} -> {:error, changeset} - end - else - {:error, :suspended} + case insert_token_for_active_user(user.id, &UserToken.build_api_token(&1, name)) do + {:ok, {token, user_token, _current_user}} -> {:ok, {token, user_token}} + error -> error end end @@ -473,8 +476,24 @@ defmodule Textbin.Accounts do end end - defp active_user?(%User{id: user_id}) do - Repo.exists?(from user in User, where: user.id == ^user_id and is_nil(user.suspended_at)) + defp insert_token_for_active_user(user_id, build_token) do + Repo.transact(fn -> + case Repo.one(from user in User, where: user.id == ^user_id, lock: "FOR UPDATE") do + %User{suspended_at: nil} = user -> + {encoded_token, user_token} = build_token.(user) + + case Repo.insert(user_token) do + {:ok, user_token} -> {:ok, {encoded_token, user_token, user}} + {:error, changeset} -> {:error, changeset} + end + + %User{} -> + {:error, :suspended} + + nil -> + {:error, :suspended} + end + end) end ## Token helper diff --git a/test/textbin/administration/concurrency_test.exs b/test/textbin/administration/concurrency_test.exs index b329d1a..ddcbfc4 100644 --- a/test/textbin/administration/concurrency_test.exs +++ b/test/textbin/administration/concurrency_test.exs @@ -2,8 +2,8 @@ defmodule Textbin.Administration.ConcurrencyTest do use ExUnit.Case, async: false alias Ecto.Adapters.SQL.Sandbox - alias Textbin.Accounts.Scope - alias Textbin.Accounts.User + alias Textbin.Accounts + alias Textbin.Accounts.{Scope, User, UserToken} alias Textbin.Administration alias Textbin.Repo @@ -53,15 +53,78 @@ defmodule Textbin.Administration.ConcurrencyTest do assert Enum.count(results, &match?({:error, :forbidden}, &1)) == 1 assert Repo.aggregate( - from(user in User, - where: - user.platform_role == "admin" and not is_nil(user.confirmed_at) and - is_nil(user.suspended_at) - ), + active_admin_query(), :count ) == 1 end + test "suspension racing revocation preserves one active administrator", %{cleanup: cleanup} do + admin_a = tracked_admin_fixture(cleanup) + admin_b = tracked_admin_fixture(cleanup) + + results = + race([ + fn -> Administration.suspend_user(admin_scope(admin_a), admin_b, "race") end, + fn -> + Administration.revoke_platform_admin(admin_scope(admin_b), admin_a, "race") + end + ]) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &match?({:error, :forbidden}, &1)) == 1 + assert Repo.aggregate(active_admin_query(), :count) == 1 + end + + test "concurrent administrator account deletions preserve one active administrator", %{ + cleanup: cleanup + } do + admin_a = tracked_admin_fixture(cleanup) + admin_b = tracked_admin_fixture(cleanup) + + results = + race([ + fn -> Accounts.delete_user(admin_scope(admin_a)) end, + fn -> Accounts.delete_user(admin_scope(admin_b)) end + ]) + + assert Enum.count(results, &match?({:ok, %User{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, :final_active_admin}, &1)) == 1 + assert Repo.aggregate(active_admin_query(), :count) == 1 + end + + test "session, API, and login token issuance serialize with suspension", %{cleanup: cleanup} do + admin = tracked_admin_fixture(cleanup) + + issuers = [ + session: fn user -> + try do + {:ok, Accounts.generate_user_session_token(user)} + rescue + ArgumentError -> {:error, :suspended} + end + end, + api: fn user -> Accounts.create_user_api_token(user, %{"name" => "race"}) end, + login: fn user -> Accounts.deliver_login_instructions(user, & &1) end + ] + + for {context, issuer} <- issuers do + user = tracked_user_fixture(cleanup) + + {_issuance_result, suspension_result} = + race_issuance_with_suspension(admin, user, fn -> issuer.(user) end) + + assert {:ok, {%User{suspended_at: %DateTime{}}, _tokens}} = suspension_result + + assert {:ok, %User{suspended_at: nil}} = + Administration.restore_user(admin_scope(admin), user, "race complete") + + refute Repo.exists?( + from token in UserToken, + where: token.user_id == ^user.id and token.context == ^Atom.to_string(context) + ) + end + end + defp race(functions) do parent = self() @@ -100,6 +163,51 @@ defmodule Textbin.Administration.ConcurrencyTest do do_await_lock_waits!(backend_pids, deadline) end + defp race_issuance_with_suspension(admin, user, issuer) do + parent = self() + + {:ok, tasks} = + Repo.transaction(fn -> + Repo.one!(from candidate in User, where: candidate.id == ^user.id, lock: "FOR UPDATE") + + issuance_task = independent_task(parent, issuer) + + suspension_task = + independent_task(parent, fn -> + Administration.suspend_user(admin_scope(admin), user, "race") + end) + + workers = + for _task <- [issuance_task, suspension_task] do + assert_receive {:ready, task_pid, backend_pid}, 5_000 + {task_pid, backend_pid} + end + + Enum.each(workers, fn {task_pid, _backend_pid} -> send(task_pid, :go) end) + await_lock_waits!(Enum.map(workers, &elem(&1, 1)), 5_000) + + [issuance_task, suspension_task] + end) + + tasks + |> Task.await_many(10_000) + |> List.to_tuple() + end + + defp independent_task(parent, function) do + Task.async(fn -> + :ok = Sandbox.checkout(Repo, sandbox: false) + + try do + %{rows: [[backend_pid]]} = Repo.query!("SELECT pg_backend_pid()") + send(parent, {:ready, self(), backend_pid}) + receive do: (:go -> function.()) + after + Sandbox.checkin(Repo) + end + end) + end + defp do_await_lock_waits!(backend_pids, deadline) do %{rows: rows} = Repo.query!( @@ -119,12 +227,24 @@ defmodule Textbin.Administration.ConcurrencyTest do end defp tracked_admin_fixture(cleanup) do - user = user_fixture() - Agent.update(cleanup, &[user.id | &1]) + user = tracked_user_fixture(cleanup) assert {:ok, _result} = Administration.bootstrap_platform_admin(user.email) Repo.get!(User, user.id) end + defp tracked_user_fixture(cleanup) do + user = user_fixture() + Agent.update(cleanup, &[user.id | &1]) + user + end + + defp active_admin_query do + from user in User, + where: + user.platform_role == "admin" and not is_nil(user.confirmed_at) and + is_nil(user.suspended_at) + end + defp admin_scope(user) do Scope.for_user(%{user | authenticated_at: DateTime.utc_now(:second)}) end From cd7c4295618469b0518998018504f37c08f74433 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 10:47:15 -0700 Subject: [PATCH 6/7] test: verify organization roles grant no platform access --- test/textbin/administration_test.exs | 40 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index c9af48e..4f5d335 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -5,6 +5,7 @@ defmodule Textbin.AdministrationTest do alias Textbin.Accounts.{Scope, User, UserToken} alias Textbin.Administration alias Textbin.Administration.PlatformAuditEvent + alias Textbin.Organizations alias Textbin.Release import Textbin.AccountsFixtures @@ -66,19 +67,38 @@ defmodule Textbin.AdministrationTest do assert {:error, :forbidden} = Administration.authorize_platform_admin(scope) end - test "organization roles and stale platform fields grant no authority" do - user = user_fixture() - stale = %{user | platform_role: "admin"} + test "organization owners and admins have no platform authority" do + owner = user_fixture() + organization_admin = user_fixture() + target = user_fixture() - assert {:error, :forbidden} = - Administration.authorize_platform_admin(Scope.for_user(stale)) + assert {:ok, organization} = + Organizations.create_organization(Scope.for_user(owner), %{ + name: "Platform isolation", + slug: "platform-isolation-#{System.unique_integer([:positive])}" + }) + + assert {:ok, memberships} = + Organizations.add_organization_member( + Scope.for_user(owner), + organization, + organization_admin + ) - assert {:error, :forbidden} = - Administration.grant_platform_admin( - Scope.for_user(stale), - user_fixture(), - "not authorized" + assert {:ok, _membership} = + Organizations.change_organization_member_role( + Scope.for_user(owner), + memberships.organization, + "admin" ) + + for user <- [owner, organization_admin] do + scope = Scope.for_user(%{user | authenticated_at: DateTime.utc_now(:second)}) + assert {:error, :forbidden} = Administration.authorize_platform_admin(scope) + + assert {:error, :forbidden} = + Administration.grant_platform_admin(scope, target, "not authorized") + end end end From c5ebd41ae66b17bb34c0718bb7567e1eacafaa94 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sat, 22 Aug 2026 10:52:34 -0700 Subject: [PATCH 7/7] refactor: flatten administration transaction flows --- lib/textbin/accounts.ex | 29 +-- lib/textbin/administration.ex | 338 +++++++++++++++++++--------------- 2 files changed, 205 insertions(+), 162 deletions(-) diff --git a/lib/textbin/accounts.ex b/lib/textbin/accounts.ex index 2ec85fb..dd23ef5 100644 --- a/lib/textbin/accounts.ex +++ b/lib/textbin/accounts.ex @@ -477,23 +477,24 @@ defmodule Textbin.Accounts do end defp insert_token_for_active_user(user_id, build_token) do - Repo.transact(fn -> - case Repo.one(from user in User, where: user.id == ^user_id, lock: "FOR UPDATE") do - %User{suspended_at: nil} = user -> - {encoded_token, user_token} = build_token.(user) + Repo.transact(fn -> insert_token_in_transaction(user_id, build_token) end) + end - case Repo.insert(user_token) do - {:ok, user_token} -> {:ok, {encoded_token, user_token, user}} - {:error, changeset} -> {:error, changeset} - end + defp insert_token_in_transaction(user_id, build_token) do + case Repo.one(from user in User, where: user.id == ^user_id, lock: "FOR UPDATE") do + %User{suspended_at: nil} = user -> insert_built_token(user, build_token) + %User{} -> {:error, :suspended} + nil -> {:error, :suspended} + end + end - %User{} -> - {:error, :suspended} + defp insert_built_token(user, build_token) do + {encoded_token, user_token} = build_token.(user) - nil -> - {:error, :suspended} - end - end) + case Repo.insert(user_token) do + {:ok, user_token} -> {:ok, {encoded_token, user_token, user}} + {:error, changeset} -> {:error, changeset} + end end ## Token helper diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index b882bc6..d5eeb05 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -29,18 +29,7 @@ defmodule Textbin.Administration do @doc false def authorize_account_deletion(%Scope{user: %User{id: user_id}} = scope) do - case Repo.transact(fn -> - with :ok <- lock_authority_changes(), - %User{suspended_at: nil} = user <- lock_user(user_id), - :ok <- require_account_deletion_reauthentication(scope, user), - :ok <- preserve_active_admin(user.id, user.platform_role) do - {:ok, :authorized} - else - nil -> {:error, :not_found} - %User{} -> {:error, :not_found} - error -> error - end - end) do + case Repo.transact(fn -> authorize_account_deletion_in_transaction(scope, user_id) end) do {:ok, :authorized} -> :ok error -> error end @@ -77,54 +66,17 @@ defmodule Textbin.Administration do @doc "Bootstraps or recovers platform authority through an audited release RPC." def bootstrap_platform_admin(email) when is_binary(email) do email = email |> String.trim() |> String.downcase() - - Repo.transact(fn -> - with :ok <- lock_authority_changes(), - %User{} = user <- lock_user_by_email(email), - :ok <- eligible_admin_target(user) do - result = - if user.platform_role == @platform_admin_role, do: :already_present, else: :granted - - with {:ok, user} <- put_platform_role(user, @platform_admin_role), - :ok <- - record_bootstrap_audit(user, result) do - {:ok, result} - end - else - nil -> {:error, :not_found} - error -> error - end - end) + Repo.transact(fn -> bootstrap_platform_admin_in_transaction(email) end) end @doc "Grants platform authority to an eligible user." def grant_platform_admin(scope, target, reason, opts \\ []) do with {:ok, reason} <- normalize_reason(reason), {:ok, target_id} <- user_id(target) do - authority_transaction(scope, fn actor -> - with %User{} = target <- lock_user(target_id), - :ok <- eligible_admin_target(target) do - if target.platform_role == @platform_admin_role do - {:ok, :already_present} - else - with {:ok, target} <- put_platform_role(target, @platform_admin_role), - :ok <- - record_user_audit( - actor, - "platform.admin.granted", - target, - reason, - %{"previous_role" => nil, "new_role" => @platform_admin_role}, - opts - ) do - {:ok, target} - end - end - else - nil -> {:error, :not_found} - error -> error - end - end) + authority_transaction( + scope, + &grant_platform_admin_in_transaction(&1, target_id, reason, opts) + ) end end @@ -132,26 +84,10 @@ defmodule Textbin.Administration do def revoke_platform_admin(scope, target, reason, opts \\ []) do with {:ok, reason} <- normalize_reason(reason), {:ok, target_id} <- user_id(target) do - authority_transaction(scope, fn actor -> - with %User{platform_role: @platform_admin_role} = target <- lock_user(target_id), - :ok <- preserve_active_admin(target.id, target.platform_role), - {:ok, target} <- put_platform_role(target, nil), - :ok <- - record_user_audit( - actor, - "platform.admin.revoked", - target, - reason, - %{"previous_role" => @platform_admin_role, "new_role" => nil}, - opts - ) do - {:ok, target} - else - nil -> {:error, :not_found} - %User{} -> {:error, :not_found} - error -> error - end - end) + authority_transaction( + scope, + &revoke_platform_admin_in_transaction(&1, target_id, reason, opts) + ) end end @@ -161,32 +97,16 @@ defmodule Textbin.Administration do {:ok, target_id} <- user_id(target), {:ok, replacement_id} <- user_id(replacement), :ok <- distinct_users(target_id, replacement_id) do - authority_transaction(scope, fn actor -> - with %User{platform_role: @platform_admin_role} = target <- lock_user(target_id), - %User{} = replacement <- lock_user(replacement_id), - :ok <- eligible_admin_target(replacement), - {:ok, replacement} <- maybe_grant_replacement(actor, replacement, reason, opts), - {:ok, target} <- put_platform_role(target, nil), - :ok <- - record_user_audit( - actor, - "platform.admin.revoked", - target, - reason, - %{ - "previous_role" => @platform_admin_role, - "new_role" => nil, - "replacement_user_id" => replacement.id - }, - opts - ) do - {:ok, %{revoked: target, replacement: replacement}} - else - nil -> {:error, :not_found} - %User{} -> {:error, :not_found} - error -> error - end - end) + authority_transaction( + scope, + &transfer_platform_admin_in_transaction( + &1, + target_id, + replacement_id, + reason, + opts + ) + ) end end @@ -196,32 +116,10 @@ defmodule Textbin.Administration do with {:ok, reason} <- normalize_reason(reason), {:ok, target_id} <- user_id(target), :ok <- not_self(scope, target_id) do - authority_transaction(scope, fn actor -> - with %User{} = target <- lock_user(target_id), - :ok <- not_suspended(target), - :ok <- preserve_active_admin(target.id, target.platform_role), - tokens <- Repo.all_by(UserToken, user_id: target.id), - {:ok, target} <- - target - |> Ecto.Changeset.change(suspended_at: DateTime.utc_now(:second)) - |> Repo.update(), - {_count, nil} <- - Repo.delete_all(from token in UserToken, where: token.user_id == ^target.id), - :ok <- - record_user_audit( - actor, - "platform.account.suspended", - target, - reason, - %{}, - opts - ) do - {:ok, {target, tokens}} - else - nil -> {:error, :not_found} - error -> error - end - end) + authority_transaction( + scope, + &suspend_user_in_transaction(&1, target_id, reason, opts) + ) end disconnect_suspended_sessions(result) @@ -231,28 +129,172 @@ defmodule Textbin.Administration do def restore_user(scope, target, reason, opts \\ []) do with {:ok, reason} <- normalize_reason(reason), {:ok, target_id} <- user_id(target) do - authority_transaction(scope, fn actor -> - with %User{suspended_at: %DateTime{}} = target <- lock_user(target_id), - {:ok, target} <- - target - |> Ecto.Changeset.change(suspended_at: nil) - |> Repo.update(), - :ok <- - record_user_audit( - actor, - "platform.account.restored", - target, - reason, - %{}, - opts - ) do - {:ok, target} - else - nil -> {:error, :not_found} - %User{} -> {:error, :not_found} - error -> error - end - end) + authority_transaction(scope, &restore_user_in_transaction(&1, target_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), + :ok <- require_account_deletion_reauthentication(scope, user), + :ok <- preserve_active_admin(user.id, user.platform_role) do + {:ok, :authorized} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end + + defp bootstrap_platform_admin_in_transaction(email) do + with :ok <- lock_authority_changes(), + %User{} = user <- lock_user_by_email(email), + :ok <- eligible_admin_target(user) do + grant_bootstrap_role(user) + else + nil -> {:error, :not_found} + error -> error + end + end + + defp grant_bootstrap_role(user) do + result = if user.platform_role == @platform_admin_role, do: :already_present, else: :granted + + with {:ok, user} <- put_platform_role(user, @platform_admin_role), + :ok <- record_bootstrap_audit(user, result) do + {:ok, result} + end + end + + defp grant_platform_admin_in_transaction(actor, target_id, reason, opts) do + with %User{} = target <- lock_user(target_id), + :ok <- eligible_admin_target(target) do + grant_platform_role(actor, target, reason, opts) + else + nil -> {:error, :not_found} + error -> error + end + end + + defp grant_platform_role(_actor, %User{platform_role: @platform_admin_role}, _reason, _opts), + do: {:ok, :already_present} + + defp grant_platform_role(actor, target, reason, opts) do + with {:ok, target} <- put_platform_role(target, @platform_admin_role), + :ok <- + record_user_audit( + actor, + "platform.admin.granted", + target, + reason, + %{"previous_role" => nil, "new_role" => @platform_admin_role}, + opts + ) do + {:ok, target} + end + end + + defp revoke_platform_admin_in_transaction(actor, target_id, reason, opts) do + with %User{platform_role: @platform_admin_role} = target <- lock_user(target_id), + :ok <- preserve_active_admin(target.id, target.platform_role), + {:ok, target} <- put_platform_role(target, nil), + :ok <- + record_user_audit( + actor, + "platform.admin.revoked", + target, + reason, + %{"previous_role" => @platform_admin_role, "new_role" => nil}, + opts + ) do + {:ok, target} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end + + defp transfer_platform_admin_in_transaction(actor, target_id, replacement_id, reason, opts) do + with %User{platform_role: @platform_admin_role} = target <- lock_user(target_id), + %User{} = replacement <- lock_user(replacement_id), + :ok <- eligible_admin_target(replacement), + {:ok, replacement} <- maybe_grant_replacement(actor, replacement, reason, opts), + {:ok, target} <- put_platform_role(target, nil), + :ok <- record_transfer_revocation(actor, target, replacement, reason, opts) do + {:ok, %{revoked: target, replacement: replacement}} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error + end + end + + defp record_transfer_revocation(actor, target, replacement, reason, opts) do + record_user_audit( + actor, + "platform.admin.revoked", + target, + reason, + %{ + "previous_role" => @platform_admin_role, + "new_role" => nil, + "replacement_user_id" => replacement.id + }, + opts + ) + end + + defp suspend_user_in_transaction(actor, target_id, reason, opts) do + with %User{} = target <- lock_user(target_id), + :ok <- not_suspended(target), + :ok <- preserve_active_admin(target.id, target.platform_role), + tokens <- Repo.all_by(UserToken, user_id: target.id), + {:ok, target} <- suspend_account(target), + {_count, nil} <- + Repo.delete_all(from token in UserToken, where: token.user_id == ^target.id), + :ok <- + record_user_audit( + actor, + "platform.account.suspended", + target, + reason, + %{}, + opts + ) do + {:ok, {target, tokens}} + else + nil -> {:error, :not_found} + error -> error + end + end + + defp suspend_account(target) do + target + |> Ecto.Changeset.change(suspended_at: DateTime.utc_now(:second)) + |> Repo.update() + end + + defp restore_user_in_transaction(actor, target_id, reason, opts) do + with %User{suspended_at: %DateTime{}} = target <- lock_user(target_id), + {:ok, target} <- + target + |> Ecto.Changeset.change(suspended_at: nil) + |> Repo.update(), + :ok <- + record_user_audit( + actor, + "platform.account.restored", + target, + reason, + %{}, + opts + ) do + {:ok, target} + else + nil -> {:error, :not_found} + %User{} -> {:error, :not_found} + error -> error end end