gitea-saas-core/gitea_saas/dashboard.py
Claude 08febd56ca fix(dashboard): require X-API-Key on /dashboard (fail-closed, #25)
The HTML operator page served the same clients/ledger/queue/review data as
the authenticated JSON summary, but with no auth at all. The loopback
requirement it relied on existed only as prose (page note + docstring); it
was never enforced by mount_dashboard, which does not own the app and
cannot constrain the bind address.

Put /dashboard behind the same _guard as /api/dashboard/summary:
- no X-API-Key -> 401, no billing data
- no configured key -> 503 (fail-closed)
- valid key -> 200 HTML (unchanged rendering)

Also drop the now-false 'loopback-only' security claims from the page note
and the mount_dashboard docstring, and update the tests:
- test_dashboard_page_open_and_html now sends a valid key
- new test_dashboard_page_guard_blocks_anonymous_access: no key -> 401,
  body contains no client data (mutation check: fails if guard removed)
- new test_dashboard_wrong_key_same_length_rejected: wrong same-length
  key -> 401 on HTML page too
- test_summary_fail_closed_without_configured_key now asserts the HTML
  page also fails closed (503), not open
- test_dashboard_mounts_onto_existing_app sends a valid key

/api/dashboard/summary behaviour unchanged. Suite: 160 passed, 1 skipped
(before: 158 passed, 1 skipped).
2026-08-26 02:54:37 -05:00

504 lines
20 KiB
Python

"""SaaS operator dashboard (issue #9).
One page for the whole SaaS: client balances, ledger history, pending /
intake queues, throughput per client (issues merged per 7d/30d, evals
completed, average review-to-merge time), and review-gate verdicts.
Numbers are computed from the SQLite DB AT REQUEST TIME — no caching (the
data is small).
Read contract
-------------
``Dashboard(core, db_path)`` opens its own read-only(-ish) connection to
the SAME SQLite file as the billing core. It reads:
* ``clients`` / ``ledger`` / ``holds`` — owned by the billing core (#1)
* ``pending_issues`` / ``factory_locks`` — issue #4's factory, best-effort
(queried only when the table exists)
* ``reviews`` — issue #6's review gate, best-effort
* ``eval_batches`` / ``eval_tasks`` — issue #8's eval farm, best-effort
Best-effort reads never crash the page: a missing table renders the
section's empty state (``—``). Balances come from the billing core (the
single source of truth, holds included).
Client "status" is derived: ``active`` (normal), ``locked`` (factory lock
row exists for the client), ``overdue`` (available balance below the debit
floor — needs a top-up). ``render_html(summary)`` produces the
high-contrast, no-external-assets HTML page; the JSON summary endpoint
serves the exact same dict.
"""
from __future__ import annotations
import hmac
import html
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
EMPTY = "\u2014" # em dash empty-state marker
# Single source for the API-key header name on this branch. The shared
# `config.API_KEY_HEADER` constant lands with issue #2/#6 (neither merged
# yet); when #24 consolidates the service's auth paths, this line is
# replaced by `from .config import API_KEY_HEADER` — no other change.
API_KEY_HEADER = "X-API-Key"
DEFAULT_DEBIT_FLOOR_MINOR = 500
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(dt: datetime) -> str:
return dt.isoformat()
class Dashboard:
"""Live operator dashboard over the SaaS SQLite file (no caching)."""
def __init__(self, core: Any, db_path, debit_floor_minor: Optional[int] = None):
self._core = core
self._db_path = Path(db_path)
self._conn = sqlite3.connect(str(self._db_path))
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
if debit_floor_minor is None:
raw = os.environ.get("SAAS_DEBIT_FLOOR_MINOR", "").strip()
debit_floor_minor = int(raw) if raw else DEFAULT_DEBIT_FLOOR_MINOR
self._floor = debit_floor_minor
# ------------------------------------------------------------------
# Shared query helpers (best-effort on foreign tables)
# ------------------------------------------------------------------
def _table_exists(self, name: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(name,)).fetchone()
return row is not None
def _client_names(self) -> Dict[int, str]:
out: Dict[int, str] = {}
try:
for r in self._conn.execute("SELECT id, name FROM clients"):
out[int(r["id"])] = str(r["name"])
except sqlite3.Error:
pass
return out
def _client_balance(self, client_id: int) -> Optional[int]:
try:
ok, details = self._core.get_client(client_id, include_balance=True)
except Exception:
return None
if not ok or not isinstance(details, dict):
return None
balance = details.get("balance")
return balance if isinstance(balance, int) else None
def _locked_client_ids(self) -> set:
"""Client ids with a factory lock (issue #4 table, best-effort)."""
if not self._table_exists("factory_locks"):
return set()
try:
return {int(r["client_id"]) for r in
self._conn.execute("SELECT client_id FROM factory_locks")}
except sqlite3.Error:
return set()
# ------------------------------------------------------------------
# Sections
# ------------------------------------------------------------------
def _clients(self) -> List[dict]:
now = _now()
month_start = _iso(now.replace(day=1, hour=0, minute=0, second=0,
microsecond=0))
locked = self._locked_client_ids()
clients = []
try:
rows = list(self._conn.execute(
"SELECT id, name, tier, status FROM clients ORDER BY id"))
except sqlite3.Error:
return clients
for r in rows:
cid = int(r["id"])
balance = self._client_balance(cid)
try:
credits = int(self._conn.execute(
"SELECT COALESCE(SUM(amount_minor), 0) FROM ledger"
" WHERE client_id = ? AND kind IN ('credit', 'refund')"
" AND created_at >= ?", (cid, month_start)).fetchone()[0])
debits = int(self._conn.execute(
"SELECT COALESCE(SUM(amount_minor), 0) FROM ledger"
" WHERE client_id = ? AND kind = 'debit' AND created_at >= ?",
(cid, month_start)).fetchone()[0])
except sqlite3.Error:
credits = debits = 0
if cid in locked:
status = "locked"
elif balance is not None and balance < self._floor:
status = "overdue"
else:
status = str(r["status"] or "active")
clients.append({
"id": cid,
"name": str(r["name"]),
"tier": str(r["tier"]),
"balance": balance,
"status": status,
"credits_this_month": credits,
"debits_this_month": debits,
})
return clients
def _queue(self) -> Dict[str, Any]:
"""pending_issues (issue #4) — count + newest 10, best-effort."""
empty = {"count": 0, "items": []}
if not self._table_exists("pending_issues"):
return empty
names = self._client_names()
locked = self._locked_client_ids()
try:
count = int(self._conn.execute(
"SELECT COUNT(*) FROM pending_issues").fetchone()[0])
rows = list(self._conn.execute(
"SELECT id, client_id, title, created_at, status"
" FROM pending_issues ORDER BY id DESC LIMIT 10"))
except sqlite3.Error:
return empty
items = []
for r in rows:
cid = int(r["client_id"])
balance = self._client_balance(cid)
items.append({
"id": int(r["id"]),
"client_id": cid,
"client_name": names.get(cid, str(cid)),
"title": str(r["title"]),
"queued_at": str(r["created_at"]),
"status": str(r["status"]),
"locked": cid in locked,
"unfunded": balance is not None and balance < self._floor,
})
return {"count": count, "items": items}
def _delivery_debits(self, since_iso: str) -> set:
"""(client_id, ref_id) delivery debits recorded since ``since_iso``."""
try:
rows = self._conn.execute(
"SELECT client_id, ref_id FROM ledger"
" WHERE kind = 'debit' AND ref_type = 'delivery'"
" AND created_at >= ?", (since_iso,)).fetchall()
return {(int(r["client_id"]), r["ref_id"] or "") for r in rows}
except sqlite3.Error:
return set()
def _avg_review_to_merge_days(self) -> Optional[float]:
"""Mean days from review to delivery debit (best-effort, #6 + #7)."""
if not self._table_exists("reviews"):
return None
try:
debits = self._conn.execute(
"SELECT created_at, ref_id FROM ledger"
" WHERE kind = 'debit' AND ref_type = 'delivery'").fetchall()
reviews = self._conn.execute(
"SELECT repo, pr, reviewed_at FROM reviews").fetchall()
except sqlite3.Error:
return None
by_ref: Dict[tuple, List[str]] = {}
for r in reviews:
by_ref.setdefault((str(r["repo"]), str(r["pr"])),
[]).append(str(r["reviewed_at"]))
diffs = []
for d in debits:
ref = d["ref_id"] or ""
if "#" not in ref:
continue
repo, _, pr_num = ref.rpartition("#")
times = by_ref.get((repo, pr_num))
if not times:
continue
try:
debit_at = datetime.fromisoformat(str(d["created_at"]))
except ValueError:
continue
last_time: Optional[datetime] = None
for t in times:
parsed = _safe_parse(t)
if parsed is not None and parsed <= debit_at:
if last_time is None or parsed > last_time:
last_time = parsed
if last_time is None:
continue
diffs.append((debit_at - last_time).total_seconds() / 86400.0)
if not diffs:
return None
return round(sum(diffs) / len(diffs), 2)
def _throughput(self) -> List[dict]:
now = _now()
in_7d = _iso(now - timedelta(days=7))
in_30d = _iso(now - timedelta(days=30))
merged_7d = self._delivery_debits(in_7d)
merged_30d = self._delivery_debits(in_30d)
avg = self._avg_review_to_merge_days()
names = self._client_names()
out = []
try:
client_ids = [int(r["id"]) for r in
self._conn.execute("SELECT id FROM clients")]
except sqlite3.Error:
client_ids = []
for cid in client_ids:
evals = 0
if self._table_exists("eval_tasks") and self._table_exists("eval_batches"):
try:
evals = int(self._conn.execute(
"SELECT COUNT(*) FROM eval_tasks t"
" JOIN eval_batches b ON b.id = t.batch_id"
" WHERE b.client_id = ? AND t.status = 'done'",
(cid,)).fetchone()[0])
except sqlite3.Error:
evals = 0
out.append({
"client_id": cid,
"client_name": names.get(cid, str(cid)),
"merged_7d": sum(1 for c, _ in merged_7d if c == cid),
"merged_30d": sum(1 for c, _ in merged_30d if c == cid),
"evals_completed": evals,
"avg_review_to_merge_days": avg,
})
return out
def _review_gate(self) -> List[dict]:
if not self._table_exists("reviews"):
return []
try:
rows = list(self._conn.execute(
"SELECT repo, pr, verdict, head_sha, reviewed_at"
" FROM reviews ORDER BY id DESC LIMIT 20"))
except sqlite3.Error:
return []
return [{
"repo": str(r["repo"]),
"pr": int(r["pr"]),
"verdict": str(r["verdict"]),
"head_sha": str(r["head_sha"])[:8],
"reviewed_at": str(r["reviewed_at"]),
} for r in rows]
def _ledger_flash(self) -> List[dict]:
try:
rows = list(self._conn.execute(
"SELECT l.id, l.client_id, l.kind, l.amount_minor, l.currency,"
" l.ref_type, l.ref_id, l.created_at, c.name AS client_name"
" FROM ledger l LEFT JOIN clients c ON c.id = l.client_id"
" ORDER BY l.id DESC LIMIT 20"))
except sqlite3.Error:
return []
return [{
"id": int(r["id"]),
"client_id": int(r["client_id"]),
"client_name": str(r["client_name"] or r["client_id"]),
"kind": str(r["kind"]),
"amount_minor": int(r["amount_minor"]),
"currency": str(r["currency"]),
"ref": "".join(x for x in (str(r["ref_type"] or ""),
("#" + str(r["ref_id"]) if r["ref_id"] else ""))
if x),
"created_at": str(r["created_at"]),
} for r in rows]
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
def summary(self) -> Dict[str, Any]:
"""The whole dashboard as plain dicts (HTML and JSON share this)."""
return {
"generated_at": _iso(_now()),
"clients": self._clients(),
"queue": self._queue(),
"throughput": self._throughput(),
"review_gate": self._review_gate(),
"ledger_flash": self._ledger_flash(),
}
def _safe_parse(value: str) -> Optional[datetime]:
try:
return datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
def render_html(summary: Dict[str, Any]) -> str:
"""High-contrast operator page, no external assets (empty state: —)."""
esc = html.escape
def money(v):
return EMPTY if v is None else f"{v:,}"
rows_clients = "".join(
f"<tr><td>{esc(c['name'])}</td><td>{esc(c['tier'])}</td>"
f"<td>{money(c['balance'])}</td>"
f"<td class=\"st-{esc(c['status'])}\">{esc(c['status'])}</td>"
f"<td>{c['credits_this_month']:,}</td><td>{c['debits_this_month']:,}</td></tr>"
for c in summary["clients"])
if not rows_clients:
rows_clients = f"<tr><td colspan=\"6\">{EMPTY}</td></tr>"
q = summary["queue"]
rows_queue = "".join(
f"<tr><td>{esc(i['client_name'])}</td><td>{esc(i['title'])}</td>"
f"<td>{esc(i['queued_at'])}</td>"
f"<td>{'locked' if i['locked'] else ''}{'|' if i['locked'] and i['unfunded'] else ''}"
f"{'unfunded' if i['unfunded'] else ''}</td></tr>"
for i in q["items"])
if not rows_queue:
rows_queue = f"<tr><td colspan=\"4\">{EMPTY}</td></tr>"
rows_tp = "".join(
f"<tr><td>{esc(t['client_name'])}</td><td>{t['merged_7d']}</td>"
f"<td>{t['merged_30d']}</td><td>{t['evals_completed']}</td>"
f"<td>{t['avg_review_to_merge_days'] if t['avg_review_to_merge_days'] is not None else EMPTY}</td></tr>"
for t in summary["throughput"])
if not rows_tp:
rows_tp = f"<tr><td colspan=\"5\">{EMPTY}</td></tr>"
rows_rg = "".join(
f"<tr><td>{esc(v['repo'])}</td><td>#{v['pr']}</td>"
f"<td class=\"st-rg\">{esc(v['verdict'])}</td>"
f"<td><code>{esc(v['head_sha'])}</code></td><td>{esc(v['reviewed_at'])}</td></tr>"
for v in summary["review_gate"])
if not rows_rg:
rows_rg = f"<tr><td colspan=\"5\">{EMPTY}</td></tr>"
rows_lf = "".join(
f"<tr><td>{esc(l['client_name'])}</td><td>{esc(l['kind'])}</td>"
f"<td>{l['amount_minor']:,}</td><td>{esc(l['ref'])}</td>"
f"<td>{esc(l['created_at'])}</td></tr>"
for l in summary["ledger_flash"])
if not rows_lf:
rows_lf = f"<tr><td colspan=\"5\">{EMPTY}</td></tr>"
return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>SaaS dashboard — gitea-saas-core</title>
<style>
body {{ background:#0b0f14; color:#e8eef5; font:14px/1.45 'Segoe UI',system-ui,sans-serif; margin:24px; }}
h1 {{ font-size:20px; border-bottom:1px solid #26303c; padding-bottom:8px; }}
h2 {{ font-size:15px; margin:22px 0 6px; color:#8fd0ff; }}
table {{ border-collapse:collapse; width:100%; margin-bottom:8px; }}
th,td {{ border:1px solid #26303c; padding:4px 8px; text-align:left; }}
th {{ background:#141b24; color:#9fb3c8; font-weight:600; }}
td.st-active {{ color:#2ecc71; }} td.st-locked {{ color:#e67e22; }} td.st-overdue {{ color:#e74c3c; }}
td.st-rg {{ font-weight:700; }} code {{ background:#141b24; padding:1px 4px; }}
.note {{ color:#7c8ea3; font-size:12px; margin-top:18px; }}
</style></head><body>
<h1>SaaS dashboard <span style="font-weight:400;color:#7c8ea3;font-size:12px">generated {esc(summary['generated_at'])} UTC</span></h1>
<h2>1 · Clients</h2>
<table><tr><th>Name</th><th>Tier</th><th>Balance</th><th>Status</th><th>Credits/30d</th><th>Debits/30d</th></tr>{rows_clients}</table>
<h2>2 · Queue</h2>
<table><tr><th>Client</th><th>Title</th><th>Queued at</th><th>Flags</th></tr>{rows_queue}</table>
<h2>3 · Throughput</h2>
<table><tr><th>Client</th><th>Merged 7d</th><th>Merged 30d</th><th>Evals done</th><th>Avg review→merge</th></tr>{rows_tp}</table>
<h2>4 · Review gate</h2>
<table><tr><th>Repo</th><th>PR</th><th>Verdict</th><th>Head</th><th>Reviewed at</th></tr>{rows_rg}</table>
<h2>5 · Ledger flash</h2>
<table><tr><th>Client</th><th>Kind</th><th>Amount</th><th>Ref</th><th>Time</th></tr>{rows_lf}</table>
<p class="note">Operator page — requires the same API key as the JSON summary. Live numbers, no caching.</p>
</body></html>"""
def mount_dashboard(app, core=None, db_path=None, api_key=None):
"""Mount the operator dashboard onto an EXISTING Flask app (issue #9).
The HTTP app factory is owned by issue #2 (``gitea_saas/server.py`` —
see ARCHITECTURE.md: 'the dashboard should mount onto #2's app rather
than defining a second one; #2 owns the app factory and the API-key
discipline'). This module deliberately does NOT define or run a second
app. Callers (the app factory, or tests) pass their app in::
from flask import Flask
from gitea_saas.billing_core import BillingCore
from gitea_saas.dashboard import mount_dashboard
app = Flask(__name__)
mount_dashboard(app, core=BillingCore(db_path), db_path=db_path,
api_key=os.environ.get("SAAS_API_KEY"))
Registered routes:
* ``GET /dashboard`` — HTML operator page (same ``X-API-Key`` auth and
fail-closed 401/503 shapes as the JSON summary; never billing data
without a valid key).
* ``GET /api/dashboard/summary`` — JSON payload (``X-API-Key`` auth;
401 on missing/wrong key, 503 when no key is configured — fail-closed).
* ``GET /healthz`` — liveness.
``api_key`` fills ``app.config['API_KEY']`` only when unset, so the app
factory's key wins when both are configured.
"""
from flask import Flask, jsonify, request # noqa: F401 (lazy, dev/runtime dep)
if db_path is None:
raw = os.environ.get("SAAS_DB_PATH", "").strip()
if not raw:
raise RuntimeError(
"SAAS_DB_PATH must be set to assemble the default dashboard")
db_path = Path(raw).expanduser()
if core is None:
from .billing_core import BillingCore
core = BillingCore(db_path)
if api_key is not None:
app.config.setdefault("API_KEY", api_key)
if not app.config.get("API_KEY"):
app.config["API_KEY"] = os.environ.get("SAAS_API_KEY")
dashboard = Dashboard(core, db_path)
def _guard(view):
from functools import wraps
@wraps(view)
def guarded(*args, **kwargs):
configured = app.config.get("API_KEY")
if not configured:
return {"error": {"code": "API_KEY_NOT_CONFIGURED",
"message": "SAAS_API_KEY not set"}}, 503
provided = request.headers.get(API_KEY_HEADER, "")
if not hmac.compare_digest(provided, configured):
return {"error": {"code": "UNAUTHORIZED",
"message": "bad or missing X-API-Key"}}, 401
return view(*args, **kwargs)
return guarded
@app.get("/healthz")
def healthz():
return jsonify({"ok": True})
@app.get("/dashboard")
@_guard
def dashboard_page():
# Same guard as /api/dashboard/summary (issue #25): X-API-Key auth,
# fail-closed 401/503 shapes, never billing data without a valid key.
# Loopback binding is defense-in-depth only; it was never enforced, so
# it is NOT the authorization boundary. Recompute from the DB at
# request time (no caching).
summary = dashboard.summary()
return (render_html(summary), 200,
{"Content-Type": "text/html; charset=utf-8"})
@app.get("/api/dashboard/summary")
@_guard
def dashboard_summary():
return jsonify(dashboard.summary())
return app