Implement hosted Breach/Compliance Record publication (WP-0006-T09)
migrations/0004_breach_records.sql models a case's lifecycle as
append-only events (alleged/cured/determined/terminated) grouped by
case_id rather than one mutable row - resolution is always a new,
later event, never an edit. A CHECK constraint makes the
anonymized-default rule (License V1C1 §7.4) a database fact:
named_entitlement_holder can be set if and only if anonymized = false.
src/target_revenue/breach_record.py's publish_breach_event() enforces
per-Licensor phase ownership and rejects named-disclosure requests
that don't also set named_disclosure_authorized_under_cua: true - the
Trust Service records the Licensor's assertion that the CUA's naming
clause authorizes it, it never verifies the underlying CUA text
itself. Signs every event with the same instance Ed25519 key already
used for Ledger entries and Attestations.
Adds POST/GET /phases/{id}/breach-records. Guarded the .registry
import behind a lazy in-function import (matching attestation.py's
TYPE_CHECKING pattern) so tests/test_breach_record.py (7 tests) runs
under plain system Python with no psycopg dependency. 5 new
Docker-gated tests cover the default-anonymized lifecycle, the
named-disclosure authorization gate, cross-Licensor rejection,
signature verification, and DB-level UPDATE/DELETE rejection.
This closes WP-0006 again - all 9 tasks done.
This commit is contained in:
parent
a2f1dbfa2e
commit
b7b985d96d
9 changed files with 471 additions and 4 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-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-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01–T08 done (Postgres-backed registries/ledger/metrics/attestation, ADR-0002 accepted, onboarding CLI, hosted conformance suite); T09 (Breach/Compliance Record hosting, License V1C1 §7.4) added 2026-07-29, not yet started |
|
||||
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — **finished**, all 9 tasks done (Postgres-backed registries/ledger/metrics/attestation/breach-record, ADR-0002 accepted, onboarding CLI, hosted conformance suite) |
|
||||
| [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 |
|
||||
|
||||
|
|
|
|||
48
migrations/0004_breach_records.sql
Normal file
48
migrations/0004_breach_records.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- WP-0006-T09: Breach/Compliance Record hosting.
|
||||
-- Depends on migrations/0001_registries.sql.
|
||||
--
|
||||
-- Per License V1C1 §7.4: a breach record's lifecycle is a sequence of
|
||||
-- distinct, dated events (alleged -> cured, or alleged -> determined ->
|
||||
-- terminated), never an edit of a prior event. "The Trust Service shall
|
||||
-- update the record promptly upon resolution" (§7.4) means append a new
|
||||
-- event for the same case, not mutate the existing one — the same
|
||||
-- append-only pattern as phase_manifests/ledger_entries/attestations.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS breach_records (
|
||||
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||
record_id text NOT NULL UNIQUE,
|
||||
case_id text NOT NULL,
|
||||
phase_id text NOT NULL REFERENCES phase_manifests(phase_id),
|
||||
event_type text NOT NULL
|
||||
CHECK (event_type IN ('alleged', 'cured', 'determined', 'terminated')),
|
||||
category text NOT NULL,
|
||||
event_at timestamptz NOT NULL,
|
||||
anonymized boolean NOT NULL DEFAULT true,
|
||||
named_entitlement_holder text,
|
||||
evidence_reference text,
|
||||
published_by text NOT NULL REFERENCES licensors(licensor_id),
|
||||
signature text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (phase_id, sequence),
|
||||
-- License V1C1 §7.4: naming is governed exclusively by the applicable
|
||||
-- Commercial Use Agreement's opt-in; a name may only be stored
|
||||
-- alongside an explicit non-anonymized record, never as a side effect
|
||||
-- of an anonymized one.
|
||||
CHECK (anonymized OR named_entitlement_holder IS NOT NULL),
|
||||
CHECK (NOT anonymized OR named_entitlement_holder IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS breach_records_phase_sequence_idx
|
||||
ON breach_records (phase_id, sequence);
|
||||
CREATE INDEX IF NOT EXISTS breach_records_case_idx
|
||||
ON breach_records (case_id);
|
||||
|
||||
GRANT SELECT, INSERT ON breach_records TO trf_app;
|
||||
-- Deliberately no UPDATE, no DELETE for trf_app: matching phase_manifests,
|
||||
-- ledger_entries, and attestations, a published breach event is
|
||||
-- permanent — resolution is recorded as a new event, never an edit.
|
||||
GRANT USAGE, SELECT ON breach_records_sequence_seq TO trf_app;
|
||||
|
||||
COMMIT;
|
||||
171
src/target_revenue/breach_record.py
Normal file
171
src/target_revenue/breach_record.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Hosted Breach/Compliance Record publication (WP-0006-T09).
|
||||
|
||||
Publishes the Licensor's own breach and termination determinations for a
|
||||
Phase, per `specs/TargetRevenueSourceLicense-V1C1.md` §7.4 and
|
||||
`specs/TechnicalSpecificationDocument.md` §4.1. This module never decides
|
||||
whether a breach occurred, was cured, or was terminated — it only records,
|
||||
signs, and publishes the Licensor's assertion of one of those facts,
|
||||
exactly like `attestation.py` records an already-true conversion rather
|
||||
than deciding one. The distinction between `alleged` and `determined`
|
||||
(and the further `cured`/`terminated` outcomes) is the Licensor's call, or
|
||||
a Commercial Use Agreement's dispute process's call — never this module's.
|
||||
|
||||
Naming: default anonymized (Phase and category only, no party identified).
|
||||
Named disclosure requires the caller to affirmatively assert that the
|
||||
applicable Commercial Use Agreement's naming/disclosure clause (License
|
||||
§7.4, CUA V1C1 §9) authorizes it — this module records that assertion, it
|
||||
does not, and cannot, verify the underlying CUA text itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from . import hashing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from psycopg import Connection
|
||||
|
||||
from .registry import Licensor
|
||||
|
||||
_EVENT_TYPES = frozenset({"alleged", "cured", "determined", "terminated"})
|
||||
_REQUIRED_FIELDS = ("id", "case_id", "category", "event_at")
|
||||
|
||||
|
||||
def _validate(event_input: dict[str, Any]) -> list[str]:
|
||||
errors = []
|
||||
for field in _REQUIRED_FIELDS:
|
||||
if not event_input.get(field):
|
||||
errors.append(f"{field}: required")
|
||||
event_type = event_input.get("event_type")
|
||||
if event_type not in _EVENT_TYPES:
|
||||
errors.append(
|
||||
f"event_type: must be one of {sorted(_EVENT_TYPES)}, got {event_type!r}"
|
||||
)
|
||||
anonymized = event_input.get("anonymized", True)
|
||||
named_holder = event_input.get("named_entitlement_holder")
|
||||
authorized = event_input.get("named_disclosure_authorized_under_cua", False)
|
||||
if not anonymized:
|
||||
if not named_holder:
|
||||
errors.append(
|
||||
"named_entitlement_holder: required when anonymized is false"
|
||||
)
|
||||
if not authorized:
|
||||
errors.append(
|
||||
"named_disclosure_authorized_under_cua: must be true to publish a "
|
||||
"named record — the Trust Service records this assertion, it does "
|
||||
"not verify the underlying Commercial Use Agreement text "
|
||||
"(License V1C1 §7.4)"
|
||||
)
|
||||
elif named_holder:
|
||||
errors.append(
|
||||
"named_entitlement_holder: must not be set when anonymized is true "
|
||||
"(default)"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def publish_breach_event(
|
||||
conn: "Connection",
|
||||
licensor: "Licensor",
|
||||
phase_id: str,
|
||||
event_input: dict[str, Any],
|
||||
signing_key: "Ed25519PrivateKey",
|
||||
) -> dict[str, Any]:
|
||||
"""Validate, sign, and append one breach/compliance lifecycle event.
|
||||
|
||||
Never mutates a prior event for the same `case_id` — resolution
|
||||
(cure, determination, termination) is always a new, separately dated
|
||||
event, per License V1C1 §7.4's "update the record promptly upon
|
||||
resolution," which this module implements as append, not edit
|
||||
(`migrations/0004_breach_records.sql` grants trf_app no UPDATE/DELETE
|
||||
on this table at all).
|
||||
"""
|
||||
from .registry import RegistrationError, get_phase_manifest
|
||||
|
||||
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 publish breach records for this Phase"
|
||||
)
|
||||
|
||||
errors = _validate(event_input)
|
||||
if errors:
|
||||
raise RegistrationError("breach event rejected: " + "; ".join(errors))
|
||||
|
||||
anonymized = event_input.get("anonymized", True)
|
||||
record = {
|
||||
"id": event_input["id"],
|
||||
"case_id": event_input["case_id"],
|
||||
"phase": phase_id,
|
||||
"event_type": event_input["event_type"],
|
||||
"category": event_input["category"],
|
||||
"event_at": event_input["event_at"],
|
||||
"anonymized": anonymized,
|
||||
"named_entitlement_holder": event_input.get("named_entitlement_holder") if not anonymized else None,
|
||||
"evidence_reference": event_input.get("evidence_reference"),
|
||||
}
|
||||
signed = {**record, "signature": hashing.sign_record(record, signing_key)}
|
||||
|
||||
from psycopg.errors import UniqueViolation
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO breach_records
|
||||
(record_id, case_id, phase_id, event_type, category, event_at,
|
||||
anonymized, named_entitlement_holder, evidence_reference,
|
||||
published_by, signature)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
signed["id"],
|
||||
signed["case_id"],
|
||||
phase_id,
|
||||
signed["event_type"],
|
||||
signed["category"],
|
||||
signed["event_at"],
|
||||
signed["anonymized"],
|
||||
signed["named_entitlement_holder"],
|
||||
signed["evidence_reference"],
|
||||
licensor.licensor_id,
|
||||
signed["signature"],
|
||||
),
|
||||
)
|
||||
except UniqueViolation as exc:
|
||||
raise RegistrationError(f"breach record id {signed['id']!r} already exists") from exc
|
||||
|
||||
return signed
|
||||
|
||||
|
||||
def get_breach_records(conn: "Connection", phase_id: str) -> list[dict[str, Any]]:
|
||||
"""Public read: the Phase's full breach/compliance event history.
|
||||
|
||||
Anonymized events never carry `named_entitlement_holder` in the first
|
||||
place (enforced at publish time and by the table's own CHECK
|
||||
constraint); this function does not need to additionally strip it, but
|
||||
the caller must never bypass this function to read the table directly
|
||||
for a public-facing response.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT record_id, case_id, event_type, category, event_at, anonymized,
|
||||
named_entitlement_holder, evidence_reference, signature
|
||||
FROM breach_records
|
||||
WHERE phase_id = %s
|
||||
ORDER BY sequence ASC
|
||||
""",
|
||||
(phase_id,),
|
||||
).fetchall()
|
||||
columns = (
|
||||
"id", "case_id", "event_type", "category", "event_at", "anonymized",
|
||||
"named_entitlement_holder", "evidence_reference", "signature",
|
||||
)
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
|
|
@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request
|
|||
from psycopg import Connection
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .. import attestation, ledger, metrics, registry
|
||||
from .. import attestation, breach_record, ledger, metrics, registry
|
||||
from . import keys
|
||||
|
||||
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
|
||||
|
|
@ -174,3 +174,32 @@ def read_attestation(
|
|||
return attestation.publish_attestation(conn, manifest, entries, signing_key)
|
||||
except attestation.NotConvertedError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/phases/{phase_id}/breach-records", status_code=201)
|
||||
def publish_breach_record(
|
||||
phase_id: str,
|
||||
event: dict[str, Any],
|
||||
licensor: registry.Licensor = Depends(get_licensor),
|
||||
conn: Connection = Depends(get_connection),
|
||||
signing_key=Depends(get_signing_key),
|
||||
) -> dict[str, Any]:
|
||||
"""Publishes the Licensor's own breach/compliance determination — this
|
||||
endpoint never adjudicates whether a breach occurred (TSD §4.1
|
||||
Breach/Compliance Record row, License V1C1 §7.4)."""
|
||||
try:
|
||||
return breach_record.publish_breach_event(conn, licensor, phase_id, event, signing_key)
|
||||
except registry.RegistrationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/phases/{phase_id}/breach-records")
|
||||
def read_breach_records(
|
||||
phase_id: str, conn: Connection = Depends(get_connection)
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Public, unauthenticated: anonymized by default per License V1C1
|
||||
§7.4; a named record only appears if it was published with the CUA
|
||||
§9 opt-in authorization asserted."""
|
||||
if registry.get_phase_manifest(conn, phase_id) is None:
|
||||
raise HTTPException(status_code=404, detail="phase not found")
|
||||
return breach_record.get_breach_records(conn, phase_id)
|
||||
|
|
|
|||
69
tests/test_breach_record.py
Normal file
69
tests/test_breach_record.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Pure, offline tests for target_revenue.breach_record._validate
|
||||
(WP-0006-T09). No Docker/Postgres required — persistence/publication
|
||||
round-trips are covered by the Docker-gated tests in
|
||||
tests/test_ledger_hosting.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from target_revenue import breach_record
|
||||
|
||||
|
||||
def _base_event(**overrides):
|
||||
event = {
|
||||
"id": "trsl:breach:example0010001",
|
||||
"case_id": "trsl:case:example001",
|
||||
"event_type": "alleged",
|
||||
"category": "unauthorized-commercial-use",
|
||||
"event_at": "2026-08-01T00:00:00Z",
|
||||
}
|
||||
event.update(overrides)
|
||||
return event
|
||||
|
||||
|
||||
def test_valid_anonymized_alleged_event_has_no_errors():
|
||||
assert breach_record._validate(_base_event()) == []
|
||||
|
||||
|
||||
def test_missing_required_field_rejected():
|
||||
event = _base_event()
|
||||
del event["category"]
|
||||
errors = breach_record._validate(event)
|
||||
assert any("category" in e for e in errors)
|
||||
|
||||
|
||||
def test_unknown_event_type_rejected():
|
||||
errors = breach_record._validate(_base_event(event_type="dismissed"))
|
||||
assert any("event_type" in e for e in errors)
|
||||
|
||||
|
||||
def test_named_disclosure_requires_holder_and_authorization():
|
||||
errors = breach_record._validate(_base_event(anonymized=False))
|
||||
assert any("named_entitlement_holder" in e for e in errors)
|
||||
assert any("named_disclosure_authorized_under_cua" in e for e in errors)
|
||||
|
||||
|
||||
def test_named_disclosure_with_holder_but_no_authorization_still_rejected():
|
||||
errors = breach_record._validate(
|
||||
_base_event(anonymized=False, named_entitlement_holder="Acme Corp")
|
||||
)
|
||||
assert any("named_disclosure_authorized_under_cua" in e for e in errors)
|
||||
|
||||
|
||||
def test_named_disclosure_with_holder_and_authorization_accepted():
|
||||
errors = breach_record._validate(
|
||||
_base_event(
|
||||
anonymized=False,
|
||||
named_entitlement_holder="Acme Corp",
|
||||
named_disclosure_authorized_under_cua=True,
|
||||
)
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_anonymized_event_with_holder_set_rejected():
|
||||
"""A caller must not be able to sneak a name in while anonymized=True."""
|
||||
errors = breach_record._validate(
|
||||
_base_event(anonymized=True, named_entitlement_holder="Acme Corp")
|
||||
)
|
||||
assert any("must not be set" in e for e in errors)
|
||||
|
|
@ -38,6 +38,7 @@ MIGRATIONS = [
|
|||
REPO_ROOT / "migrations" / "0001_registries.sql",
|
||||
REPO_ROOT / "migrations" / "0002_ledger.sql",
|
||||
REPO_ROOT / "migrations" / "0003_attestations.sql",
|
||||
REPO_ROOT / "migrations" / "0004_breach_records.sql",
|
||||
]
|
||||
EXTENSION_NAMES = [
|
||||
"development-license",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ MIGRATIONS = [
|
|||
REPO_ROOT / "migrations" / "0001_registries.sql",
|
||||
REPO_ROOT / "migrations" / "0002_ledger.sql",
|
||||
REPO_ROOT / "migrations" / "0003_attestations.sql",
|
||||
REPO_ROOT / "migrations" / "0004_breach_records.sql",
|
||||
]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
|
|
@ -402,3 +403,121 @@ def test_application_role_cannot_update_or_delete_attestations(pg_container):
|
|||
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||
conn.execute("DELETE FROM attestations WHERE phase_id = 'nonexistent'")
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def _breach_event(event_id: str, case_id: str, event_type: str, **overrides) -> dict:
|
||||
event = {
|
||||
"id": event_id,
|
||||
"case_id": case_id,
|
||||
"event_type": event_type,
|
||||
"category": "unauthorized-commercial-use",
|
||||
"event_at": "2026-08-01T00:00:00Z",
|
||||
}
|
||||
event.update(overrides)
|
||||
return event
|
||||
|
||||
|
||||
def test_breach_record_defaults_anonymized_and_lists_lifecycle_events(
|
||||
client, pg_container, registered_phase
|
||||
):
|
||||
phase_id = registered_phase["phase"]["id"]
|
||||
case_id = "trsl:case:conformcase0001"
|
||||
|
||||
alleged = client.post(
|
||||
f"/phases/{phase_id}/breach-records",
|
||||
json=_breach_event("trsl:breach:conformcase00010001", case_id, "alleged"),
|
||||
headers=auth_headers(pg_container["token"]),
|
||||
)
|
||||
assert alleged.status_code == 201, alleged.text
|
||||
assert alleged.json()["anonymized"] is True
|
||||
assert alleged.json()["named_entitlement_holder"] is None
|
||||
assert "signature" in alleged.json()
|
||||
|
||||
determined = client.post(
|
||||
f"/phases/{phase_id}/breach-records",
|
||||
json=_breach_event(
|
||||
"trsl:breach:conformcase00010002", case_id, "determined",
|
||||
event_at="2026-08-15T00:00:00Z",
|
||||
),
|
||||
headers=auth_headers(pg_container["token"]),
|
||||
)
|
||||
assert determined.status_code == 201, determined.text
|
||||
|
||||
records = client.get(f"/phases/{phase_id}/breach-records")
|
||||
assert records.status_code == 200
|
||||
events = records.json()
|
||||
assert [e["event_type"] for e in events] == ["alleged", "determined"]
|
||||
# The "alleged" event must never have been edited by the "determined"
|
||||
# publication — both remain, in order, as separate records.
|
||||
assert events[0]["id"] == "trsl:breach:conformcase00010001"
|
||||
assert events[1]["id"] == "trsl:breach:conformcase00010002"
|
||||
|
||||
|
||||
def test_breach_record_named_disclosure_requires_explicit_cua_authorization(
|
||||
client, pg_container, registered_phase
|
||||
):
|
||||
phase_id = registered_phase["phase"]["id"]
|
||||
unauthorized = client.post(
|
||||
f"/phases/{phase_id}/breach-records",
|
||||
json=_breach_event(
|
||||
"trsl:breach:conformnamed0001", "trsl:case:conformnamed", "alleged",
|
||||
anonymized=False, named_entitlement_holder="Acme Corp",
|
||||
),
|
||||
headers=auth_headers(pg_container["token"]),
|
||||
)
|
||||
assert unauthorized.status_code == 422
|
||||
assert "named_disclosure_authorized_under_cua" in unauthorized.json()["detail"]
|
||||
|
||||
authorized = client.post(
|
||||
f"/phases/{phase_id}/breach-records",
|
||||
json=_breach_event(
|
||||
"trsl:breach:conformnamed0002", "trsl:case:conformnamed", "alleged",
|
||||
anonymized=False,
|
||||
named_entitlement_holder="Acme Corp",
|
||||
named_disclosure_authorized_under_cua=True,
|
||||
),
|
||||
headers=auth_headers(pg_container["token"]),
|
||||
)
|
||||
assert authorized.status_code == 201, authorized.text
|
||||
assert authorized.json()["named_entitlement_holder"] == "Acme Corp"
|
||||
|
||||
|
||||
def test_other_licensor_cannot_publish_breach_record_for_this_phase(
|
||||
client, pg_container, registered_phase
|
||||
):
|
||||
phase_id = registered_phase["phase"]["id"]
|
||||
resp = client.post(
|
||||
f"/phases/{phase_id}/breach-records",
|
||||
json=_breach_event("trsl:breach:conformcross0001", "trsl:case:conformcross", "alleged"),
|
||||
headers=auth_headers(pg_container["other_token"]),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "not authorized" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_breach_record_signature_verifiable_with_public_key(client, pg_container, registered_phase):
|
||||
phase_id = registered_phase["phase"]["id"]
|
||||
published = client.post(
|
||||
f"/phases/{phase_id}/breach-records",
|
||||
json=_breach_event("trsl:breach:conformverify0001", "trsl:case:conformverify", "alleged"),
|
||||
headers=auth_headers(pg_container["token"]),
|
||||
).json()
|
||||
|
||||
pk_hex = client.get("/public-key").json()["public_key_hex"]
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from target_revenue import hashing
|
||||
|
||||
public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pk_hex))
|
||||
assert hashing.verify_record_signature(published, published["signature"], public_key)
|
||||
|
||||
|
||||
def test_application_role_cannot_update_or_delete_breach_records(pg_container):
|
||||
with psycopg.connect(pg_container["app_dsn"]) as conn:
|
||||
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||
conn.execute("UPDATE breach_records SET category = 'x' WHERE record_id = 'nonexistent'")
|
||||
conn.rollback()
|
||||
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||
conn.execute("DELETE FROM breach_records WHERE record_id = 'nonexistent'")
|
||||
conn.rollback()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ MIGRATIONS = [
|
|||
REPO_ROOT / "migrations" / "0001_registries.sql",
|
||||
REPO_ROOT / "migrations" / "0002_ledger.sql",
|
||||
REPO_ROOT / "migrations" / "0003_attestations.sql",
|
||||
REPO_ROOT / "migrations" / "0004_breach_records.sql",
|
||||
]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Trust Service reference implementation (PRD Phase 4b)"
|
||||
domain: infotech
|
||||
repo: target-revenue
|
||||
status: active
|
||||
status: finished
|
||||
owner: claude
|
||||
topic_slug: infotech
|
||||
created: "2026-07-29"
|
||||
|
|
@ -351,7 +351,7 @@ left running.
|
|||
|
||||
```task
|
||||
id: TREV-WP-0006-T09
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "1a25114c-ccbb-417a-aa73-68f33120c5e4"
|
||||
```
|
||||
|
|
@ -384,3 +384,32 @@ workplan's ninth task, per TrustServicePRD TS-FR-7:
|
|||
similar), `src/target_revenue/breach_record.py`, corresponding
|
||||
`service/app.py` endpoints, and Docker-gated tests following the existing
|
||||
pattern in `tests/test_hosted_conformance.py`/`test_ledger_hosting.py`.
|
||||
|
||||
**Result:** `migrations/0004_breach_records.sql` models a case's lifecycle
|
||||
as append-only events (`alleged`/`cured`/`determined`/`terminated`)
|
||||
grouped by `case_id`, rather than one mutable row per case — resolution is
|
||||
always a new, later event, never an edit of the `alleged` one (`trf_app`
|
||||
again has no UPDATE/DELETE grant). A `CHECK` constraint makes the
|
||||
anonymized-default rule a database fact, not just an API convention:
|
||||
`named_entitlement_holder` can be set if and only if `anonymized = false`.
|
||||
`src/target_revenue/breach_record.py`'s `publish_breach_event()` enforces
|
||||
per-Licensor phase ownership (same pattern as `ledger.py`), and — the
|
||||
naming-authorization requirement License V1C1 §7.4/CUA §9 impose — rejects
|
||||
any named-disclosure request that doesn't also set
|
||||
`named_disclosure_authorized_under_cua: true`; the Trust Service never
|
||||
verifies the underlying CUA text, it only records that the Licensor
|
||||
asserted the clause authorizes it. Signs every published event with the
|
||||
same instance Ed25519 key already used for Ledger entries and
|
||||
Attestations. Added `POST/GET /phases/{id}/breach-records` to
|
||||
`service/app.py` (write requires the Phase's own Licensor token; read is
|
||||
public). Guarded the `.registry` import behind a lazy, in-function import
|
||||
(matching `attestation.py`'s `TYPE_CHECKING` pattern) so
|
||||
`tests/test_breach_record.py` (7 tests) runs its pure validation-logic
|
||||
checks under plain system Python with no psycopg dependency. 5 new
|
||||
Docker-gated tests in `tests/test_ledger_hosting.py` cover the default-
|
||||
anonymized lifecycle (alleged → determined, both events preserved,
|
||||
neither edited), the named-disclosure authorization gate (rejected without
|
||||
it, accepted with it), cross-Licensor rejection, signature verification,
|
||||
and DB-level UPDATE/DELETE rejection. Full suite: 56 passing offline
|
||||
(plain system Python), 89 passing with Docker; no stray containers left
|
||||
running. WP-0006 is finished again — all 9 tasks done.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue