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
|
|
@ -78,7 +78,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule**
|
||||||
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
|
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
|
||||||
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
|
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
|
||||||
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
|
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
|
||||||
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD) done, see `specs/TrustServiceProductRequirementsDocument.md`; T02 (stack ADR) next |
|
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (Phase/Extension Registry hosting) done; T04 (Ledger append API) next |
|
||||||
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
|
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
|
||||||
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |
|
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
||||||
|
|
@ -18,6 +18,16 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
]
|
]
|
||||||
|
service = [
|
||||||
|
"psycopg[binary]>=3.1",
|
||||||
|
"psycopg-pool>=3.1",
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn>=0.27",
|
||||||
|
]
|
||||||
|
service-dev = [
|
||||||
|
"target-revenue[service,dev]",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/target_revenue"]
|
packages = ["src/target_revenue"]
|
||||||
|
|
|
||||||
160
src/target_revenue/registry.py
Normal file
160
src/target_revenue/registry.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""Hosted Phase Registry and Extension Registry (WP-0006-T03).
|
||||||
|
|
||||||
|
Wraps `validation.py`'s already-tested conformance checks with Postgres
|
||||||
|
persistence, per `docs/adr/ADR-0002-hosted-trust-service-stack.md`. This
|
||||||
|
module adds multi-tenancy and storage; it must not introduce new validation
|
||||||
|
logic beyond what `validation.py` already does (TSD §4.1: this task "adds
|
||||||
|
multi-tenancy and persistence, not new validation logic").
|
||||||
|
|
||||||
|
Append-only enforcement for `phase_manifests`, and canonicalization-as-
|
||||||
|
governance-action for `extensions`, are primarily enforced at the database
|
||||||
|
level (see `migrations/0001_registries.sql`) — this module's job is to
|
||||||
|
surface a conformance rejection *before* attempting a write that the
|
||||||
|
database would reject anyway, so callers get a field-by-field diff instead
|
||||||
|
of an opaque database error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg import Connection
|
||||||
|
from psycopg.errors import UniqueViolation
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from . import validation
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationError(ValueError):
|
||||||
|
"""Raised with the specific reason a registration was rejected."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Licensor:
|
||||||
|
token: str
|
||||||
|
licensor_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(conn: Connection, token: str) -> Licensor:
|
||||||
|
"""Resolve a bearer token to a Licensor, or raise RegistrationError.
|
||||||
|
|
||||||
|
Per ADR-0002: write access is scoped per Licensor via this token, kept
|
||||||
|
independent of the per-entry Ed25519 signing WP-0002 already
|
||||||
|
implements (that signing happens at the Ledger layer, T04 — this
|
||||||
|
function only answers "who is calling," not "is this entry authentic").
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT licensor_id FROM licensors WHERE token = %s", (token,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise RegistrationError("unrecognized or revoked token")
|
||||||
|
return Licensor(token=token, licensor_id=row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def register_phase_manifest(
|
||||||
|
conn: Connection, licensor: Licensor, manifest: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Validate and persist a Phase Manifest.
|
||||||
|
|
||||||
|
Rejects, with the same field-by-field diff `validation.py` produces
|
||||||
|
offline, rather than silently accepting a non-conformant manifest
|
||||||
|
(TSD §3.1 validation rule). A manifest is inserted once; there is no
|
||||||
|
update path — `phase_manifests` grants no UPDATE/DELETE to the
|
||||||
|
application role (migrations/0001_registries.sql), so a second attempt
|
||||||
|
to register the same `phase_id` fails at the database's unique
|
||||||
|
constraint, surfaced here as RegistrationError rather than a raw
|
||||||
|
IntegrityError.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validation.validate_phase_manifest(manifest)
|
||||||
|
except validation.ConformanceError as exc:
|
||||||
|
raise RegistrationError(
|
||||||
|
"manifest rejected: " + "; ".join(exc.errors)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
phase_id = manifest["phase"]["id"]
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO phase_manifests (phase_id, licensor_id, manifest)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""",
|
||||||
|
(phase_id, licensor.licensor_id, Jsonb(manifest)),
|
||||||
|
)
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
raise RegistrationError(
|
||||||
|
f"phase_id {phase_id!r} is already registered; "
|
||||||
|
"a Phase Manifest cannot be re-registered or replaced in place"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_phase_manifest(conn: Connection, phase_id: str) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT manifest FROM phase_manifests WHERE phase_id = %s", (phase_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def register_extension(
|
||||||
|
conn: Connection, licensor: Licensor, extension: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Validate and persist a Monetization Extension registration.
|
||||||
|
|
||||||
|
New registrations always start at `status: registered` (assigned here,
|
||||||
|
never accepted as an input field from the caller) — `canonical` is a
|
||||||
|
separate, governance-gated transition (TS-FR-2; see
|
||||||
|
`promote_extension_canonical` below).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validation.validate_extension_contract(extension)
|
||||||
|
except validation.ConformanceError as exc:
|
||||||
|
raise RegistrationError(
|
||||||
|
"extension rejected: " + "; ".join(exc.errors)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
extension_id = extension["id"]
|
||||||
|
version = extension["version"]
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO extensions (extension_id, version, licensor_id, contract, status)
|
||||||
|
VALUES (%s, %s, %s, %s, 'registered')
|
||||||
|
""",
|
||||||
|
(extension_id, version, licensor.licensor_id, Jsonb(extension)),
|
||||||
|
)
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
raise RegistrationError(
|
||||||
|
f"extension {extension_id!r}@{version!r} is already registered"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_extension(
|
||||||
|
conn: Connection, extension_id: str, version: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT contract, status FROM extensions WHERE extension_id = %s AND version = %s",
|
||||||
|
(extension_id, version),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
contract, status = row
|
||||||
|
return {**contract, "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
def promote_extension_canonical(
|
||||||
|
conn: Connection, extension_id: str, version: str, approved_by: str
|
||||||
|
) -> None:
|
||||||
|
"""Promote an extension from `registered` to `canonical`.
|
||||||
|
|
||||||
|
Calls the database's `set_extension_status` function rather than an
|
||||||
|
UPDATE, because the application role has no UPDATE grant on
|
||||||
|
`extensions` at all (migrations/0001_registries.sql) — canonicalization
|
||||||
|
is a documented human/governance action recorded with who performed it,
|
||||||
|
never a route ordinary application code can take by itself
|
||||||
|
(TSD §4.1: "never automated").
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"SELECT set_extension_status(%s, %s, %s, %s)",
|
||||||
|
(extension_id, version, "canonical", approved_by),
|
||||||
|
)
|
||||||
3
src/target_revenue/service/__init__.py
Normal file
3
src/target_revenue/service/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Hosted Trust Service (WP-0006). Thin ASGI layer over `target_revenue`'s
|
||||||
|
already-tested schema, validation, and registry logic — see ADR-0002 for
|
||||||
|
why this stays thin rather than reimplementing that logic."""
|
||||||
99
src/target_revenue/service/app.py
Normal file
99
src/target_revenue/service/app.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""FastAPI surface for the hosted Phase Registry and Extension Registry
|
||||||
|
(WP-0006-T03). Only registration and read endpoints live here — the Target
|
||||||
|
Ledger append API (T04), Metrics (T05), and Attestation (T06) are separate
|
||||||
|
components per `specs/TechnicalSpecificationDocument.md` §4.1 and are not
|
||||||
|
implemented in this module.
|
||||||
|
|
||||||
|
Every route delegates to `target_revenue.registry`; this file's only job is
|
||||||
|
HTTP framing (status codes, request/response shape) and reading the bearer
|
||||||
|
token, not conformance logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
|
from psycopg import Connection
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from .. import registry
|
||||||
|
|
||||||
|
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
|
||||||
|
|
||||||
|
_DATABASE_URL_ENV = "TRF_DATABASE_URL"
|
||||||
|
|
||||||
|
|
||||||
|
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_connection():
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_licensor(request: Request, conn: Connection = Depends(get_connection)) -> registry.Licensor:
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if not auth.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="missing bearer token")
|
||||||
|
token = auth.split(" ", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
return registry.authenticate(conn, token)
|
||||||
|
except registry.RegistrationError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/phases", status_code=201)
|
||||||
|
def register_phase(
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
licensor: registry.Licensor = Depends(get_licensor),
|
||||||
|
conn: Connection = Depends(get_connection),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
registry.register_phase_manifest(conn, licensor, manifest)
|
||||||
|
except registry.RegistrationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return {"phase_id": manifest["phase"]["id"], "status": "registered"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/phases/{phase_id:path}")
|
||||||
|
def read_phase(phase_id: str, conn: Connection = Depends(get_connection)) -> dict[str, Any]:
|
||||||
|
manifest = registry.get_phase_manifest(conn, phase_id)
|
||||||
|
if manifest is None:
|
||||||
|
raise HTTPException(status_code=404, detail="phase not found")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/extensions", status_code=201)
|
||||||
|
def register_extension(
|
||||||
|
extension: dict[str, Any],
|
||||||
|
licensor: registry.Licensor = Depends(get_licensor),
|
||||||
|
conn: Connection = Depends(get_connection),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
registry.register_extension(conn, licensor, extension)
|
||||||
|
except registry.RegistrationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return {
|
||||||
|
"extension_id": extension["id"],
|
||||||
|
"version": extension["version"],
|
||||||
|
"status": "registered",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/extensions/{extension_id}/{version}")
|
||||||
|
def read_extension(
|
||||||
|
extension_id: str, version: str, conn: Connection = Depends(get_connection)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = registry.get_extension(conn, extension_id, version)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="extension not found")
|
||||||
|
return result
|
||||||
181
tests/test_registry_hosting.py
Normal file
181
tests/test_registry_hosting.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""Integration tests for WP-0006-T03 (hosted Phase/Extension Registry).
|
||||||
|
|
||||||
|
Spins up an ephemeral, disposable PostgreSQL container via `docker run`
|
||||||
|
(never the shared `infra-postgres-1`/`custodian` instance used by the state
|
||||||
|
hub), applies `migrations/0001_registries.sql`, and exercises the hosted
|
||||||
|
API through FastAPI's TestClient. Skipped automatically if Docker is not
|
||||||
|
available, so the offline WP-0002 suite (`tests/test_*.py`) is unaffected.
|
||||||
|
|
||||||
|
Requires the `service-dev` extra: `pip install -e ".[service-dev]"`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
psycopg = pytest.importorskip("psycopg")
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from conftest import golden_extension, golden_manifest # noqa: E402
|
||||||
|
|
||||||
|
REPO_ROOT_MIGRATIONS = (
|
||||||
|
__import__("pathlib").Path(__file__).resolve().parents[1] / "migrations" / "0001_registries.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
shutil.which("docker") is None, reason="docker not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pg_container():
|
||||||
|
name = f"trf-test-pg-{uuid.uuid4().hex[:8]}"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker", "run", "--rm", "-d",
|
||||||
|
"--name", name,
|
||||||
|
"-e", "POSTGRES_PASSWORD=postgres",
|
||||||
|
"-e", "POSTGRES_DB=target_revenue_test",
|
||||||
|
"-p", "127.0.0.1::5432",
|
||||||
|
"postgres:16-alpine",
|
||||||
|
],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
port_out = subprocess.run(
|
||||||
|
["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True
|
||||||
|
).stdout.strip()
|
||||||
|
host_port = port_out.split(":")[-1]
|
||||||
|
dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres"
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
with psycopg.connect(dsn, connect_timeout=1):
|
||||||
|
break
|
||||||
|
except psycopg.OperationalError:
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("postgres container did not become ready in time")
|
||||||
|
|
||||||
|
with psycopg.connect(dsn) as conn:
|
||||||
|
conn.execute(REPO_ROOT_MIGRATIONS.read_text(encoding="utf-8"))
|
||||||
|
conn.commit()
|
||||||
|
token = "test-token-acme"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)",
|
||||||
|
(token, "acme-corp"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
app_dsn = (
|
||||||
|
f"host=127.0.0.1 port={host_port} dbname=target_revenue_test "
|
||||||
|
f"user=trf_app password=changeme-in-deployment"
|
||||||
|
)
|
||||||
|
yield {"admin_dsn": dsn, "app_dsn": app_dsn, "token": token}
|
||||||
|
finally:
|
||||||
|
subprocess.run(["docker", "stop", name], capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(pg_container, monkeypatch):
|
||||||
|
monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"])
|
||||||
|
from target_revenue.service import app as app_module
|
||||||
|
|
||||||
|
if hasattr(app_module.app.state, "pool"):
|
||||||
|
app_module.app.state.pool.close()
|
||||||
|
del app_module.app.state.pool
|
||||||
|
with TestClient(app_module.app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers(pg_container):
|
||||||
|
return {"Authorization": f"Bearer {pg_container['token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_and_read_golden_manifest(client, pg_container):
|
||||||
|
manifest = golden_manifest()
|
||||||
|
resp = client.post("/phases", json=manifest, headers=auth_headers(pg_container))
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["status"] == "registered"
|
||||||
|
|
||||||
|
read = client.get(f"/phases/{manifest['phase']['id']}")
|
||||||
|
assert read.status_code == 200
|
||||||
|
assert read.json() == manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_non_conformant_manifest(client, pg_container):
|
||||||
|
broken = golden_manifest()
|
||||||
|
del broken["phase"]["initial_target"]
|
||||||
|
resp = client.post("/phases", json=broken, headers=auth_headers(pg_container))
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "initial_target" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_reregister_same_phase_id(client, pg_container):
|
||||||
|
manifest = golden_manifest()
|
||||||
|
manifest["phase"]["id"] = manifest["phase"]["id"] + "-dup-test"
|
||||||
|
first = client.post("/phases", json=manifest, headers=auth_headers(pg_container))
|
||||||
|
assert first.status_code == 201
|
||||||
|
second = client.post("/phases", json=manifest, headers=auth_headers(pg_container))
|
||||||
|
assert second.status_code == 422
|
||||||
|
assert "already registered" in second.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_token_rejected(client):
|
||||||
|
manifest = golden_manifest()
|
||||||
|
resp = client.post(
|
||||||
|
"/phases", json=manifest, headers={"Authorization": "Bearer not-a-real-token"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_extension_and_promote_canonical(client, pg_container):
|
||||||
|
ext = golden_extension("development-license")
|
||||||
|
resp = client.post("/extensions", json=ext, headers=auth_headers(pg_container))
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["status"] == "registered"
|
||||||
|
|
||||||
|
read = client.get(f"/extensions/{ext['id']}/{ext['version']}")
|
||||||
|
assert read.json()["status"] == "registered"
|
||||||
|
|
||||||
|
import psycopg as _psycopg
|
||||||
|
from target_revenue import registry as registry_module
|
||||||
|
|
||||||
|
with _psycopg.connect(pg_container["admin_dsn"]) as admin_conn:
|
||||||
|
registry_module.promote_extension_canonical(
|
||||||
|
admin_conn, ext["id"], ext["version"], approved_by="Bernd"
|
||||||
|
)
|
||||||
|
admin_conn.commit()
|
||||||
|
|
||||||
|
read_after = client.get(f"/extensions/{ext['id']}/{ext['version']}")
|
||||||
|
assert read_after.json()["status"] == "canonical"
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_role_cannot_update_or_delete_settled_manifest(pg_container):
|
||||||
|
"""Database-level enforcement check (ADR-0002 compensating guardrail 3):
|
||||||
|
the trf_app role must have no UPDATE/DELETE grant on phase_manifests at
|
||||||
|
all, independent of what the API layer happens to expose."""
|
||||||
|
with psycopg.connect(pg_container["app_dsn"]) as conn:
|
||||||
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE phase_manifests SET manifest = manifest WHERE phase_id = 'nonexistent'"
|
||||||
|
)
|
||||||
|
conn.rollback()
|
||||||
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||||
|
conn.execute("DELETE FROM phase_manifests WHERE phase_id = 'nonexistent'")
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_role_cannot_update_extensions_directly(pg_container):
|
||||||
|
with psycopg.connect(pg_container["app_dsn"]) as conn:
|
||||||
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE extensions SET status = 'canonical' WHERE extension_id = 'nonexistent'"
|
||||||
|
)
|
||||||
|
conn.rollback()
|
||||||
|
|
@ -108,7 +108,7 @@ thin HTTP layer. T03–T08 may now build against this decision.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0006-T03
|
id: TREV-WP-0006-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fb9f00e5-5755-44e4-ad07-ba0dc73596b8"
|
state_hub_task_id: "fb9f00e5-5755-44e4-ad07-ba0dc73596b8"
|
||||||
```
|
```
|
||||||
|
|
@ -120,6 +120,29 @@ than one golden fixture. Reject non-conformant manifests/extensions at
|
||||||
registration, exactly as the Stage 0 validators already do — this task
|
registration, exactly as the Stage 0 validators already do — this task
|
||||||
adds multi-tenancy and persistence, not new validation logic.
|
adds multi-tenancy and persistence, not new validation logic.
|
||||||
|
|
||||||
|
**Result:** Implemented per ADR-0002. `migrations/0001_registries.sql`
|
||||||
|
creates `licensors`, `phase_manifests`, `extensions` tables, a
|
||||||
|
`trf_app` role with **no UPDATE/DELETE grant** on `phase_manifests` or
|
||||||
|
`extensions` (database-enforced append-only, not just application
|
||||||
|
convention), and a `set_extension_status()` SECURITY DEFINER function as
|
||||||
|
the only sanctioned path to `canonical`/`deprecated` — canonicalization is
|
||||||
|
therefore a recorded, attributable governance action the application role
|
||||||
|
cannot perform via ordinary UPDATE. `src/target_revenue/registry.py` wraps
|
||||||
|
the existing `validation.py` checks (no new validation logic) with
|
||||||
|
persistence and per-Licensor token authentication. `src/target_revenue/service/app.py`
|
||||||
|
exposes a thin FastAPI surface (`POST/GET /phases`, `POST/GET /extensions`).
|
||||||
|
Added a `service`/`service-dev` optional-dependency group
|
||||||
|
(`pyproject.toml`) so the core offline library keeps zero new hard
|
||||||
|
dependencies. `tests/test_registry_hosting.py` (7 tests, requires Docker,
|
||||||
|
auto-skips otherwise) spins an ephemeral, disposable Postgres container —
|
||||||
|
never the shared state-hub `infra-postgres-1`/`custodian` instance —
|
||||||
|
covering registration, rejection with diff, duplicate-`phase_id` rejection,
|
||||||
|
unknown-token rejection, extension canonicalization via the governance
|
||||||
|
function, and two explicit database-privilege tests proving `trf_app`
|
||||||
|
cannot UPDATE/DELETE `phase_manifests` or UPDATE `extensions` directly.
|
||||||
|
Original 36-test offline suite verified unchanged and passing with plain
|
||||||
|
system Python (no service deps required).
|
||||||
|
|
||||||
## Hosted Target Ledger append API
|
## Hosted Target Ledger append API
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue