feat: consume account authority and refresh blocked dependencies
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
6c7c899d75
commit
4153ad7305
11 changed files with 498 additions and 25 deletions
101
hub_core/security/account_reader.py
Normal file
101
hub_core/security/account_reader.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue