target-revenue/src/target_revenue/control_plane.py
tegwick 885da0a1cb Implement Control Plane backend: rights enforcement + audit log (WP-0009-T03)
migrations/0006_control_plane.sql: control_plane_audit_log (append-only,
no UPDATE/DELETE for trf_app) and control_plane_proposed_entries (the
Contributor tier's "propose, don't append" workflow from concept §2) -
review decisions go through a review_proposed_entry() SECURITY DEFINER
function, same governance-action pattern as
set_extension_status/revoke_credential, not a direct UPDATE.

src/target_revenue/control_plane.py is the enforcement layer concept
§2 called for: register_phase/append_development_credit require
Operator+; propose_ledger_entry requires Contributor+ and stores a
pending proposal without touching the real Ledger; approve_proposed_entry
(Operator+) appends it under the *reviewer's own* credential/attribution
(not the original proposer's - the reviewer is who's authorizing it into
the real Ledger, while the proposer stays on record in the proposal row
and audit log); reject_proposed_entry (Operator+) discards it. issue_/
revoke_user_credential (Admin+) wrap registry.py's T02 functions with
the same rights check and audit logging. Every action funnels through
record_audit_event, independent of the Trust Service's own signed
records.

tests/test_control_plane.py (12 tests): rights enforcement at each
tier boundary, the full propose -> approve -> appended-under-reviewer
flow, propose -> reject -> nothing appended, double-review rejection,
audit log content/attribution, DB-level UPDATE rejection on both new
tables. Full suite: 84 offline (unchanged), 53 with Docker (up from
41); no stray containers left running.
2026-07-30 15:08:59 +02:00

279 lines
9.8 KiB
Python

"""Target Revenue Control Plane backend (WP-0009-T03).
A client layer over the already-finished hosted Trust Service
(`registry.py`, `ledger.py`) — not a replacement for it, per
`specs/TargetRevenueControlPlaneConcept.md`. Every write-capable Control
Plane action funnels through this module so it can (a) check the acting
credential's rights tier before doing anything, and (b) record who did
what in `control_plane_audit_log`, independent of the Trust Service's own
signed records, which only ever attest "the tenant did this," never the
individual human (concept §2; `migrations/0005_licensor_credentials.sql`).
Rights enforcement lives here, not in `registry.py`/`ledger.py` — those
modules only ever needed to answer "which tenant is this," and per
WP-0009-T01's accepted decision, per-human rights tiers
(Viewer/Contributor/Operator/Admin) are a Control Plane concern layered on
top, not a Trust Service one.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from . import ledger, registry
from .registry import Licensor
if TYPE_CHECKING:
from psycopg import Connection
class ControlPlaneError(ValueError):
"""Raised when a Control Plane action is rejected (insufficient
rights, an unknown proposal id, etc.)."""
def _require_right(licensor: Licensor, minimum: str) -> None:
if not registry.has_right(licensor.rights, minimum):
raise ControlPlaneError(
f"insufficient rights: {minimum!r} required, this credential has "
f"{licensor.rights!r}"
)
def record_audit_event(
conn: "Connection",
actor: Licensor,
action: str,
phase_id: str | None = None,
trust_service_record_id: str | None = None,
detail: dict[str, Any] | None = None,
) -> None:
"""Append one row to the Control Plane's own audit log. Called by
every function below after (never instead of, and never before
confirming) the underlying Trust Service action actually succeeded."""
from psycopg.types.json import Jsonb
conn.execute(
"""
INSERT INTO control_plane_audit_log
(actor_token, actor_licensor_id, actor_credential_label, action,
phase_id, trust_service_record_id, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
actor.token,
actor.licensor_id,
actor.credential_label,
action,
phase_id,
trust_service_record_id,
Jsonb(detail) if detail is not None else None,
),
)
def get_audit_log(conn: "Connection", phase_id: str | None = None) -> list[dict[str, Any]]:
"""Read the audit log, optionally scoped to one Phase, in append order."""
if phase_id:
rows = conn.execute(
"""
SELECT id, actor_licensor_id, actor_credential_label, action, phase_id,
trust_service_record_id, detail, created_at
FROM control_plane_audit_log
WHERE phase_id = %s
ORDER BY id ASC
""",
(phase_id,),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, actor_licensor_id, actor_credential_label, action, phase_id,
trust_service_record_id, detail, created_at
FROM control_plane_audit_log
ORDER BY id ASC
"""
).fetchall()
columns = (
"id", "actor_licensor_id", "actor_credential_label", "action", "phase_id",
"trust_service_record_id", "detail", "created_at",
)
return [dict(zip(columns, row)) for row in rows]
# --- Human-user (credential) management: Admin tier -------------------------
def issue_user_credential(
conn: "Connection", admin: Licensor, licensor_id: str, credential_label: str, rights: str
) -> Licensor:
"""Admin-only: issue a new credential for a human user acting as the
given Licensor tenant. Wraps `registry.issue_sub_credential` with the
rights check and audit log this module exists to add."""
_require_right(admin, "admin")
new_credential = registry.issue_sub_credential(
conn,
licensor_id=licensor_id,
credential_label=credential_label,
rights=rights,
issued_by=admin.credential_label or admin.token,
)
record_audit_event(
conn, admin, action="issue_user_credential",
detail={"credential_label": credential_label, "rights": rights},
)
return new_credential
def revoke_user_credential(conn: "Connection", admin: Licensor, token: str) -> None:
"""Admin-only: revoke a credential. Logs only the revoked token's
suffix, not the full secret, in the audit detail."""
_require_right(admin, "admin")
registry.revoke_sub_credential(conn, token)
record_audit_event(
conn, admin, action="revoke_user_credential",
detail={"revoked_token_suffix": token[-6:]},
)
# --- Phase / Ledger actions --------------------------------------------------
def register_phase(conn: "Connection", licensor: Licensor, manifest: dict[str, Any]) -> None:
"""Operator+ only, per concept §2's rights table."""
_require_right(licensor, "operator")
registry.register_phase_manifest(conn, licensor, manifest)
record_audit_event(
conn, licensor, action="register_phase", phase_id=manifest["phase"]["id"],
detail={"milestone_release": manifest["phase"]["milestone_release"]["name"]},
)
def append_development_credit(
conn: "Connection",
licensor: Licensor,
phase_id: str,
entry_input: dict[str, Any],
signing_key: Any,
) -> dict[str, Any]:
"""Operator+ only — appends directly. A Contributor must use
`propose_ledger_entry` instead (concept §2: "not directly appended")."""
_require_right(licensor, "operator")
stored = ledger.append_entry(conn, licensor, phase_id, entry_input, signing_key)
record_audit_event(
conn, licensor, action="append_ledger_entry", phase_id=phase_id,
trust_service_record_id=stored["id"],
detail={"type": stored["type"], "amount": stored["amount"]},
)
return stored
def propose_ledger_entry(
conn: "Connection", licensor: Licensor, phase_id: str, entry_input: dict[str, Any]
) -> int:
"""Contributor+ — submit a proposed entry for an Operator/Admin to
review. Not appended to the real Ledger until approved."""
from psycopg.types.json import Jsonb
_require_right(licensor, "contributor")
row = conn.execute(
"""
INSERT INTO control_plane_proposed_entries (phase_id, entry, proposed_by_token)
VALUES (%s, %s, %s)
RETURNING id
""",
(phase_id, Jsonb(entry_input), licensor.token),
).fetchone()
proposal_id = row[0]
record_audit_event(
conn, licensor, action="propose_ledger_entry", phase_id=phase_id,
detail={"proposal_id": proposal_id, "type": entry_input.get("type")},
)
return proposal_id
def list_proposed_entries(
conn: "Connection", phase_id: str | None = None, status: str = "pending"
) -> list[dict[str, Any]]:
if phase_id:
rows = conn.execute(
"""
SELECT id, phase_id, entry, proposed_by_token, status, created_at
FROM control_plane_proposed_entries
WHERE phase_id = %s AND status = %s
ORDER BY id ASC
""",
(phase_id, status),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, phase_id, entry, proposed_by_token, status, created_at
FROM control_plane_proposed_entries
WHERE status = %s
ORDER BY id ASC
""",
(status,),
).fetchall()
columns = ("id", "phase_id", "entry", "proposed_by_token", "status", "created_at")
return [dict(zip(columns, row)) for row in rows]
def approve_proposed_entry(
conn: "Connection",
reviewer: Licensor,
proposal_id: int,
signing_key: Any,
review_note: str | None = None,
) -> dict[str, Any]:
"""Operator+ approves: actually appends the entry, under the
*reviewer's* own credential/attribution — the reviewer is the one
authorizing it into the real Ledger. The original proposer remains on
record in the proposal row and the audit log, not lost or overwritten.
"""
_require_right(reviewer, "operator")
row = conn.execute(
"SELECT phase_id, entry FROM control_plane_proposed_entries "
"WHERE id = %s AND status = 'pending'",
(proposal_id,),
).fetchone()
if row is None:
raise ControlPlaneError(f"no pending proposal with id {proposal_id!r}")
phase_id, entry_input = row
stored = ledger.append_entry(conn, reviewer, phase_id, entry_input, signing_key)
conn.execute(
"SELECT review_proposed_entry(%s, %s, %s, %s, %s)",
(proposal_id, "approved", reviewer.token, review_note, stored["id"]),
)
record_audit_event(
conn, reviewer, action="approve_proposed_entry", phase_id=phase_id,
trust_service_record_id=stored["id"], detail={"proposal_id": proposal_id},
)
return stored
def reject_proposed_entry(
conn: "Connection", reviewer: Licensor, proposal_id: int, review_note: str | None = None
) -> None:
"""Operator+ rejects: the proposal is marked rejected, nothing is
appended to the real Ledger."""
_require_right(reviewer, "operator")
row = conn.execute(
"SELECT phase_id FROM control_plane_proposed_entries "
"WHERE id = %s AND status = 'pending'",
(proposal_id,),
).fetchone()
if row is None:
raise ControlPlaneError(f"no pending proposal with id {proposal_id!r}")
phase_id = row[0]
conn.execute(
"SELECT review_proposed_entry(%s, %s, %s, %s, %s)",
(proposal_id, "rejected", reviewer.token, review_note, None),
)
record_audit_event(
conn, reviewer, action="reject_proposed_entry", phase_id=phase_id,
detail={"proposal_id": proposal_id},
)