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.

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
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

View file

@ -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"
}
]

View file

@ -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")

View file

@ -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"]}]

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
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