Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests, extensions tables; trf_app role with no UPDATE/DELETE grant on either table, canonicalization only via a SECURITY DEFINER function), and src/target_revenue/registry.py + service/app.py: a thin FastAPI layer wrapping the existing validation.py checks with persistence and per-Licensor token auth, adding no new validation logic per ADR-0002. New optional service/service-dev dependency groups keep the core offline library dependency-free. tests/test_registry_hosting.py (7 tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable Postgres container and verifies registration, rejection, duplicate/ unknown-token handling, extension canonicalization, and two explicit database-privilege checks that the app role cannot bypass the append-only/governance-gated guarantees.
This commit is contained in:
parent
e8e8629efd
commit
7e0c62a8b5
8 changed files with 580 additions and 2 deletions
102
migrations/0001_registries.sql
Normal file
102
migrations/0001_registries.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- WP-0006-T03: Phase Registry and Extension Registry hosting.
|
||||
-- Enforces ADR-0002's storage decision at the database level, not merely by
|
||||
-- application convention: settled Phase Manifests are append-only; an
|
||||
-- Extension's canonicalization is a governance action that a plain
|
||||
-- application role cannot perform via UPDATE.
|
||||
--
|
||||
-- This migration is idempotent (safe to re-run) and assumes it runs against
|
||||
-- a dedicated database (e.g. `target_revenue`), not a shared instance's
|
||||
-- default database. It does not assume, and must not be pointed at, the
|
||||
-- state hub's own `custodian` database.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licensors (
|
||||
token text PRIMARY KEY,
|
||||
licensor_id text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One row per Phase, keyed by the schema's own globally unique phase.id.
|
||||
-- Per TSD §3.1: phase.id and phase.initial_target.amount are immutable
|
||||
-- after first publication except through an explicit, versioned correction
|
||||
-- record — which Stage 0 has no type for yet (validation.py's
|
||||
-- check_manifest_immutability flags any change as an error). Consequently
|
||||
-- this table has no supported update path at all: a Phase Manifest is
|
||||
-- inserted once and never changed by this component.
|
||||
CREATE TABLE IF NOT EXISTS phase_manifests (
|
||||
phase_id text PRIMARY KEY,
|
||||
licensor_id text NOT NULL REFERENCES licensors(licensor_id),
|
||||
manifest jsonb NOT NULL,
|
||||
registered_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Extension registrations. `status` starts at 'registered' (conformance
|
||||
-- passed) and may only become 'canonical' or 'deprecated' through the
|
||||
-- canonicalize_extension()/deprecate_extension() functions below — never a
|
||||
-- direct UPDATE by the application role (TSD §4.1: "never automated").
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
extension_id text NOT NULL,
|
||||
version text NOT NULL,
|
||||
licensor_id text NOT NULL REFERENCES licensors(licensor_id),
|
||||
contract jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'registered'
|
||||
CHECK (status IN ('registered', 'canonical', 'deprecated')),
|
||||
registered_at timestamptz NOT NULL DEFAULT now(),
|
||||
status_changed_by text,
|
||||
status_changed_at timestamptz,
|
||||
PRIMARY KEY (extension_id, version)
|
||||
);
|
||||
|
||||
-- Governance-only status transition. SECURITY DEFINER so it can run with
|
||||
-- the owning role's privilege even though the calling application role has
|
||||
-- no UPDATE grant on extensions.status itself (see grants below).
|
||||
CREATE OR REPLACE FUNCTION set_extension_status(
|
||||
p_extension_id text,
|
||||
p_version text,
|
||||
p_new_status text,
|
||||
p_changed_by text
|
||||
) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_new_status NOT IN ('canonical', 'deprecated') THEN
|
||||
RAISE EXCEPTION 'set_extension_status only permits canonical/deprecated, got %', p_new_status;
|
||||
END IF;
|
||||
UPDATE extensions
|
||||
SET status = p_new_status,
|
||||
status_changed_by = p_changed_by,
|
||||
status_changed_at = now()
|
||||
WHERE extension_id = p_extension_id AND version = p_version;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'no extension %/%', p_extension_id, p_version;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Application role: adjust the name to match the deployment's actual role.
|
||||
-- Created here (idempotent) rather than assumed to pre-exist, so this
|
||||
-- migration is self-contained for a fresh `target_revenue` database.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'trf_app') THEN
|
||||
CREATE ROLE trf_app LOGIN PASSWORD 'changeme-in-deployment';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
GRANT SELECT, INSERT ON licensors TO trf_app;
|
||||
GRANT SELECT, INSERT ON phase_manifests TO trf_app;
|
||||
-- Deliberately no UPDATE, no DELETE on phase_manifests for trf_app: this is
|
||||
-- the database-level enforcement ADR-0002 requires for append-only Phase
|
||||
-- Manifests, not merely an API design intention.
|
||||
GRANT SELECT, INSERT ON extensions TO trf_app;
|
||||
-- Deliberately no UPDATE, no DELETE on extensions for trf_app either — the
|
||||
-- only sanctioned status transition is via set_extension_status(), a
|
||||
-- SECURITY DEFINER function, so canonicalization is always a recorded,
|
||||
-- attributable governance action, never a route the application's own
|
||||
-- ordinary write path can take.
|
||||
GRANT EXECUTE ON FUNCTION set_extension_status(text, text, text, text) TO trf_app;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Add a link
Reference in a new issue