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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue