admin: internal-only superadmin console (users, payments, DB stats)

New independent `admin` service (admin.main:app) on the same image, reusing
app.db/app.models read-only. Never runs migrations or the scheduler; issues
SELECTs only.

- Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by
  default when the password is empty.
- Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network.
- Pages: overview stats, user list + search, per-user history/payment detail,
  DB usage (information_schema size + row estimates).
- Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile
  bakes admin/ into runtime + test stages.
- Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-01 16:08:20 +02:00
parent 8946dee2e0
commit 411094d7b8
20 changed files with 1143 additions and 1 deletions

140
admin/main.py Normal file
View file

@ -0,0 +1,140 @@
"""Superadmin console FastAPI app.
Independent of ``app.main``: it wires only its own routes and reuses
``app.db``'s session factory for read-only queries. No lifespan, no
migrations, no scheduler.
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Depends, FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from admin import auth, queries
from app.db import get_session
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
def _fmt_bytes(n: int | None) -> str:
if not n:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = float(n)
for u in units:
if size < 1024 or u == units[-1]:
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
size /= 1024
return f"{n} B"
def _fmt_dt(dt: datetime | None) -> str:
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
templates.env.filters["bytes"] = _fmt_bytes
templates.env.filters["dt"] = _fmt_dt
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
async def healthz() -> str:
return "ok"
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_form(request: Request):
# Already signed in? Skip the form.
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
if cookie and auth.verify_session(cookie):
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": None})
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_submit(request: Request, password: str = Form(...)):
if not auth.check_password(password):
return templates.TemplateResponse(
request, "login.html", {"error": "Incorrect password."},
status_code=401,
)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(
auth.SESSION_COOKIE_NAME,
auth.sign_session(),
max_age=auth.SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
# secure=False: the console is served over http on a localhost SSH
# tunnel, never public TLS. A secure cookie would never be sent.
secure=False,
)
return resp
@app.get("/logout", include_in_schema=False)
async def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
return resp
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.overview_stats(session)
recent, _ = await queries.list_users(session, limit=10)
return templates.TemplateResponse(
request, "overview.html", {"stats": stats, "recent": recent},
)
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def users_list(
request: Request,
session: AsyncSession = Depends(get_session),
q: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
):
per_page = 200
offset = (page - 1) * per_page
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
return templates.TemplateResponse(
request, "users.html",
{"rows": rows, "total": total, "q": q or "",
"page": page, "per_page": per_page},
)
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def user_detail(
request: Request, user_id: int,
session: AsyncSession = Depends(get_session),
):
detail = await queries.user_detail(session, user_id)
if detail is None:
return templates.TemplateResponse(
request, "not_found.html", {"user_id": user_id}, status_code=404,
)
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
dependencies=[Depends(auth.require_admin)])
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
stats = await queries.db_stats(session)
return templates.TemplateResponse(request, "db.html", {"stats": stats})