feat(AUDIT-WP-0008): enforce temporary sender expiry
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a025c2-407a-7a32-b40a-f37a52f03f62
This commit is contained in:
tegwick 2026-08-22 11:59:26 +02:00
parent e916c957ea
commit abd22fa0a6
6 changed files with 136 additions and 7 deletions

View file

@ -17,6 +17,7 @@ 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
@ -35,6 +36,10 @@ class SenderIdentity:
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).
@ -52,10 +57,19 @@ class SenderIdentity:
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")
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.
@ -84,7 +98,12 @@ class SenderRegistry:
if not self.identities:
raise ValueError("at least one sender identity is required")
def authenticate(self, authorization_header: str | None) -> SenderIdentity | None:
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
@ -92,6 +111,7 @@ class SenderRegistry:
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:
@ -101,7 +121,7 @@ class SenderRegistry:
# compare_digest rejects non-ASCII str; such a header is
# simply not a valid credential.
hit = False
if hit and matched is None:
if hit and identity.is_active(checked_at) and matched is None:
matched = identity
return matched
@ -200,6 +220,7 @@ def _apply_scope_overlay(
bool(extra["may_read"]) if "may_read" in extra else identity.may_read
),
secret_policy=identity.secret_policy,
expires_at=identity.expires_at,
)
)
return merged
@ -227,9 +248,22 @@ def _identity_from(entry: Any) -> SenderIdentity:
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")),
)
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.