feat: integrate durable authorization audit and runtime composition
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 12:01:42 +02:00
parent 3e386147fd
commit c9b6916dac
14 changed files with 767 additions and 16 deletions

View file

@ -0,0 +1,94 @@
import asyncio
import json
import httpx
import pytest
from hub_core.security.audit import AuditCoreSink
from hub_core.security.identity import AccessFailure
RECORD = {'profile': 'hub-core.access/1.0.0', 'correlation_id': 'request:123',
'outcome': 'authorized', 'subject': 'root-sub', 'actor_tenant': 'tenant:platform',
'target_tenant': 'tenant:platform', 'decision_id': 'decision:123'}
READY = {'status': 'ok', 'durable': True, 'custody_class': 'operational'}
def run_sink(tmp_path, handler, record=RECORD):
credential = tmp_path/'audit-token'
credential.write_text('audit-only-fixture')
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
sink = AuditCoreSink(base_url='https://audit.example', token_file=credential, client=client)
await sink.append(record)
asyncio.run(run())
@pytest.mark.parametrize('code,state', [(202, 'accepted'), (200, 'duplicate')])
def test_requires_exact_durable_custody_receipt(tmp_path, code, state):
requests = []
def handle(request):
requests.append(request)
assert not request.extensions.get('follow_redirects')
if request.url.path == '/readyz':
assert 'authorization' not in request.headers
return httpx.Response(200, json=READY)
envelope = json.loads(request.content)
assert set(envelope) == {'id','type','source','subject','tenant','correlation_id','occurred_at','data'}
assert request.headers['authorization'] == 'Bearer audit-only-fixture'
assert request.headers['idempotency-key'] == envelope['id']
assert envelope['data'] == RECORD
assert envelope['source'] == 'hub-core'
assert envelope['tenant'] == 'tenant:platform'
return httpx.Response(code, json={'status': state, 'reference': 'audit:'+envelope['id']})
run_sink(tmp_path, handle)
assert [r.url.path for r in requests] == ['/readyz', '/v1/events']
@pytest.mark.parametrize('code,receipt', [
(200, {'status':'ok'}), (202, {'status':'accepted'}),
(200, {'status':'accepted','reference':'x'}), (202, {'status':'duplicate','reference':'x'}),
(400, {'status':'accepted','reference':'x'}), (401, {}), (403, {}), (409, {}), (503, {}),
(307, {}),
])
def test_unacknowledged_custody_never_allows_execution(tmp_path, code, receipt):
def handle(request):
if request.url.path == '/readyz':
return httpx.Response(200, json=READY)
return httpx.Response(code, json=receipt, headers={'Location':'https://untrusted.example'})
with pytest.raises(AccessFailure, match='audit_unavailable'):
run_sink(tmp_path, handle)
@pytest.mark.parametrize('readiness', [
{**READY, 'custody_class':'development'}, {**READY, 'durable':False},
{**READY, 'status':'unavailable'}, {**READY, 'durable':'true'}, {},
])
def test_receiver_fallback_fails_before_post(tmp_path, readiness):
def handle(request):
assert request.url.path == '/readyz'
return httpx.Response(200, json=readiness)
with pytest.raises(AccessFailure):
run_sink(tmp_path, handle)
def test_rotation_is_read_each_time_and_lost_receipt_denies(tmp_path):
token_file = tmp_path/'audit-token'
seen = []
def handle(request):
if request.url.path == '/readyz':
return httpx.Response(200, json=READY)
seen.append(request.headers['authorization'])
if len(seen) == 2:
raise httpx.ReadTimeout('sensitive private upstream details')
return httpx.Response(202, json={'status':'accepted','reference':'audit:1'})
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
sink = AuditCoreSink(base_url='https://audit.example', token_file=token_file, client=client)
token_file.write_text('first')
await sink.append(RECORD)
token_file.write_text('replacement')
with pytest.raises(AccessFailure) as result:
await sink.append(RECORD)
assert str(result.value) == 'audit_unavailable'
asyncio.run(run())
assert seen == ['Bearer first', 'Bearer replacement']

View file

@ -225,3 +225,5 @@ def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor():
with TestClient(runtime(owners)) as client:
assert client.get('/docs', headers=HEADERS).status_code == 403
assert owners.records[-1]['subject'] == 'ordinary'
assert owners.records[-1]['action']
assert owners.records[-1]['request_digest']

View file

@ -0,0 +1,64 @@
from dataclasses import replace
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.security.config import SecuritySettings
def settings():
return SecuritySettings(issuer='https://issuer.example', audience='hub-core', root_subject='root-sub',
policy_url='https://policy.example', policy_caller='system:serviceaccount:hub-core:hub-core',
policy_token_file=Path('/run/policy-token'), policy_keys_file=Path('/run/keys.json'),
audit_url='https://audit.example', audit_token_file=Path('/run/audit-token'))
def test_partial_configuration_does_not_silently_disable_enforcement(monkeypatch):
monkeypatch.setenv('HUB_CORE_SECURITY_ISSUER','https://issuer.example')
with pytest.raises(ValueError, match='incomplete'):
SecuritySettings.from_env()
def test_missing_facts_and_development_mode_cannot_compose():
with pytest.raises(ValueError, match='authoritative owner facts'):
create_app(settings=RuntimeSettings(access_mode='enforce'), security_settings=settings())
with pytest.raises(ValueError, match='enforcement mode'):
create_app(settings=RuntimeSettings(), security_settings=settings(), access_facts=object())
@pytest.mark.parametrize('changes', [
{'issuer':'http://issuer.example'}, {'audit_url':'https://localhost'},
{'policy_token_file':Path('relative')}, {'root_subject':''},
{'audit_token_file':Path('/run/policy-token')},
])
def test_invalid_trust_configuration(changes):
with pytest.raises(ValueError):
replace(settings(), **changes)
def test_composed_clients_are_closed_by_runtime_lifespan():
app = create_app(settings=RuntimeSettings(access_mode='enforce'),
security_settings=settings(), access_facts=object())
controller = app.state.access_controller
client = controller.identity.client
assert client is controller.audit.client is controller.policy.client
with TestClient(app) as api:
assert api.get('/healthz').status_code == 200
assert not client.is_closed
assert client.is_closed
def test_environment_composition_requires_explicit_owner_adapter(monkeypatch):
configured = settings()
for field in configured.__dataclass_fields__:
monkeypatch.setenv('HUB_CORE_SECURITY_'+field.upper(),str(getattr(configured,field)))
closed = create_app(settings=RuntimeSettings(access_mode='enforce'))
with TestClient(closed) as client:
assert client.get('/docs',headers={'Authorization':'Bearer untrusted'}).status_code == 503
composed = create_app(settings=RuntimeSettings(access_mode='enforce'),access_facts=object())
with TestClient(composed) as client:
assert client.get('/healthz').status_code == 200
assert composed.state.access_controller is not None

View file

