diff --git a/docs/tasks/13-admin-operator-features.md b/docs/tasks/13-admin-operator-features.md index 38b4d41..dd56f0f 100644 --- a/docs/tasks/13-admin-operator-features.md +++ b/docs/tasks/13-admin-operator-features.md @@ -32,15 +32,15 @@ This slice exists to increase development observability: - [x] T13-15: Add S3-compatible SQLite backup upload for SQLite deployments. - [x] T13-16: Add SMTP adapter and account security notifications. - [x] T13-17: Add telemetry events for XRPC, repo writes, blobs, and firehose. -- [ ] T13-18: Add UI for sessions, OAuth grants, app passwords, and delegated access. -- [ ] T13-19: Add UI for email, password, MFA, backup codes, and trusted devices. -- [ ] T13-20: Add admin dashboard for account status, sequencer, storage, and relay crawl status. -- [ ] T13-21: Add invite-code management UI. -- [ ] T13-22: Add repo verify/export/import actions in operator UI. -- [ ] T13-23: Add backup create/restore dry-run UI. -- [ ] T13-24: Add storage status UI for local blobs, S3/R2 blobs, and backups. -- [ ] T13-25: Add account migration in/out status UI. -- [ ] T13-26: Add compatibility status view based on the reference endpoint matrix. +- [x] T13-18: Add UI for sessions, OAuth grants, app passwords, and delegated access. +- [x] T13-19: Add UI for email, password, MFA, backup codes, and trusted devices. +- [x] T13-20: Add admin dashboard for account status, sequencer, storage, and relay crawl status. +- [x] T13-21: Add invite-code management UI. +- [x] T13-22: Add repo verify/export/import actions in operator UI. +- [x] T13-23: Add backup create/restore dry-run UI. +- [x] T13-24: Add storage status UI for local blobs, S3/R2 blobs, and backups. +- [x] T13-25: Add account migration in/out status UI. +- [x] T13-26: Add compatibility status view based on the reference endpoint matrix. ## Integration Tests diff --git a/lib/tempest/admin.ex b/lib/tempest/admin.ex index ae39b51..73238d5 100644 --- a/lib/tempest/admin.ex +++ b/lib/tempest/admin.ex @@ -8,6 +8,33 @@ defmodule Tempest.Admin do alias Tempest.Accounts.Account alias Tempest.{Blobs, Config, Repo, RepoStorage, Sequencer, Storage} + @compatibility_methods ~w( + com.atproto.server.describeServer com.atproto.server.createAccount com.atproto.server.createSession + com.atproto.server.refreshSession com.atproto.server.deleteSession com.atproto.server.getSession + com.atproto.server.createAppPassword com.atproto.server.listAppPasswords com.atproto.server.revokeAppPassword + com.atproto.server.getServiceAuth com.atproto.server.checkAccountStatus com.atproto.server.activateAccount + com.atproto.server.deactivateAccount com.atproto.server.requestAccountDelete com.atproto.server.deleteAccount + com.atproto.server.requestPasswordReset com.atproto.server.resetPassword com.atproto.server.confirmEmail + com.atproto.server.requestEmailConfirmation com.atproto.server.requestEmailUpdate com.atproto.server.updateEmail + com.atproto.server.reserveSigningKey com.atproto.identity.resolveHandle com.atproto.identity.updateHandle + com.atproto.identity.getRecommendedDidCredentials com.atproto.identity.requestPlcOperationSignature + com.atproto.identity.signPlcOperation com.atproto.identity.submitPlcOperation com.atproto.repo.createRecord + com.atproto.repo.putRecord com.atproto.repo.deleteRecord com.atproto.repo.applyWrites com.atproto.repo.getRecord + com.atproto.repo.listRecords com.atproto.repo.describeRepo com.atproto.repo.uploadBlob + com.atproto.repo.listMissingBlobs com.atproto.repo.importRepo com.atproto.sync.getRepo + com.atproto.sync.getBlocks com.atproto.sync.getRecord com.atproto.sync.getLatestCommit + com.atproto.sync.getRepoStatus com.atproto.sync.listRepos com.atproto.sync.listBlobs + com.atproto.sync.getBlob com.atproto.sync.requestCrawl com.atproto.sync.subscribeRepos + com.atproto.sync.notifyOfUpdate app.bsky.actor.getPreferences app.bsky.actor.putPreferences + ) + + @partial_methods ~w( + com.atproto.server.requestPasswordReset com.atproto.server.resetPassword com.atproto.server.confirmEmail + com.atproto.server.requestEmailConfirmation com.atproto.server.requestEmailUpdate com.atproto.server.updateEmail + ) + + @deferred_methods ~w(com.atproto.sync.notifyOfUpdate) + @doc """ Returns admin-visible service status. """ @@ -27,6 +54,37 @@ defmodule Tempest.Admin do } end + def compatibility_status do + endpoints = Enum.map(@compatibility_methods, &compatibility_endpoint/1) + + %{ + endpoints: endpoints, + summary: Enum.frequencies_by(endpoints, & &1.status), + notes: [ + "Unknown app.bsky.* methods use the configured proxy/fallback policy.", + "Status is route-based and should be read with the smoke-test matrix." + ] + } + end + + defp compatibility_endpoint(method) do + %{ + method: method, + status: compatibility_status_for(method), + route: if(method == "com.atproto.sync.subscribeRepos", do: "websocket", else: "xrpc") + } + end + + defp compatibility_status_for(method) when method in @partial_methods, do: "partial" + defp compatibility_status_for(method) when method in @deferred_methods, do: "deferred" + + defp compatibility_status_for(method) do + case Tempest.Xrpc.Registry.fetch(method) do + {:ok, _method} -> "implemented" + {:error, _reason} -> "planned" + end + end + defp database_status(config) do %{ "accountDb" => file_status(Config.account_db_path(config)), diff --git a/lib/tempest/security.ex b/lib/tempest/security.ex index f132825..ca75c91 100644 --- a/lib/tempest/security.ex +++ b/lib/tempest/security.ex @@ -5,7 +5,8 @@ defmodule Tempest.Security do import Ecto.Query - alias Tempest.Accounts.{Account, Password, Session} + alias Tempest.Accounts.{Account, AppPassword, Password, Session} + alias Tempest.OAuth.Token alias Tempest.Security.{BackupCode, DelegatedAccessGrant, EmailToken, MfaCredential, RateLimiter, SecurityEvent, Totp} alias Tempest.Repo @@ -190,6 +191,71 @@ defmodule Tempest.Security do end end + def account_security_inventory(%Account{} = account) do + %{ + sessions: list_sessions(account), + oauth_grants: list_oauth_grants(account), + app_passwords: list_app_password_records(account), + delegated_access: list_delegations(account), + mfa_credentials: list_mfa_credentials(account), + backup_codes: list_backup_code_summaries(account), + security_events: list_security_events(account, limit: 50) + } + end + + def list_oauth_grants(%Account{} = account) do + Token + |> where([t], t.account_id == ^account.id) + |> order_by([t], desc: t.inserted_at) + |> Repo.all() + end + + def list_app_password_records(%Account{} = account) do + AppPassword + |> where([p], p.account_id == ^account.id) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + def list_delegations(%Account{} = account) do + DelegatedAccessGrant + |> where([g], g.owner_account_id == ^account.id) + |> order_by([g], desc: g.inserted_at) + |> Repo.all() + end + + def list_mfa_credentials(%Account{} = account) do + MfaCredential + |> where([c], c.account_id == ^account.id) + |> order_by([c], desc: c.inserted_at) + |> Repo.all() + end + + def list_backup_code_summaries(%Account{} = account) do + BackupCode + |> where([c], c.account_id == ^account.id) + |> order_by([c], asc: c.id) + |> Repo.all() + |> Enum.map(fn code -> + %{ + id: code.id, + used?: not is_nil(code.used_at), + used_at: code.used_at, + inserted_at: code.inserted_at + } + end) + end + + def list_security_events(%Account{} = account, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + + SecurityEvent + |> where([e], e.account_id == ^account.id) + |> order_by([e], desc: e.inserted_at) + |> limit(^limit) + |> Repo.all() + end + def request_password_reset(identifier) do identifier = identifier |> to_string() |> String.trim() |> String.downcase() diff --git a/lib/tempest_web/controllers/admin_controller.ex b/lib/tempest_web/controllers/admin_controller.ex index 360fca4..51e985e 100644 --- a/lib/tempest_web/controllers/admin_controller.ex +++ b/lib/tempest_web/controllers/admin_controller.ex @@ -1,13 +1,49 @@ defmodule TempestWeb.AdminController do use TempestWeb, :controller - alias Tempest.AdminAuth + alias Tempest.{Admin, AdminAuth} alias TempestWeb.XrpcErrorJSON def status(conn, _params) do + with_admin(conn, fn conn -> json(conn, Admin.status()) end) + end + + def dashboard(conn, _params) do + with_admin(conn, fn conn -> render(conn, :dashboard, status: Admin.status()) end) + end + + def invites(conn, _params) do + with_admin(conn, fn conn -> render(conn, :invites, status: Admin.status()) end) + end + + def repo(conn, _params) do + with_admin(conn, fn conn -> render(conn, :repo, result: nil) end) + end + + def repo_action(conn, %{"op" => op} = params) do + with_admin(conn, fn conn -> render(conn, :repo, result: run_repo_op(op, params)) end) + end + + def backups(conn, _params) do + with_admin(conn, fn conn -> render(conn, :backups, result: nil) end) + end + + def backup_action(conn, %{"op" => op} = params) do + with_admin(conn, fn conn -> render(conn, :backups, result: run_backup_op(op, params)) end) + end + + def storage(conn, _params) do + with_admin(conn, fn conn -> render(conn, :storage, status: Admin.status()) end) + end + + def compatibility(conn, _params) do + with_admin(conn, fn conn -> render(conn, :compatibility, status: Admin.compatibility_status()) end) + end + + defp with_admin(conn, fun) do case AdminAuth.verify_authorization_header(conn.req_headers) do :ok -> - json(conn, Tempest.Admin.status()) + fun.(conn) {:error, :missing_admin_token} -> reject(conn, 401, "AuthenticationRequired", "Admin bearer token is required") @@ -20,6 +56,39 @@ defmodule TempestWeb.AdminController do end end + defp run_repo_op("verify", %{"did" => did}), do: Admin.RepoOps.verify(did) + defp run_repo_op("export", %{"did" => did, "path" => path}), do: Admin.RepoOps.export(did, path) + defp run_repo_op("import", %{"did" => did, "path" => path}), do: Admin.RepoOps.import(did, path) + defp run_repo_op(_op, _params), do: {:error, :invalid_repo_operation} + + defp run_backup_op("create", params) do + opts = if blank?(params["path"]), do: [], else: [path: params["path"]] + Admin.Backup.create(opts) + end + + defp run_backup_op("restore_dry_run", %{"path" => path} = params) do + target = if blank?(params["target"]), do: Tempest.Config.load!().data_dir, else: params["target"] + manifest = Path.join(path, "manifest.json") + + cond do + not File.dir?(path) -> + {:error, :backup_not_found} + + not File.exists?(manifest) -> + {:error, :backup_manifest_missing} + + File.exists?(Path.join(target, "account.sqlite")) or File.exists?(Path.join(target, "sequencer.sqlite")) -> + {:ok, %{dry_run: true, refused: :target_not_empty, target: target}} + + true -> + {:ok, %{dry_run: true, would_restore: true, target: target}} + end + end + + defp run_backup_op(_op, _params), do: {:error, :invalid_backup_operation} + + defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" + defp reject(conn, status, error, message) do XrpcErrorJSON.render(conn, status, error, message) end diff --git a/lib/tempest_web/controllers/admin_html.ex b/lib/tempest_web/controllers/admin_html.ex new file mode 100644 index 0000000..2f1ac83 --- /dev/null +++ b/lib/tempest_web/controllers/admin_html.ex @@ -0,0 +1,24 @@ +defmodule TempestWeb.AdminHTML do + use TempestWeb, :html + + embed_templates "admin_html/*" + + def admin_nav(assigns) do + ~H""" + + """ + end + + def present(nil), do: "—" + def present(value) when is_boolean(value), do: inspect(value) + def present(value), do: to_string(value) + + def json_pretty(value), do: Jason.encode!(value, pretty: true) +end diff --git a/lib/tempest_web/controllers/admin_html/backups.html.heex b/lib/tempest_web/controllers/admin_html/backups.html.heex new file mode 100644 index 0000000..500c1a8 --- /dev/null +++ b/lib/tempest_web/controllers/admin_html/backups.html.heex @@ -0,0 +1,23 @@ +
+
+ <.admin_nav /> + +
+
Backup Create and Restore Dry Run
+
+ + + +
+
+
+
diff --git a/lib/tempest_web/controllers/admin_html/compatibility.html.heex b/lib/tempest_web/controllers/admin_html/compatibility.html.heex new file mode 100644 index 0000000..48011a4 --- /dev/null +++ b/lib/tempest_web/controllers/admin_html/compatibility.html.heex @@ -0,0 +1,31 @@ +
+
+ <.admin_nav /> + +
+
Compatibility Status
+
+
+
implemented
{@status.summary["implemented"] || 0}
+
partial
{@status.summary["partial"] || 0}
+
planned
{@status.summary["planned"] || 0}
+
deferred
{@status.summary["deferred"] || 0}
+
+
+
+ +
+
Reference Endpoint Matrix
+ +
+
+
diff --git a/lib/tempest_web/controllers/admin_html/dashboard.html.heex b/lib/tempest_web/controllers/admin_html/dashboard.html.heex new file mode 100644 index 0000000..5f70cfd --- /dev/null +++ b/lib/tempest_web/controllers/admin_html/dashboard.html.heex @@ -0,0 +1,52 @@ +
+
+ <.admin_nav /> + +
+
Admin Dashboard
+ +
+ +
+
+
Service
+
+
+
status
{@status["status"]}
+
version
{@status["version"]}
+
admin token
{present(@status["admin"]["tokenConfigured"])}
+
accounts
{length(@status["accounts"])}
+
+
+
+ +
+
Sequencer
+
+
+
current seq
{@status["sequencer"]["currentSeq"]}
+
torn writes
{@status["sequencer"]["tornWriteCount"]}
+
relay crawl
requestCrawl endpoint enabled
+
+
+
+
+ +
+
Account Status
+ +
+
+
diff --git a/lib/tempest_web/controllers/admin_html/invites.html.heex b/lib/tempest_web/controllers/admin_html/invites.html.heex new file mode 100644 index 0000000..cb1d919 --- /dev/null +++ b/lib/tempest_web/controllers/admin_html/invites.html.heex @@ -0,0 +1,16 @@ +
+
+ <.admin_nav /> + +
+
Invite Code Management
+
+
+
invite requirement
{@status["describeServerInviteRequired"] || "disabled"}
+
configured account creation
open
+
+ +
+
+
+
diff --git a/lib/tempest_web/controllers/admin_html/repo.html.heex b/lib/tempest_web/controllers/admin_html/repo.html.heex new file mode 100644 index 0000000..bc799a9 --- /dev/null +++ b/lib/tempest_web/controllers/admin_html/repo.html.heex @@ -0,0 +1,20 @@ +
+
+ <.admin_nav /> + +
+
Repo Verify, Export, and Import
+
+ + +
+
+
+
diff --git a/lib/tempest_web/controllers/admin_html/storage.html.heex b/lib/tempest_web/controllers/admin_html/storage.html.heex new file mode 100644 index 0000000..2618d0b --- /dev/null +++ b/lib/tempest_web/controllers/admin_html/storage.html.heex @@ -0,0 +1,31 @@ +
+
+ <.admin_nav /> + +
+
Storage Status
+ +
+ +
+
Blobs and Backups
+
+
+
blob adapter
{@status["blobStore"]["adapter"]}
+
blob path
{@status["blobStore"]["path"]}
+
public blobs
{@status["blobStore"]["publicBlobCount"]}
+
backup store
{Application.get_env(:tempest, Tempest.Admin.Backup, []) |> Keyword.get(:store, :local) |> present()}
+
+
+
+
+
diff --git a/lib/tempest_web/controllers/operator_account_controller.ex b/lib/tempest_web/controllers/operator_account_controller.ex index 2b9ff05..addb916 100644 --- a/lib/tempest_web/controllers/operator_account_controller.ex +++ b/lib/tempest_web/controllers/operator_account_controller.ex @@ -1,7 +1,7 @@ defmodule TempestWeb.OperatorAccountController do use TempestWeb, :controller - alias Tempest.{Accounts, Blobs, RepoStorage, Sequencer} + alias Tempest.{Accounts, Blobs, RepoStorage, Security, Sequencer} alias TempestWeb.XrpcErrorJSON @page_limit 50 @@ -58,6 +58,31 @@ defmodule TempestWeb.OperatorAccountController do end end + def access(conn, _params) do + with {:ok, auth} <- authenticate(conn) do + render(conn, :access, account: auth.account, inventory: Security.account_security_inventory(auth.account)) + else + {:error, reason} -> reject(conn, reason) + end + end + + def security(conn, _params) do + with {:ok, auth} <- authenticate(conn) do + render(conn, :security, account: auth.account, inventory: Security.account_security_inventory(auth.account)) + else + {:error, reason} -> reject(conn, reason) + end + end + + def migration(conn, _params) do + with {:ok, auth} <- authenticate(conn), + {:ok, status} <- Accounts.check_account_status(auth) do + render(conn, :migration, account: auth.account, status: status) + else + {:error, reason} -> reject(conn, reason) + end + end + def firehose(conn, _params) do with {:ok, auth} <- authenticate(conn), {:ok, events} <- Sequencer.list_after(0, limit: 20, did: auth.account.did) do diff --git a/lib/tempest_web/controllers/operator_account_html.ex b/lib/tempest_web/controllers/operator_account_html.ex index 46dc32c..ecfde8b 100644 --- a/lib/tempest_web/controllers/operator_account_html.ex +++ b/lib/tempest_web/controllers/operator_account_html.ex @@ -3,8 +3,35 @@ defmodule TempestWeb.OperatorAccountHTML do embed_templates "operator_account_html/*" + attr :section, :atom, default: nil + + def account_nav(assigns) do + ~H""" + + """ + end + def json_pretty(value), do: value |> json_safe() |> Jason.encode!(pretty: true) + def status_value(nil), do: "active" + def status_value(%{revoked_at: revoked}) when not is_nil(revoked), do: "revoked" + def status_value(%{rotated_at: rotated}) when not is_nil(rotated), do: "rotated" + def status_value(%{disabled_at: disabled}) when not is_nil(disabled), do: "disabled" + def status_value(%{confirmed_at: nil}), do: "pending" + def status_value(_record), do: "active" + + def present(nil), do: "—" + def present(value), do: to_string(value) + defp json_safe(%Tempest.RepoCore.Drisl.Bytes{bytes: bytes}), do: %{"$bytes" => Base.encode64(bytes)} defp json_safe(value) when is_map(value), do: Map.new(value, fn {key, value} -> {key, json_safe(value)} end) defp json_safe(value) when is_list(value), do: Enum.map(value, &json_safe/1) diff --git a/lib/tempest_web/controllers/operator_account_html/access.html.heex b/lib/tempest_web/controllers/operator_account_html/access.html.heex new file mode 100644 index 0000000..68a9aff --- /dev/null +++ b/lib/tempest_web/controllers/operator_account_html/access.html.heex @@ -0,0 +1,70 @@ +
+
+ <.account_nav /> + +
+
Sessions and Delegated Access
+
+ +
+
+ +
+
Sessions
+ +
+ +
+
+
OAuth Grants
+ +
+ +
+
App Passwords
+ +
+
+ +
+
Delegated Access
+ +
+
+
diff --git a/lib/tempest_web/controllers/operator_account_html/blobs.html.heex b/lib/tempest_web/controllers/operator_account_html/blobs.html.heex index 5bba393..5f3f275 100644 --- a/lib/tempest_web/controllers/operator_account_html/blobs.html.heex +++ b/lib/tempest_web/controllers/operator_account_html/blobs.html.heex @@ -1,12 +1,6 @@
- + <.account_nav />
Blob Browser
diff --git a/lib/tempest_web/controllers/operator_account_html/dashboard.html.heex b/lib/tempest_web/controllers/operator_account_html/dashboard.html.heex index 7da6a8f..3dd9730 100644 --- a/lib/tempest_web/controllers/operator_account_html/dashboard.html.heex +++ b/lib/tempest_web/controllers/operator_account_html/dashboard.html.heex @@ -14,13 +14,7 @@
- + <.account_nav />
diff --git a/lib/tempest_web/controllers/operator_account_html/firehose.html.heex b/lib/tempest_web/controllers/operator_account_html/firehose.html.heex index 386edf5..9533877 100644 --- a/lib/tempest_web/controllers/operator_account_html/firehose.html.heex +++ b/lib/tempest_web/controllers/operator_account_html/firehose.html.heex @@ -1,12 +1,6 @@