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

Filter by extension

Filter by extension

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

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

Expand All @@ -107,6 +116,7 @@ defmodule Textbin.Administration do
opts
)
)
|> notify_platform_authority_change()
end
end

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

Expand Down Expand Up @@ -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
55 changes: 55 additions & 0 deletions lib/textbin_web/live/ui/admin_live.ex
Original file line number Diff line number Diff line change
@@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<main id="admin-page" class="mx-auto w-full max-w-5xl px-4 py-10 sm:px-6 lg:px-8">
<section class="overflow-hidden rounded-2xl border border-base-300 bg-base-100 shadow-sm">
<div class="border-b border-base-300 bg-base-200/35 px-6 py-8 sm:px-8">
<div class="flex items-start gap-4">
<div class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<.icon name="hero-shield-check" class="size-6" />
</div>
<div>
<p class="text-sm font-semibold uppercase tracking-[0.16em] text-primary">
Platform controls
</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-base-content">
Administration
</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-base-content/65">
This restricted area is authorized against current platform authority on every
mount and authority change.
</p>
</div>
</div>
</div>

<div id="admin-foundation-status" class="px-6 py-6 sm:px-8">
<div class="flex items-center gap-3 rounded-xl border border-success/25 bg-success/5 px-4 py-3">
<span class="flex size-8 items-center justify-center rounded-full bg-success/15 text-success">
<.icon name="hero-lock-closed" class="size-4" />
</span>
<div>
<p class="text-sm font-semibold text-base-content">Authorization boundary active</p>
<p class="mt-0.5 text-sm text-base-content/60">
Operational views will be introduced in the next delivery phase.
</p>
</div>
</div>
</div>
</section>
</main>
</Layouts.app>
"""
end
end
1 change: 1 addition & 0 deletions lib/textbin_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 46 additions & 9 deletions lib/textbin_web/user_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -324,23 +324,60 @@ 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

defp authorize_platform_admin_mount(socket) do
connected? = Phoenix.LiveView.connected?(socket)

match?({:ok, _user}, Administration.authorize_platform_admin(socket.assigns.current_scope)) ->
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}

true ->
{:error, :forbidden} ->
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.redirect(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)
Expand Down
52 changes: 42 additions & 10 deletions rfd/0001/IMPLEMENTATION.org
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,65 @@

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.
- [X] Deleting an account that carries a platform role requires recent
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.

- [X] A non-admin cannot mount an admin route or obtain admin data by calling a
context function directly.
- [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
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.
18 changes: 18 additions & 0 deletions rfd/0001/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Loading
Loading