railiance-telemetry/scripts/alert_policy.py
tegwick e7282e493d Implement authenticated alert receipt acknowledgments and audit delivery
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f1-443f-7783-9920-a16b2ffc467f
2026-09-28 11:11:33 +02:00

111 lines
6.2 KiB
Python

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