"""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