feat: add admin panel

This commit is contained in:
hh
2026-05-20 13:00:08 +02:00
parent 7970d4be9b
commit 0128191ac3
26 changed files with 2985 additions and 115 deletions
@@ -0,0 +1,707 @@
"""Single-operator admin console.
Jinja2 + HTMX, no SPA framework. Session cookies signed with
``SESSION_SECRET`` via ``itsdangerous`` (8h TTL). CSRF is double-submit:
the signed session payload carries a random token that must echo back
on every state-changing request as a ``csrf_token`` form field.
What lives in here:
* ``GET /login`` / ``POST /login`` — env-cred check (``ADMIN_USER`` /
``ADMIN_PASS`` compared with :func:`hmac.compare_digest`).
* ``POST /logout`` — clears the cookie.
* ``GET /`` — dashboard: declared agents + MCP namespaces + the last
audit slice.
* ``GET /tokens`` + ``POST /tokens`` + ``POST /tokens/{id}/revoke`` —
bearer-token CRUD. Plaintext is rendered exactly once at creation; the
DB only ever holds the Argon2 hash. HTMX fragments swap rows in place.
* ``GET /audit`` — paginated audit list (id-cursor).
Things the admin frontend is *not* responsible for: HTTP-token verify
on ``/v1/messages`` and ``/mcp`` — that lives on those frontends and
uses :class:`TokenStore`. Admin's only authentication path is the
cookie session.
"""
from __future__ import annotations
import hmac
import json
import logging
import secrets
import time
from collections import deque
from typing import TYPE_CHECKING, Annotated, Any
import itsdangerous
from fastapi import FastAPI, Form, HTTPException, Request, status
from fastapi.responses import (
HTMLResponse,
RedirectResponse,
Response,
StreamingResponse,
)
from jinja2 import Environment, PackageLoader, select_autoescape
from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage import (
create_token,
list_audit_records,
list_tokens,
revoke_token,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from datetime import datetime
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
_log = logging.getLogger("beaver_gateway.frontends.admin")
# Cookie name is namespaced so a user can run multiple gateway instances
# behind one host header without sessions colliding. ``_v1`` lets us
# bump the payload schema later by invalidating old cookies via salt.
SESSION_COOKIE = "beaver_admin_session"
SESSION_MAX_AGE = 8 * 3600
SESSION_SALT = "beaver-gateway.admin.session.v1"
# Scopes the admin is allowed to mint. ``admin`` is reserved (no bearer
# route consumes it yet, but listing it keeps future programmatic
# admin access first-class) — see ``core.auth.VALID_SCOPES``.
CREATABLE_SCOPES = ("*", "messages", "mcp", "admin")
# Audit-log page size — also used by the dashboard's "recent activity"
# panel. Small enough to keep the dashboard cheap, big enough to be
# useful at a glance.
AUDIT_PAGE_SIZE = 50
# /login brute-force ceiling: 5 failed attempts per source IP within
# LOGIN_WINDOW_SECONDS shuts the door with a 429 until the oldest
# failure ages out. Single-operator admin, so a legit user only ever
# burns one IP — a forgiving window beats a tight one with a lockout.
LOGIN_MAX_ATTEMPTS = 5
LOGIN_WINDOW_SECONDS = 300.0
__all__ = ["AdminFrontend"]
class AdminFrontend(Frontend):
"""FastAPI app behind ``/login`` / ``/tokens`` / ``/audit`` / ``/``."""
def __init__(self, *, host: str = "0.0.0.0", port: int = 8002) -> None: # noqa: S104
self.host = host
self.port = port
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
def configure(self, runtime: GatewayRuntime) -> None:
# Refuse to wire up if the env didn't carry the bits we need —
# an admin UI with empty creds is a footgun, and an unsigned
# session cookie is no session at all.
if not runtime.session_secret:
msg = "AdminFrontend requires SESSION_SECRET in env"
raise RuntimeError(msg)
if not runtime.admin_user or not runtime.admin_pass:
msg = "AdminFrontend requires ADMIN_USER and ADMIN_PASS in env"
raise RuntimeError(msg)
self._runtime = runtime
self._app = self._build_app(runtime)
async def serve(self) -> None:
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
await uvicorn.Server(config).serve()
# ---- app construction ----------------------------------------------
def _build_app(self, runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
# Routes are declared inline so they close over ``runtime`` /
# ``signer`` / ``templates`` instead of threading them through
# self-state. Splitting the function would just trade size for
# bookkeeping — same total surface, harder to follow.
templates = _build_template_env()
signer = itsdangerous.URLSafeTimedSerializer(
runtime.session_secret, salt=SESSION_SALT
)
login_limit = _LoginRateLimit(
max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS
)
app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None)
def render(name: str, **ctx: Any) -> str:
return templates.get_template(name).render(**ctx)
# ---- login ----
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> Response:
# Already-signed-in users skip the form. Login flash is
# carried in a one-shot query param so a failed POST can
# redirect back here without leaking creds in the URL.
if _current_user(request, signer):
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
error = request.query_params.get("error")
return HTMLResponse(render("login.html", error=error))
@app.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: Annotated[str, Form(...)],
password: Annotated[str, Form(...)],
) -> Response:
ip = _client_ip(request)
if not login_limit.check(ip):
_log.warning("admin login rate-limited: ip=%s user=%r", ip, username)
await audit.log(
runtime,
actor=f"admin:{username}",
kind="login_failed",
reason="rate_limited",
ip=ip,
)
# 429 carries the rendered form back inline so the user
# sees the same page with an error banner — no redirect
# roundtrip, and an explicit Retry-After for any well-
# behaved client (or Caddy doing its own bookkeeping).
return HTMLResponse(
render(
"login.html",
error="too many attempts; try again in a few minutes",
),
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
headers={"Retry-After": str(int(LOGIN_WINDOW_SECONDS))},
)
ok = hmac.compare_digest(
username, runtime.admin_user
) and hmac.compare_digest(password, runtime.admin_pass)
if not ok:
login_limit.record_failure(ip)
_log.info("admin login failed: user=%r ip=%s", username, ip)
await audit.log(
runtime,
actor=f"admin:{username}",
kind="login_failed",
reason="bad_credentials",
ip=ip,
)
# 303-redirect after POST so the browser doesn't replay
# the form on refresh. Status-303 forces GET on the
# follow-up regardless of the original method.
return RedirectResponse(
"/login?error=invalid+credentials",
status_code=status.HTTP_303_SEE_OTHER,
)
login_limit.clear(ip)
csrf = secrets.token_urlsafe(24)
cookie = signer.dumps({"user": username, "csrf": csrf})
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
_set_session_cookie(response, cookie)
_log.info("admin login ok: user=%s ip=%s", username, ip)
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
return response
@app.post("/logout")
async def logout(request: Request) -> Response:
session = _require_session(request, signer)
await _require_csrf(request, session)
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(SESSION_COOKIE)
await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout")
return response
# ---- dashboard ----
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request) -> Response:
session = _require_session(request, signer)
async with runtime.db.session() as db_session:
audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE)
tokens = await list_tokens(db_session, include_revoked=False)
endpoints = _build_endpoint_catalog(request, runtime)
return HTMLResponse(
render(
"dashboard.html",
user=session["user"],
csrf=session["csrf"],
agents=list(runtime.agents),
mcps=list(runtime.mcps),
audit=audit,
tokens=tokens,
endpoints=endpoints,
)
)
# ---- tokens ----
@app.get("/tokens", response_class=HTMLResponse)
async def tokens_page(request: Request) -> Response:
session = _require_session(request, signer)
include_revoked = request.query_params.get("include_revoked") == "1"
async with runtime.db.session() as db_session:
tokens = await list_tokens(db_session, include_revoked=include_revoked)
return HTMLResponse(
render(
"tokens.html",
user=session["user"],
csrf=session["csrf"],
tokens=tokens,
scopes=CREATABLE_SCOPES,
include_revoked=include_revoked,
)
)
@app.post("/tokens", response_class=HTMLResponse)
async def tokens_create(
request: Request,
name: Annotated[str, Form(...)],
scope: Annotated[str, Form(...)],
) -> Response:
session = _require_session(request, signer)
await _require_csrf(request, session)
name = name.strip()
if not name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "token name is required"
)
if scope not in VALID_SCOPES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"invalid scope: {scope!r}"
)
# Generate the plaintext server-side — clients never pick
# their own. URL-safe so it copies cleanly into ``.env``.
plaintext = secrets.token_urlsafe(32)
hashed = hash_token(plaintext)
async with runtime.db.session() as db_session:
try:
row = await create_token(
db_session, name=name, scope=scope, hashed_value=hashed
)
except Exception as exc: # noqa: BLE001
# Likely UNIQUE(name) violation; the column is
# indexed so collisions are common. We surface a
# readable HTMX-friendly response rather than 500.
_log.warning("token create failed: %s", exc)
return HTMLResponse(
render(
"_token_error.html",
message=f"could not create token {name!r}: {exc}",
),
status_code=status.HTTP_409_CONFLICT,
)
await runtime.token_store.invalidate()
await audit.log(
runtime,
actor=f"admin:{session['user']}",
kind="token_create",
name=name,
scope=scope,
token_id=row.id,
)
return HTMLResponse(
render(
"_token_created.html",
token=row,
plaintext=plaintext,
csrf=session["csrf"],
)
)
@app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse)
async def tokens_revoke(request: Request, token_id: int) -> Response:
session = _require_session(request, signer)
await _require_csrf(request, session)
async with runtime.db.session() as db_session:
ok = await revoke_token(db_session, token_id=token_id)
if not ok:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}"
)
await runtime.token_store.invalidate()
await audit.log(
runtime,
actor=f"admin:{session['user']}",
kind="token_revoke",
token_id=token_id,
)
# Re-fetch so the row reflects the just-stamped revoked_at.
async with runtime.db.session() as db_session:
rows = await list_tokens(db_session, include_revoked=True)
row = next((r for r in rows if r.id == token_id), None)
if row is None:
# Shouldn't happen — revoke_token said ok — but stay
# honest: respond empty so the row disappears from the
# table.
return HTMLResponse("")
return HTMLResponse(
render("_token_row.html", token=row, csrf=session["csrf"])
)
# ---- audit ----
@app.get("/audit", response_class=HTMLResponse)
async def audit_page(request: Request) -> Response:
session = _require_session(request, signer)
before_raw = request.query_params.get("before")
before_id: int | None = None
if before_raw and before_raw.isdigit():
before_id = int(before_raw)
async with runtime.db.session() as db_session:
rows = await list_audit_records(
db_session, limit=AUDIT_PAGE_SIZE, before_id=before_id
)
# Cursor for the "next page" link — the smallest id on this
# page; ``None`` if we've run out of rows.
next_before = rows[-1].id if rows and len(rows) == AUDIT_PAGE_SIZE else None
return HTMLResponse(
render(
"audit.html",
user=session["user"],
csrf=session["csrf"],
audit=rows,
next_before=next_before,
)
)
# ---- chat ----
# In-process playground: lets the operator drive any configured
# agent without minting a bearer token. Auth comes from the
# admin cookie; CSRF rides an ``X-CSRF-Token`` header because
# the body is JSON (no form to read). We call the backend
# directly — no HTTP hop to ``/v1/messages`` — so a chat turn
# bypasses the token store but is still audited as
# ``kind="messages"`` with ``actor="admin:<user>"`` and
# ``source="admin_chat"`` in the detail.
@app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request) -> Response:
session = _require_session(request, signer)
available = [
a for a in runtime.agents if runtime.backends.get(a.name) is not None
]
return HTMLResponse(
render(
"chat.html",
user=session["user"],
csrf=session["csrf"],
agents=available,
)
)
@app.post("/chat/send")
async def chat_send(request: Request) -> Response:
session = _require_session(request, signer)
submitted = request.headers.get("x-csrf-token")
if not isinstance(submitted, str) or not hmac.compare_digest(
submitted, session["csrf"]
):
raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed")
try:
body = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
) from exc
model = body.get("model")
if not isinstance(model, str):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "missing or non-string `model`"
)
agent = runtime.agents.get(model)
if agent is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"unknown agent: {model!r}"
)
backend = runtime.backends.get(agent.name)
if backend is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
f"no backend configured for agent {agent.name!r}",
)
messages = body.get("messages") or []
if not isinstance(messages, list):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "`messages` must be a list"
)
system = body.get("system")
await audit.log(
runtime,
actor=f"admin:{session['user']}",
kind="messages",
agent_name=agent.name,
source="admin_chat",
msgs=len(messages),
)
events = backend.complete(
agent=agent,
messages=messages,
system=system if isinstance(system, str) else None,
)
return StreamingResponse(
_sse_events(events), media_type="text/event-stream"
)
return app
# ---- helpers ------------------------------------------------------------
def _build_template_env() -> Environment:
env = Environment(
loader=PackageLoader("beaver_gateway.frontends.admin", "templates"),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["fmt_dt"] = _fmt_dt
env.filters["fmt_detail"] = _fmt_detail
return env
def _fmt_dt(value: datetime | None) -> str:
if value is None:
return ""
# ISO without microseconds, with explicit ``Z`` when UTC — easier to
# scan than the default ``+00:00``.
s = value.replace(microsecond=0).isoformat()
return s.replace("+00:00", "Z")
def _fmt_detail(raw: str) -> str:
"""Pretty-print the audit detail blob; leave invalid JSON alone."""
if not raw or raw == "{}":
return ""
try:
return json.dumps(json.loads(raw), separators=(", ", ": "))
except json.JSONDecodeError:
return raw
def _build_endpoint_catalog(
request: Request, runtime: GatewayRuntime
) -> dict[str, Any]:
"""Collect copy-pastable URLs for sibling bearer frontends.
Precedence per frontend:
1. ``frontend.public_base_url`` if set — the operator's explicit
statement of "this is the URL my reverse proxy (Caddy / nginx /
Cloudflare / …) puts in front of me". Used verbatim with the
internal path (``/v1/messages``, ``/<ns>/`` for MCP) appended.
2. ``{scheme}://{request_hostname}:{frontend_port}`` — derived from
the browser's own request so dev / no-proxy setups Just Work.
Scheme honours ``X-Forwarded-Proto`` so a TLS terminator in
front of the admin gets the right protocol.
Imports happen inside the function to avoid a hard dep from
``admin.frontend`` on the other frontend modules — they're optional
and may have non-trivial transitive deps (aiohttp etc.).
"""
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.mcp_server import McpServerFrontend
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
hostname = request.url.hostname or "localhost"
def _base_for(fe: AnthropicMessagesFrontend | McpServerFrontend) -> str:
if fe.public_base_url:
return fe.public_base_url
return f"{scheme}://{hostname}:{fe.port}"
anthropic_base: str | None = None
mcp_base: str | None = None
for fe in runtime.frontends:
if isinstance(fe, AnthropicMessagesFrontend) and anthropic_base is None:
anthropic_base = _base_for(fe)
elif isinstance(fe, McpServerFrontend) and mcp_base is None:
mcp_base = _base_for(fe)
agent_rows: list[dict[str, Any]] = []
if anthropic_base is not None:
messages_url = f"{anthropic_base}/v1/messages"
agent_rows.extend(
{
"agent": a.name,
"model": a.model,
"agent_type": a.__class__.__name__,
"url": messages_url,
}
for a in runtime.agents
)
mcp_rows: list[dict[str, Any]] = []
if mcp_base is not None:
mcp_rows.extend(
{"namespace": m.name, "kind": m.kind, "url": f"{mcp_base}/{m.name}/"}
for m in runtime.mcps
)
# ``all`` is synthesised by the aggregator whenever at least
# one MCP is configured — see McpServerFrontend._upstream_url.
if runtime.mcps:
mcp_rows.append(
{"namespace": "all", "kind": "bundle", "url": f"{mcp_base}/all/"}
)
return {
"anthropic_base": anthropic_base,
"mcp_base": mcp_base,
"agents": agent_rows,
"mcps": mcp_rows,
}
async def _sse_events(
events: AsyncIterator[MessageStreamEvent],
) -> AsyncIterator[bytes]:
r"""Serialize a backend stream into Anthropic's ``text/event-stream`` form.
Same wire shape as :mod:`beaver_gateway.frontends.anthropic` —
duplicated rather than imported so the admin frontend stays
independent of that module's private helpers, and so a mid-stream
failure surfaces as an in-band ``error`` event the chat UI can
render rather than a dangling connection.
"""
try:
async for ev in events:
payload = ev.model_dump_json()
yield f"event: {ev.type}\ndata: {payload}\n\n".encode()
except Exception as exc: # noqa: BLE001
_log.exception("admin chat backend stream failed")
err = json.dumps(
{"type": "error", "error": {"type": "api_error", "message": str(exc)}}
)
yield f"event: error\ndata: {err}\n\n".encode()
def _set_session_cookie(response: Response, value: str) -> None:
# ``samesite=lax`` keeps the cookie out of cross-site POSTs but
# follows top-level navigation; ``httponly`` keeps it out of JS;
# ``secure`` is gated on the deployment scheme — toggled by reverse
# proxies in front. Skip the secure flag here so localhost dev
# works over plain HTTP; production deployments behind a TLS
# terminator should set ``Secure`` via the proxy.
response.set_cookie(
SESSION_COOKIE,
value,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
)
def _current_user(
request: Request, signer: itsdangerous.URLSafeTimedSerializer
) -> dict[str, Any] | None:
raw = request.cookies.get(SESSION_COOKIE)
if not raw:
return None
try:
payload = signer.loads(raw, max_age=SESSION_MAX_AGE)
except itsdangerous.BadSignature:
return None
if not isinstance(payload, dict):
return None
user = payload.get("user")
csrf = payload.get("csrf")
if not isinstance(user, str) or not isinstance(csrf, str):
return None
return {"user": user, "csrf": csrf}
def _require_session(
request: Request, signer: itsdangerous.URLSafeTimedSerializer
) -> dict[str, Any]:
session = _current_user(request, signer)
if session is None:
# GET endpoints want a redirect (so the browser walks the user
# to the login form), not a JSON 401. Mutating endpoints will
# still trip CSRF below, so the redirect is harmless for those.
raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
return session
def _client_ip(request: Request) -> str:
"""Best-effort source IP, in precedence order.
1. ``Cf-Connecting-IP`` — Cloudflare's edge writes this and strips
anything inbound, so when it's present it's authoritative.
2. ``X-Forwarded-For`` leftmost entry — what Caddy / nginx set when
they're the only proxy. We trust this because the deploy plan
puts Caddy directly in front; if the chain ever grows untrusted
hops, this header becomes spoofable from the public side.
3. Socket peer — direct Tailscale / localhost hits.
"""
cf = request.headers.get("cf-connecting-ip")
if cf:
return cf.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
first = xff.split(",", 1)[0].strip()
if first:
return first
return request.client.host if request.client else "unknown"
class _LoginRateLimit:
"""In-memory sliding-window failure counter for ``POST /login``.
Keyed by source IP (see :func:`_client_ip`). Each failure appends a
monotonic timestamp; :meth:`check` drops timestamps older than
``window`` and refuses once ``max_attempts`` remain in the bucket.
A successful login calls :meth:`clear` to wipe the IP's bucket.
No external store: a single-operator admin only needs to survive
process lifetime. Lives entirely on the event loop thread, so no
lock is needed — every method is synchronous and doesn't ``await``.
"""
__slots__ = ("_failures", "_max_attempts", "_window")
def __init__(self, *, max_attempts: int, window: float) -> None:
self._failures: dict[str, deque[float]] = {}
self._max_attempts = max_attempts
self._window = window
def check(self, ip: str) -> bool:
bucket = self._failures.get(ip)
if bucket is None:
return True
cutoff = time.monotonic() - self._window
while bucket and bucket[0] < cutoff:
bucket.popleft()
if not bucket:
self._failures.pop(ip, None)
return True
return len(bucket) < self._max_attempts
def record_failure(self, ip: str) -> None:
bucket = self._failures.setdefault(ip, deque())
cutoff = time.monotonic() - self._window
while bucket and bucket[0] < cutoff:
bucket.popleft()
bucket.append(time.monotonic())
def clear(self, ip: str) -> None:
self._failures.pop(ip, None)
async def _require_csrf(request: Request, session: dict[str, Any]) -> None:
form = await request.form()
submitted = form.get("csrf_token")
if not isinstance(submitted, str) or not hmac.compare_digest(
submitted, session["csrf"]
):
raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed")