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 json
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@ -35,6 +36,10 @@ class SenderIdentity:
tenants: frozenset[str] = field(default_factory=lambda: frozenset({WILDCARD})) tenants: frozenset[str] = field(default_factory=lambda: frozenset({WILDCARD}))
may_write: bool = True may_write: bool = True
may_read: bool = False 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 # Secret-shaped field handling. Defaults to redact-and-accept, so a
# legitimate event is not lost over one field; a higher-assurance channel # legitimate event is not lost over one field; a higher-assurance channel
# can be set to reject instead (AUDIT-WP-0004-T04). # 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") raise ValueError(f"sender {self.name!r} needs at least one non-empty token")
if not self.sources: if not self.sources:
raise ValueError(f"sender {self.name!r} needs at least one permitted source") 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: def permits_source(self, source: str) -> bool:
return WILDCARD in self.sources or source in self.sources 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: def has_full_tenant_scope(self) -> bool:
"""Whether this identity is unrestricted across tenants. """Whether this identity is unrestricted across tenants.
@ -84,7 +98,12 @@ class SenderRegistry:
if not self.identities: if not self.identities:
raise ValueError("at least one sender identity is required") 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. """Return the matching identity, or None.
Every candidate token is compared even after a match is found, so the Every candidate token is compared even after a match is found, so the
@ -92,6 +111,7 @@ class SenderRegistry:
identities precede it. identities precede it.
""" """
supplied = str(authorization_header or "") supplied = str(authorization_header or "")
checked_at = now or datetime.now(timezone.utc)
matched: SenderIdentity | None = None matched: SenderIdentity | None = None
for identity in self.identities: for identity in self.identities:
for token in identity.tokens: for token in identity.tokens:
@ -101,7 +121,7 @@ class SenderRegistry:
# compare_digest rejects non-ASCII str; such a header is # compare_digest rejects non-ASCII str; such a header is
# simply not a valid credential. # simply not a valid credential.
hit = False hit = False
if hit and matched is None: if hit and identity.is_active(checked_at) and matched is None:
matched = identity matched = identity
return matched return matched
@ -200,6 +220,7 @@ def _apply_scope_overlay(
bool(extra["may_read"]) if "may_read" in extra else identity.may_read bool(extra["may_read"]) if "may_read" in extra else identity.may_read
), ),
secret_policy=identity.secret_policy, secret_policy=identity.secret_policy,
expires_at=identity.expires_at,
) )
) )
return merged return merged
@ -227,9 +248,22 @@ def _identity_from(entry: Any) -> SenderIdentity:
may_write=bool(entry.get("may_write", True)), may_write=bool(entry.get("may_write", True)),
may_read=bool(entry.get("may_read", False)), may_read=bool(entry.get("may_read", False)),
secret_policy=str(entry.get("secret_policy", POLICY_REDACT)), 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: def development_registry(token: str) -> SenderRegistry:
"""A single permissive sender, for tests and local development. """A single permissive sender, for tests and local development.

View file

@ -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 A dated `live-e2` engagement may request exactly two temporary identities. Each
identity is bound to `source=whitehat-security`, one named synthetic tenant, identity is bound to `source=whitehat-security`, one named synthetic tenant,
`may_write=true`, and `may_read=true`. Provision token values only through the `may_write=true`, `may_read=true`, and an RFC3339 `expires_at` no later than the
sanctioned sender-registry custody lane and the plane's mount-only credential engagement end. Audit-core evaluates expiry on every authentication, so a stale
projection. Never reuse the production `user-engine` identity. Revoke both registry copy cannot extend the bearer lifetime. Provision token values only
identities and remove their registry entries when the plane lease ends; an through the sanctioned sender-registry custody lane and the plane's mount-only
engagement is incomplete until that cleanup is evidenced. 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 OpenBao path `platform/workloads/audit-core/senders` is the authority for
ExternalSecret `audit-core-senders`. The initial in-cluster registry was ExternalSecret `audit-core-senders`. The initial in-cluster registry was

View file

@ -16,5 +16,15 @@
"may_write": true, "may_write": true,
"may_read": true, "may_read": true,
"secret_policy": "redact" "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"
} }
] ]

View file

@ -1,5 +1,6 @@
import io import io
import json import json
from datetime import datetime, timezone
import pytest import pytest
@ -239,6 +240,7 @@ def bound_app(tmp_path, **kw):
tenants=frozenset(kw.get("tenants", {"tenant:friendly:binky"})), tenants=frozenset(kw.get("tenants", {"tenant:friendly:binky"})),
may_read=kw.get("may_read", False), may_read=kw.get("may_read", False),
secret_policy=kw.get("secret_policy", "redact"), secret_policy=kw.get("secret_policy", "redact"),
expires_at=kw.get("expires_at"),
) )
# An unrestricted operator sits alongside the scoped sender. The # An unrestricted operator sits alongside the scoped sender. The
# instance-wide read surfaces — stats, dead letters, secret findings, # 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") 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): def test_sender_credential_cannot_read_the_trail_back(tmp_path):
app, _ = bound_app(tmp_path, may_read=False) app, _ = bound_app(tmp_path, may_read=False)
assert invoke(app, event())[0].startswith("202") assert invoke(app, event())[0].startswith("202")

View file

@ -1,4 +1,5 @@
import json import json
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -87,6 +88,68 @@ def test_missing_overlay_leaves_secret_as_is():
assert identity.tenants == frozenset({"tenant:friendly:binky"}) 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(): def test_invalid_scope_overlay_is_a_startup_error():
secret = json.dumps( secret = json.dumps(
[{"name": "user-engine", "tokens": ["t"], "sources": ["user-engine"]}] [{"name": "user-engine", "tokens": ["t"], "sources": ["user-engine"]}]

View file

@ -421,6 +421,14 @@ that apply was authorized without a runner or credential in
`7e0c1d68-1a68-4345-a845-44bd0dae8373`. Operator approval and target-owner `7e0c1d68-1a68-4345-a845-44bd0dae8373`. Operator approval and target-owner
acknowledgement remain deliberately pending. 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 ```task
id: AUDIT-WP-0008-T06 id: AUDIT-WP-0008-T06
status: done status: done