hub-core/tests/test_access_boundary.py

251 lines
10 KiB
Python
Raw Normal View History

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'