feat: add fail-closed Hub access profile foundation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
df39fd5f43
commit
3e386147fd
35 changed files with 2009 additions and 195 deletions
227
tests/test_access_boundary.py
Normal file
227
tests/test_access_boundary.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
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')
|
||||
|
||||
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')
|
||||
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'
|
||||
Loading…
Add table
Add a link
Reference in a new issue