Implement hosted Target Ledger append API (WP-0006-T04)
migrations/0002_ledger.sql adds ledger_entries with an identity-column
sequence for exact append order and no UPDATE/DELETE grant for trf_app.
src/target_revenue/ledger.py: append_entry() rejects caller-supplied
previous_entry_hash/signature, enforces per-Licensor phase ownership,
serializes concurrent appends via pg_advisory_xact_lock, computes the
chain tip and signs with the Trust Service instance's own Ed25519 key
(service/keys.py), reusing validation.py's checks unchanged. Adds
POST/GET /phases/{id}/ledger and an unauthenticated GET /public-key.
Also fixes a route-ordering bug found while wiring this in: phase IDs
never needed the {phase_id:path} converter (they contain colons, not
slashes), and its greedy matching was swallowing /ledger-suffixed
paths into the plain GET /phases/{id} route.
tests/test_ledger_hosting.py (8 Docker-gated tests) exercises hash-chain
linkage, forged-field rejection, cross-Licensor isolation, currency and
duplicate-id rejection, DB-privilege enforcement, signature
verification via the public-key endpoint, and the task's own
highest-priority property: append -> export -> offline fold reproduces
the exact expected Development/Remission Credit and Outstanding Target.
This commit is contained in:
parent
a419178c08
commit
5064815e77
7 changed files with 567 additions and 12 deletions
132
src/target_revenue/ledger.py
Normal file
132
src/target_revenue/ledger.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Hosted Target Ledger append API (WP-0006-T04).
|
||||
|
||||
The highest-risk property this module owns: any party who independently
|
||||
computes `fold.fold_outstanding_target` over an exported Manifest + Ledger
|
||||
must get exactly the same Outstanding Target the hosted service itself
|
||||
would report. This module therefore does nothing clever — it authenticates
|
||||
the write, validates the entry with the same offline `validation.py`
|
||||
checks Stage 0 already tested, computes `previous_entry_hash` and
|
||||
`signature` itself (never trusting client-supplied values for either), and
|
||||
appends. The fold is never computed or stored here; `fold.py` remains the
|
||||
single source of truth, run fresh from whatever a caller exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from psycopg import Connection
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from . import hashing, validation
|
||||
from .registry import Licensor, RegistrationError, get_phase_manifest
|
||||
|
||||
|
||||
def append_entry(
|
||||
conn: Connection,
|
||||
licensor: Licensor,
|
||||
phase_id: str,
|
||||
entry_input: dict[str, Any],
|
||||
signing_key: Ed25519PrivateKey,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate, chain, sign, and persist one Target Ledger entry.
|
||||
|
||||
`entry_input` must not include `previous_entry_hash` or `signature` —
|
||||
those are exclusively server-computed. Supplying them is rejected
|
||||
outright rather than silently overwritten, so a caller never mistakes
|
||||
a value it sent for one the service actually used.
|
||||
"""
|
||||
if "previous_entry_hash" in entry_input or "signature" in entry_input:
|
||||
raise RegistrationError(
|
||||
"previous_entry_hash and signature are computed by the Trust "
|
||||
"Service instance and must not be supplied by the caller"
|
||||
)
|
||||
|
||||
manifest = get_phase_manifest(conn, phase_id)
|
||||
if manifest is None:
|
||||
raise RegistrationError(f"phase {phase_id!r} is not registered")
|
||||
|
||||
manifest_licensor = conn.execute(
|
||||
"SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", (phase_id,)
|
||||
).fetchone()[0]
|
||||
if manifest_licensor != licensor.licensor_id:
|
||||
raise RegistrationError(
|
||||
"this token is not authorized to append to this Phase's ledger"
|
||||
)
|
||||
|
||||
if entry_input.get("phase") != phase_id:
|
||||
raise RegistrationError(
|
||||
f"entry.phase {entry_input.get('phase')!r} does not match "
|
||||
f"the target Phase {phase_id!r}"
|
||||
)
|
||||
|
||||
# Serialize concurrent appends to this Phase so `previous_entry_hash`
|
||||
# always reflects a real, uncontested chain tip — a transaction-scoped
|
||||
# advisory lock, released automatically at commit/rollback.
|
||||
conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (phase_id,))
|
||||
|
||||
tip = conn.execute(
|
||||
"""
|
||||
SELECT entry FROM ledger_entries
|
||||
WHERE phase_id = %s
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(phase_id,),
|
||||
).fetchone()
|
||||
previous_entry_hash = hashing.entry_hash(tip[0]) if tip else hashing.GENESIS
|
||||
|
||||
candidate = {**entry_input, "previous_entry_hash": previous_entry_hash}
|
||||
|
||||
try:
|
||||
validation.validate_ledger_entry(candidate)
|
||||
except validation.ConformanceError as exc:
|
||||
raise RegistrationError(
|
||||
"ledger entry rejected: " + "; ".join(exc.errors)
|
||||
) from exc
|
||||
|
||||
currency_errors = validation.check_currency_consistency(manifest, [candidate])
|
||||
if currency_errors:
|
||||
raise RegistrationError(
|
||||
"ledger entry rejected: " + "; ".join(currency_errors)
|
||||
)
|
||||
|
||||
signed = {**candidate, "signature": hashing.sign_record(candidate, signing_key)}
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ledger_entries
|
||||
(entry_id, phase_id, entry, previous_entry_hash, signature, recognized_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
signed["id"],
|
||||
phase_id,
|
||||
Jsonb(signed),
|
||||
previous_entry_hash,
|
||||
signed["signature"],
|
||||
signed["recognized_at"],
|
||||
),
|
||||
)
|
||||
except UniqueViolation as exc:
|
||||
raise RegistrationError(
|
||||
f"entry id {signed['id']!r} already exists"
|
||||
) from exc
|
||||
|
||||
return signed
|
||||
|
||||
|
||||
def get_ledger(conn: Connection, phase_id: str) -> list[dict[str, Any]]:
|
||||
"""Return a Phase's entries in exact append order (fold input order)."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT entry FROM ledger_entries
|
||||
WHERE phase_id = %s
|
||||
ORDER BY sequence ASC
|
||||
""",
|
||||
(phase_id,),
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
"""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.
|
||||
"""FastAPI surface for the hosted Phase Registry, Extension Registry
|
||||
(WP-0006-T03), and Target Ledger append API (WP-0006-T04). Metrics (T05)
|
||||
and Conversion 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.
|
||||
Every route delegates to `target_revenue.registry` / `target_revenue.ledger`;
|
||||
this file's only job is HTTP framing (status codes, request/response shape,
|
||||
bearer-token extraction), not conformance or chaining logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -18,7 +18,8 @@ from fastapi import Depends, FastAPI, HTTPException, Request
|
|||
from psycopg import Connection
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .. import registry
|
||||
from .. import ledger, registry
|
||||
from . import keys
|
||||
|
||||
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
|
||||
|
||||
|
|
@ -34,6 +35,12 @@ def get_pool() -> ConnectionPool:
|
|||
return app.state.pool
|
||||
|
||||
|
||||
def get_signing_key():
|
||||
if not hasattr(app.state, "signing_key"):
|
||||
app.state.signing_key = keys.load_signing_key()
|
||||
return app.state.signing_key
|
||||
|
||||
|
||||
def get_connection():
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
|
|
@ -64,7 +71,7 @@ def register_phase(
|
|||
return {"phase_id": manifest["phase"]["id"], "status": "registered"}
|
||||
|
||||
|
||||
@app.get("/phases/{phase_id:path}")
|
||||
@app.get("/phases/{phase_id}")
|
||||
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:
|
||||
|
|
@ -97,3 +104,39 @@ def read_extension(
|
|||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="extension not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/public-key")
|
||||
def read_public_key() -> dict[str, str]:
|
||||
"""The Ed25519 public key ledger entry signatures verify against.
|
||||
|
||||
Deliberately unauthenticated: an external verifier must be able to
|
||||
check a signature without trusting anything about this API's own
|
||||
access control (TrustServicePRD §3 point 2, offline verifiability).
|
||||
"""
|
||||
return {"algorithm": "ed25519", "public_key_hex": keys.public_key_hex(get_signing_key())}
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/ledger", status_code=201)
|
||||
def append_ledger_entry(
|
||||
phase_id: str,
|
||||
entry: dict[str, Any],
|
||||
licensor: registry.Licensor = Depends(get_licensor),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return ledger.append_entry(conn, licensor, phase_id, entry, signing_key)
|
||||
except registry.RegistrationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/phases/{phase_id}/ledger")
|
||||
def read_ledger(
|
||||
phase_id: str, conn: Connection = Depends(get_connection)
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Public: ledger facts are public per FR-10 (aggregate figures,
|
||||
confidential evidence *references* — not the evidence itself)."""
|
||||
if registry.get_phase_manifest(conn, phase_id) is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
return ledger.get_ledger(conn, phase_id)
|
||||
|
|
|
|||
42
src/target_revenue/service/keys.py
Normal file
42
src/target_revenue/service/keys.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Trust Service instance signing key.
|
||||
|
||||
Per ADR-0002: the Ed25519 signature over each Ledger entry is a guarantee
|
||||
independent of the per-Licensor API token — it is what an external party
|
||||
verifies without trusting the token/access-control layer at all (TSD §3.2,
|
||||
TrustServicePRD §3 point 2). This module loads that instance key from
|
||||
`TRF_SIGNING_KEY_HEX` (a 32-byte hex-encoded Ed25519 seed) or, if unset,
|
||||
generates an ephemeral one for local development/testing only — an
|
||||
ephemeral key means every restart invalidates prior signatures' verifiable
|
||||
association with "this instance," which is fine for a dev loop and not
|
||||
acceptable for any real deployment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
|
||||
_ENV_VAR = "TRF_SIGNING_KEY_HEX"
|
||||
|
||||
|
||||
def load_signing_key() -> Ed25519PrivateKey:
|
||||
hex_seed = os.environ.get(_ENV_VAR)
|
||||
if hex_seed:
|
||||
return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(hex_seed))
|
||||
warnings.warn(
|
||||
f"{_ENV_VAR} not set — generating an ephemeral signing key. "
|
||||
"Do not use this in any deployment where signatures must remain "
|
||||
"verifiable across restarts.",
|
||||
stacklevel=2,
|
||||
)
|
||||
return Ed25519PrivateKey.generate()
|
||||
|
||||
|
||||
def public_key_hex(private_key: Ed25519PrivateKey) -> str:
|
||||
public_key: Ed25519PublicKey = private_key.public_key()
|
||||
return public_key.public_bytes_raw().hex()
|
||||
Loading…
Add table
Add a link
Reference in a new issue