feat: integrate durable authorization audit and runtime composition
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
3e386147fd
commit
c9b6916dac
14 changed files with 767 additions and 16 deletions
94
tests/test_access_audit.py
Normal file
94
tests/test_access_audit.py
Normal 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']
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
64
tests/test_access_config.py
Normal file
64
tests/test_access_config.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
212
tests/test_audit_core_owner_contract.py
Normal file
212
tests/test_audit_core_owner_contract.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue