from __future__ import annotations import asyncio import json import time from dataclasses import replace from pathlib import Path import httpx 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.runtime.store import InMemoryPortStore from hub_core.security.boundary import ( AccessController, Actor, Decision, LiveFacts, iter_routes, route_key, ) from hub_core.security.identity import AccessFailure class Owners: def __init__(self): self.actor = Actor('https://issuer.example', 'immutable-root', 'tenant:platform', 'human', 'aal2', int(time.time()), int(time.time()) + 300) self.facts = LiveFacts(self.actor.issuer, self.actor.subject, self.actor.tenant, 'tenant:platform', True, True, True, True, time.time(), 'owner-receipt', frozenset({'agent:root'})) self.records, self.requests = [], [] self.allow = True self.audit_down = False self.policy_down = False async def authenticate(self, token): if token != 'verified-root': raise AccessFailure(401, 'invalid_access_token') return self.actor async def resolve(self, actor, resource): return self.facts async def evaluate(self, request): self.requests.append(request) if self.policy_down: raise ConnectionError('private backend details') return Decision(self.allow, 'decision:1', 'policy:v1', time.time()+30) async def append(self, record): if self.audit_down: raise ConnectionError('private audit details') self.records.append(record) def controller(self): return AccessController(identity=self, facts=self, policy=self, audit=self, root_issuer='https://issuer.example', root_subject='immutable-root') def runtime(owners=None): return create_app(settings=RuntimeSettings(environment='test', access_mode='enforce'), port_store=InMemoryPortStore(), access_controller=owners.controller() if owners else None) HEADERS = {'Authorization': 'Bearer verified-root'} CATALOG = json.loads(Path('hub_core/security/routes.json').read_text())['routes'] SURFACES = [(key.split(':', 2)[0], key.split(':', 2)[1]) for key in CATALOG] @pytest.mark.parametrize('method,path', SURFACES) def test_every_catalog_surface_denies_anonymous(method, path): with TestClient(runtime()) as client: response = client.request(method, path) assert response.status_code == 401 def test_production_is_closed_without_owner_adapters(): app = create_app(settings=RuntimeSettings(environment='production')) with TestClient(app) as client: assert client.get('/healthz').json() == {'status': 'ok'} assert client.get('/readyz').status_code == 401 assert client.get('/ports/projections/hub_registry', headers=HEADERS).status_code == 503 assert client.get('/healthz/').status_code == 401 with pytest.raises(ValueError): RuntimeSettings(environment='production', access_mode='development') def test_root_access_requires_fresh_facts_and_audit_on_every_request(): owners = Owners() with TestClient(runtime(owners)) as client: first = client.get('/ports/projections/hub_registry', headers=HEADERS) assert first.status_code == 200 assert first.headers['cache-control'] == 'no-store' assert owners.requests[0].facts.root_entitled owners.facts = replace(owners.facts, root_entitled=False) assert client.get('/ports/projections/hub_registry', headers=HEADERS).status_code == 403 assert len(owners.requests) == 1 assert owners.records[0]['outcome'] == 'authorized' @pytest.mark.parametrize('change,status', [ ({'subject': 'ordinary'}, 403), ({'issuer': 'https://other.example'}, 403), ({'assurance': 'aal1'}, 403), ({'tenant': 'tenant:other'}, 403), ({'expires_at': 1}, 401), ]) def test_root_cannot_be_claimed_by_name_or_role(change, status): owners = Owners() owners.actor = replace(owners.actor, **change) with TestClient(runtime(owners)) as client: assert client.get('/docs', headers=HEADERS).status_code == status @pytest.mark.parametrize('change,status', [ ({'checked_at': 1}, 503), ({'subject': 'different'}, 503), ({'account_active': False}, 403), ({'actor_tenant_active': False}, 403), ({'target_tenant_active': False}, 403), ({'target_tenant': 'tenant:other'}, 403), ]) def test_authoritative_account_and_tenant_checks(change, status): owners = Owners() owners.facts = replace(owners.facts, **change) with TestClient(runtime(owners)) as client: assert client.get('/openapi.json', headers=HEADERS).status_code == status @pytest.mark.parametrize('attribute,status', [('allow', 403), ('policy_down', 503), ('audit_down', 503)]) def test_denial_and_dependency_failure_never_reach_handler(attribute, status): owners = Owners() setattr(owners, attribute, attribute != 'allow') with TestClient(runtime(owners)) as client: result = client.post('/ports/messaging/messages', headers=HEADERS, json={}) assert result.status_code == status assert 'private' not in result.text def test_new_route_and_wrong_method_remain_denied(): owners = Owners() app = runtime(owners) calls = [] @app.get('/newly-added') def new_route(): calls.append(True) with TestClient(app) as client: for path in ['/newly-added', '/unknown', '/ports/projections/hub_registry/']: assert client.get(path, headers=HEADERS).status_code == 403 assert client.delete('/docs', headers=HEADERS).status_code == 403 assert calls == [] def test_native_sender_is_bound_and_body_reaches_handler(): owners = Owners() body = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a', 'from_address': 'agent:root', 'to_addresses': ['agent:reader'], 'body': 'private text'} with TestClient(runtime(owners)) as client: assert client.post('/ports/messaging/messages', headers=HEADERS, json=body).status_code == 202 body['from_address'] = 'agent:someone-else' assert client.post('/ports/messaging/messages', headers=HEADERS, json=body).status_code == 403 assert 'private text' not in json.dumps(owners.records) def test_catalog_covers_current_routes_and_does_not_auto_admit(): app = runtime() missing = [route_key(r, method) for r in iter_routes(app) if hasattr(r, 'methods') for method in r.methods if r.path != '/healthz' and route_key(r, method) not in CATALOG] assert missing == [] def test_concurrent_requests_keep_separate_contexts(): owners = Owners() app = runtime(owners) async def run(): async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client: return await asyncio.gather(*[ client.get('/ports/projections/hub_registry', headers=HEADERS, params={'n': n}) for n in range(10) ]) results = asyncio.run(run()) assert all(r.status_code == 200 for r in results) assert len({r.correlation_id for r in owners.requests}) == 10 assert len({r.request_digest for r in owners.requests}) == 10 def test_event_provenance_overrides_asserted_producer(): from datetime import datetime, timezone owners = Owners() event = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a', 'event_type': 'hub.progress.recorded', 'occurred_at': datetime.now(timezone.utc).isoformat(), 'subject_refs': {'hub': 'untrusted-business-reference'}, 'payload': {'_hub_access': {'subject': 'forged'}}} with TestClient(runtime(owners)) as client: assert client.post('/ports/events/progress', headers=HEADERS, json=event).status_code == 202 record = client.get('/ports/projections/progress_events', headers=HEADERS).json() item = record['data']['items'][0] assert item['payload']['_hub_access']['subject'] == 'immutable-root' def test_embedded_router_uses_the_same_boundary(): from fastapi import FastAPI from hub_core.security.boundary import AccessBoundary owners = Owners() app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) calls = [] @app.get('/embedded') def embedded(): calls.append(True) return {'ok': True} route = next(iter(iter_routes(app))) app.add_middleware(AccessBoundary, host=app, controller=owners.controller(), catalog={route_key(route, 'GET'): 'extension.read'}) with TestClient(app) as client: assert client.get('/embedded').status_code == 401 assert client.get('/embedded', headers=HEADERS).status_code == 200 assert calls == [True] def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor(): owners = Owners() with pytest.raises(ValueError): replace(owners.facts, root_entitled='false') with pytest.raises(ValueError): Decision('allow', 'id', 'v1', time.time()+30) owners.actor = replace(owners.actor, subject='ordinary') 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'] def test_slow_request_body_times_out_before_authority_or_handler(): from hub_core.security.boundary import AccessBoundary owners = Owners() host = runtime(owners) called = [] messages = [] async def handler(scope, receive, send): called.append(True) boundary = AccessBoundary(handler, host=host, controller=owners.controller(), body_timeout=0.01) scope = {'type':'http','method':'POST','path':'/ports/messaging/messages', 'headers':[(b'authorization',b'Bearer verified-root')], 'query_string':b''} async def receive(): await asyncio.Future() async def send(message): messages.append(message) asyncio.run(boundary(scope,receive,send)) assert messages[0]['status'] == 408 assert not called and not owners.requests assert owners.records[-1]['reason'] == 'request_body_timeout'