feat(AUDIT-WP-0008): enforce temporary sender expiry
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a025c2-407a-7a32-b40a-f37a52f03f62
This commit is contained in:
parent
e916c957ea
commit
abd22fa0a6
6 changed files with 136 additions and 7 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]}]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue