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
158
tests/test_alert_ack.py
Normal file
158
tests/test_alert_ack.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from dataclasses import replace
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from urllib.parse import urlencode
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from alert_ack import Actor, Application, Store, occurrence
|
||||
|
||||
NOW = 1790553600 # 2026-09-28 UTC
|
||||
START = '2026-09-28T00:00:00Z'
|
||||
|
||||
|
||||
class AcknowledgmentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = Path(self.temp.name) / 'ack.db'
|
||||
self.store = Store(self.path)
|
||||
self.actor = Actor('https://issuer.example', 'test-human', 'tenant:platform',
|
||||
('railiance-admin',), NOW + 900, 'c' * 32)
|
||||
self.decision = {'id': 'test-decision', 'effect': 'allow', 'expires_at': NOW + 30}
|
||||
self.app = Application(self.store, 'https://telemetry.example', 'w' * 32,
|
||||
lambda env: self.actor, lambda *args: self.decision,
|
||||
clock=lambda: NOW)
|
||||
self.alert = {'status': 'firing', 'fingerprint': '0123456789abcdef',
|
||||
'startsAt': START, 'labels': {'alertname': 'TestFailure', 'owner': 'railiance-telemetry'}}
|
||||
self.payload = {'version': '4', 'receiver': 'railiance-admin-email', 'alerts': [self.alert]}
|
||||
self.identity = occurrence(self.alert['fingerprint'], START)
|
||||
self.query = urlencode({'fingerprint': self.alert['fingerprint'], 'starts_at': START})
|
||||
|
||||
def request(self, method='GET', path='/ack/alerts', body=b'', **extra):
|
||||
env = {'REQUEST_METHOD': method, 'PATH_INFO': path, 'QUERY_STRING': self.query,
|
||||
'CONTENT_LENGTH': str(len(body)), 'wsgi.input': io.BytesIO(body),
|
||||
'HTTP_ORIGIN': 'https://telemetry.example'}
|
||||
env.update(extra)
|
||||
result = {}
|
||||
raw = b''.join(self.app(env, lambda status, headers: result.update(status=status, headers=headers)))
|
||||
return result['status'], raw.decode()
|
||||
|
||||
def receive(self):
|
||||
self.store.receive(self.payload, NOW)
|
||||
|
||||
def post(self, **kw):
|
||||
return self.request('POST', body=urlencode({'csrf': self.actor.csrf}).encode(), **kw)
|
||||
|
||||
def test_scanner_get_does_not_acknowledge(self):
|
||||
self.receive()
|
||||
status, page = self.request()
|
||||
self.assertEqual(status, '200 OK')
|
||||
self.assertIn('Acknowledge receipt', page)
|
||||
self.assertIsNone(self.store.get(self.identity)['occurred_at'])
|
||||
with self.store.connect() as db:
|
||||
self.assertEqual(db.execute('SELECT COUNT(*) FROM outbox').fetchone()[0], 0)
|
||||
|
||||
def test_click_commits_ack_and_audit_once_across_restart(self):
|
||||
self.receive()
|
||||
self.assertIn('Audit delivery pending', self.post()[1])
|
||||
self.store = Store(self.path)
|
||||
self.app.store = self.store
|
||||
self.post()
|
||||
with self.store.connect() as db:
|
||||
rows = db.execute('SELECT body FROM outbox').fetchall()
|
||||
self.assertEqual(len(rows), 1)
|
||||
event = json.loads(rows[0]['body'])
|
||||
self.assertEqual(event['data']['actor_subject'], 'test-human')
|
||||
self.assertEqual(event['data']['role'], 'railiance-admin')
|
||||
self.assertEqual(event['subject'], 'alert:' + self.identity)
|
||||
self.assertEqual(event['type'], 'telemetry.alert.acknowledged')
|
||||
|
||||
def test_failed_outbox_rolls_back_ack(self):
|
||||
self.receive()
|
||||
with self.store.connect() as db:
|
||||
db.execute("CREATE TRIGGER simulate_full BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'full'); END")
|
||||
self.assertEqual(self.post()[0], '503 Service Unavailable')
|
||||
self.assertIsNone(self.store.get(self.identity)['occurred_at'])
|
||||
|
||||
def test_wrong_actor_role_tenant_service_expiry_denied(self):
|
||||
self.receive()
|
||||
original = self.actor
|
||||
for change in ({'roles': ()}, {'tenant': 'tenant:other'}, {'principal_type': 'service'}, {'expires_at': NOW}):
|
||||
self.actor = replace(original, **change)
|
||||
self.assertIn(self.post()[0], ('401 Unauthorized', '403 Forbidden'))
|
||||
self.assertIsNone(self.store.get(self.identity)['occurred_at'])
|
||||
|
||||
def test_email_or_headers_do_not_grant_role(self):
|
||||
self.receive()
|
||||
self.actor = replace(self.actor, roles=())
|
||||
self.assertEqual(self.post(HTTP_X_EMAIL='bernd.worsch@gmail.com', HTTP_X_ROLE='railiance-admin')[0], '403 Forbidden')
|
||||
|
||||
def test_origin_csrf_and_expired_pdp_denied(self):
|
||||
self.receive()
|
||||
self.assertEqual(self.post(HTTP_ORIGIN='https://attacker.example')[0], '403 Forbidden')
|
||||
self.assertEqual(self.request('POST', body=b'csrf=wrong')[0], '403 Forbidden')
|
||||
self.decision['expires_at'] = NOW
|
||||
self.assertEqual(self.post()[0], '403 Forbidden')
|
||||
self.assertIsNone(self.store.get(self.identity)['occurred_at'])
|
||||
|
||||
def test_webhook_auth_and_retry_and_batch_rollback(self):
|
||||
raw = json.dumps(self.payload).encode()
|
||||
self.assertEqual(self.request('POST', '/webhook', raw)[0], '401 Unauthorized')
|
||||
for _ in range(2):
|
||||
self.assertEqual(self.request('POST', '/webhook', raw, HTTP_AUTHORIZATION='Bearer ' + 'w' * 32)[0], '200 OK')
|
||||
changed = json.loads(raw)
|
||||
changed['alerts'][0]['labels']['alertname'] = 'Different'
|
||||
with self.assertRaises(ValueError): self.store.receive(changed, NOW)
|
||||
self.assertEqual(self.store.get(self.identity)['alertname'], 'TestFailure')
|
||||
|
||||
def test_new_firing_occurrence_requires_new_ack(self):
|
||||
self.receive()
|
||||
self.post()
|
||||
self.alert['startsAt'] = '2026-09-27T23:59:59Z'
|
||||
other = self.store.receive(self.payload, NOW)[0]
|
||||
self.assertNotEqual(other, self.identity)
|
||||
self.assertIsNone(self.store.get(other)['occurred_at'])
|
||||
|
||||
def test_lost_audit_receipt_replays_original_event(self):
|
||||
self.receive()
|
||||
self.post()
|
||||
sent = []
|
||||
def lost(event):
|
||||
sent.append(event)
|
||||
raise TimeoutError()
|
||||
self.store.drain(lost)
|
||||
def duplicate(event):
|
||||
self.assertEqual(event, sent[0])
|
||||
return 200, {'status': 'duplicate', 'reference': 'audit:' + event['id']}
|
||||
self.store.drain(duplicate)
|
||||
self.assertEqual(self.store.get(self.identity)['audit_status'], 'delivered')
|
||||
self.assertIn('Audit record archived', self.request()[1])
|
||||
|
||||
def test_audit_refusal_retained_and_wrong_receipt_not_accepted(self):
|
||||
self.receive()
|
||||
self.post()
|
||||
self.store.drain(lambda event: (202, {'status': 'accepted', 'reference': 'wrong'}))
|
||||
self.assertEqual(self.store.get(self.identity)['audit_status'], 'pending')
|
||||
self.store.drain(lambda event: (403, {}))
|
||||
self.assertEqual(self.store.get(self.identity)['audit_status'], 'blocked')
|
||||
self.assertIn('Audit delivery pending', self.request()[1])
|
||||
self.assertEqual(self.store.audit_debt(), {'blocked': 1})
|
||||
with self.store.connect() as db:
|
||||
event = json.loads(db.execute('SELECT body FROM outbox').fetchone()[0])
|
||||
self.assertTrue(self.store.requeue(event['id']))
|
||||
self.store.drain(lambda value: (202, {'status': 'accepted', 'reference': 'audit:' + value['id']}))
|
||||
self.assertEqual(self.store.audit_debt(), {})
|
||||
|
||||
def test_unsafe_database_refused(self):
|
||||
os.chmod(self.path, 0o644)
|
||||
with self.assertRaises(ValueError): Store(self.path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
48
tests/test_alert_audit_native.py
Normal file
48
tests/test_alert_audit_native.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Opt in with RTEL_AUDIT_CORE_SOURCE; uses the real receiver, synthetic custody."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from alert_ack import Actor, Store
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('RTEL_AUDIT_CORE_SOURCE'), 'set RTEL_AUDIT_CORE_SOURCE for real receiver check')
|
||||
class NativeAuditTests(unittest.TestCase):
|
||||
def test_real_receiver_accepts_and_deduplicates_after_lost_reply(self):
|
||||
sys.path.insert(0, os.environ['RTEL_AUDIT_CORE_SOURCE'])
|
||||
from audit_core.ingestion import IngestionApplication
|
||||
from audit_core.senders import SenderIdentity, SenderRegistry
|
||||
from audit_core.sqlite_backend import SQLiteAuditBackend
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Store(Path(tmp) / 'ack.db')
|
||||
identity = store.receive({'version': '4', 'receiver': 'railiance-admin-email', 'alerts': [
|
||||
{'status': 'firing', 'fingerprint': '0123456789abcdef', 'startsAt': '2026-09-28T00:00:00Z',
|
||||
'labels': {'alertname': 'ControlledFailure', 'owner': 'railiance-telemetry'}}]}, 1790553600)[0]
|
||||
store.acknowledge(identity, Actor('https://issuer.example', 'fixture-human', 'tenant:platform',
|
||||
('railiance-admin',), 1790554500, 'c' * 32), 'fixture-decision', 1790553600)
|
||||
sender = SenderIdentity(name='railiance-telemetry', tokens=('fixture-only',),
|
||||
sources=frozenset({'railiance-telemetry'}), tenants=frozenset({'tenant:platform'}),
|
||||
evidence_kind='load-bearing', may_read=False)
|
||||
app = IngestionApplication(SQLiteAuditBackend(str(Path(tmp) / 'audit.db')), SenderRegistry([sender]))
|
||||
calls = []
|
||||
def send(event):
|
||||
raw = json.dumps(event).encode()
|
||||
env = {'REQUEST_METHOD': 'POST', 'PATH_INFO': '/v1/events',
|
||||
'CONTENT_LENGTH': str(len(raw)), 'wsgi.input': io.BytesIO(raw),
|
||||
'HTTP_AUTHORIZATION': 'Bearer fixture-only', 'HTTP_IDEMPOTENCY_KEY': event['id']}
|
||||
response = {}
|
||||
body = b''.join(app(env, lambda status, headers: response.update(status=int(status[:3]))))
|
||||
calls.append(response['status'])
|
||||
if len(calls) == 1:
|
||||
raise TimeoutError('simulated lost response')
|
||||
return response['status'], json.loads(body)
|
||||
store.drain(send)
|
||||
store = Store(Path(tmp) / 'ack.db')
|
||||
store.drain(send)
|
||||
self.assertEqual(calls, [202, 200])
|
||||
self.assertEqual(store.get(identity)['audit_status'], 'delivered')
|
||||
50
tests/test_alert_audit_transport.py
Normal file
50
tests/test_alert_audit_transport.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from alert_audit import AuditTransport
|
||||
|
||||
|
||||
class AuditTransportTests(unittest.TestCase):
|
||||
def test_real_http_headers_and_redirect_refusal(self):
|
||||
calls = []
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
calls.append((self.path, self.headers['Authorization'], self.headers['Idempotency-Key']))
|
||||
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||
if body['id'] == 'redirect':
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/credential-leak')
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(202)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'accepted', 'reference': 'audit:' + body['id']}).encode())
|
||||
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever)
|
||||
thread.start()
|
||||
try:
|
||||
transport = AuditTransport(f'http://127.0.0.1:{server.server_port}',
|
||||
lambda: 'fixture-credential', allow_internal_http=True)
|
||||
self.assertEqual(transport({'id': 'test'}), (202, {'status': 'accepted', 'reference': 'audit:test'}))
|
||||
self.assertEqual(transport({'id': 'redirect'}), (302, {}))
|
||||
self.assertEqual(calls, [('/v1/events', 'Bearer fixture-credential', 'test'),
|
||||
('/v1/events', 'Bearer fixture-credential', 'redirect')])
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
server.server_close()
|
||||
|
||||
def test_public_cleartext_and_credentialed_origin_refused(self):
|
||||
for origin in ('http://audit.example', 'https://user:password@audit.example',
|
||||
'https://audit.example/other', 'https://audit.example?next=elsewhere'):
|
||||
with self.assertRaises(ValueError):
|
||||
AuditTransport(origin, lambda: 'fixture', allow_internal_http=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue