feat: add fail-closed Hub access profile foundation
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 11:44:50 +02:00
parent df39fd5f43
commit 3e386147fd
35 changed files with 2009 additions and 195 deletions

7
tests/fixtures/flex-auth/README.md vendored Normal file
View 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.

View 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": {}
}

View 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"
]
}
]
}
}

View 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
View 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."
}
]
}

View 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'

View 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
View 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']

View file

@ -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,

View file

@ -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'}

View file

@ -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,

View file

@ -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"