feat: report bounded verified access dependency readiness
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 14:32:36 +02:00
parent 7f0dc78607
commit c0383c9c2b
9 changed files with 294 additions and 17 deletions

View file

@ -23,6 +23,9 @@ from hub_core.security.identity import AccessFailure, Actor
from hub_core.security.context import current_authorization
PROFILE = "hub-core.access/1.0.0"
ACCESS_DEPENDENCIES = ("identity", "facts", "policy", "audit")
DEPENDENCY_OBSERVATION_TTL = 10
AUTHORIZATION_TIMEOUT = 10
@dataclass(frozen=True)
@ -107,10 +110,51 @@ class AccessController:
raise ValueError("immutable root identity is required")
self.identity, self.facts, self.policy, self.audit = identity, facts, policy, audit
self.root_identity = (root_issuer, root_subject)
self._observations: dict[str, tuple[str, float]] = {}
def readiness_checks(self) -> dict[str, str]:
"""Recent usable operations, not independent owner liveness or grants.
Diagnostic samples never participate in authorization. Missing adapters
or observations cannot be promoted to ready by configuration alone.
"""
now = time.monotonic()
checks = {}
for name in ACCESS_DEPENDENCIES:
sample = self._observations.get(name)
state = "unavailable" if sample is None else sample[0]
if state == "ok" and now - sample[1] > DEPENDENCY_OBSERVATION_TTL:
state = "stale"
checks["access_" + name] = state
checks["access_profile"] = "ok" if all(v == "ok" for v in checks.values()) else "unavailable"
return checks
async def _dependency(self, name, operation):
try:
result = await operation()
if name == "identity" and not isinstance(result, Actor):
raise ValueError("identity adapter returned no verified actor")
if name == "policy" and not isinstance(result, Decision):
raise ValueError("policy adapter returned no verified decision")
except BaseException as exc:
# A malformed user token does not prove an identity owner outage.
# Preserve any prior sample without refreshing its age.
if not (name == "identity" and isinstance(exc, AccessFailure) and exc.status == 401):
self._observations[name] = ("unavailable", time.monotonic())
raise
self._observations[name] = ("ok", time.monotonic())
return result
async def append_audit(self, record: dict) -> None:
await self._dependency("audit", lambda: self.audit.append(record))
async def authorize(self, token: str, action: str, resource: str,
correlation_id: str, request_digest: str) -> Authorization:
actor = await self.identity.authenticate(token)
async with asyncio.timeout(AUTHORIZATION_TIMEOUT):
return await self._authorize(token, action, resource, correlation_id, request_digest)
async def _authorize(self, token, action, resource, correlation_id, request_digest):
actor = await self._dependency("identity", lambda: self.identity.authenticate(token))
try:
return await self._authorize_actor(actor, action, resource, correlation_id, request_digest)
except Exception as exc:
@ -127,11 +171,7 @@ class AccessController:
or actor.tenant != "tenant:platform" or actor.assurance not in {"aal2", "aal3"}
):
raise AccessFailure(403, "root_required")
facts = await self.facts.resolve(actor, resource)
if (facts.issuer, facts.subject, facts.actor_tenant) != (
actor.issuer, actor.subject, actor.tenant
) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id:
raise AccessFailure(503, "untrusted_or_stale_facts")
facts = await self._dependency("facts", lambda: self._resolve_facts(actor, resource))
# v1 explicitly classifies Hub records as platform-owned. Other tenants
# require the Phase 2 resource resolver/storage contract, not a header.
if facts.target_tenant != "tenant:platform":
@ -141,8 +181,8 @@ class AccessController:
if actor.principal_type == "human" and not facts.root_entitled:
raise AccessFailure(403, "root_entitlement_required")
context = Authorization(actor, action, resource, facts, correlation_id, request_digest)
decision = await self.policy.evaluate(context)
await self.audit.append({
decision = await self._dependency("policy", lambda: self.policy.evaluate(context))
await self.append_audit({
"profile": PROFILE, "correlation_id": correlation_id,
"issuer": actor.issuer, "subject": actor.subject,
"principal_type": actor.principal_type, "actor_tenant": actor.tenant,
@ -159,10 +199,19 @@ class AccessController:
if actor.expires_at <= time.time():
raise AccessFailure(401, "expired_access_token")
if time.time() - facts.checked_at > 5:
self._observations["facts"] = ("unavailable", time.monotonic())
raise AccessFailure(503, "facts_expired_during_authorization")
return replace(context, decision_id=decision.decision_id,
policy_version=decision.policy_version, policy_caller=decision.caller)
async def _resolve_facts(self, actor, resource):
facts = await self.facts.resolve(actor, resource)
if (facts.issuer, facts.subject, facts.actor_tenant) != (
actor.issuer, actor.subject, actor.tenant
) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id:
raise AccessFailure(503, "untrusted_or_stale_facts")
return facts
def route_key(route, method: str) -> str:
endpoint = route.endpoint
@ -279,7 +328,7 @@ class AccessBoundary:
if self.controller is not None:
try:
async with asyncio.timeout(3):
await self.controller.audit.append({
await self.controller.append_audit({
"profile": PROFILE, "correlation_id": correlation,
"outcome": "refused", "reason": failure.code,
"subject": actor.subject if actor else None,

View file

@ -259,7 +259,7 @@ class BrowserSessions:
401 if isinstance(exc, jwt.PyJWTError) else 503, "browser_login_unavailable")
try:
async with asyncio.timeout(3):
await self.controller.audit.append({
await self.controller.append_audit({
"profile": PROFILE, "correlation_id": correlation,
"outcome": "refused", "reason": failure.code,
"action": SESSION_ACTION, "target_tenant": "tenant:platform",