target-revenue/src/target_revenue/service/control_plane_app.py
tegwick d894599647 Implement WP-0015-T03: Control Plane reference-rendering routes
New service/reference_docs.py renders specs/policies/*.md and
specs/profiles/*.md read-only at request time via a small markdown
library (added markdown + PyYAML to the service extras) -- not a
static-build pipeline, matching the WP-0012-T03 decision to skip
state-hub's heavier Observable Framework pattern.

One parameterized route, GET /reference/{kind}/{slug}, covers both
addendum URL shapes. Discovered phase_detail.html's Status table never
displayed the degeneration_policy id at all -- added that row (with
the reference link) rather than wiring a link with nothing to attach
it to. phase_new.html gets a plain link next to the field.

Deliberately did not wire extension-id links into the UI in this task
-- extension ids don't appear anywhere in the Control Plane today
(that's WP-0014's gap, not this one's to expand).

6 new Docker-gated tests. Full suite: 94 passing offline, 164 passing
with Docker (up from 158).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:45:59 +02:00

440 lines
16 KiB
Python

"""Target Revenue Control Plane — interactive UI (WP-0009-T04).
An HTML front end over `target_revenue.control_plane`, itself a client
layer over the hosted Trust Service (`registry.py`, `ledger.py`) — this
module adds no new rights-enforcement or audit logic of its own, it only
frames `control_plane.py`'s functions as browser-usable forms and pages
(session-based sign-in, flash messages, whynot-design-styled templates).
Session model: the browser session stores only the raw credential token
(`itsdangerous`-signed cookie, via Starlette's `SessionMiddleware` — never
the resolved rights tier, which is re-checked against the database on
every request so a mid-session revocation takes effect immediately).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from psycopg import Connection
from psycopg_pool import ConnectionPool
from starlette.middleware.sessions import SessionMiddleware
from .. import control_plane, ledger, metrics, registry
from . import keys, reference_docs
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
_DATABASE_URL_ENV = "TRF_DATABASE_URL"
_SECRET_KEY_ENV = "TRF_CONTROL_PLANE_SECRET_KEY"
app = FastAPI(title="Target Revenue Control Plane", version="0.1.0")
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
templates.env.globals["policy_slug"] = reference_docs.policy_slug_from_id
templates.env.globals["extension_slug"] = reference_docs.extension_slug_from_id
_secret_key = os.environ.get(_SECRET_KEY_ENV)
if not _secret_key:
import secrets
import warnings
warnings.warn(
f"{_SECRET_KEY_ENV} not set — generating an ephemeral session secret. "
"Every restart invalidates existing sessions; do not use this in any "
"real deployment.",
stacklevel=1,
)
_secret_key = secrets.token_hex(32)
app.add_middleware(SessionMiddleware, secret_key=_secret_key)
def get_pool() -> ConnectionPool:
if not hasattr(app.state, "pool"):
dsn = os.environ.get(_DATABASE_URL_ENV)
if not dsn:
raise RuntimeError(f"{_DATABASE_URL_ENV} is not set")
app.state.pool = ConnectionPool(dsn, min_size=1, max_size=5, open=True)
return app.state.pool
def get_signing_key():
if not hasattr(app.state, "signing_key"):
app.state.signing_key = keys.load_signing_key()
return app.state.signing_key
def get_connection():
pool = get_pool()
with pool.connection() as conn:
yield conn
def get_session_licensor(
request: Request, conn: Connection = Depends(get_connection)
) -> registry.Licensor | None:
"""The signed-in credential, re-authenticated against the database on
every request (not trusted from the session cookie alone) so a
revocation between requests takes effect immediately."""
token = request.session.get("token")
if not token:
return None
try:
return registry.authenticate(conn, token)
except registry.RegistrationError:
request.session.clear()
return None
class _NotAuthenticated(Exception):
"""Raised by `require_login` when there is no valid session — handled
below by a dedicated exception handler so it never collides with (or
overrides the default handling of) ordinary HTTPExceptions like a
404 for an unknown Phase id."""
def require_login(
licensor: registry.Licensor | None = Depends(get_session_licensor),
) -> registry.Licensor:
if licensor is None:
raise _NotAuthenticated()
return licensor
def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]:
context = {
"request": request,
"session_credential_label": licensor.credential_label if licensor else None,
"session_rights": licensor.rights if licensor else None,
"session_licensor_id": licensor.licensor_id if licensor else None,
"flash": request.session.pop("flash", None),
"flash_variant": request.session.pop("flash_variant", None),
}
context.update(extra)
return context
def _redirect(url: str, request: Request, flash: str | None = None, variant: str = "info") -> RedirectResponse:
if flash:
request.session["flash"] = flash
request.session["flash_variant"] = variant
return RedirectResponse(url=url, status_code=303)
@app.exception_handler(_NotAuthenticated)
async def _redirect_to_login(request: Request, exc: _NotAuthenticated):
return RedirectResponse(url="/login", status_code=303)
# --- Auth --------------------------------------------------------------
@app.get("/login")
def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
if licensor is not None:
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse(request, "login.html", _template_context(request, None))
@app.post("/login")
def login_submit(
request: Request,
token: str = Form(...),
conn: Connection = Depends(get_connection),
):
try:
registry.authenticate(conn, token)
except registry.RegistrationError:
request.session["flash"] = "Invalid or revoked credential token."
request.session["flash_variant"] = "danger"
return RedirectResponse(url="/login", status_code=303)
request.session["token"] = token
return RedirectResponse(url="/", status_code=303)
@app.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
# --- Dashboard -----------------------------------------------------------
@app.get("/")
def dashboard(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
return templates.TemplateResponse(
request, "dashboard.html", _template_context(request, licensor, phases=phases)
)
# --- Phases ----------------------------------------------------------------
@app.get("/phases/new")
def phase_new_form(
request: Request,
licensor: registry.Licensor = Depends(require_login),
):
if not registry.has_right(licensor.rights, "operator"):
return _redirect("/", request, "Operator rights required to register a Phase.", "danger")
return templates.TemplateResponse(request, "phase_new.html", _template_context(request, licensor))
@app.post("/phases/new")
def phase_new_submit(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
phase_id: str = Form(...),
milestone_release_name: str = Form(...),
source_revision: str = Form(...),
repo_hub: str = Form(...),
repo_hub_uri: str = Form(...),
repo_id: int = Form(...),
repo_name: str = Form(...),
base_phase_id: str = Form(""),
initial_target_amount: float = Form(...),
currency: str = Form(...),
future_license: str = Form(...),
degeneration_policy: str = Form(...),
longstop_at: str = Form(...),
):
manifest = {
"framework": "TRF-0.1",
"license": "TRSL-0.1",
"phase": {
"id": phase_id,
"milestone_release": {
"name": milestone_release_name,
"source_revision": source_revision,
"repo_hub": repo_hub,
"repo_hub_uri": repo_hub_uri,
"repo_id": repo_id,
"repo_name": repo_name,
},
"initial_target": {"amount": initial_target_amount, "currency": currency},
"future_license": future_license,
"degeneration_policy": degeneration_policy,
"longstop_at": longstop_at,
# Auto-computed, never hand-typed (WP-0012-T04) -- this Trust
# Service instance is always the ledger's host in Stage 0.
"ledger": f"/phases/{phase_id}/ledger",
},
}
if base_phase_id:
manifest["phase"]["base_phase_id"] = base_phase_id
try:
control_plane.register_phase(conn, licensor, manifest)
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
return _redirect("/phases/new", request, str(exc), "danger")
return _redirect(f"/phases/{phase_id}", request, f"Phase {phase_id} registered.", "success")
@app.get("/phases/{phase_id}")
def phase_detail(
phase_id: str,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
manifest = registry.get_phase_manifest(conn, phase_id)
if manifest is None:
raise HTTPException(status_code=404, detail="phase not found")
entries = ledger.get_ledger(conn, phase_id)
computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
return templates.TemplateResponse(
request,
"phase_detail.html",
_template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
)
@app.post("/phases/{phase_id}/ledger")
def phase_ledger_submit(
phase_id: str,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
signing_key=Depends(get_signing_key),
entry_id: str = Form(...),
amount: float = Form(...),
currency: str = Form(...),
recognized_at: str = Form(...),
evidence_reference: str = Form(...),
extension_id: str = Form(...),
extension_version: str = Form(...),
):
entry_input = {
"id": entry_id,
"phase": phase_id,
"type": "development-credit",
"amount": amount,
"currency": currency,
"recognized_at": recognized_at,
"evidence_reference": evidence_reference,
"extension": {"id": extension_id, "version": extension_version},
}
try:
if registry.has_right(licensor.rights, "operator"):
control_plane.append_development_credit(conn, licensor, phase_id, entry_input, signing_key)
flash = f"Entry {entry_id} appended."
else:
control_plane.propose_ledger_entry(conn, licensor, phase_id, entry_input)
flash = f"Entry {entry_id} submitted for review."
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
return _redirect(f"/phases/{phase_id}", request, flash, "success")
# --- Proposals (Operator+) --------------------------------------------------
@app.get("/proposals")
def proposals_list(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
if not registry.has_right(licensor.rights, "operator"):
return _redirect("/", request, "Operator rights required to review proposals.", "danger")
proposals = control_plane.list_proposed_entries(conn)
return templates.TemplateResponse(
request, "proposals.html", _template_context(request, licensor, proposals=proposals)
)
@app.post("/proposals/{proposal_id}/approve")
def proposal_approve(
proposal_id: int,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
signing_key=Depends(get_signing_key),
):
try:
control_plane.approve_proposed_entry(conn, licensor, proposal_id, signing_key)
except control_plane.ControlPlaneError as exc:
return _redirect("/proposals", request, str(exc), "danger")
return _redirect("/proposals", request, f"Proposal {proposal_id} approved.", "success")
@app.post("/proposals/{proposal_id}/reject")
def proposal_reject(
proposal_id: int,
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
try:
control_plane.reject_proposed_entry(conn, licensor, proposal_id)
except control_plane.ControlPlaneError as exc:
return _redirect("/proposals", request, str(exc), "danger")
return _redirect("/proposals", request, f"Proposal {proposal_id} rejected.", "success")
# --- Credentials (Admin) ----------------------------------------------------
@app.get("/admin/credentials")
def admin_credentials_form(
request: Request,
licensor: registry.Licensor = Depends(require_login),
):
if not registry.has_right(licensor.rights, "admin"):
return _redirect("/", request, "Admin rights required.", "danger")
return templates.TemplateResponse(request, "admin_credentials.html", _template_context(request, licensor))
@app.post("/admin/credentials")
def admin_credentials_issue(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
credential_label: str = Form(...),
rights: str = Form(...),
):
if not registry.has_right(licensor.rights, "admin"):
return _redirect("/", request, "Admin rights required.", "danger")
try:
new_credential = control_plane.issue_user_credential(
conn, licensor, licensor.licensor_id, credential_label, rights
)
except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
return _redirect("/admin/credentials", request, str(exc), "danger")
return templates.TemplateResponse(
request,
"admin_credentials.html",
_template_context(
request, licensor,
new_token=new_credential.token, new_token_label=new_credential.credential_label,
),
)
@app.post("/admin/credentials/revoke")
def admin_credentials_revoke(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
token: str = Form(...),
):
if not registry.has_right(licensor.rights, "admin"):
return _redirect("/", request, "Admin rights required.", "danger")
try:
control_plane.revoke_user_credential(conn, licensor, token)
except control_plane.ControlPlaneError as exc:
return _redirect("/admin/credentials", request, str(exc), "danger")
return _redirect("/admin/credentials", request, "Credential revoked.", "success")
# --- Audit log ---------------------------------------------------------
@app.get("/audit")
def audit_log(
request: Request,
licensor: registry.Licensor = Depends(require_login),
conn: Connection = Depends(get_connection),
):
log = control_plane.get_audit_log(conn)
return templates.TemplateResponse(request, "audit.html", _template_context(request, licensor, log=log))
# --- Reference docs (specs/policies/, specs/profiles/) ----------------------
@app.get("/reference/{kind}/{slug}")
def reference_doc(
kind: str,
slug: str,
request: Request,
licensor: registry.Licensor = Depends(require_login),
):
"""Read-only rendering of a `specs/policies/`/`specs/profiles/`
markdown file (WP-0015-T03) — never editable from here."""
result = reference_docs.load_reference_doc(kind, slug)
if result is None:
raise HTTPException(status_code=404, detail="reference document not found")
doc_html, frontmatter = result
return templates.TemplateResponse(
request,
"reference.html",
_template_context(
request, licensor,
doc_html=doc_html,
doc_title=frontmatter.get("title", slug),
source_path=f"specs/{kind}/{slug}.md",
),
)