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
7
tests/fixtures/flex-auth/README.md
vendored
Normal file
7
tests/fixtures/flex-auth/README.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
These public conformance fixtures were copied from flex-auth
|
||||
`examples/secrets-engine/replay/` and `check_request_allow_rotate.json` on
|
||||
2026-09-28. They retain the owner's Go-generated signature and submitted digest.
|
||||
`keys.json` contains a well-known **test-only public key**, not production trust.
|
||||
The old decision is used only to test cryptographic interoperability, never as
|
||||
an active authorization decision. Hub policy lifetime/caller tests use fresh
|
||||
synthetic decisions with ephemeral test keys.
|
||||
23
tests/fixtures/flex-auth/check_request_allow_rotate.json
vendored
Normal file
23
tests/fixtures/flex-auth/check_request_allow_rotate.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "check:secrets-engine-rotate",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {}
|
||||
}
|
||||
118
tests/fixtures/flex-auth/decision_rotate_signed.json
vendored
Normal file
118
tests/fixtures/flex-auth/decision_rotate_signed.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
118
tests/fixtures/flex-auth/decision_rotate_signed_tampered.json
vendored
Normal file
118
tests/fixtures/flex-auth/decision_rotate_signed_tampered.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "deny",
|
||||
"reason": "tampered_after_signing",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
tests/fixtures/flex-auth/keys.json
vendored
Normal file
11
tests/fixtures/flex-auth/keys.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"algorithm": "ed25519",
|
||||
"keys": [
|
||||
{
|
||||
"kid": "testdata-ed25519",
|
||||
"alg": "ed25519",
|
||||
"public_key": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI",
|
||||
"note": "Well-known non-production seed 0x42 repeated. Not a custody path. FLEX-WP-0024-T03 fixtures only."
|
||||
}
|
||||
]
|
||||
}
|
||||
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'
|
||||
102
tests/test_access_identity.py
Normal file
102
tests/test_access_identity.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from hub_core.security.identity import AccessFailure, OIDCVerifier
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def signing_key():
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def setup(signing_key, changes=None, header_changes=None):
|
||||
now = int(time.time())
|
||||
claims = dict(iss='https://issuer.example', sub='immutable-root', aud='hub-core',
|
||||
iat=now, exp=now+300, nbf=now, tenant='tenant:platform',
|
||||
principal_type='human', groups=[], roles=[], scope='openid',
|
||||
assurance=dict(level='aal2', methods=['pwd', 'otp'], mfa=True,
|
||||
source='key-cape', at=now))
|
||||
claims.update(changes or {})
|
||||
headers = {'kid': 'key-1', 'typ': 'at+jwt', **(header_changes or {})}
|
||||
token = jwt.encode(claims, signing_key, algorithm='RS256', headers=headers)
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))
|
||||
jwk.update(kid='key-1', use='sig', alg='RS256')
|
||||
responses = {'discovery': 200, 'keys': [jwk]}
|
||||
|
||||
def handle(request):
|
||||
if request.url.path.endswith('openid-configuration'):
|
||||
return httpx.Response(responses['discovery'], json={
|
||||
'issuer': 'https://issuer.example', 'jwks_uri': 'https://issuer.example/keys',
|
||||
})
|
||||
return httpx.Response(200, json={'keys': responses['keys']})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
|
||||
verifier = OIDCVerifier(issuer='https://issuer.example', audience='hub-core', client=client)
|
||||
return token, verifier, responses, client
|
||||
|
||||
|
||||
def test_accepts_valid_iam_access_token(signing_key):
|
||||
token, verifier, _, client = setup(signing_key)
|
||||
async def run():
|
||||
async with client:
|
||||
actor = await verifier.authenticate(token)
|
||||
assert actor.subject == 'immutable-root'
|
||||
assert actor.tenant == 'tenant:platform'
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('claims,headers', [
|
||||
({'iss': 'https://evil.example'}, {}), ({'aud': 'different'}, {}),
|
||||
({'exp': 1}, {}), ({'nbf': int(time.time())+3600}, {}),
|
||||
({'iat': int(time.time())+3600}, {}), ({'sub': ''}, {}),
|
||||
({'roles': 'platform-root'}, {}), ({'groups': {}}, {}),
|
||||
({'tenant': None}, {}), ({'scope': None}, {}),
|
||||
({'assurance': {'level': 'aal2'}}, {}), ({'principal_type': 'root'}, {}),
|
||||
({'exp': int(time.time())+3600}, {}), ({}, {'typ': 'JWT'}),
|
||||
({}, {'kid': 'unknown'}),
|
||||
({'principal_type': 'agent', 'agent': {'id': 'a', 'mode': 'delegated'}}, {}),
|
||||
])
|
||||
def test_invalid_tokens_are_401(signing_key, claims, headers):
|
||||
token, verifier, _, client = setup(signing_key, claims, headers)
|
||||
async def run():
|
||||
async with client:
|
||||
with pytest.raises(AccessFailure) as result:
|
||||
await verifier.authenticate(token)
|
||||
assert result.value.status == 401
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_key_rotation_removes_old_trust_and_outage_fails_closed(signing_key):
|
||||
token, verifier, responses, client = setup(signing_key)
|
||||
second = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
next_jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(second.public_key()))
|
||||
next_jwk.update(kid='key-2', alg='RS256', use='sig')
|
||||
claims = jwt.decode(token, options={'verify_signature': False})
|
||||
rotated = jwt.encode(claims, second, algorithm='RS256', headers={'kid': 'key-2', 'typ': 'at+jwt'})
|
||||
async def run():
|
||||
async with client:
|
||||
await verifier.authenticate(token)
|
||||
responses['keys'] = [next_jwk]
|
||||
verifier._loaded -= 2
|
||||
await verifier.authenticate(rotated)
|
||||
with pytest.raises(AccessFailure) as result:
|
||||
await verifier.authenticate(token)
|
||||
assert result.value.status == 401
|
||||
responses['discovery'] = 503
|
||||
verifier._loaded = 0
|
||||
with pytest.raises(AccessFailure) as result:
|
||||
await verifier.authenticate(rotated)
|
||||
assert result.value.status == 503
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_untrusted_issuer_configuration_rejected():
|
||||
for issuer in ['http://issuer.example', 'https://localhost', 'https://u:p@example.com']:
|
||||
with pytest.raises(ValueError):
|
||||
OIDCVerifier(issuer=issuer, audience='hub-core', client=None)
|
||||
159
tests/test_access_policy.py
Normal file
159
tests/test_access_policy.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import base64
|
||||
import copy
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from hub_core.security.policy import go_json, parse_json, submitted_digest, verify_decision, verify_signature
|
||||
|
||||
FIXTURES = Path(__file__).parent / 'fixtures/flex-auth'
|
||||
|
||||
|
||||
def test_real_go_signer_fixture_and_tampered_pair():
|
||||
keys = parse_json((FIXTURES / 'keys.json').read_bytes())
|
||||
verify_signature(parse_json((FIXTURES / 'decision_rotate_signed.json').read_bytes()), keys)
|
||||
with pytest.raises(InvalidSignature):
|
||||
verify_signature(parse_json((FIXTURES / 'decision_rotate_signed_tampered.json').read_bytes()), keys)
|
||||
|
||||
|
||||
def test_go_submitted_digest_known_answer():
|
||||
request = parse_json((FIXTURES / 'check_request_allow_rotate.json').read_bytes())
|
||||
envelope = parse_json((FIXTURES / 'decision_rotate_signed.json').read_bytes())
|
||||
assert submitted_digest(request) == envelope['binding']['submitted_request_digest']
|
||||
|
||||
|
||||
def case():
|
||||
now = datetime.now(timezone.utc)
|
||||
request = {'id': 'request:1', 'tenant': 'tenant:platform',
|
||||
'subject': {'id': 'root-sub', 'type': 'human', 'tenant': 'tenant:platform'},
|
||||
'action': 'hub.read', 'resource': {'id': '/docs', 'type': 'hub-route',
|
||||
'system': 'hub-core', 'tenant': 'tenant:platform'},
|
||||
'context': {'http_request_digest': 'request-hash', 'root_entitled': True}}
|
||||
envelope = {'id': 'decision:1', 'contract_version': 'flex-auth.decision-record.v1',
|
||||
'request_id': request['id'], 'effect': 'allow',
|
||||
'resource': copy.deepcopy(request['resource']), 'subject': copy.deepcopy(request['subject']),
|
||||
'binding': {**copy.deepcopy(request), 'submitted_request_digest': submitted_digest(request)},
|
||||
'lifetime': {'kind': 'ttl', 'not_before': now.isoformat(),
|
||||
'expires_at': (now+timedelta(seconds=300)).isoformat()},
|
||||
'provenance': {'policy_version': 'v1', 'policy_package_digest': 'sha256:'+'a'*64,
|
||||
'decision_time': now.isoformat(), 'caller': {
|
||||
'mode': 'enforce', 'principal': 'workload:hub', 'audience': 'flex-auth',
|
||||
'not_after': (now+timedelta(seconds=300)).isoformat()}}}
|
||||
return request, envelope, now
|
||||
|
||||
|
||||
def sign(envelope):
|
||||
key = Ed25519PrivateKey.generate()
|
||||
encode = lambda value: base64.urlsafe_b64encode(value).decode().rstrip('=')
|
||||
envelope.pop('signature', None)
|
||||
signature = key.sign(go_json(envelope))
|
||||
envelope['signature'] = {'mode': 'signed', 'alg': 'ed25519', 'kid': 'test', 'value': encode(signature)}
|
||||
return {'keys': [{'kid': 'test', 'alg': 'ed25519',
|
||||
'public_key': encode(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))}]}
|
||||
|
||||
|
||||
def test_signed_bound_allow():
|
||||
request, envelope, now = case()
|
||||
decision = verify_decision(envelope, request=request, keys=sign(envelope), caller='workload:hub', now=now)
|
||||
assert decision.allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mutation', [
|
||||
lambda d: d.update(effect='redact'), lambda d: d.update(request_id='other'),
|
||||
lambda d: d['binding'].update(submitted_request_digest='sha256:wrong'),
|
||||
lambda d: d['binding'].update(action='destroy'),
|
||||
lambda d: d['binding']['subject'].update(id='other'),
|
||||
lambda d: d['binding']['resource'].update(tenant='tenant:other'),
|
||||
lambda d: d['binding']['resource'].update(id='/secrets'),
|
||||
lambda d: d['binding']['context'].update(root_entitled=False),
|
||||
lambda d: d['lifetime'].update(expires_at='2000-01-01T00:00:00Z'),
|
||||
lambda d: d['lifetime'].update(not_before='2099-01-01T00:00:00Z'),
|
||||
lambda d: d['provenance'].update(decision_time='2000-01-01T00:00:00Z'),
|
||||
lambda d: d['provenance']['caller'].update(mode='warn'),
|
||||
lambda d: d['provenance']['caller'].update(principal='other'),
|
||||
lambda d: d.update(obligations=[{'type': 'approval'}]),
|
||||
])
|
||||
def test_even_authentically_signed_wrong_decisions_are_rejected(mutation):
|
||||
request, envelope, now = case()
|
||||
mutation(envelope)
|
||||
with pytest.raises(ValueError):
|
||||
verify_decision(envelope, request=request, keys=sign(envelope), caller='workload:hub', now=now)
|
||||
|
||||
|
||||
def test_unsigned_untrusted_and_ambiguous_inputs_fail():
|
||||
request, envelope, now = case()
|
||||
keys = sign(envelope)
|
||||
for wrong in ({'keys': []}, {'keys': keys['keys']*2}):
|
||||
with pytest.raises(ValueError):
|
||||
verify_decision(envelope, request=request, keys=wrong, caller='workload:hub', now=now)
|
||||
envelope['signature'] = {'mode': 'unsigned'}
|
||||
with pytest.raises(ValueError):
|
||||
verify_signature(envelope, keys)
|
||||
for raw in ['{"effect":"deny","effect":"allow"}', '{"x":NaN}', '{"x":1.1}']:
|
||||
with pytest.raises(ValueError):
|
||||
parse_json(raw)
|
||||
|
||||
|
||||
def test_policy_client_rotates_workload_credentials_and_retains_no_user_token(tmp_path):
|
||||
import asyncio
|
||||
import time
|
||||
import httpx
|
||||
from hub_core.security.boundary import Actor, Authorization, LiveFacts
|
||||
from hub_core.security.policy import FlexPolicy
|
||||
from hub_core.security.identity import AccessFailure
|
||||
token_file, keys_file = tmp_path/'caller', tmp_path/'keys'
|
||||
token_file.write_text('first-workload-token')
|
||||
seen = []
|
||||
unavailable = False
|
||||
caller = 'system:serviceaccount:hub-core:hub-core'
|
||||
actor = Actor('https://issuer.example', 'root-sub', 'tenant:platform', 'human', 'aal2',
|
||||
int(time.time()), int(time.time())+300)
|
||||
facts = LiveFacts(actor.issuer, actor.subject, actor.tenant, 'tenant:platform',
|
||||
True, True, True, True, time.time(), 'owner:receipt')
|
||||
authorization = Authorization(actor, 'hub.read', '/docs', facts, 'request:1', 'body-hash')
|
||||
|
||||
def handle(request):
|
||||
seen.append(request.headers['authorization'])
|
||||
if unavailable:
|
||||
return httpx.Response(503, text='sensitive backend details')
|
||||
check = json.loads(request.content)
|
||||
_, envelope, _ = case()
|
||||
envelope['subject'], envelope['resource'] = check['subject'], check['resource']
|
||||
envelope['binding'] = {k: v for k, v in check.items() if k != 'id'}
|
||||
envelope['binding']['submitted_request_digest'] = submitted_digest(check)
|
||||
envelope['provenance']['caller']['principal'] = caller
|
||||
# Key publication precedes this call in reality; write the fixture before
|
||||
# evaluation and sign with the corresponding ephemeral test key below.
|
||||
envelope.pop('signature', None)
|
||||
encode = lambda v: base64.urlsafe_b64encode(v).decode().rstrip('=')
|
||||
envelope['signature'] = {'mode': 'signed', 'alg': 'ed25519', 'kid': 'test',
|
||||
'value': encode(signing_key.sign(go_json(envelope)))}
|
||||
assert 'token' not in request.content.decode()
|
||||
return httpx.Response(200, content=go_json(envelope))
|
||||
|
||||
signing_key = Ed25519PrivateKey.generate()
|
||||
keys_file.write_text(json.dumps({'keys': [{'kid': 'test', 'alg': 'ed25519',
|
||||
'public_key': base64.urlsafe_b64encode(signing_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw)).decode().rstrip('=')}]}))
|
||||
|
||||
async def run():
|
||||
nonlocal unavailable
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
|
||||
policy = FlexPolicy(base_url='https://policy.example', client=client, caller=caller,
|
||||
caller_token_file=token_file, trusted_keys_file=keys_file)
|
||||
assert (await policy.evaluate(authorization)).allowed
|
||||
token_file.write_text('second-workload-token')
|
||||
assert (await policy.evaluate(authorization)).allowed
|
||||
unavailable = True
|
||||
with pytest.raises(AccessFailure) as error:
|
||||
await policy.evaluate(authorization)
|
||||
assert error.value.status == 503
|
||||
assert 'sensitive' not in str(error.value)
|
||||
asyncio.run(run())
|
||||
assert seen == ['Bearer first-workload-token', 'Bearer second-workload-token',
|
||||
'Bearer second-workload-token']
|
||||
|
|
@ -26,7 +26,7 @@ def compatibility_client(tmp_path, *, write_groups: frozenset[str] = GROUPS) ->
|
|||
asyncio.run(create_schema())
|
||||
store = PostgresPortStore.from_url(database_url)
|
||||
settings = RuntimeSettings(
|
||||
environment="production",
|
||||
environment="test", # Exercise the retained development compatibility lane.
|
||||
backend="postgresql",
|
||||
allow_ephemeral=False,
|
||||
database_url=database_url,
|
||||
|
|
|
|||
|
|
@ -69,3 +69,34 @@ def test_repository_navigation_mcp_tool_exposes_all_six_facets() -> None:
|
|||
"business_stake",
|
||||
"business_mechanic",
|
||||
} <= set(schema["properties"])
|
||||
|
||||
|
||||
def test_mcp_credentials_are_per_invocation_and_redirects_do_not_relay_them():
|
||||
from contextvars import ContextVar
|
||||
import pytest
|
||||
credential = ContextVar('hub_credential')
|
||||
server = HubCoreMCPServer(name='secure', api_base='https://hub.example', register_tools=False,
|
||||
token_provider=credential.get, require_credentials=True)
|
||||
async def invoke(token):
|
||||
credential.set(token)
|
||||
await asyncio.sleep(0)
|
||||
with server._client() as client:
|
||||
assert client.headers['authorization'] == f'Bearer {token}'
|
||||
assert not client.follow_redirects
|
||||
async def run():
|
||||
await asyncio.gather(invoke('caller-a'), invoke('caller-b'))
|
||||
asyncio.run(run())
|
||||
with pytest.raises(LookupError):
|
||||
server._client()
|
||||
missing = HubCoreMCPServer(name='missing', api_base='https://hub.example', register_tools=False,
|
||||
require_credentials=True)
|
||||
with pytest.raises(ValueError, match='current Hub credential'):
|
||||
missing._client()
|
||||
|
||||
|
||||
def test_mcp_provider_errors_do_not_echo_credentials():
|
||||
def failed_provider():
|
||||
raise RuntimeError('secret-value-must-not-escape')
|
||||
server = HubCoreMCPServer(name='failing', api_base='https://hub.example', register_tools=False,
|
||||
token_provider=failed_provider, require_credentials=True)
|
||||
assert server._get('/docs') == {'error': 'Request failed'}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def test_durable_store_survives_reopen_and_keeps_event_families_separate(tmp_pat
|
|||
.read_text(encoding="utf-8")
|
||||
)
|
||||
settings = RuntimeSettings(
|
||||
environment="production",
|
||||
environment="test",
|
||||
backend="postgresql",
|
||||
allow_ephemeral=False,
|
||||
database_url=database_url,
|
||||
|
|
@ -80,7 +80,7 @@ def test_postgresql_readiness_fails_when_database_is_unavailable() -> None:
|
|||
database_url = "sqlite+aiosqlite:////definitely-missing-parent/runtime.db"
|
||||
store = PostgresPortStore.from_url(database_url)
|
||||
settings = RuntimeSettings(
|
||||
environment="production",
|
||||
environment="test",
|
||||
backend="postgresql",
|
||||
allow_ephemeral=False,
|
||||
database_url=database_url,
|
||||
|
|
@ -96,7 +96,7 @@ def test_postgresql_readiness_fails_when_runtime_tables_are_unavailable(tmp_path
|
|||
database_url = f"sqlite+aiosqlite:///{tmp_path / 'empty.db'}"
|
||||
store = PostgresPortStore.from_url(database_url)
|
||||
settings = RuntimeSettings(
|
||||
environment="production",
|
||||
environment="test",
|
||||
backend="postgresql",
|
||||
allow_ephemeral=False,
|
||||
database_url=database_url,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ def test_health_and_ephemeral_readiness() -> None:
|
|||
|
||||
|
||||
def test_production_readiness_fails_closed_for_ephemeral_backend() -> None:
|
||||
response = client(allow_ephemeral=False, environment="production").get("/readyz")
|
||||
response = client(allow_ephemeral=False).get("/readyz")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["status"] == "degraded"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue