diff --git a/src/egressweave/decision_evidence.py b/src/egressweave/decision_evidence.py index b6edddb..198063c 100644 --- a/src/egressweave/decision_evidence.py +++ b/src/egressweave/decision_evidence.py @@ -91,14 +91,15 @@ def _policy_fingerprint(policy: EgressPolicy) -> str: return _sha256_canonical_json(payload) -@dataclass(frozen=True) +@dataclass(frozen=True, init=False) class EgressDecisionEvidence: """Immutable audit evidence for one successfully authorized authority. - The record intentionally omits the request path and every resolved address. - Fingerprints are deterministic correlation values, not signatures or message - authentication codes, and must not be treated as proof against a process - that can execute arbitrary Python code. + The supported issuance path is :func:`build_egress_decision_evidence`, which + revalidates signed URL state before creating a record. The record deliberately + omits the request path and every resolved address. Fingerprints are deterministic + correlation values, not signatures or message authentication codes, and must not + be treated as proof against a process that can execute arbitrary Python code. """ schema_version: str @@ -110,6 +111,13 @@ class EgressDecisionEvidence: policy_fingerprint: str decision_fingerprint: str + def __init__(self, *args: object, **kwargs: object) -> None: + """Reject every direct construction shape in favor of the validating builder.""" + del args, kwargs + raise TypeError( + "EgressDecisionEvidence objects must come from the evidence builder" + ) + def as_dict(self) -> dict[str, object]: """Return a detached JSON-compatible representation of this evidence.""" return { @@ -124,6 +132,30 @@ def as_dict(self) -> dict[str, object]: } +def _make_egress_decision_evidence( + *, + schema_version: str, + authority: str, + allowed_methods: tuple[str, ...], + address_count: int, + ipv4_address_count: int, + ipv6_address_count: int, + policy_fingerprint: str, + decision_fingerprint: str, +) -> EgressDecisionEvidence: + """Create one record only after the public builder completes revalidation.""" + evidence = object.__new__(EgressDecisionEvidence) + object.__setattr__(evidence, "schema_version", schema_version) + object.__setattr__(evidence, "authority", authority) + object.__setattr__(evidence, "allowed_methods", allowed_methods) + object.__setattr__(evidence, "address_count", address_count) + object.__setattr__(evidence, "ipv4_address_count", ipv4_address_count) + object.__setattr__(evidence, "ipv6_address_count", ipv6_address_count) + object.__setattr__(evidence, "policy_fingerprint", policy_fingerprint) + object.__setattr__(evidence, "decision_fingerprint", decision_fingerprint) + return evidence + + def build_egress_decision_evidence( validated: ValidatedEgressURL, *, @@ -151,7 +183,7 @@ def build_egress_decision_evidence( "policy_fingerprint": policy_digest, } decision_digest = _sha256_canonical_json(evidence_payload) - return EgressDecisionEvidence( + return _make_egress_decision_evidence( schema_version=DECISION_EVIDENCE_SCHEMA_VERSION, authority=str(evidence_payload["authority"]), allowed_methods=tuple(sorted(policy.allowed_methods)), diff --git a/tests/test_decision_evidence_factory_only.py b/tests/test_decision_evidence_factory_only.py new file mode 100644 index 0000000..940ed38 --- /dev/null +++ b/tests/test_decision_evidence_factory_only.py @@ -0,0 +1,32 @@ +"""Regression tests for builder-issued decision evidence.""" + +from __future__ import annotations + +import pytest + +from egressweave import EgressDecisionEvidence + +_DIRECT_CONSTRUCTION_ERROR = ( + "EgressDecisionEvidence objects must come from the evidence builder" +) + + +def test_decision_evidence_rejects_direct_public_construction() -> None: + """Require decision evidence to come from the validating builder.""" + with pytest.raises(TypeError, match=_DIRECT_CONSTRUCTION_ERROR): + EgressDecisionEvidence( + schema_version="egressweave.decision-evidence.v1", + authority="api.example.com:443", + allowed_methods=("GET",), + address_count=1, + ipv4_address_count=1, + ipv6_address_count=0, + policy_fingerprint="0" * 64, + decision_fingerprint="1" * 64, + ) + + +def test_decision_evidence_rejects_empty_direct_construction_consistently() -> None: + """Keep the intentional factory-only error for argument-shape mistakes.""" + with pytest.raises(TypeError, match=_DIRECT_CONSTRUCTION_ERROR): + EgressDecisionEvidence()