"""Consume native Flex Auth decisions with exact request and package binding. Wire canonicalization follows the existing Informed Decision consumer contract: Go struct field order, sorted maps and Go JSON HTML escaping. No local allow rule. """ from datetime import datetime import hashlib import json import re import time import uuid from telemetry_http import Transport, origin DIGEST = re.compile(r'sha256:[0-9a-f]{64}') def request_digest(request): def ordered(value): if isinstance(value, dict): return {key: ordered(value[key]) for key in sorted(value)} if isinstance(value, list): return [ordered(v) for v in value] if value is None or type(value) in (str, bool) or type(value) is int and abs(value) <= 2**53: return value raise ValueError('unsupported policy input') def ref(value, fields): return {k: ordered(value[k]) for k in fields if value.get(k)} material = dict(tenant=request['tenant'], subject=ref(request['subject'], ('id', 'type', 'tenant', 'attributes')), action=request['action'], resource=ref(request['resource'], ('id', 'type', 'system', 'tenant', 'attributes'))) if request.get('context'): material['context'] = ordered(request['context']) raw = json.dumps(material, ensure_ascii=False, separators=(',', ':'), allow_nan=False) for char, escaped in (('<', r'\u003c'), ('>', r'\u003e'), ('&', r'\u0026'), ('\u2028', r'\u2028'), ('\u2029', r'\u2029')): raw = raw.replace(char, escaped) return 'sha256:' + hashlib.sha256(raw.encode()).hexdigest() def timestamp(value): parsed = datetime.fromisoformat(value.replace('Z', '+00:00')) if parsed.tzinfo is None: raise ValueError('missing timezone') return parsed.timestamp() class Policy: def __init__(self, endpoint, token_provider, package, version, digest, *, transport=None, clock=time.time): self.endpoint = origin(endpoint, internal=True) + '/v1/check' if not package or not version or not DIGEST.fullmatch(digest): raise ValueError('pinned policy required') self.package, self.version, self.digest = package, version, digest self.token_provider, self.transport, self.clock = token_provider, transport or Transport(), clock def __call__(self, actor, action, resource): deny = {'effect': 'deny', 'id': '', 'expires_at': 0} if action not in ('read', 'acknowledge') or not re.fullmatch(r'alert:[0-9a-f-]{36}', resource): return deny tenant_source = {'directory': 'directory-asserted', 'registration': 'registration-supplied'}.get(actor.tenant_source) if not tenant_source: return deny request = dict(id=str(uuid.uuid4()), tenant='tenant:platform', subject=dict(id=actor.subject, type=actor.principal_type, tenant=actor.tenant, attributes=dict(issuer=actor.issuer, roles=list(actor.roles), groups=list(actor.groups), assurance=actor.assurance, tenant_source=tenant_source, principal_type_source='authentication-derived')), action=action, resource=dict(id=resource, type='telemetry-alert', system='railiance-telemetry', tenant='tenant:platform'), policy_version=self.version) # Registry assignments and current authentication evidence are distinct. # Registry enrichment may replace subject attributes; it must not replace # the live signed MFA/role observations used for revocation/freshness. request['context'] = {'authentication': dict(request['subject']['attributes'])} started = self.clock() try: token = self.token_provider() if not isinstance(token, str) or not 1 <= len(token) <= 32768 or any(ord(c) < 33 or ord(c) > 126 for c in token): return deny status, data = self.transport.request('POST', self.endpoint, json.dumps(request, separators=(',', ':'), ensure_ascii=False).encode(), {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token}) if (status != 200 or data.get('contract_version') != 'flex-auth.decision-record.v1' or data.get('request_id') != request['id'] or data.get('effect') != 'allow' or data.get('obligations', []) != [] or not isinstance(data.get('id'), str) or not data['id']): return deny binding, provenance = data['binding'], data['provenance'] if (binding['submitted_request_digest'] != request_digest(request) or not DIGEST.fullmatch(binding['request_digest']) or binding['tenant'] != request['tenant'] or binding['action'] != action or binding.get('context', {}) != request.get('context', {})): return deny for key, fields in (('subject', ('id', 'type', 'tenant')), ('resource', ('id', 'type', 'system', 'tenant'))): for name in fields: if binding[key].get(name) != request[key].get(name) or data[key].get(name) != binding[key].get(name): return deny if (provenance['policy_package'] != self.package or provenance['policy_version'] != self.version or provenance['policy_package_digest'] != self.digest or data['matched_policy_version'] != self.version or not DIGEST.fullmatch(provenance['registry_snapshot_digest']) or not provenance['evaluator'].startswith('flex-auth/') or not started - 30 <= timestamp(provenance['decision_time']) <= self.clock() + 30): return deny lifetime = data['lifetime'] until = min(timestamp(lifetime['expires_at']), started + 30, actor.expires_at) if (lifetime['kind'] != 'ttl' or until <= self.clock() or timestamp(lifetime.get('not_before', provenance['decision_time'])) > self.clock()): return deny return {'effect': 'allow', 'id': data['id'], 'expires_at': until} except (OSError, KeyError, ValueError, TypeError, AttributeError): return deny