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
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),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue