Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions src/egressweave/decision_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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)),
Expand Down
32 changes: 32 additions & 0 deletions tests/test_decision_evidence_factory_only.py
Original file line number Diff line number Diff line change
@@ -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()
Loading