@ -157,3 +157,14 @@ def test_policy_client_rotates_workload_credentials_and_retains_no_user_token(tm
asyncio.run(run())
assert seen == ['Bearer first-workload-token', 'Bearer second-workload-token',
'Bearer second-workload-token']
def test_signed_decision_is_retained_exactly_and_sensitive_fields_are_refused():
request,envelope,now = case()
keys = sign(envelope)
result = verify_decision(envelope,request=request,keys=keys,caller='workload:hub',now=now)
assert result.signed_envelope == go_json(envelope).decode()
verify_signature(parse_json(result.signed_envelope),keys)
envelope['diagnostics'] = {'access_token':'must-not-be-archived'}
with pytest.raises(ValueError,match='sensitive decision field'):
verify_decision(envelope,request=request,keys=sign(envelope),caller='workload:hub',now=now)

View file

@ -0,0 +1,212 @@
"""Opt-in interoperability with Audit Core's actual receiver, not live custody.
HUB_CORE_AUDIT_CORE_SOURCE=/checkout/audit-core pytest -q tests/test_audit_core_owner_contract.py
"""
import asyncio
from dataclasses import replace
import json
import os
from pathlib import Path
import sys
import httpx
import pytest
source = os.getenv('HUB_CORE_AUDIT_CORE_SOURCE')
if not source:
pytest.skip('set HUB_CORE_AUDIT_CORE_SOURCE for owner-source interoperability', allow_module_level=True)
sys.path.insert(0, str(Path(source).resolve()))
from audit_core.ingestion import IngestionApplication
from audit_core.senders import SenderIdentity, SenderRegistry
from audit_core.sqlite_backend import SQLiteAuditBackend
from hub_core.security.audit import AuditCoreSink
from hub_core.security.identity import AccessFailure
class OperationalReceiptFixture(SQLiteAuditBackend):
"""Exercise production receipt parsing using synthetic local SQLite custody.
This override is test-only. It proves no operational/deployed custody claim.
The separate fallback test uses the unmodified owner development receipt.
"""
@property
def retention_policy(self):
return replace(super().retention_policy, custody_class='operational')
def receiver(backend):
return IngestionApplication(backend, SenderRegistry([
SenderIdentity(name='hub-core-fixture', tokens=('fixture-only',),
sources=frozenset({'hub-core'}), tenants=frozenset({'tenant:platform'}),
may_write=True, may_read=False, secret_policy='reject', evidence_kind='load-bearing')
]))
def emit(tmp_path, backend, *, credential='fixture-only', lost_receipt=False):
token = tmp_path/'sender'
token.write_text(credential)
sent = []
record = {'profile':'hub-core.access/1.0.0', 'correlation_id':'request:owner-contract',
'outcome':'authorized', 'subject':'fixture-root', 'actor_tenant':'tenant:platform',
'target_tenant':'tenant:platform', 'policy_caller':'workload:fixture'}
with httpx.Client(transport=httpx.WSGITransport(receiver(backend))) as native:
def handle(request):
response = native.request(request.method, str(request.url), content=request.content,
headers=request.headers)
if request.method == 'POST':
sent.append(json.loads(request.content))
if lost_receipt:
assert response.status_code == 202
raise httpx.ReadTimeout('simulated lost receipt after real receiver acceptance')
return httpx.Response(response.status_code, content=response.content, headers=response.headers)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
sink = AuditCoreSink(base_url='https://audit.fixture', token_file=token, client=client)
await sink.append(record)
try:
asyncio.run(run())
except AccessFailure:
return sent, False
return sent, True
def test_real_receiver_persists_exact_hub_envelope_and_reopens(tmp_path):
db = tmp_path/'audit.db'
backend = OperationalReceiptFixture(str(db))
sent, accepted = emit(tmp_path, backend)
assert accepted
backend.close()
reopened = SQLiteAuditBackend(str(db))
try:
stored = reopened.get(sent[0]['id'])
assert stored['details']['data'] == sent[0]['data']
assert stored['source'] == 'hub-core'
assert stored['actor'] is None # Actor attribution belongs in data, per owner contract.
finally:
reopened.close()
def test_real_development_receiver_is_rejected_before_ingestion(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path/'audit.db'))
try:
sent, accepted = emit(tmp_path, backend)
assert not accepted and sent == []
finally:
backend.close()
def test_lost_receipt_blocks_source_even_when_owner_has_durable_attempt(tmp_path):
backend = OperationalReceiptFixture(str(tmp_path/'audit.db'))
try:
sent, accepted = emit(tmp_path, backend, lost_receipt=True)
assert not accepted
assert backend.get(sent[0]['id']) is not None
finally:
backend.close()
def test_wrong_sender_credential_never_creates_a_record(tmp_path):
backend = OperationalReceiptFixture(str(tmp_path/'audit.db'))
try:
sent, accepted = emit(tmp_path, backend, credential='wrong')
assert not accepted
assert backend.get(sent[0]['id']) is None
finally:
backend.close()
def test_root_gate_retains_verifiable_decision_before_native_write(tmp_path):
import base64
import time
from datetime import datetime, timedelta, timezone
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from fastapi.testclient import TestClient
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.security.boundary import LiveFacts
from hub_core.security.config import SecuritySettings
from hub_core.security.policy import go_json, parse_json, submitted_digest, verify_signature
issuer = 'https://issuer.fixture'
now = int(time.time())
identity_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(identity_key.public_key()))
jwk.update(kid='identity-1', alg='RS256', use='sig')
claims = dict(iss=issuer, sub='immutable-root', aud='hub-core', iat=now, exp=now+300,
tenant='tenant:platform', principal_type='human', groups=[], roles=[], scope='openid',
assurance=dict(level='aal2', methods=['pwd','otp'], mfa=True, source='fixture', at=now))
token = jwt.encode(claims, identity_key, algorithm='RS256', headers={'kid':'identity-1','typ':'at+jwt'})
policy_key = Ed25519PrivateKey.generate()
encode = lambda v: base64.urlsafe_b64encode(v).decode().rstrip('=')
keys = {'keys':[{'kid':'policy-1','alg':'ed25519','public_key':encode(
policy_key.public_key().public_bytes(Encoding.Raw,PublicFormat.Raw))}]}
policy_token, audit_token, key_file = (tmp_path/name for name in ('policy-token','audit-token','keys.json'))
policy_token.write_text('policy-fixture')
audit_token.write_text('fixture-only')
key_file.write_text(json.dumps(keys))
caller = 'system:serviceaccount:hub-core:hub-core'
backend = OperationalReceiptFixture(str(tmp_path/'archive.db'))
archive_ids = []
class Facts:
entitled = True
async def resolve(self, actor, resource):
return LiveFacts(actor.issuer, actor.subject, actor.tenant, 'tenant:platform',
True, True, True, self.entitled, time.time(), 'owner:current',
frozenset({'agent:root'}))
facts = Facts()
native = httpx.Client(transport=httpx.WSGITransport(receiver(backend)))
def handle(request):
if request.url.host == 'issuer.fixture':
if request.url.path.endswith('openid-configuration'):
return httpx.Response(200,json={'issuer':issuer,'jwks_uri':issuer+'/keys'})
return httpx.Response(200,json={'keys':[jwk]})
if request.url.host == 'policy.fixture':
assert request.headers['authorization'] == 'Bearer policy-fixture'
check = json.loads(request.content)
timestamp = datetime.now(timezone.utc)
envelope = dict(id='decision:'+check['id'], contract_version='flex-auth.decision-record.v1',
request_id=check['id'], effect='allow', subject=check['subject'],resource=check['resource'],
binding={**{k:v for k,v in check.items() if k!='id'},
'submitted_request_digest':submitted_digest(check)},
lifetime={'kind':'ttl','not_before':timestamp.isoformat(),
'expires_at':(timestamp+timedelta(seconds=300)).isoformat()},
provenance={'policy_version':'fixture-v1','policy_package_digest':'sha256:'+'a'*64,
'decision_time':timestamp.isoformat(),
'caller':{'mode':'enforce','principal':caller,'audience':'flex-auth',
'not_after':(timestamp+timedelta(seconds=300)).isoformat()}})
envelope['signature'] = {'mode':'signed','alg':'ed25519','kid':'policy-1',
'value':encode(policy_key.sign(go_json(envelope)))}
return httpx.Response(200,content=go_json(envelope))
result = native.request(request.method,str(request.url),headers=request.headers,content=request.content)
if request.method == 'POST':
archive_ids.append(json.loads(request.content)['id'])
return httpx.Response(result.status_code,content=result.content,headers=result.headers)
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
security = SecuritySettings(issuer=issuer,audience='hub-core',root_subject='immutable-root',
policy_url='https://policy.fixture',policy_caller=caller,policy_token_file=policy_token,
policy_keys_file=key_file,audit_url='https://audit.fixture',audit_token_file=audit_token)
controller = security.compose(facts=facts,client=client)
app = create_app(settings=RuntimeSettings(access_mode='enforce'),access_controller=controller)
body = dict(schema_version='0.1.0',correlation_id='f7cffcab-4c02-419e-89e5-0b463f5b433a',
from_address='agent:root',to_addresses=['agent:reader'],body='private business content')
try:
with TestClient(app) as api:
result = api.post('/ports/messaging/messages',headers={'Authorization':'Bearer '+token},json=body)
assert result.status_code == 202, result.text
stored = backend.get(archive_ids[0])['details']['data']
verify_signature(parse_json(stored['signed_decision']), keys)
assert stored['subject'] == 'immutable-root'
assert 'private business content' not in json.dumps(stored)
assert token not in json.dumps(stored)
facts.entitled = False
result = api.post('/ports/messaging/messages',headers={'Authorization':'Bearer '+token},json=body)
assert result.status_code == 403
assert backend.get(archive_ids[-1])['details']['data']['reason'] == 'root_entitlement_required'
finally:
asyncio.run(client.aclose())
native.close()
backend.close()