Implement authenticated alert receipt acknowledgments and audit delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e6f1-443f-7783-9920-a16b2ffc467f
This commit is contained in:
parent
67283b66c2
commit
e7282e493d
25 changed files with 1881 additions and 0 deletions
111
scripts/alert_policy.py
Normal file
111
scripts/alert_policy.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue