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.
102 lines
4.3 KiB
PL/PgSQL
102 lines
4.3 KiB
PL/PgSQL
-- 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;
|