target-revenue/migrations/0006_control_plane.sql
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

92 lines
3.8 KiB
PL/PgSQL

-- WP-0009-T03: Control Plane backend — audit log and proposed-entry review.
-- Depends on migrations/0001_registries.sql, 0002_ledger.sql, and
-- 0005_licensor_credentials.sql (credential_label/rights/licensor_identities).
--
-- These tables belong to the Control Plane, a client layer on top of the
-- already-finished hosted Trust Service (registry.py/ledger.py) — not new
-- Trust Service surface itself. Per
-- specs/TargetRevenueControlPlaneConcept.md §5: the audit log here is
-- deliberately separate from the Trust Service's own signed records
-- (phase_manifests, ledger_entries, attestations, breach_records), which
-- only ever attest "the tenant did this," never the individual human.
BEGIN;
-- Append-only audit trail: which credential (hence which human) took
-- which Control Plane action, and — where applicable — which Trust
-- Service record resulted. `trf_app` has no UPDATE/DELETE grant: an audit
-- log that could be edited after the fact isn't an audit log.
CREATE TABLE IF NOT EXISTS control_plane_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_token text NOT NULL REFERENCES licensors(token),
actor_licensor_id text NOT NULL,
actor_credential_label text,
action text NOT NULL,
phase_id text,
trust_service_record_id text,
detail jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS control_plane_audit_log_phase_idx
ON control_plane_audit_log (phase_id);
GRANT SELECT, INSERT ON control_plane_audit_log TO trf_app;
-- Contributor-tier proposed entries, per
-- specs/TargetRevenueControlPlaneConcept.md §2's rights table: a
-- Contributor may submit a proposed Development Credit entry but not
-- append it directly; an Operator/Admin reviews and either approves
-- (which actually appends it to the real Ledger under the reviewer's own
-- credential) or rejects it. `trf_app` again has no UPDATE/DELETE grant —
-- review decisions are recorded via a SECURITY DEFINER function, the same
-- governance-action pattern as `set_extension_status`/`revoke_credential`.
CREATE TABLE IF NOT EXISTS control_plane_proposed_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
phase_id text NOT NULL,
entry jsonb NOT NULL,
proposed_by_token text NOT NULL REFERENCES licensors(token),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
reviewed_by_token text REFERENCES licensors(token),
reviewed_at timestamptz,
review_note text,
appended_entry_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS control_plane_proposed_entries_phase_idx
ON control_plane_proposed_entries (phase_id, status);
GRANT SELECT, INSERT ON control_plane_proposed_entries TO trf_app;
CREATE OR REPLACE FUNCTION review_proposed_entry(
p_id bigint,
p_status text,
p_reviewed_by_token text,
p_review_note text,
p_appended_entry_id text
) RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF p_status NOT IN ('approved', 'rejected') THEN
RAISE EXCEPTION 'review_proposed_entry only permits approved/rejected, got %', p_status;
END IF;
UPDATE control_plane_proposed_entries
SET status = p_status,
reviewed_by_token = p_reviewed_by_token,
reviewed_at = now(),
review_note = p_review_note,
appended_entry_id = p_appended_entry_id
WHERE id = p_id AND status = 'pending';
IF NOT FOUND THEN
RAISE EXCEPTION 'no pending proposal with id %', p_id;
END IF;
END;
$$;
GRANT EXECUTE ON FUNCTION review_proposed_entry(bigint, text, text, text, text) TO trf_app;
COMMIT;