feat: atomically journal and deliver authorized native operation outcomes
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
3c5cbfbafe
commit
f0eff0ac92
13 changed files with 583 additions and 28 deletions
|
|
@ -16,7 +16,7 @@ class AuditCoreSink:
|
|||
"""No allow returns before durable remote custody acknowledges its record.
|
||||
|
||||
A lost receipt blocks execution, even if the archive already stored the
|
||||
attempt. This is an authorization-attempt journal, not a mutation outbox.
|
||||
attempt. Committed outcomes arrive separately from the transactional outbox.
|
||||
"""
|
||||
|
||||
def __init__(self, *, base_url: str, token_file: Path, client: httpx.AsyncClient):
|
||||
|
|
@ -34,30 +34,32 @@ class AuditCoreSink:
|
|||
raise ValueError("operational audit custody is required")
|
||||
|
||||
async def append(self, record: dict) -> None:
|
||||
correlation, outcome = record.get("correlation_id"), record.get("outcome")
|
||||
if not isinstance(correlation, str) or not correlation or outcome not in {"authorized", "denied", "refused"}:
|
||||
raise AccessFailure(503, "audit_unavailable")
|
||||
await self._deliver({
|
||||
"id": str(uuid4()), "type": "hub.access." + outcome, "source": "hub-core",
|
||||
"subject": "hub-access:" + correlation, "tenant": "tenant:platform",
|
||||
"correlation_id": correlation, "occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": record,
|
||||
})
|
||||
|
||||
async def append_outcome(self, event: dict) -> None:
|
||||
# The transaction supplies the immutable ID, timestamp and full envelope.
|
||||
if (event.get("type") != "hub.operation.committed" or event.get("source") != "hub-core"
|
||||
or event.get("tenant") != "tenant:platform"
|
||||
or not isinstance(event.get("id"), str) or not event["id"]
|
||||
or set(event) != {"id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data"}):
|
||||
raise AccessFailure(503, "audit_unavailable")
|
||||
await self._deliver(event)
|
||||
|
||||
async def _deliver(self, event: dict) -> None:
|
||||
try:
|
||||
async with asyncio.timeout(3):
|
||||
# Probe each time, so a receiver's development fallback cannot
|
||||
# be mistaken for admitted custody through a cached readiness.
|
||||
await self.readiness()
|
||||
token = self.token_file.read_text().strip()
|
||||
if not token or not token.isascii() or any(c.isspace() for c in token):
|
||||
raise ValueError("invalid sender credential")
|
||||
correlation = record.get("correlation_id")
|
||||
outcome = record.get("outcome")
|
||||
if not isinstance(correlation, str) or not correlation or outcome not in {
|
||||
"authorized", "denied", "refused",
|
||||
}:
|
||||
raise ValueError("invalid authorization audit record")
|
||||
event = {
|
||||
"id": str(uuid4()),
|
||||
"type": "hub.access." + outcome,
|
||||
"source": "hub-core",
|
||||
"subject": "hub-access:" + correlation,
|
||||
"tenant": "tenant:platform",
|
||||
"correlation_id": correlation,
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": record,
|
||||
}
|
||||
raw = json.dumps(event, ensure_ascii=False, allow_nan=False).encode()
|
||||
if len(raw) > 256 * 1024:
|
||||
raise ValueError("audit envelope exceeds receiver limit")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import hashlib
|
|||
import json
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from importlib.resources import files
|
||||
from typing import Protocol
|
||||
from uuid import uuid4
|
||||
|
|
@ -20,6 +20,7 @@ from starlette.responses import JSONResponse
|
|||
from starlette.routing import Match
|
||||
|
||||
from hub_core.security.identity import AccessFailure, Actor
|
||||
from hub_core.security.context import current_authorization
|
||||
|
||||
PROFILE = "hub-core.access/1.0.0"
|
||||
|
||||
|
|
@ -59,6 +60,9 @@ class Authorization:
|
|||
facts: LiveFacts
|
||||
correlation_id: str
|
||||
request_digest: str
|
||||
decision_id: str = ""
|
||||
policy_version: str = ""
|
||||
policy_caller: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -156,7 +160,8 @@ class AccessController:
|
|||
raise AccessFailure(401, "expired_access_token")
|
||||
if time.time() - facts.checked_at > 5:
|
||||
raise AccessFailure(503, "facts_expired_during_authorization")
|
||||
return context
|
||||
return replace(context, decision_id=decision.decision_id,
|
||||
policy_version=decision.policy_version, policy_caller=decision.caller)
|
||||
|
||||
|
||||
def route_key(route, method: str) -> str:
|
||||
|
|
@ -312,4 +317,8 @@ class AccessBoundary:
|
|||
(b"x-correlation-id", correlation.encode())])
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, replay, protected_send)
|
||||
binding = current_authorization.set(context)
|
||||
try:
|
||||
await self.app(scope, replay, protected_send)
|
||||
finally:
|
||||
current_authorization.reset(binding)
|
||||
|
|
|
|||
8
hub_core/security/context.py
Normal file
8
hub_core/security/context.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Request-scoped, verified authority for transaction attribution (never tokens)."""
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hub_core.security.boundary import Authorization
|
||||
|
||||
current_authorization: ContextVar['Authorization | None'] = ContextVar('hub_authorization', default=None)
|
||||
Loading…
Add table
Add a link
Reference in a new issue