diff --git a/audit_core/senders.py b/audit_core/senders.py index 26fc877..4b34129 100644 --- a/audit_core/senders.py +++ b/audit_core/senders.py @@ -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. diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 3506bf0..26ae71f 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -127,11 +127,13 @@ still apply to every request. The policy is connectivity, not authorization. A dated `live-e2` engagement may request exactly two temporary identities. Each identity is bound to `source=whitehat-security`, one named synthetic tenant, -`may_write=true`, and `may_read=true`. Provision token values only through the -sanctioned sender-registry custody lane and the plane's mount-only credential -projection. Never reuse the production `user-engine` identity. Revoke both -identities and remove their registry entries when the plane lease ends; an -engagement is incomplete until that cleanup is evidenced. +`may_write=true`, `may_read=true`, and an RFC3339 `expires_at` no later than the +engagement end. Audit-core evaluates expiry on every authentication, so a stale +registry copy cannot extend the bearer lifetime. Provision token values only +through the sanctioned sender-registry custody lane and the plane's mount-only +credential projection. Never reuse the production `user-engine` identity. +Revoke both identities and remove their registry entries when the plane lease +ends; application expiry is a backstop, not a substitute for evidenced cleanup. OpenBao path `platform/workloads/audit-core/senders` is the authority for ExternalSecret `audit-core-senders`. The initial in-cluster registry was diff --git a/docs/senders.example.json b/docs/senders.example.json index c905baf..fd9e4ab 100644 --- a/docs/senders.example.json +++ b/docs/senders.example.json @@ -16,5 +16,15 @@ "may_write": true, "may_read": true, "secret_policy": "redact" + }, + { + "name": "temporary-evidence-sender", + "tokens": ["replace-with-short-lived-token"], + "sources": ["whitehat-security"], + "tenants": ["tenant:trial:named-fixture"], + "may_write": true, + "may_read": true, + "secret_policy": "redact", + "expires_at": "2026-08-22T18:15:00Z" } ] diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py index 753e62d..84b4891 100644 --- a/tests/test_ingestion.py +++ b/tests/test_ingestion.py @@ -1,5 +1,6 @@ import io import json +from datetime import datetime, timezone import pytest @@ -239,6 +240,7 @@ def bound_app(tmp_path, **kw): tenants=frozenset(kw.get("tenants", {"tenant:friendly:binky"})), may_read=kw.get("may_read", False), secret_policy=kw.get("secret_policy", "redact"), + expires_at=kw.get("expires_at"), ) # An unrestricted operator sits alongside the scoped sender. The # instance-wide read surfaces — stats, dead letters, secret findings, @@ -281,6 +283,16 @@ def test_rotation_accepts_both_tokens(tmp_path): assert invoke(app, event(id="evt-3"), key="evt-3", token="retired")[0].startswith("401") +def test_expired_sender_is_unauthorized_at_http_boundary(tmp_path): + app, _ = bound_app( + tmp_path, + expires_at=datetime(2000, 1, 1, tzinfo=timezone.utc), + ) + status, body = invoke(app, event()) + assert status.startswith("401") + assert body["error"] == "unauthorized" + + def test_sender_credential_cannot_read_the_trail_back(tmp_path): app, _ = bound_app(tmp_path, may_read=False) assert invoke(app, event())[0].startswith("202") diff --git a/tests/test_senders.py b/tests/test_senders.py index 73c4826..6a5d93b 100644 --- a/tests/test_senders.py +++ b/tests/test_senders.py @@ -1,4 +1,5 @@ import json +from datetime import datetime, timezone from pathlib import Path import pytest @@ -87,6 +88,68 @@ def test_missing_overlay_leaves_secret_as_is(): assert identity.tenants == frozenset({"tenant:friendly:binky"}) +def test_temporary_sender_is_rejected_at_and_after_expiry(): + registry = SenderRegistry.from_env( + { + "AUDIT_CORE_SENDERS": json.dumps( + [ + { + "name": "whitehat-a", + "tokens": ["temporary"], + "sources": ["whitehat-security"], + "tenants": ["tenant:trial:whitehat-a"], + "expires_at": "2026-08-22T18:15:00Z", + } + ] + ) + } + ) + + before = datetime(2026, 8, 22, 18, 14, 59, tzinfo=timezone.utc) + boundary = datetime(2026, 8, 22, 18, 15, 0, tzinfo=timezone.utc) + assert registry.authenticate("Bearer temporary", now=before) is not None + assert registry.authenticate("Bearer temporary", now=boundary) is None + + +@pytest.mark.parametrize("expires_at", ["not-a-time", "2026-08-22T18:15:00"]) +def test_temporary_sender_expiry_must_be_valid_and_timezone_aware(expires_at): + sender = json.dumps( + [ + { + "name": "whitehat-a", + "tokens": ["temporary"], + "sources": ["whitehat-security"], + "expires_at": expires_at, + } + ] + ) + with pytest.raises(ValueError, match="expires_at"): + SenderRegistry.from_env({"AUDIT_CORE_SENDERS": sender}) + + +def test_scope_overlay_cannot_remove_secret_backed_expiry(): + registry = SenderRegistry.from_env( + { + "AUDIT_CORE_SENDERS": json.dumps( + [ + { + "name": "user-engine", + "tokens": ["temporary"], + "sources": ["user-engine"], + "expires_at": "2026-08-22T18:15:00Z", + } + ] + ), + "AUDIT_CORE_SENDERS_SCOPE": json.dumps( + [{"name": "user-engine", "tenants": ["*"]}] + ), + } + ) + assert registry.identities[0].expires_at == datetime( + 2026, 8, 22, 18, 15, 0, tzinfo=timezone.utc + ) + + def test_invalid_scope_overlay_is_a_startup_error(): secret = json.dumps( [{"name": "user-engine", "tokens": ["t"], "sources": ["user-engine"]}] diff --git a/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md b/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md index e0c87f9..5693f4a 100644 --- a/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md +++ b/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md @@ -421,6 +421,14 @@ that apply was authorized without a runner or credential in `7e0c1d68-1a68-4345-a845-44bd0dae8373`. Operator approval and target-owner acknowledgement remain deliberately pending. +Custody review identified that expiry of a projected Secret alone does not +invalidate a token already loaded into the audit-core process. Sender +identities now accept an optional timezone-aware `expires_at`, evaluated on +every authentication, so temporary evidence credentials fail closed at the +engagement boundary even before registry cleanup completes. The field is +optional for existing production identities and is preserved across the +non-secret scope overlay. + ```task id: AUDIT-WP-0008-T06 status: done