feat: compose fresh owner facts with explicit binding and evidence
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
f11b948e8c
commit
2a0586b4c9
5 changed files with 358 additions and 0 deletions
112
hub_core/security/facts.py
Normal file
112
hub_core/security/facts.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Join admitted owner observations; this module defines no owner HTTP API.
|
||||
|
||||
Reader implementations must authenticate their own workload and return current,
|
||||
side-effect-free observations. A mapping reference is owner review evidence,
|
||||
not an entitlement grant. Neither token roles nor static root allowlists suffice.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from hub_core.security.boundary import LiveFacts
|
||||
from hub_core.security.identity import AccessFailure, Actor
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountObservation:
|
||||
issuer: str
|
||||
subject: str
|
||||
tenant: str
|
||||
account_active: bool
|
||||
root_entitled: bool
|
||||
checked_at: float
|
||||
evidence_id: str
|
||||
producer_addresses: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantObservation:
|
||||
identifier: str
|
||||
active: bool
|
||||
checked_at: float
|
||||
evidence_id: str
|
||||
|
||||
|
||||
class AccountReader(Protocol):
|
||||
async def read(self, *, issuer: str, subject: str, tenant: str) -> AccountObservation:
|
||||
"""Authenticated read; unknown identities must not be provisioned."""
|
||||
...
|
||||
|
||||
|
||||
class TenantReader(Protocol):
|
||||
async def read(self, *, identifier: str) -> TenantObservation:
|
||||
"""Authenticated current lifecycle lookup with owner evidence."""
|
||||
...
|
||||
|
||||
|
||||
class PlatformFacts:
|
||||
"""Root-first, platform-only composition with no authority cache.
|
||||
|
||||
account_tenant and mapping_evidence require explicit owner-reviewed mapping
|
||||
to tenant:platform. They carry no default alias or implicit role translation.
|
||||
Workload and cross-tenant admission need a separately reviewed contract.
|
||||
"""
|
||||
def __init__(self, *, accounts: AccountReader, tenants: TenantReader,
|
||||
account_tenant: str, mapping_evidence: str):
|
||||
_reference(account_tenant)
|
||||
_reference(mapping_evidence)
|
||||
self.accounts, self.tenants = accounts, tenants
|
||||
self.account_tenant, self.mapping_evidence = account_tenant, mapping_evidence
|
||||
|
||||
async def resolve(self, actor: Actor, resource: str) -> LiveFacts:
|
||||
if actor.principal_type != 'human' or actor.tenant != 'tenant:platform':
|
||||
raise AccessFailure(403, 'unsupported_facts_principal')
|
||||
try:
|
||||
# A single budget includes both observations. Sequential reads avoid
|
||||
# orphaned work on failure; preserve the oldest source timestamp.
|
||||
async with asyncio.timeout(3):
|
||||
account = await self.accounts.read(issuer=actor.issuer,subject=actor.subject,
|
||||
tenant=self.account_tenant)
|
||||
tenant = await self.tenants.read(identifier='tenant:platform')
|
||||
if not isinstance(account,AccountObservation) or not isinstance(tenant,TenantObservation):
|
||||
raise ValueError('typed owner observations required')
|
||||
if (account.issuer,account.subject,account.tenant) != (
|
||||
actor.issuer,actor.subject,self.account_tenant):
|
||||
raise ValueError('account binding mismatch')
|
||||
if tenant.identifier != 'tenant:platform':
|
||||
raise ValueError('tenant binding mismatch')
|
||||
for value in (account.account_active,account.root_entitled,tenant.active):
|
||||
if type(value) is not bool:
|
||||
raise ValueError('explicit owner state required')
|
||||
now = time.time()
|
||||
for observed in (account,tenant):
|
||||
_reference(observed.evidence_id)
|
||||
if (type(observed.checked_at) not in {int,float}
|
||||
or not math.isfinite(observed.checked_at)
|
||||
or not 0 <= now-observed.checked_at <= 5):
|
||||
raise ValueError('stale owner observation')
|
||||
if not isinstance(account.producer_addresses,frozenset):
|
||||
raise ValueError('immutable producer bindings required')
|
||||
for address in account.producer_addresses:
|
||||
_reference(address)
|
||||
evidence = 'sha256:' + hashlib.sha256(json.dumps({
|
||||
'account':account.evidence_id,'tenant':tenant.evidence_id,
|
||||
'mapping':self.mapping_evidence,
|
||||
},sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
return LiveFacts(actor.issuer,actor.subject,actor.tenant,'tenant:platform',
|
||||
account.account_active,tenant.active,tenant.active,
|
||||
account.root_entitled,min(account.checked_at,tenant.checked_at),
|
||||
evidence,account.producer_addresses)
|
||||
except Exception as exc:
|
||||
raise AccessFailure(503,'owner_facts_unavailable_or_untrusted') from exc
|
||||
|
||||
|
||||
def _reference(value):
|
||||
if not isinstance(value,str) or not value.strip():
|
||||
raise ValueError('nonempty owner reference required')
|
||||
Loading…
Add table
Add a link
Reference in a new issue