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
131
tests_runtime/test_identity_policy.py
Normal file
131
tests_runtime/test_identity_policy.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from alert_identity import Login, SCOPES
|
||||
from alert_policy import Policy, request_digest
|
||||
from alert_ack import Actor
|
||||
|
||||
|
||||
class Issuer:
|
||||
def __init__(self):
|
||||
self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
self.jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self.key.public_key()))
|
||||
self.jwk.update(kid='fixture', use='sig', alg='RS256')
|
||||
self.nonce = ''
|
||||
self.change = lambda c: c
|
||||
|
||||
def request(self, method, url, body=None, headers=None):
|
||||
if url.endswith('/.well-known/openid-configuration'):
|
||||
return 200, dict(issuer='https://issuer.example', authorization_endpoint='https://issuer.example/authorize',
|
||||
token_endpoint='https://issuer.example/token', jwks_uri='https://issuer.example/jwks',
|
||||
code_challenge_methods_supported=['S256'])
|
||||
if url.endswith('/jwks'):
|
||||
return 200, {'keys': [self.jwk]}
|
||||
now = int(time.time())
|
||||
claims = dict(iss='https://issuer.example', sub='fixture-human', aud='railiance-telemetry-admin',
|
||||
iat=now, exp=now + 600, nonce=self.nonce, tenant='tenant:platform',
|
||||
tenant_source='registration', principal_type='human', groups=[], roles=['railiance-admin'],
|
||||
assurance=dict(level='aal2', mfa=True, source='key-cape', methods=['pwd', 'otp'], at=now))
|
||||
claims = self.change(claims)
|
||||
identity = jwt.encode(claims, self.key, algorithm='RS256', headers={'kid': 'fixture'})
|
||||
access = dict(claims, aud='railiance-telemetry', scope=' '.join(SCOPES))
|
||||
return 200, dict(id_token=identity, access_token=jwt.encode(access, self.key, algorithm='RS256', headers={'kid': 'fixture'}))
|
||||
|
||||
|
||||
class IdentityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.issuer = Issuer()
|
||||
self.login = Login('https://issuer.example', 'https://telemetry.example', transport=self.issuer)
|
||||
|
||||
def start(self):
|
||||
url, browser = self.login.start('/ack/alerts?fingerprint=0123456789abcdef&starts_at=2026-09-28T00:00:00Z')
|
||||
params = parse_qs(urlsplit(url).query)
|
||||
self.issuer.nonce = params['nonce'][0]
|
||||
self.assertEqual(params['code_challenge_method'], ['S256'])
|
||||
return params['state'][0], browser
|
||||
|
||||
def actor(self):
|
||||
state, browser = self.start()
|
||||
sid, target = self.login.finish(state, browser, 'fixture-code')
|
||||
return self.login.session(sid)
|
||||
|
||||
def test_verified_human_session_and_logout(self):
|
||||
state, browser = self.start()
|
||||
sid, target = self.login.finish(state, browser, 'fixture-code')
|
||||
actor = self.login.session(sid)
|
||||
self.assertEqual(actor.subject, 'fixture-human')
|
||||
self.assertEqual(actor.roles, ('railiance-admin',))
|
||||
self.assertLessEqual(actor.expires_at, time.time() + 600)
|
||||
self.login.logout(sid)
|
||||
self.assertIsNone(self.login.session(sid))
|
||||
with self.assertRaises(ValueError): self.login.finish(state, browser, 'fixture-code')
|
||||
|
||||
def test_wrong_browser_and_state_replay_refused(self):
|
||||
state, browser = self.start()
|
||||
with self.assertRaises(ValueError): self.login.finish(state, 'other', 'fixture-code')
|
||||
with self.assertRaises(ValueError): self.login.finish(state, browser, 'fixture-code')
|
||||
|
||||
def test_nonce_wrong_issuer_wrong_tenant_and_weak_mfa(self):
|
||||
for changes in ({'nonce': 'wrong'}, {'iss': 'https://other.example'}, {'tenant': 'tenant:other'},
|
||||
{'principal_type': 'service'}, {'assurance': {'mfa': False}}, {'exp': 1}):
|
||||
self.issuer.change = lambda claims, changes=changes: dict(claims, **changes)
|
||||
state, browser = self.start()
|
||||
with self.assertRaises(ValueError): self.login.finish(state, browser, 'fixture-code')
|
||||
|
||||
def test_return_path_cannot_escape_surface(self):
|
||||
for value in ('https://attacker.example/ack/alerts', '//attacker.example/ack/alerts', '/other'):
|
||||
with self.assertRaises(ValueError): self.login.start(value)
|
||||
|
||||
|
||||
class DecisionTransport:
|
||||
def __init__(self):
|
||||
self.change = lambda response: response
|
||||
|
||||
def request(self, method, url, body=None, headers=None):
|
||||
request = json.loads(body)
|
||||
now = time.time()
|
||||
stamp = lambda delta: datetime.fromtimestamp(now + delta, timezone.utc).isoformat()
|
||||
response = dict(id='fixture-decision', request_id=request['id'], contract_version='flex-auth.decision-record.v1',
|
||||
effect='allow', obligations=[], matched_policy_version='v1', subject=request['subject'], resource=request['resource'],
|
||||
binding=dict(tenant=request['tenant'], action=request['action'], subject=request['subject'], resource=request['resource'],
|
||||
context=request.get('context', {}),
|
||||
submitted_request_digest=request_digest(request), request_digest='sha256:' + 'b' * 64),
|
||||
provenance=dict(policy_package='telemetry.ack', policy_version='v1', policy_package_digest='sha256:' + 'a' * 64,
|
||||
registry_snapshot_digest='sha256:' + 'c' * 64, evaluator='flex-auth/fixture', decision_time=stamp(0)),
|
||||
lifetime=dict(kind='ttl', not_before=stamp(-1), expires_at=stamp(30)))
|
||||
return 200, self.change(response)
|
||||
|
||||
|
||||
class PolicyTests(unittest.TestCase):
|
||||
def test_policy_pins_and_action_bindings_fail_closed(self):
|
||||
actor = Actor('https://issuer.example', 'fixture-human', 'tenant:platform', ('railiance-admin',),
|
||||
time.time() + 600, 'c' * 32, tenant_source='directory')
|
||||
transport = DecisionTransport()
|
||||
policy = Policy('https://policy.example', lambda: 'fixture-workload-token', 'telemetry.ack', 'v1',
|
||||
'sha256:' + 'a' * 64, transport=transport)
|
||||
resource = 'alert:11111111-1111-1111-1111-111111111111'
|
||||
self.assertEqual(policy(actor, 'acknowledge', resource)['effect'], 'allow')
|
||||
mutations = [lambda r: dict(r, effect='deny'), lambda r: dict(r, obligations=['unknown']),
|
||||
lambda r: dict(r, request_id='other'),
|
||||
lambda r: dict(r, binding=dict(r['binding'], action='read')),
|
||||
lambda r: dict(r, binding=dict(r['binding'], submitted_request_digest='sha256:' + '0' * 64)),
|
||||
lambda r: dict(r, provenance=dict(r['provenance'], policy_package_digest='sha256:' + '0' * 64)),
|
||||
lambda r: dict(r, lifetime=dict(r['lifetime'], expires_at='2000-01-01T00:00:00Z'))]
|
||||
for change in mutations:
|
||||
transport.change = change
|
||||
self.assertEqual(policy(actor, 'acknowledge', resource)['effect'], 'deny')
|
||||
self.assertEqual(policy(actor, 'delete', resource)['effect'], 'deny')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
42
tests_runtime/test_policy_native.py
Normal file
42
tests_runtime/test_policy_native.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
from alert_ack import Actor
|
||||
from alert_policy import Policy
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('RTEL_FLEX_AUTH_BINARY'), 'set RTEL_FLEX_AUTH_BINARY')
|
||||
class NativePolicyTests(unittest.TestCase):
|
||||
def test_native_wire_digest_and_revoked_or_wrong_identity_denials(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
request_file = Path(directory) / 'request.json'
|
||||
class Native:
|
||||
def request(self, method, url, body=None, headers=None):
|
||||
request_file.write_bytes(body)
|
||||
result = subprocess.run([os.environ['RTEL_FLEX_AUTH_BINARY'], 'check', '--policy',
|
||||
str(ROOT / 'integration/telemetry-policy.md'), '--registry', str(ROOT / 'integration/registry.json'),
|
||||
'--request', str(request_file)], capture_output=True, check=True)
|
||||
return 200, json.loads(result.stdout)
|
||||
actor = Actor('https://kc.coulomb.social', 'uid=tegwick,ou=people,dc=netkingdom,dc=local',
|
||||
'tenant:platform', ('railiance-admin',), time.time() + 600, 'c' * 32,
|
||||
assurance=dict(level='aal2', mfa=True, source='key-cape', methods=['pwd', 'otp'], at=int(time.time())),
|
||||
groups=('railiance-admins',), tenant_source='directory')
|
||||
# Native digest is pinned by the owner packet, not inferred from a response.
|
||||
policy = Policy('https://policy.example', lambda: 'fixture-only',
|
||||
'railiance-telemetry.alert-acknowledgment', 'v1',
|
||||
'sha256:02938202cf75140d6ab638b8d0fbce6ebb22832354efc89cab040b13e8943f09', transport=Native())
|
||||
resource = 'alert:11111111-1111-1111-1111-111111111111'
|
||||
self.assertEqual(policy(actor, 'acknowledge', resource)['effect'], 'allow')
|
||||
for changed in (replace(actor, roles=()), replace(actor, groups=()), replace(actor, subject='other'),
|
||||
replace(actor, tenant='tenant:other'), replace(actor, issuer='https://other.example'),
|
||||
replace(actor, assurance=dict(actor.assurance, at=1))):
|
||||
self.assertEqual(policy(changed, 'acknowledge', resource)['effect'], 'deny')
|
||||
57
tests_runtime/test_service.py
Normal file
57
tests_runtime/test_service.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
from alert_ack import Store
|
||||
from alert_identity import Login
|
||||
from alert_service import Router
|
||||
from test_identity_policy import Issuer
|
||||
|
||||
|
||||
class ServiceTests(unittest.TestCase):
|
||||
def test_browser_login_confirmation_and_logout(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
token = Path(tmp) / 'webhook-token'
|
||||
token.write_text('w' * 32)
|
||||
store = Store(Path(tmp) / 'state.db')
|
||||
issuer = Issuer()
|
||||
login = Login('https://issuer.example', 'https://telemetry.example', transport=issuer)
|
||||
router = Router({'origin': 'https://telemetry.example', 'webhook_token_file': str(token)}, store,
|
||||
login=login, policy=lambda *args: dict(id='fixture-decision', effect='allow', expires_at=time.time() + 30))
|
||||
query = urlencode(dict(fingerprint='0123456789abcdef', starts_at='2026-09-28T00:00:00Z'))
|
||||
def call(path, method='GET', cookie='', query=query, body=b''):
|
||||
env = dict(PATH_INFO=path, REQUEST_METHOD=method, HTTP_COOKIE=cookie, QUERY_STRING=query,
|
||||
CONTENT_LENGTH=str(len(body)), HTTP_ORIGIN='https://telemetry.example')
|
||||
env['wsgi.input'] = io.BytesIO(body)
|
||||
result = {}
|
||||
output = b''.join(router(env, lambda status, headers: result.update(status=status, headers=headers)))
|
||||
return result, output.decode()
|
||||
response, _ = call('/ack/alerts')
|
||||
self.assertEqual(response['status'], '303 See Other')
|
||||
headers = dict(response['headers'])
|
||||
browser_cookie = headers['Set-Cookie'].split(';')[0]
|
||||
params = parse_qs(urlsplit(headers['Location']).query)
|
||||
issuer.nonce = params['nonce'][0]
|
||||
response, _ = call('/ack/auth/callback', cookie=browser_cookie,
|
||||
query=urlencode(dict(state=params['state'][0], code='fixture-code')))
|
||||
self.assertEqual(response['status'], '303 See Other')
|
||||
cookies = [value for key, value in response['headers'] if key == 'Set-Cookie']
|
||||
session_cookie = next(c.split(';')[0] for c in cookies if c.startswith('__Host-rtel-session='))
|
||||
actor = login.session(session_cookie.split('=', 1)[1])
|
||||
identity = store.receive({'version': '4', 'receiver': 'railiance-admin-email', 'alerts': [
|
||||
{'status': 'firing', 'fingerprint': '0123456789abcdef', 'startsAt': '2026-09-28T00:00:00Z',
|
||||
'labels': {'alertname': 'TestFailure', 'owner': 'railiance-telemetry'}}]}, time.time())[0]
|
||||
response, page = call('/ack/alerts', cookie=session_cookie)
|
||||
self.assertIn('Acknowledge receipt', page)
|
||||
self.assertIsNone(store.get(identity)['occurred_at'])
|
||||
form = urlencode(dict(csrf=actor.csrf)).encode()
|
||||
response, page = call('/ack/alerts', 'POST', session_cookie, body=form)
|
||||
self.assertIn('Receipt acknowledged', page)
|
||||
response, _ = call('/ack/logout', 'POST', session_cookie, query='', body=form)
|
||||
self.assertEqual(response['status'], '200 OK')
|
||||
self.assertIsNone(login.session(session_cookie.split('=', 1)[1]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue