The contract T05 waited on is published: info-tech-canon emission-cadence wire schema 0.1, contract digest b08b4d95fc4b0bd3. A source-owned declaration now travels as emission_cadence on the sender registration; expected-rate entries raise below_declared_cadence on /v1/stream-findings from the same counts /v1/reconciliation returns. heartbeat-or-reconciliation entries are validated and left to T04/T06. Records the observer evaluation of net-kingdom's local-identity declaration: structurally clean, not operationally evaluated, one heartbeat event_class mapping incompatibility. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 151986@bnt-lap001 Assistant-Session: ccd02b6b-80ae-48e5-8cad-9c8f74d21a67
472 lines
20 KiB
Python
472 lines
20 KiB
Python
"""Sender identities and what they are permitted to claim.
|
|
|
|
AUDIT-WP-0004-T03. WP-0003 recorded tenant isolation as delivered, but the
|
|
receiver accepted whatever ``tenant`` and ``source`` a caller sent as long as
|
|
it held the one shared token. This module binds a credential to the sources and
|
|
tenants it may write for, and drives that binding from configuration rather
|
|
than literals.
|
|
|
|
Each identity carries a *list* of tokens so a credential can be rotated without
|
|
a delivery gap: publish the replacement alongside the incumbent, move the
|
|
sender, then drop the old one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
from audit_core.redaction import POLICIES, POLICY_REDACT
|
|
from audit_core.emission_cadence import parse_declaration
|
|
|
|
WILDCARD = "*"
|
|
|
|
# §9.6 evidence kinds. The obligations differ, so the archive must record
|
|
# which one a source declared rather than infer it from traffic.
|
|
#
|
|
# load-bearing: a control's soundness depends on the event being present, so
|
|
# the source owes emission atomicity and a detection surface (heartbeat or
|
|
# reconciliation) for rare classes.
|
|
# attributive: the trail is useful but no control depends on its completeness,
|
|
# which is what lets a deliberately non-atomic trail stay legitimate — while
|
|
# it is declared, and never described as complete.
|
|
EVIDENCE_LOAD_BEARING = "load-bearing"
|
|
EVIDENCE_ATTRIBUTIVE = "attributive"
|
|
EVIDENCE_KINDS = (EVIDENCE_LOAD_BEARING, EVIDENCE_ATTRIBUTIVE)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SenderIdentity:
|
|
"""A credential holder and the claims it is allowed to make."""
|
|
|
|
name: str
|
|
tokens: tuple[str, ...]
|
|
sources: frozenset[str]
|
|
tenants: frozenset[str] = field(default_factory=lambda: frozenset({WILDCARD}))
|
|
may_write: bool = True
|
|
may_read: bool = False
|
|
# Optional absolute expiry for deliberately temporary credentials. A token
|
|
# remains unusable after this instant even if a stale Secret value is still
|
|
# present in the process environment.
|
|
expires_at: datetime | None = None
|
|
# Secret-shaped field handling. Defaults to redact-and-accept, so a
|
|
# legitimate event is not lost over one field; a higher-assurance channel
|
|
# can be set to reject instead (AUDIT-WP-0004-T04).
|
|
secret_policy: str = POLICY_REDACT
|
|
# §9.6 evidence kind (AUDIT-WP-0009-T03). Defaults to ``attributive`` on
|
|
# purpose: a source that has not declared must not be silently treated as
|
|
# load-bearing, because that would let audit-core imply a completeness
|
|
# obligation the source never accepted. Declaring load-bearing is an
|
|
# explicit act by the source and its owner.
|
|
evidence_kind: str = EVIDENCE_ATTRIBUTIVE
|
|
# The trade an attributive source has deliberately made — typically
|
|
# emitting after commit rather than inside the state-change transaction.
|
|
# §9.6 requires the trade be declared where the trail is documented, so it
|
|
# travels with the identity rather than living only in prose.
|
|
completeness_trade: str | None = None
|
|
# §9.6 heartbeat declaration (AUDIT-WP-0009-T04): event class -> the
|
|
# longest gap, in seconds, that is not yet a finding.
|
|
#
|
|
# This is deliberately NOT the emission-cadence declaration (T05, below).
|
|
# Cadence describes a stream's expected *rate* and its shape is
|
|
# info-tech-canon's; this is a registration property saying how often a source promises to say
|
|
# "nothing to report" for a class that may legitimately be silent. The two
|
|
# are complementary and audit-core is not inventing a competing rate shape.
|
|
#
|
|
# Keyed per class, not per source, on purpose. A per-source heartbeat from
|
|
# a mixed-volume emitter is satisfied by its chattiest class and says
|
|
# nothing about the quiet, security-relevant one — which is the only case
|
|
# heartbeats exist for.
|
|
heartbeat_classes: tuple[tuple[str, int], ...] = ()
|
|
# The source-owned emission cadence declaration (AUDIT-WP-0009-T05),
|
|
# parsed from info-tech-canon wire schema 0.1 — the §17 shape the heartbeat
|
|
# field above deliberately did not invent. ``None`` means undeclared.
|
|
emission_cadence: Any = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.name:
|
|
raise ValueError("sender identity needs a name")
|
|
if self.secret_policy not in POLICIES:
|
|
raise ValueError(
|
|
f"sender {self.name!r}: secret_policy must be one of {POLICIES}, "
|
|
f"got {self.secret_policy!r}"
|
|
)
|
|
if not self.tokens or any(not t for t in self.tokens):
|
|
raise ValueError(f"sender {self.name!r} needs at least one non-empty token")
|
|
if not self.sources:
|
|
raise ValueError(f"sender {self.name!r} needs at least one permitted source")
|
|
if self.expires_at is not None and self.expires_at.tzinfo is None:
|
|
raise ValueError(f"sender {self.name!r}: expires_at must include a timezone")
|
|
if self.evidence_kind not in EVIDENCE_KINDS:
|
|
raise ValueError(
|
|
f"sender {self.name!r}: evidence_kind must be one of "
|
|
f"{EVIDENCE_KINDS}, got {self.evidence_kind!r}"
|
|
)
|
|
if self.completeness_trade is not None and not str(self.completeness_trade).strip():
|
|
raise ValueError(
|
|
f"sender {self.name!r}: completeness_trade must say what the trade "
|
|
"is, or be omitted"
|
|
)
|
|
if self.is_load_bearing and self.completeness_trade is not None:
|
|
# A load-bearing source has no trade to make: §9.6 requires
|
|
# atomicity of it. Carrying a declared trade alongside would
|
|
# record a contradiction as though it were a policy.
|
|
raise ValueError(
|
|
f"sender {self.name!r}: a load-bearing source may not declare a "
|
|
"completeness_trade — §9.6 requires emission atomicity of it"
|
|
)
|
|
|
|
for action, interval in self.heartbeat_classes:
|
|
if not action or not str(action).strip():
|
|
raise ValueError(
|
|
f"sender {self.name!r}: a heartbeat class needs an event class"
|
|
)
|
|
if not isinstance(interval, int) or isinstance(interval, bool) or interval <= 0:
|
|
raise ValueError(
|
|
f"sender {self.name!r}: heartbeat interval for {action!r} must "
|
|
f"be a positive number of seconds, got {interval!r}"
|
|
)
|
|
|
|
@property
|
|
def is_load_bearing(self) -> bool:
|
|
return self.evidence_kind == EVIDENCE_LOAD_BEARING
|
|
|
|
def evidence_declaration(self) -> dict[str, Any]:
|
|
"""What audit-core will say about this source's stream.
|
|
|
|
Deliberately states the bound rather than only the kind. Neither kind
|
|
licenses the claim that the archive proves an event occurred, or that
|
|
absence proves it did not (§9.6).
|
|
"""
|
|
return {
|
|
"sender": self.name,
|
|
"evidence_kind": self.evidence_kind,
|
|
"completeness_claimed": False,
|
|
"completeness_trade": self.completeness_trade,
|
|
"heartbeat_classes": dict(self.heartbeat_classes),
|
|
"emission_cadence": (
|
|
self.emission_cadence.summary() if self.emission_cadence else None
|
|
),
|
|
"detection_surface": None,
|
|
}
|
|
|
|
def permits_source(self, source: str) -> bool:
|
|
return WILDCARD in self.sources or source in self.sources
|
|
|
|
def is_active(self, now: datetime) -> bool:
|
|
"""Whether the credential is inside its configured lifetime."""
|
|
|
|
if now.tzinfo is None:
|
|
raise ValueError("authentication time must include a timezone")
|
|
return self.expires_at is None or now < self.expires_at
|
|
|
|
def has_full_tenant_scope(self) -> bool:
|
|
"""Whether this identity is unrestricted across tenants.
|
|
|
|
The read surfaces that are not tenant-keyed — chain verification,
|
|
counters, dead letters, secret findings — cannot be filtered, so they
|
|
are reserved for an identity that could read every tenant anyway.
|
|
Anything else would hand a scoped reader instance-wide facts.
|
|
"""
|
|
return WILDCARD in self.tenants
|
|
|
|
def permits_tenant(self, tenant: str) -> bool:
|
|
"""Whether this identity may write for ``tenant``.
|
|
|
|
Tenant identifiers are opaque here. Their shape
|
|
(``tenant:<grouping>:<name>``) is owned by the IAM Profile and
|
|
tenant-engine; matching is exact, never parsed or pattern-derived.
|
|
"""
|
|
return WILDCARD in self.tenants or tenant in self.tenants
|
|
|
|
|
|
class SenderRegistry:
|
|
"""Resolves a credential to the identity that holds it."""
|
|
|
|
def __init__(self, identities: Iterable[SenderIdentity]) -> None:
|
|
self.identities = tuple(identities)
|
|
if not self.identities:
|
|
raise ValueError("at least one sender identity is required")
|
|
|
|
def authenticate(
|
|
self,
|
|
authorization_header: str | None,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> SenderIdentity | None:
|
|
"""Return the matching identity, or None.
|
|
|
|
Every candidate token is compared even after a match is found, so the
|
|
work done does not depend on which credential was supplied or how many
|
|
identities precede it.
|
|
"""
|
|
supplied = str(authorization_header or "")
|
|
checked_at = now or datetime.now(timezone.utc)
|
|
matched: SenderIdentity | None = None
|
|
for identity in self.identities:
|
|
for token in identity.tokens:
|
|
try:
|
|
hit = hmac.compare_digest(supplied, f"Bearer {token}")
|
|
except TypeError:
|
|
# compare_digest rejects non-ASCII str; such a header is
|
|
# simply not a valid credential.
|
|
hit = False
|
|
if hit and identity.is_active(checked_at) and matched is None:
|
|
matched = identity
|
|
return matched
|
|
|
|
@classmethod
|
|
def from_env(cls, env: dict[str, str] | None = None) -> "SenderRegistry":
|
|
"""Build a registry from ``AUDIT_CORE_SENDERS`` (JSON).
|
|
|
|
Shape::
|
|
|
|
[{"name": "user-engine",
|
|
"tokens": ["current", "next"],
|
|
"sources": ["user-engine"],
|
|
"tenants": ["tenant:friendly:binky"],
|
|
"may_read": false}]
|
|
|
|
``tenants`` defaults to ``["*"]``. Omitting it is a deliberate choice to
|
|
accept any tenant from that sender and should be justified per sender,
|
|
not adopted by default.
|
|
"""
|
|
env = env if env is not None else dict(os.environ)
|
|
raw = env.get("AUDIT_CORE_SENDERS")
|
|
if raw:
|
|
return cls(_apply_scope_overlay(_parse_identities(raw), env))
|
|
|
|
legacy = (env.get("AUDIT_CORE_INGEST_TOKEN") or "").strip()
|
|
if not legacy:
|
|
raise ValueError(
|
|
"no sender configuration: set AUDIT_CORE_SENDERS (preferred) "
|
|
"or AUDIT_CORE_INGEST_TOKEN"
|
|
)
|
|
# Legacy single-token form. Kept so an existing deployment keeps
|
|
# working, but it grants every tenant — which is what T03 exists to
|
|
# stop, so it is deliberately noisy about what it is.
|
|
return cls([
|
|
SenderIdentity(
|
|
name="legacy-ingest-token",
|
|
tokens=(legacy,),
|
|
sources=frozenset({"user-engine"}),
|
|
tenants=frozenset({WILDCARD}),
|
|
)
|
|
])
|
|
|
|
|
|
def _load_scope_overlay(env: dict[str, str]) -> list[dict[str, Any]]:
|
|
"""Non-secret sender policy. Tokens never come from here.
|
|
|
|
``AUDIT_CORE_SENDERS_SCOPE`` is inline JSON (tests).
|
|
``AUDIT_CORE_SENDERS_SCOPE_PATH`` is a file (production ConfigMap).
|
|
"""
|
|
inline = env.get("AUDIT_CORE_SENDERS_SCOPE")
|
|
if inline:
|
|
payload = json.loads(inline)
|
|
else:
|
|
path = env.get("AUDIT_CORE_SENDERS_SCOPE_PATH")
|
|
if not path:
|
|
return []
|
|
payload = json.loads(Path(path).read_text())
|
|
if not isinstance(payload, list):
|
|
raise ValueError("sender scope overlay must be a JSON list")
|
|
return [entry for entry in payload if isinstance(entry, dict) and entry.get("name")]
|
|
|
|
|
|
def _apply_scope_overlay(
|
|
identities: list[SenderIdentity], env: dict[str, str]
|
|
) -> list[SenderIdentity]:
|
|
"""Overlay non-secret fields from Git/ConfigMap onto Secret-backed tokens.
|
|
|
|
ExternalSecret refresh cannot shrink ``user-engine`` tenants below the
|
|
declared scope (AUDIT-WP-0006-T05). Tokens are never taken from the overlay.
|
|
"""
|
|
overlay = {entry["name"]: entry for entry in _load_scope_overlay(env)}
|
|
if not overlay:
|
|
return identities
|
|
merged: list[SenderIdentity] = []
|
|
for identity in identities:
|
|
extra = overlay.get(identity.name)
|
|
if extra is None:
|
|
merged.append(identity)
|
|
continue
|
|
tenants = extra.get("tenants")
|
|
sources = extra.get("sources")
|
|
merged.append(
|
|
SenderIdentity(
|
|
name=identity.name,
|
|
tokens=identity.tokens,
|
|
sources=(
|
|
frozenset(str(s) for s in sources) if sources else identity.sources
|
|
),
|
|
tenants=(
|
|
frozenset(str(t) for t in tenants) if tenants else identity.tenants
|
|
),
|
|
may_write=(
|
|
bool(extra["may_write"]) if "may_write" in extra else identity.may_write
|
|
),
|
|
may_read=(
|
|
bool(extra["may_read"]) if "may_read" in extra else identity.may_read
|
|
),
|
|
secret_policy=identity.secret_policy,
|
|
expires_at=identity.expires_at,
|
|
evidence_kind=_overlay_evidence_kind(identity, extra),
|
|
completeness_trade=(
|
|
_clean_trade(extra["completeness_trade"])
|
|
if "completeness_trade" in extra
|
|
else identity.completeness_trade
|
|
),
|
|
heartbeat_classes=_overlay_heartbeats(identity, extra),
|
|
emission_cadence=_overlay_cadence(identity, extra),
|
|
)
|
|
)
|
|
return merged
|
|
|
|
|
|
def _overlay_evidence_kind(identity: SenderIdentity, extra: dict[str, Any]) -> str:
|
|
"""Let the overlay raise the evidence kind, never lower it.
|
|
|
|
Same principle as tenants: the ConfigMap is the authority for non-secret
|
|
policy, but a refresh must not be able to quietly weaken a declaration.
|
|
Downgrading a load-bearing source to attributive would drop its atomicity
|
|
and detection obligations without anyone deciding to, so it is refused
|
|
here and has to be a deliberate change to the Secret-backed declaration.
|
|
"""
|
|
declared = str(extra.get("evidence_kind") or identity.evidence_kind)
|
|
if declared == identity.evidence_kind:
|
|
return declared
|
|
if identity.is_load_bearing and declared == EVIDENCE_ATTRIBUTIVE:
|
|
raise ValueError(
|
|
f"sender {identity.name!r}: the scope overlay may not downgrade a "
|
|
"load-bearing source to attributive"
|
|
)
|
|
return declared
|
|
|
|
|
|
def _overlay_heartbeats(
|
|
identity: SenderIdentity, extra: dict[str, Any]
|
|
) -> tuple[tuple[str, int], ...]:
|
|
"""The overlay may add a heartbeat class or shorten an interval, never
|
|
lengthen or remove one.
|
|
|
|
Same asymmetry as ``evidence_kind`` and for the same reason: a ConfigMap
|
|
refresh must not be able to widen the window in which a suppressed class
|
|
goes unnoticed, or drop the obligation entirely, without anyone deciding
|
|
to. Tightening detection is safe in the direction a mistake would take it.
|
|
"""
|
|
if "heartbeat_classes" not in extra:
|
|
return identity.heartbeat_classes
|
|
declared = dict(identity.heartbeat_classes)
|
|
proposed = dict(_heartbeat_classes(extra["heartbeat_classes"]))
|
|
for action, interval in declared.items():
|
|
if action not in proposed:
|
|
raise ValueError(
|
|
f"sender {identity.name!r}: the scope overlay may not remove "
|
|
f"heartbeat class {action!r}"
|
|
)
|
|
if proposed[action] > interval:
|
|
raise ValueError(
|
|
f"sender {identity.name!r}: the scope overlay may not lengthen "
|
|
f"the heartbeat interval for {action!r} "
|
|
f"({interval}s -> {proposed[action]}s)"
|
|
)
|
|
return tuple(sorted(proposed.items()))
|
|
|
|
|
|
def _overlay_cadence(identity: SenderIdentity, extra: dict[str, Any]) -> Any:
|
|
"""The overlay may not touch an emission cadence declaration.
|
|
|
|
A declaration is the source's, pinned to a contract digest; a partial
|
|
overlay has no sound merge with it, and replacing it wholesale could widen
|
|
a window or drop a class without anyone deciding to. Changing it is a
|
|
change to the Secret-backed registration.
|
|
"""
|
|
if "emission_cadence" in extra:
|
|
raise ValueError(
|
|
f"sender {identity.name!r}: the scope overlay may not set or change "
|
|
"emission_cadence"
|
|
)
|
|
return identity.emission_cadence
|
|
|
|
|
|
def _parse_identities(raw: str) -> list[SenderIdentity]:
|
|
try:
|
|
entries = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"AUDIT_CORE_SENDERS is not valid JSON: {exc}") from exc
|
|
if not isinstance(entries, list) or not entries:
|
|
raise ValueError("AUDIT_CORE_SENDERS must be a non-empty JSON list")
|
|
return [_identity_from(entry) for entry in entries]
|
|
|
|
|
|
def _identity_from(entry: Any) -> SenderIdentity:
|
|
if not isinstance(entry, dict):
|
|
raise ValueError("each sender entry must be an object")
|
|
tokens = entry.get("tokens") or ([entry["token"]] if entry.get("token") else [])
|
|
return SenderIdentity(
|
|
name=str(entry.get("name") or ""),
|
|
tokens=tuple(str(t) for t in tokens),
|
|
sources=frozenset(str(s) for s in (entry.get("sources") or [])),
|
|
tenants=frozenset(str(t) for t in (entry.get("tenants") or [WILDCARD])),
|
|
may_write=bool(entry.get("may_write", True)),
|
|
may_read=bool(entry.get("may_read", False)),
|
|
secret_policy=str(entry.get("secret_policy", POLICY_REDACT)),
|
|
expires_at=_parse_expiry(entry.get("expires_at")),
|
|
evidence_kind=str(entry.get("evidence_kind", EVIDENCE_ATTRIBUTIVE)),
|
|
completeness_trade=_clean_trade(entry.get("completeness_trade")),
|
|
heartbeat_classes=_heartbeat_classes(entry.get("heartbeat_classes")),
|
|
emission_cadence=(
|
|
parse_declaration(entry["emission_cadence"])
|
|
if entry.get("emission_cadence") is not None
|
|
else None
|
|
),
|
|
)
|
|
|
|
|
|
def _heartbeat_classes(value: Any) -> tuple[tuple[str, int], ...]:
|
|
if not value:
|
|
return ()
|
|
if not isinstance(value, dict):
|
|
raise ValueError("heartbeat_classes must be an object of class -> seconds")
|
|
return tuple(sorted((str(k), v) for k, v in value.items()))
|
|
|
|
|
|
def _clean_trade(value: Any) -> str | None:
|
|
if value in (None, ""):
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
def _parse_expiry(value: Any) -> datetime | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except (TypeError, ValueError):
|
|
raise ValueError("sender expires_at must be an RFC3339 timestamp") from None
|
|
if parsed.tzinfo is None:
|
|
raise ValueError("sender expires_at must include a timezone")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def development_registry(token: str) -> SenderRegistry:
|
|
"""A single permissive sender, for tests and local development.
|
|
|
|
Accepts any tenant. Not a production configuration — production binds each
|
|
sender to the tenants it may write for via ``AUDIT_CORE_SENDERS``.
|
|
"""
|
|
return SenderRegistry([
|
|
SenderIdentity(
|
|
name="development",
|
|
tokens=(token,),
|
|
sources=frozenset({"user-engine"}),
|
|
tenants=frozenset({WILDCARD}),
|
|
may_read=True,
|
|
)
|
|
])
|