migrations/0005_licensor_credentials.sql: licensors can now hold multiple rows per licensor_id (credential_label, rights tier, issued_by, revoked_at). Real structural finding: licensor_id couldn't simply become non-unique, since phase_manifests, extensions, and breach_records all FK to licensors(licensor_id), which only worked because that column used to be unique. Introduced licensor_identities (one row per tenant) as the new FK target for all four tables, with an ensure_licensor_identity trigger auto-creating the identity on first credential insert - so existing code (including every earlier test fixture) needed no changes. registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS + has_right() ordinal helper (enforcement is Control Plane's job, T03/ T04, not this task's); issue_sub_credential/revoke_sub_credential (revocation via a SECURITY DEFINER function, matching set_extension_status's existing pattern - trf_app has no UPDATE grant on licensors); authenticate() rejects revoked credentials identically to unrecognized ones. Attribution scoped honestly: ledger_entry.schema.json stays unmodified (frozen Stage 0 surface, additionalProperties:false) - per-entry human attribution is a hosting-layer-only column (ledger_entries.submitted_by_token, ledger.get_ledger_attribution()), recorded alongside but never inside the signed entry payload. Narrower than "the signature names the human," but exactly the "(or an accompanying attributable field)" alternative this task's own description anticipated. All four Docker-gated test files that append Ledger entries needed migration 0005 added (append_entry's INSERT now references the new column). New tests/test_licensor_credentials.py (8 tests): multi- credential resolution, duplicate-label rejection, revocation and its idempotence, invalid-rights rejection, the has_right helper, per-entry attribution recorded and not leaking into exported ledger JSON, and DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up from 30); no stray containers left running.
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""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,
|
|
submitted_by_token)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
signed["id"],
|
|
phase_id,
|
|
Jsonb(signed),
|
|
previous_entry_hash,
|
|
signed["signature"],
|
|
signed["recognized_at"],
|
|
licensor.token,
|
|
),
|
|
)
|
|
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]
|
|
|
|
|
|
def get_ledger_attribution(conn: Connection, phase_id: str) -> list[dict[str, Any]]:
|
|
"""Return, per entry in append order, which credential submitted it
|
|
(WP-0009-T02: `licensors.credential_label`, joined via
|
|
`ledger_entries.submitted_by_token`). This is a hosting-layer-only
|
|
view — the credential label is never part of the signed entry payload
|
|
itself (`get_ledger`'s output), so an export of the raw Ledger remains
|
|
exactly as portable and schema-conformant as before this task."""
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT le.entry_id, li.credential_label, li.rights
|
|
FROM ledger_entries le
|
|
LEFT JOIN licensors li ON li.token = le.submitted_by_token
|
|
WHERE le.phase_id = %s
|
|
ORDER BY le.sequence ASC
|
|
""",
|
|
(phase_id,),
|
|
).fetchall()
|
|
return [
|
|
{"entry_id": entry_id, "credential_label": credential_label, "rights": rights}
|
|
for entry_id, credential_label, rights in rows
|
|
]
|