hub-core/hub_core/security/account_reader.py
tegwick 4153ad7305
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s
feat: consume account authority and refresh blocked dependencies
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
2026-10-04 12:12:09 +02:00

101 lines
5.4 KiB
Python

"""User Engine account-authority HTTP reader; no credential issuance or fallback."""
from __future__ import annotations
import asyncio
import hashlib
import json
import time
from datetime import datetime
from pathlib import Path
import httpx
from hub_core.security.facts import AccountObservation
from hub_core.security.identity import AccessFailure, require_https
from hub_core.security.policy import parse_json
STATUSES = {'invited', 'active', 'disabled', 'suspended', 'deletion_pending', 'deleted'}
GRANT = {'scope_type': 'tenant', 'scope_id': 'platform',
'relation': 'platform-root-custodian', 'owner_system': 'net-kingdom-custody'}
def timestamp(value):
if not isinstance(value, str):
raise ValueError('owner timestamp required')
result = datetime.fromisoformat(value.replace('Z', '+00:00'))
if result.utcoffset() is None:
raise ValueError('owner timestamp timezone required')
return result.timestamp()
def normalize_account(body, *, issuer, subject, tenant, now):
if not isinstance(body, dict) or (body['issuer'], body['subject'], body['tenant']) != (issuer, subject, tenant):
raise ValueError('account binding mismatch')
for key in ('identity_id', 'user_id', 'account_id', 'decision_id'):
if not isinstance(body[key], str) or not body[key].strip():
raise ValueError('owner evidence missing')
if body['account_status'] not in STATUSES or body['tenant_account_status'] not in STATUSES | {None}:
raise ValueError('unknown account state')
observed = timestamp(body['observed_at'])
if not 0 <= now-observed <= 5:
raise ValueError('stale account observation')
# An explicit null is a valid no-grant result. Missing field is an old or
# incomplete authority contract, not permission to infer entitlement.
grant = body['root_grant']
deadline = None
if grant is not None:
if tenant != 'platform' or set(grant) != {'subject','grant','source','observed_at','expires_at'}:
raise ValueError('unsupported grant profile')
if grant['subject'] != {'issuer':issuer,'subject':subject} or grant['grant'] != GRANT:
raise ValueError('grant identity or scope mismatch')
source = grant['source']
if (set(source) != {'record_id','revision'} or source['record_id'] != 'nk-custody:platform-root'
or type(source['revision']) is not int or source['revision'] < 1):
raise ValueError('invalid custody provenance')
grant_time = timestamp(grant['observed_at'])
deadline = timestamp(grant['expires_at'])
if not 0 <= now-grant_time <= 5 or not now < deadline <= grant_time+900:
raise ValueError('stale custody observation')
observed = min(observed, grant_time)
evidence = {key: body[key] for key in ('issuer','subject','tenant','identity_id','user_id',
'account_id','decision_id','account_status','tenant_account_status','observed_at','root_grant')}
digest = hashlib.sha256(json.dumps(evidence,sort_keys=True,separators=(',',':')).encode()).hexdigest()
return AccountObservation(issuer,subject,tenant,
body['account_status'] == body['tenant_account_status'] == 'active',
grant is not None, observed, 'user-engine:sha256:'+digest, valid_until=deadline)
class UserEngineAccountReader:
def __init__(self, *, base_url: str, token_file: Path, client: httpx.AsyncClient):
require_https(base_url)
if not isinstance(token_file,Path) or not token_file.is_absolute():
raise ValueError('absolute dedicated workload token path required')
self.base_url, self.token_file, self.client = base_url.rstrip('/'), token_file, client
async def read(self, *, issuer: str, subject: str, tenant: str) -> AccountObservation:
try:
async with asyncio.timeout(3):
with self.token_file.open('rb') as stream:
raw = stream.read(16385)
if len(raw) > 16384:
raise ValueError('oversized credential')
token = raw.decode('ascii').strip()
if not token or any(ord(c) < 33 or ord(c) > 126 for c in token):
raise ValueError('invalid credential')
async with self.client.stream('POST',self.base_url+'/api/v1/platform/account-authority/lookup',
json={'issuer':issuer,'subject':subject,'tenant':tenant},
headers={'Authorization':'Bearer '+token,'Cache-Control':'no-cache',
'Accept-Encoding':'identity'},timeout=3,follow_redirects=False) as response:
if response.status_code != 200 or response.headers.get('age','0') != '0':
raise ValueError('owner lookup refused or cached')
if response.headers.get('content-encoding','identity') != 'identity':
raise ValueError('encoded authority response')
data = bytearray()
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > 65536:
raise ValueError('oversized authority response')
return normalize_account(parse_json(bytes(data)),issuer=issuer,subject=subject,
tenant=tenant,now=time.time())
except Exception as exc:
raise AccessFailure(503,'account_authority_unavailable_or_untrusted') from exc