feat: report bounded verified access dependency readiness
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
7f0dc78607
commit
c0383c9c2b
9 changed files with 294 additions and 17 deletions
|
|
@ -245,6 +245,10 @@ class ConformanceHarness:
|
|||
body = response.json()
|
||||
checks = body.get("checks", {})
|
||||
dependency_keys = ("database", "repo_manager_projection", "workload_projection")
|
||||
if "access_profile" in checks:
|
||||
dependency_keys += ("access_profile", "access_identity", "access_facts", "access_policy", "access_audit")
|
||||
if "outcome_delivery" in checks:
|
||||
dependency_keys += ("outcome_delivery",)
|
||||
dependency_values = {}
|
||||
for required in dependency_keys:
|
||||
if required not in checks:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from hub_core.runtime.workload_projection import (
|
|||
WorkloadProjectionService,
|
||||
)
|
||||
from hub_core.runtime.workload_projection_routes import create_workload_projection_router
|
||||
from hub_core.security.boundary import AccessBoundary, AccessController, FactSource
|
||||
from hub_core.security.boundary import AccessBoundary, AccessController, FactSource, ACCESS_DEPENDENCIES
|
||||
from hub_core.security.config import SecuritySettings
|
||||
from hub_core.security.browser import BrowserSettings, BrowserSessions, create_browser_router
|
||||
|
||||
|
|
@ -173,7 +173,10 @@ def create_app(
|
|||
**await workload_projection.readiness_checks(),
|
||||
}
|
||||
if resolved_settings.enforce_access:
|
||||
dependency_checks["access_profile"] = "ok" if access_controller else "unavailable"
|
||||
dependency_checks.update(access_controller.readiness_checks() if access_controller else {
|
||||
**{"access_" + name: "unavailable" for name in ACCESS_DEPENDENCIES},
|
||||
"access_profile": "unavailable",
|
||||
})
|
||||
if callable(getattr(resolved_store, "deliver_outcomes", None)):
|
||||
dependency_checks["outcome_delivery"] = (await resolved_store.outcome_readiness()) if outcome_delivery else "unavailable"
|
||||
ready = resolved_settings.is_ready(resolved_store.backend_name) and all(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue