Prepare authenticated lifecycle runtime and production release candidate

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 21:40:19 +02:00
parent f3b71b1521
commit 4f546ae3b1
41 changed files with 2598 additions and 52 deletions

View file

@ -26,7 +26,7 @@ FIXTURE = Path(__file__).parent / 'fixtures/review-policy'
@contextmanager
def flex_component(binary, directory, signing_key):
def flex_component(binary, directory, signing_key, *, fixture=FIXTURE):
tokens={'synthetic-workload-token':'system:serviceaccount:informed-decision:review',
'synthetic-wrong-caller':'system:serviceaccount:other:other'}
calls=[]
@ -55,7 +55,7 @@ def flex_component(binary, directory, signing_key):
listener.bind(('127.0.0.1',0));port=listener.getsockname()[1]
origin=f'http://127.0.0.1:{port}'
process=subprocess.Popen([str(binary),'serve','--addr',f'127.0.0.1:{port}',
'--registry',str(FIXTURE/'registry.json'),'--policy',str(FIXTURE/'policy.md'),
'--registry',str(fixture/'registry.json'),'--policy',str(fixture/'policy.md'),
'--caller-auth-mode','enforce','--caller-binding','informed-decision=system:serviceaccount:informed-decision:review',
'--caller-kubernetes-url',f'http://127.0.0.1:{server.server_port}',
'--caller-reviewer-token-file',str(reviewer),'--caller-ca-file',str(ca)],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
@ -69,7 +69,7 @@ def flex_component(binary, directory, signing_key):
except TransportError:pass
time.sleep(.05)
else:raise RuntimeError('fixture evaluator did not start')
pin=json.loads((FIXTURE/'pin.json').read_text());pin.pop('fixture_only')
pin=json.loads((fixture/'pin.json').read_text());pin.pop('fixture_only', None)
client=PolicyClient(origin,lambda:'synthetic-workload-token',allow_internal_http=True,**pin)
yield client,calls
finally:

View file

@ -0,0 +1,15 @@
"""Prevent a tested dependency and its shipped container pin from diverging."""
from pathlib import Path
import re
import tomllib
def test_container_dependencies_match_project_lock():
root = Path(__file__).resolve().parents[1]
lock = tomllib.loads((root/'uv.lock').read_text())
locked = {p['name'].lower(): p['version'] for p in lock['package']}
requirements = dict(re.findall(r'^([a-zA-Z0-9_-]+)==([^ ;\\\n]+)',
(root/'requirements.lock').read_text(), re.MULTILINE))
assert requirements
for name, version in requirements.items():
assert locked[name.lower()] == version, name

View file

@ -97,3 +97,53 @@ def test_optional_workspace_requires_distinct_pins_and_exact_policy_network_peer
data['lifecycle_policy']['package'] = data['policy']['package']
with pytest.raises(ValueError):
module.render(data)
def workload_inputs():
data = inputs()
data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle', 'pod_name': 'flex-auth-lifecycle'}
data['lifecycle_delivery'] = True
data['intake_attributes'] = True
data['intake_delivery'] = {'hub_origin': 'http://state-hub.state-hub.svc.cluster.local:8000'}
data['workload_api'] = {'intake': True, 'lifecycle': True,
'registries_configmap': 'informed-decision-workload-registry',
'peers': [{'namespace': 'state-hub', 'app': 'state-hub'}],
'api_egress': [{'ip': '10.43.0.1', 'port': 443}, {'ip': '92.205.62.239', 'port': 6443}]}
return data
def test_private_workload_candidate_has_exact_rbac_peers_and_projection():
objects = module.render(workload_inputs())['items']
services = [o for o in objects if o['kind'] == 'Service']
assert len(services) == 2
assert services[0]['spec']['ports'][0]['port'] == 80
assert services[1]['spec']['ports'][0]['port'] == 8081
assert all(o['kind'] not in ('Secret', 'Ingress') for o in objects)
role = next(o for o in objects if o['kind'] == 'ClusterRole')
assert role['rules'] == [{'apiGroups':['authentication.k8s.io'],'resources':['tokenreviews'],'verbs':['create']}]
binding = next(o for o in objects if o['kind'] == 'ClusterRoleBinding')
assert binding['subjects'] == [{'kind':'ServiceAccount','name':'review','namespace':'informed-decision'}]
policy = next(o for o in objects if o['kind']=='NetworkPolicy' and o['metadata']['name']=='informed-decision-review')
rule = policy['spec']['ingress'][-1]
assert rule['ports'] == [{'protocol':'TCP','port':8081}]
assert rule['from'][0]['namespaceSelector']['matchLabels'] == {'kubernetes.io/metadata.name':'state-hub'}
pod = next(o for o in objects if o['kind']=='Deployment')['spec']['template']['spec']
registry = next(m for m in pod['containers'][0]['volumeMounts'] if m['name']=='workload-registry')
assert registry['readOnly'] and 'subPath' not in registry
requester = next(v for v in pod['volumes'] if v['name']=='sitting-requester')
assert requester['secret']['secretName'] == 'informed-decision-sitting-requester'
assert requester['secret']['defaultMode'] == 0o440
config = json.loads(next(o for o in objects if o['kind']=='ConfigMap')['data']['runtime.json'])
assert config['workload_api']['token_review']['origin'] == 'https://kubernetes.default.svc:443'
@pytest.mark.parametrize('fault', ['wildcard','missing_delivery','no_policy','broad_api','false_type','empty_peers'])
def test_private_workload_candidate_refuses_incomplete_or_broad_configuration(fault):
data = workload_inputs()
if fault == 'wildcard': data['workload_api']['peers'][0]['app']='*'
if fault == 'missing_delivery': del data['intake_delivery']
if fault == 'no_policy': del data['lifecycle_policy']
if fault == 'broad_api': data['workload_api']['api_egress'][0]['ip']='10.0.0.0/8'
if fault == 'false_type': data['workload_api']['intake']='true'
if fault == 'empty_peers': data['workload_api']['peers']=[]
with pytest.raises(ValueError): module.render(data)

View file

@ -0,0 +1,56 @@
"""Real candidate Access Engine policy; authentication custody remains synthetic."""
from dataclasses import replace
import os
from pathlib import Path
import time
import pytest
from informed_decision.disposition import Actor, ActorKind
from informed_decision.lifecycle import Attribution, LifecycleRepository
from informed_decision.lifecycle_service import Caller, LifecycleError, LifecycleService
from informed_decision.records import dumps
from informed_decision.store import Store
from review_components import flex_component
from test_browser_auth import signing_key
from test_lifecycle import workspace
from test_policy_client import human
def test_initial_audience_collaborates_across_revisions_with_real_pdp(workspace, tmp_path, signing_key):
binary = os.environ.get('INFD_FLEX_AUTH_BINARY')
source = os.environ.get('INFD_FLEX_AUTH_SOURCE')
if not binary or not source:
pytest.skip('set INFD_FLEX_AUTH_BINARY and INFD_FLEX_AUTH_SOURCE for candidate owner policy')
fixture = Path(source) / 'examples/informed-decision-lifecycle'
pins = (fixture / 'pin.json').read_bytes()
with flex_component(binary, tmp_path, signing_key, fixture=fixture) as (pdp, calls):
repo, memo = workspace
app = LifecycleService(repo, pdp)
hub = Caller(Attribution('tenant:platform', Actor('system:serviceaccount:state-hub:state-hub', ActorKind.SERVICE)),
time.time() + 600, dumps({'principal_type_source': 'authentication-derived',
'tenant_source': 'registration-supplied', 'authentication_audience': 'informed-decision-lifecycle',
'workload_uid': 'synthetic-verified-uid'}))
person = Caller.from_human_session(human())
app.register(hub, memo, key='first')
with pytest.raises(LifecycleError):
app.retrieve(person, memo.id)
app.publish(hub, memo.id, 1, audience={'groups': ['net-kingdom-admins'], 'subjects': []}, key='publish')
app.annotate(person, memo.id, 1, kind='comment', body={'text': 'Consider the alternative'}, key='comment')
app.revise(person, memo.next_version(brief='Alternative assessed'), expected_version=1, summary='Refined', key='refine')
# The successor is unpublished; its editor and original author retain access.
assert app.retrieve(person, memo.id).version == 2
assert app.retrieve(hub, memo.id).version == 2
other = Caller.from_human_session(replace(human(), subject='another-admin'))
with pytest.raises(LifecycleError):
app.retrieve(other, memo.id)
app.publish(person, memo.id, 2, audience={'groups': ['net-kingdom-admins'], 'subjects': []}, key='republish')
assert app.retrieve(other, memo.id).version == 2
app.register(hub, replace(memo, id='second-decision'), key='second')
reopened = LifecycleService(LifecycleRepository(Store(repo.store.path)), pdp)
assert reopened.retrieve(person, memo.id).version == 2
foreign = replace(hub, attribution=Attribution('tenant:platform', Actor('system:serviceaccount:other:other', ActorKind.SERVICE)))
with pytest.raises(LifecycleError):
reopened.retrieve(foreign, memo.id)
assert calls and all(c['known'] and c['audiences'] == ['flex-auth'] for c in calls)
assert (fixture / 'pin.json').read_bytes() == pins

View file

@ -186,8 +186,11 @@ def test_actual_engine_human_entry_and_independent_audit_link_to_source_revision
status, loaded = request(intake, receipt['envelope'])
assert status == 201, loaded
controller = ReviewController(app.repository.store, policy(),
lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport))
lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport), intake_attributes=True)
page = controller.open(human, receipt['review_memo_id'])
context = controller.policy.transport.calls[-1][3]['context']
assert context['intake_submitter'] == 'state-hub'
assert context['approval_requester'] == 'informed-decision' and context['approval_human_control'] is True
controller.acknowledge(human, page.presentation.id, memo.required_ack_ids)
controller.act(human, page.presentation.id, Verb.ACCEPT, operation_id='human-accept')
result = app.outcomes(actor, memo.id, 2)
@ -278,3 +281,41 @@ def test_schema_four_upgrade_preserves_evidence_and_fences_old_readers(handoff):
assert app.events(actor, memo.id) == before
receipt = prepare(handoff)
assert prepare(handoff) == receipt
def test_attribute_review_checks_live_requester_before_policy(handoff):
from informed_decision.review import ReviewController, ReviewError
from test_policy_client import human
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
assert request(intake, receipt['envelope'])[0] == 201
stored = app.repository.store.memo(receipt['review_memo_id'])
session = replace(human(), subject=stored.binding.principal.id)
class Engine:
def get_approval(self, approval_id):
return {'binding': {'digest': stored.approval_binding_digest, 'actor': 'wrong-requester', 'human_control': True}}
controller = ReviewController(app.repository.store, policy(), lambda session: Engine(), intake_attributes=True)
with pytest.raises(ReviewError, match='binding_changed'):
controller.open(session, stored.id)
assert controller.policy.transport.calls == []
def test_intake_policy_guard_rechecks_retained_metadata(handoff):
from informed_decision.policy import build_request
from test_policy_client import human
app, actor, memo, options, intake, calls = handoff
receipt = prepare(handoff)
assert request(intake, receipt['envelope'])[0] == 201
store = app.repository.store
review = store.memo(receipt['review_memo_id'])
session = replace(human(), subject=review.binding.principal.id)
request_body = build_request(session, review, 'read', 'v1', intake_context=store.intake_context(review))
observation = policy().check(request_body)
policy_id = store.observe_policy(observation)
with store._transaction() as db:
row = db.execute('SELECT metadata FROM intake_ledger WHERE memo_id=?', (review.id,)).fetchone()
metadata = json.loads(row['metadata']); metadata['review_group'] = 'changed'
db.execute('UPDATE intake_ledger SET metadata=? WHERE memo_id=?', (json.dumps(metadata), review.id))
with pytest.raises(Conflict, match='policy observation'):
store.present(review.id, principal_sub=session.subject, tenant=session.tenant,
principal_type=session.principal_type, policy_id=policy_id)

View file

@ -0,0 +1,191 @@
import io
import json
import ssl
import time
import jwt
import pytest
from informed_decision.disposition import ActorKind
from informed_decision.intake_service import IntakeError, AUDIENCE
from informed_decision.workload import KubernetesTokenReview, LifecycleWorkloadIdentity, LIFECYCLE_AUDIENCE, PrivateWorkloadApp
from informed_decision.runtime import Runtime
from test_review_runtime import config
SA = 'system:serviceaccount:state-hub:state-hub'
def verified(body):
return {'status': {'authenticated': True, 'audiences': body['spec']['audiences'],
'user': {'username': SA, 'uid': 'actual-sa-uid'}}}
def token(exp=None):
return jwt.encode({'exp': int(time.time())+300 if exp is None else exp}, 'synthetic-test-key-with-at-least-thirty-two-bytes', algorithm='HS256')
def test_workload_identity_uses_verified_subject_and_separate_audience(tmp_path):
path = tmp_path/'callers.json'
path.write_text(json.dumps({SA: {'tenant': 'tenant:platform'}}))
requests = []
def review(body):
requests.append(body)
return verified(body)
identity = LifecycleWorkloadIdentity(review, path)
caller = identity(token())
assert requests[0]['spec']['audiences'] == [LIFECYCLE_AUDIENCE]
assert caller.attribution.actor.sub == SA and caller.attribution.actor.kind is ActorKind.SERVICE
assert caller.expires_at <= time.time()+30
assert json.loads(caller.attributes_json)['workload_uid'] == 'actual-sa-uid'
assert 'token' not in caller.attributes_json
path.write_text('{}')
with pytest.raises(ValueError):
identity(token())
@pytest.mark.parametrize('fault', ['wrong_audience', 'unauthenticated', 'human', 'missing_uid', 'expired', 'no_expiry', 'tenant'])
def test_workload_identity_fails_closed(tmp_path, fault):
path = tmp_path/'callers.json'
path.write_text(json.dumps({SA: {'tenant': 'tenant:other' if fault == 'tenant' else 'tenant:platform'}}))
def review(body):
result = verified(body)
if fault == 'wrong_audience': result['status']['audiences'] = [AUDIENCE]
if fault == 'unauthenticated': result['status']['authenticated'] = False
if fault == 'human': result['status']['user']['username'] = 'human'
if fault == 'missing_uid': result['status']['user']['uid'] = ''
return result
value = token(int(time.time())-1) if fault == 'expired' else token()
if fault == 'no_expiry': value = jwt.encode({}, 'synthetic-test-key-with-at-least-thirty-two-bytes', algorithm='HS256')
with pytest.raises(ValueError):
LifecycleWorkloadIdentity(review, path)(value)
def test_tokenreview_uses_owned_ca_rotating_caller_and_bounded_transport(tmp_path, monkeypatch):
seen = []
sentinel = ssl.create_default_context()
def context(*, cafile):
assert cafile == str(tmp_path/'ca.crt')
return sentinel
monkeypatch.setattr(ssl, 'create_default_context', context)
current = ['synthetic-caller-one']
class Transport:
def __init__(self, **kwargs):
assert kwargs['ssl_context'] is sentinel
def request(self, method, url, **kwargs):
seen.append((method, url, kwargs))
return 201, verified(json.loads(kwargs['body']))
review = KubernetesTokenReview('https://kubernetes.default.svc:443', tmp_path/'ca.crt',
lambda: current[0], transport_factory=Transport)
body = {'spec': {'token': 'synthetic-submitter', 'audiences': [AUDIENCE]}}
assert review(body)['status']['authenticated']
current[0] = 'synthetic-caller-two'
review(body)
assert seen[0][2]['headers']['Authorization'] != seen[1][2]['headers']['Authorization']
assert seen[0][1] == 'https://kubernetes.default.svc:443/apis/authentication.k8s.io/v1/tokenreviews'
def fail(**kwargs): raise OSError('private-path-or-response')
monkeypatch.setattr(ssl, 'create_default_context', fail)
with pytest.raises(IntakeError, match='workload_identity_unavailable') as error:
review(body)
assert 'private-path' not in str(error.value)
def test_explicit_runtime_configuration_does_not_read_custody_or_start_listener(tmp_path):
path, data = config(tmp_path)
data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle'}
data['workload_api'] = {'token_review': {'origin': 'https://kubernetes.default.svc:443',
'ca_file': str(tmp_path/'absent-ca'), 'token_file': str(tmp_path/'absent-token')},
'lifecycle_callers_file': str(tmp_path/'absent-callers'),
'intake': {'secret_file': str(tmp_path/'absent-secret'), 'submitters_file': str(tmp_path/'absent-submitters'),
'groups_file': str(tmp_path/'absent-groups')}}
data['intake_attributes'] = True
path.write_text(json.dumps(data))
runtime = Runtime.from_file(path)
assert isinstance(runtime.workload, PrivateWorkloadApp)
assert runtime.workload.intake.store is runtime.controller.store
assert runtime.workload.lifecycle.service is runtime.lifecycle
assert runtime.controller.intake_attributes
del data['lifecycle_policy']
path.write_text(json.dumps(data))
with pytest.raises(ValueError): Runtime.from_file(path)
def test_private_router_does_not_expose_browser_or_unknown_routes():
calls = []
def app(environ, start):
calls.append(environ['PATH_INFO']); start('200 OK', []); return [b'ok']
router = PrivateWorkloadApp(intake=app, lifecycle=app)
for path in ('/v1/memos', '/v1/decisions', '/v1/decisions/example/events'):
assert b''.join(router({'PATH_INFO': path}, lambda s, h: None)) == b'ok'
for path in ('/', '/auth/start', '/review', '/v1/decisionsevil'):
statuses = []
router({'PATH_INFO': path}, lambda s, h: statuses.append(s))
assert statuses == ['404 Not Found']
assert len(calls) == 3
def test_two_listener_startup_failure_closes_first_socket_and_dispatcher(monkeypatch):
import waitress
from informed_decision.web import serve_with_workload
calls = []
class Server:
def close(self): calls.append('close')
def shutdown(self): calls.append('shutdown')
@property
def task_dispatcher(self): return self
def create(app, **kwargs):
calls.append(kwargs)
if kwargs['port'] == 8081: raise OSError('occupied')
return Server()
monkeypatch.setattr(waitress, 'create_server', create)
with pytest.raises(OSError): serve_with_workload(object(), object(), host='127.0.0.1')
assert calls[0]['map'] is calls[1]['map']
assert calls[-2:] == ['close', 'shutdown']
def test_real_tls_tokenreview_requires_ca_and_hostname(tmp_path):
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import threading
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
now = datetime.now(timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'synthetic-api')])
certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
.not_valid_after(now+timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False)
.sign(key, hashes.SHA256()))
ca, private_key = tmp_path/'ca.pem', tmp_path/'key.pem'
ca.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
private_key.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
received = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): pass
def do_POST(self):
received.append(self.path)
assert self.headers['Authorization'] == 'Bearer synthetic-reviewer'
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
raw = json.dumps(verified(body)).encode()
self.send_response(201); self.end_headers(); self.wfile.write(raw)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.load_cert_chain(ca, private_key)
server.socket = tls.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
body = {'spec': {'token': 'synthetic-caller', 'audiences': [LIFECYCLE_AUDIENCE]}}
try:
origin = f'https://127.0.0.1:{server.server_port}'
review = KubernetesTokenReview(origin, ca, lambda: 'synthetic-reviewer')
assert review(body)['status']['authenticated']
wrong_host = KubernetesTokenReview(origin.replace('127.0.0.1', 'localhost'), ca, lambda: 'synthetic-reviewer')
with pytest.raises(IntakeError): wrong_host(body)
untrusted = KubernetesTokenReview(origin, '/etc/ssl/certs/ca-certificates.crt', lambda: 'synthetic-reviewer')
with pytest.raises(IntakeError): untrusted(body)
assert len(received) == 1
finally:
server.shutdown(); server.server_close(); thread.join(timeout=5)