feat: integrate durable authorization audit and runtime composition
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 12:01:42 +02:00
parent 3e386147fd
commit c9b6916dac
14 changed files with 767 additions and 16 deletions

View file

@ -47,8 +47,9 @@ A host composes `create_app(access_controller=AccessController(...))` with:
set. The token is reread on every call and is separate from the end-user token.
Remote `/v1/keys` responses never establish their own trust. Current/previous keys
can coexist in the mounted trust file; removing a key takes effect next call.
- `Audit.append`: an **owner implementation still owed** that returns only after
durable acceptance. Every allow must reach this sink before handler execution;
- `AuditCoreSink`: implemented against the owner
[eight-field ingestion contract](../../audit-core/docs/event-envelope.md), returning
only after an operational-custody probe and explicit durable acceptance. Every allow must reach this sink before handler execution;
a failed sink blocks reads as well as writes. Authorization receipts say
`authorized`, not “operation completed.” Domain commit/outcome audit remains a
separate requirement; this source seam does not claim transactional audit.
@ -58,7 +59,10 @@ A host composes `create_app(access_controller=AccessController(...))` with:
Do not implement these missing adapters as a constant allow, in-memory audit sink,
or an assertion copied from token claims. Tests use synthetic owners explicitly.
The current CLI deliberately provides no fixture adapter or production bypass.
A deployment composition factory and dependency health probes remain T02–T04 work.
An explicit `SecuritySettings`/owner-facts composition now owns the HTTP client
and closes it with the runtime. Audit custody is probed per append. Real owner-facts
admission and additional owner dependency probes remain T02–T04 work; see the
[owner integration review](owner-access-integration.md).
For this candidate all Hub resources are explicitly **platform-owned**. Other
target tenants are refused. Root requires AAL2/3, active account and tenants,
@ -124,8 +128,8 @@ browser PKCE sessions/logout and real MCP root login remain open.
2. T02: immutable root binding, registered audience/redirects, PKCE/MFA/recovery,
admitted live facts adapters, attended login/logout and revocation receipts.
3. T03: dedicated Hub policy and fact provenance review; authenticated deployment,
credential/key custody, durable audit implementation and native rotation probes.
4. T04–T05: composed deployable runtime, dependency health probes, all client/extension
credential/key custody, real Audit Core sender admission and native rotation probes.
4. T04–T05: deploy the admitted composition, complete dependency health probes and all client/extension
migrations, domain outcome audit, legacy lane rollback and full root journeys.
5. T06–T08: every platform/Railiance receipt, separate public-enable approval and
later role/delegation/tenant isolation. No milestone is closed by local fixtures.

View file

@ -0,0 +1,46 @@
# HUB-WP-0012 owner integration continuation — 2026-09-28
Initial foundation committed and pushed: `3e38614`.
Implemented in the follow-up:
- Audit Core sender conforming to its real eight-field ingestion contract,
authenticated with a distinct rotating credential, and requiring operational
durable custody plus an explicit accepted/duplicate receipt before execution.
- Exact verified signed policy artifacts retained in the archive; secret-shaped
decision fields fail closed before serialization. Refusals retain verified
identity, action and request/resource digests where available.
- Explicit `SecuritySettings` + owner `FactSource` runtime composition, bounded
HTTP calls, no proxy-environment inheritance, and runtime-owned client cleanup.
Configuration alone cannot activate a missing authority implementation.
Validation:
- Full suite with `HUB_CORE_AUDIT_CORE_SOURCE=/home/worsch/audit-core`:
**304 passed**, one existing TestClient deprecation warning, 52.56 seconds.
- Final focused owner/denial checks after enriching refusal attribution: **7 passed**.
- Source inventory unchanged and validated: 161 Hub surfaces / 48 platform rows /
250 observed cluster objects.
- `uv build` succeeds; `git diff --check` passes.
Five opt-in owner-source cases use the actual Audit Core receiver:
1. Hub envelope accepted and exact evidence retained across storage reopen.
2. Unmodified development custody rejected before posting.
3. A lost receipt blocks execution even though the receiver holds the attempt.
4. Invalid sender credential creates no record.
5. Composed JWT verification, current facts, signed decision, real receiver
ingestion and native Hub write; archived decision independently verifies;
root entitlement withdrawal denies the next attempt.
These are local tests with synthetic identities, keys and sender registration.
SQLite is durable test storage, not operational production custody. A clearly
named test-only readiness fixture simulates that custody classification; no
live receiver admission, real root login or platform access is claimed.
The [owner review](../owner-access-integration.md) records concrete next gates:
User Engine's `platform:root` mapping, a non-provisioning immutable identity and
root-entitlement lookup (its `/api/v1/me` can create an account), authenticated
Tenant Engine caller admission, and actual Hub Audit Core sender custody.
`warden route show audit-core-senders --json` routes registry ownership to
ops-mason/OpenBao and is unresolved. No secrets were retrieved or grants changed.

View file

@ -0,0 +1,101 @@
# HUB-WP-0012 owner integration review — 2026-09-28
The Hub foundation is committed as `3e38614`. This follow-up delivers an Audit
Core sender and runtime composition, and identifies the remaining source
contracts. These are owner review inputs, not evidence of deployed grants.
## Account, root entitlement and tenant facts
| Owner surface reviewed | Observed behavior | Required integration |
| --- | --- | --- |
| User Engine `web.py`, `service.py::me` | `/api/v1/me` resolves `(iss, sub)` but creates User/Account/ExternalIdentity records if absent | A side-effect-free authority lookup; Hub must not bootstrap identities to check access |
| User Engine `service.py` | `PLATFORM_TENANT = "platform:root"`; `platform-operator` is an actor role | Explicit mapping to IAM `tenant:platform` and the one immutable root principal; a role or string substitution is insufficient |
| User Engine tenant administration APIs | Human/edge-oriented routes; no reviewed Hub workload lookup for current root entitlement | Independently authenticated Hub workload, exact lookup scope and current entitlement provenance |
| Tenant Engine `/tenants/{tenant_id}` | Current lifecycle and record version, authorized `tenant.read` | Resolve immutable tenant ID versus canonical identifier; preserve owner version and lifecycle |
| Tenant Engine `/tenants/{tenant_id}/roles/live` | Live role state with `tenant.role.read.live`, caller supplies an `actor` query | Admit/authenticate the actual Hub workload and its actor assertion; do not mistake query text or a network path for caller authentication |
The accepted integration must return identity references, account status,
explicit current root entitlement, actor/target tenant status, observation times
and owner evidence/version references. Unknown identity is denied without
creating anything. Revocation must be visible on the next privileged read as
well as write; no cached token role supplies current entitlement.
`LiveFacts` is the Hub-side normalized result, not a wire endpoint invented for
an owner. Each source lookup must complete inside its timeout. The observation
age is measured from the actual source observation and cannot be reset after
slow downstream calls. All joined facts must still be at most five seconds old
when policy/audit finish. Missing, ambiguous, inactive or stale facts deny.
Producer aliases must come from an explicit owner binding to that identity.
T01/T02 require owner review of the lookup and root/tenant mapping before a
production `FactSource` is configured. The runtime will not load fixture facts,
query owner databases directly, forward Hub bearer tokens to other audiences,
or use `/me` as an account-provisioning side effect.
## Audit Core adapter and sender admission
`AuditCoreSink` implements the actual `docs/event-envelope.md` contract:
`POST /v1/events` with eight fields, a distinct rotating sender bearer, and an
`Idempotency-Key` matching the event ID. Source is exactly `hub-core`, tenant
exactly `tenant:platform`. Only a matching `202 accepted` or `200 duplicate`
with a nonempty archive reference counts as custody. Before each append,
`/readyz` must report `status=ok`, `durable=true`, and custody class
`operational` or its rollout alias `archive`. The entire append is bounded
at three seconds, uses TLS, and never follows redirects.
Allow is blocked until the archive accepts the authorization record. A lost
receipt blocks the business operation even if the attempt reached storage. This
is a pre-execution authorization journal, not proof that an operation committed.
There is no local success buffer or silent redaction. Domain transaction/outcome
atomicity and failure detection remain separate T03/T04 acceptance gates.
The exact verified signed decision is retained under `data.signed_decision` as
serialized JSON so another serialization of the archive cannot reorder its Go
struct fields. The verifier refuses secret-shaped field names before retention.
No end-user bearer, message body or command body is emitted. Independent tests
reverify the signed artifact after retrieval from the owner's receiver.
Proposed receiver registration (no credential values):
- Name/source: `hub-core`; allowed tenants: only `tenant:platform`.
- Write enabled, read disabled; distinct sender tokens with rotation overlap.
- `secret_policy=reject`; authorization record classes `hub.access.authorized`,
`hub.access.denied`, `hub.access.refused`.
- Load-bearing authorization evidence: execution requires receipt. Owner review
must settle exact emission-cadence/failure detection; no claim that this journal
provides complete domain mutation evidence or archive tamper evidence.
Credential routing: `warden route show audit-core-senders --json` identifies
`ops-mason`, subsystem OpenBao + audit-core, `warden_executes=false`, and currently
`resolvable=false`. This route points to registry custody; it does not prove an
admitted Hub sender or mint a credential. No secret was requested or retrieved.
## Runtime assembly
`SecuritySettings` uses these `HUB_CORE_SECURITY_` environment suffixes:
| Suffix | Value |
| --- | --- |
| `ISSUER`, `AUDIENCE`, `ROOT_SUBJECT` | Admitted HTTPS issuer, Hub audience, immutable root subject |
| `POLICY_URL`, `POLICY_CALLER` | Dedicated HTTPS PDP and exact admitted ServiceAccount principal |
| `POLICY_TOKEN_FILE`, `POLICY_KEYS_FILE` | Absolute projected caller-token and trusted public-key paths |
| `AUDIT_URL`, `AUDIT_TOKEN_FILE` | HTTPS receiver and separate absolute sender-token path |
A host calls `create_app(access_facts=reviewed_owner_adapter)` in enforcement
mode. It can alternatively supply an explicit `SecuritySettings` instance.
The runtime owns and closes the shared HTTP client; proxy environment variables
are not inherited. Partial/invalid composition is rejected. Without an explicit
facts adapter, the standalone app stays closed even if security variables exist.
No file-backed allowlist or dynamic arbitrary-module loader supplies authority.
## Evidence boundary
The opt-in `tests/test_audit_core_owner_contract.py` exercises the actual Audit
Core WSGI receiver and durable SQLite storage, including reopen, wrong-credential
and lost-receipt cases. A **test-only** readiness fixture reports operational
custody to exercise the production client's receipt checks; this is not native
operational custody. Unmodified development custody is independently refused.
A composed fixture journey verifies a real RSA JWT, fresh facts, an Ed25519
policy decision, receiver custody and native Hub write, followed by entitlement
withdrawal denial. Real root enrollment/login, service deployment, live key and
sender custody, and operational acceptance remain open.

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager, suppress
import httpx
from fastapi import FastAPI, Response, status
from hub_core import __version__
@ -27,7 +29,8 @@ from hub_core.runtime.workload_projection import (
WorkloadProjectionService,
)
from hub_core.runtime.workload_projection_routes import create_workload_projection_router
from hub_core.security.boundary import AccessBoundary, AccessController
from hub_core.security.boundary import AccessBoundary, AccessController, FactSource
from hub_core.security.config import SecuritySettings
def create_app(
@ -37,8 +40,25 @@ def create_app(
repo_projection_client: RepoProjectionClient | None = None,
workload_projection_client: WorkloadProjectionClient | None = None,
access_controller: AccessController | None = None,
access_facts: FactSource | None = None,
security_settings: SecuritySettings | None = None,
) -> FastAPI:
resolved_settings = settings or RuntimeSettings.from_env()
# Importing this module also constructs the standalone app. Environment
# configuration is activated only by an explicit owner-facts composition;
# without that adapter the default app stays closed, not import-broken.
if security_settings is None and access_facts is not None:
security_settings = SecuritySettings.from_env()
security_client = None
if access_controller is not None and (access_facts is not None or security_settings is not None):
raise ValueError("choose an access controller or owner-facts composition")
if security_settings is not None or access_facts is not None:
if not resolved_settings.enforce_access:
raise ValueError("security composition requires enforcement mode")
if security_settings is None or access_facts is None:
raise ValueError("security composition requires configuration and authoritative owner facts")
security_client = httpx.AsyncClient(trust_env=False)
access_controller = security_settings.compose(facts=access_facts, client=security_client)
resolved_store = port_store or _create_store(resolved_settings)
owns_store = port_store is None
resolved_repo_projection_client = repo_projection_client
@ -84,11 +104,15 @@ def create_app(
await workload_projection.refresh()
except WorkloadProjectionRejected:
pass
try:
yield
finally:
if refresh_task is not None:
refresh_task.cancel()
with suppress(asyncio.CancelledError):
await refresh_task
if security_client is not None:
await security_client.aclose()
if owns_repo_projection_client:
await resolved_repo_projection_client.aclose() # type: ignore[union-attr]
if owns_store and (closer := getattr(resolved_store, "aclose", None)):

View file

@ -0,0 +1,77 @@
"""Synchronous authorization custody using Audit Core's eight-field contract."""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import httpx
from hub_core.security.identity import AccessFailure, require_https
class AuditCoreSink:
"""No allow returns before durable remote custody acknowledges its record.
A lost receipt blocks execution, even if the archive already stored the
attempt. This is an authorization-attempt journal, not a mutation outbox.
"""
def __init__(self, *, base_url: str, token_file: Path, client: httpx.AsyncClient):
require_https(base_url)
self.base_url = base_url.rstrip("/")
self.token_file, self.client = token_file, client
async def readiness(self) -> None:
response = await self.client.get(self.base_url + "/readyz", timeout=2,
follow_redirects=False)
response.raise_for_status()
receipt = response.json()
if (receipt.get("status") != "ok" or receipt.get("durable") is not True
or receipt.get("custody_class") not in {"archive", "operational"}):
raise ValueError("operational audit custody is required")
async def append(self, record: dict) -> None:
try:
async with asyncio.timeout(3):
# Probe each time, so a receiver's development fallback cannot
# be mistaken for admitted custody through a cached readiness.
await self.readiness()
token = self.token_file.read_text().strip()
if not token or not token.isascii() or any(c.isspace() for c in token):
raise ValueError("invalid sender credential")
correlation = record.get("correlation_id")
outcome = record.get("outcome")
if not isinstance(correlation, str) or not correlation or outcome not in {
"authorized", "denied", "refused",
}:
raise ValueError("invalid authorization audit record")
event = {
"id": str(uuid4()),
"type": "hub.access." + outcome,
"source": "hub-core",
"subject": "hub-access:" + correlation,
"tenant": "tenant:platform",
"correlation_id": correlation,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"data": record,
}
raw = json.dumps(event, ensure_ascii=False, allow_nan=False).encode()
if len(raw) > 256 * 1024:
raise ValueError("audit envelope exceeds receiver limit")
response = await self.client.post(
self.base_url + "/v1/events", content=raw, timeout=2,
follow_redirects=False,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Idempotency-Key": event["id"]},
)
expected = {200: "duplicate", 202: "accepted"}.get(response.status_code)
receipt = response.json()
if (expected is None or receipt.get("status") != expected
or not isinstance(receipt.get("reference"), str) or not receipt["reference"]):
raise ValueError("audit custody not acknowledged")
except Exception as exc:
# Never return receiver bodies, credential values or private paths.
raise AccessFailure(503, "audit_unavailable") from exc

View file

@ -67,6 +67,7 @@ class Decision:
decision_id: str
policy_version: str
caller: str = ""
signed_envelope: str | None = None
def __post_init__(self):
if type(self.allowed) is not bool or not self.decision_id or not self.policy_version:
@ -146,6 +147,7 @@ class AccessController:
"request_digest": request_digest, "facts_evidence": facts.evidence_id,
"decision_id": decision.decision_id, "policy_version": decision.policy_version,
"policy_caller": decision.caller,
"signed_decision": decision.signed_envelope,
"outcome": "authorized" if decision.allowed else "denied",
})
if not decision.allowed:
@ -196,6 +198,8 @@ class AccessBoundary:
return
correlation = str(uuid4())
context = None
action = None
digest = None
try:
headers = Request(scope).headers.getlist("authorization")
if len(headers) != 1 or not headers[0].startswith("Bearer "):
@ -250,6 +254,11 @@ class AccessBoundary:
"subject": actor.subject if actor else None,
"issuer": actor.issuer if actor else None,
"actor_tenant": actor.tenant if actor else None,
"principal_type": actor.principal_type if actor else None,
"action": action,
"resource_digest": hashlib.sha256(scope["path"].encode()).hexdigest(),
"request_digest": digest,
"target_tenant": "tenant:platform",
})
except Exception:
failure = AccessFailure(503, "audit_unavailable")

View file

@ -0,0 +1,62 @@
"""Explicit runtime composition: owner facts remain an injected trust adapter."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
import httpx
from hub_core.security.audit import AuditCoreSink
from hub_core.security.boundary import AccessController, FactSource
from hub_core.security.identity import OIDCVerifier, require_https
from hub_core.security.policy import FlexPolicy
@dataclass(frozen=True)
class SecuritySettings:
issuer: str
audience: str
root_subject: str
policy_url: str
policy_caller: str
policy_token_file: Path
policy_keys_file: Path
audit_url: str
audit_token_file: Path
def __post_init__(self):
for url in (self.issuer, self.policy_url, self.audit_url):
require_https(url)
if not self.audience or not self.root_subject:
raise ValueError("explicit audience and immutable root subject required")
if not self.policy_caller.startswith("system:serviceaccount:"):
raise ValueError("explicit policy workload principal required")
for path in (self.policy_token_file, self.policy_keys_file, self.audit_token_file):
if not isinstance(path, Path) or not path.is_absolute():
raise ValueError("absolute credential/trust paths required")
if self.policy_token_file == self.audit_token_file:
raise ValueError("policy and audit require separate credentials")
@classmethod
def from_env(cls) -> SecuritySettings | None:
fields = tuple(cls.__dataclass_fields__)
values = {field: os.getenv("HUB_CORE_SECURITY_" + field.upper(), "") for field in fields}
if not any(values.values()):
return None
missing = [field for field, value in values.items() if not value]
if missing:
raise ValueError("incomplete Hub security configuration: " + ", ".join(missing))
return cls(**{field: Path(value) if field.endswith("_file") else value
for field, value in values.items()})
def compose(self, *, facts: FactSource, client: httpx.AsyncClient) -> AccessController:
return AccessController(
identity=OIDCVerifier(issuer=self.issuer, audience=self.audience, client=client),
facts=facts,
policy=FlexPolicy(base_url=self.policy_url, client=client,
caller_token_file=self.policy_token_file,
trusted_keys_file=self.policy_keys_file, caller=self.policy_caller),
audit=AuditCoreSink(base_url=self.audit_url, token_file=self.audit_token_file, client=client),
root_issuer=self.issuer, root_subject=self.root_subject,
)

View file

@ -92,6 +92,20 @@ def _time(value: str) -> datetime:
def verify_decision(envelope: dict, *, request: dict, keys: dict,
caller: str, now: datetime | None = None) -> Decision:
verify_signature(envelope, keys)
# The exact signed artifact is retained for independent verification. Do
# not hide secret-shaped fields in its serialized audit representation.
def check_fields(value):
if isinstance(value, dict):
for key, item in value.items():
if any(fragment in key.lower() for fragment in (
"password", "secret", "token", "credential", "private_key",
)):
raise ValueError("sensitive decision field is outside the audit profile")
check_fields(item)
elif isinstance(value, list):
for item in value:
check_fields(item)
check_fields(envelope)
now = now or datetime.now(timezone.utc)
if (envelope["contract_version"] != "flex-auth.decision-record.v1"
or envelope["request_id"] != request["id"] or not envelope["id"]):
@ -132,7 +146,8 @@ def verify_decision(envelope: dict, *, request: dict, keys: dict,
if (lifetime["kind"] != "ttl" or not
_time(lifetime["not_before"]) <= now < _time(lifetime["expires_at"])):
raise ValueError("invalid decision lifetime")
return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"], caller)
return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"],
caller, go_json(envelope).decode())
class FlexPolicy:

View file

@ -0,0 +1,94 @@
import asyncio
import json
import httpx
import pytest
from hub_core.security.audit import AuditCoreSink
from hub_core.security.identity import AccessFailure
RECORD = {'profile': 'hub-core.access/1.0.0', 'correlation_id': 'request:123',
'outcome': 'authorized', 'subject': 'root-sub', 'actor_tenant': 'tenant:platform',
'target_tenant': 'tenant:platform', 'decision_id': 'decision:123'}
READY = {'status': 'ok', 'durable': True, 'custody_class': 'operational'}
def run_sink(tmp_path, handler, record=RECORD):
credential = tmp_path/'audit-token'
credential.write_text('audit-only-fixture')
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
sink = AuditCoreSink(base_url='https://audit.example', token_file=credential, client=client)
await sink.append(record)
asyncio.run(run())
@pytest.mark.parametrize('code,state', [(202, 'accepted'), (200, 'duplicate')])
def test_requires_exact_durable_custody_receipt(tmp_path, code, state):
requests = []
def handle(request):
requests.append(request)
assert not request.extensions.get('follow_redirects')
if request.url.path == '/readyz':
assert 'authorization' not in request.headers
return httpx.Response(200, json=READY)
envelope = json.loads(request.content)
assert set(envelope) == {'id','type','source','subject','tenant','correlation_id','occurred_at','data'}
assert request.headers['authorization'] == 'Bearer audit-only-fixture'
assert request.headers['idempotency-key'] == envelope['id']
assert envelope['data'] == RECORD
assert envelope['source'] == 'hub-core'
assert envelope['tenant'] == 'tenant:platform'
return httpx.Response(code, json={'status': state, 'reference': 'audit:'+envelope['id']})
run_sink(tmp_path, handle)
assert [r.url.path for r in requests] == ['/readyz', '/v1/events']
@pytest.mark.parametrize('code,receipt', [
(200, {'status':'ok'}), (202, {'status':'accepted'}),
(200, {'status':'accepted','reference':'x'}), (202, {'status':'duplicate','reference':'x'}),
(400, {'status':'accepted','reference':'x'}), (401, {}), (403, {}), (409, {}), (503, {}),
(307, {}),
])
def test_unacknowledged_custody_never_allows_execution(tmp_path, code, receipt):
def handle(request):
if request.url.path == '/readyz':
return httpx.Response(200, json=READY)
return httpx.Response(code, json=receipt, headers={'Location':'https://untrusted.example'})
with pytest.raises(AccessFailure, match='audit_unavailable'):
run_sink(tmp_path, handle)
@pytest.mark.parametrize('readiness', [
{**READY, 'custody_class':'development'}, {**READY, 'durable':False},
{**READY, 'status':'unavailable'}, {**READY, 'durable':'true'}, {},
])
def test_receiver_fallback_fails_before_post(tmp_path, readiness):
def handle(request):
assert request.url.path == '/readyz'
return httpx.Response(200, json=readiness)
with pytest.raises(AccessFailure):
run_sink(tmp_path, handle)
def test_rotation_is_read_each_time_and_lost_receipt_denies(tmp_path):
token_file = tmp_path/'audit-token'
seen = []
def handle(request):
if request.url.path == '/readyz':
return httpx.Response(200, json=READY)
seen.append(request.headers['authorization'])
if len(seen) == 2:
raise httpx.ReadTimeout('sensitive private upstream details')
return httpx.Response(202, json={'status':'accepted','reference':'audit:1'})
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
sink = AuditCoreSink(base_url='https://audit.example', token_file=token_file, client=client)
token_file.write_text('first')
await sink.append(RECORD)
token_file.write_text('replacement')
with pytest.raises(AccessFailure) as result:
await sink.append(RECORD)
assert str(result.value) == 'audit_unavailable'
asyncio.run(run())
assert seen == ['Bearer first', 'Bearer replacement']

View file

@ -225,3 +225,5 @@ def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor():
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']

View file

@ -0,0 +1,64 @@
from dataclasses import replace
from pathlib import Path
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.security.config import SecuritySettings
def settings():
return SecuritySettings(issuer='https://issuer.example', audience='hub-core', root_subject='root-sub',
policy_url='https://policy.example', policy_caller='system:serviceaccount:hub-core:hub-core',
policy_token_file=Path('/run/policy-token'), policy_keys_file=Path('/run/keys.json'),
audit_url='https://audit.example', audit_token_file=Path('/run/audit-token'))
def test_partial_configuration_does_not_silently_disable_enforcement(monkeypatch):
monkeypatch.setenv('HUB_CORE_SECURITY_ISSUER','https://issuer.example')
with pytest.raises(ValueError, match='incomplete'):
SecuritySettings.from_env()
def test_missing_facts_and_development_mode_cannot_compose():
with pytest.raises(ValueError, match='authoritative owner facts'):
create_app(settings=RuntimeSettings(access_mode='enforce'), security_settings=settings())
with pytest.raises(ValueError, match='enforcement mode'):
create_app(settings=RuntimeSettings(), security_settings=settings(), access_facts=object())
@pytest.mark.parametrize('changes', [
{'issuer':'http://issuer.example'}, {'audit_url':'https://localhost'},
{'policy_token_file':Path('relative')}, {'root_subject':''},
{'audit_token_file':Path('/run/policy-token')},
])
def test_invalid_trust_configuration(changes):
with pytest.raises(ValueError):
replace(settings(), **changes)
def test_composed_clients_are_closed_by_runtime_lifespan():
app = create_app(settings=RuntimeSettings(access_mode='enforce'),
security_settings=settings(), access_facts=object())
controller = app.state.access_controller
client = controller.identity.client
assert client is controller.audit.client is controller.policy.client
with TestClient(app) as api:
assert api.get('/healthz').status_code == 200
assert not client.is_closed
assert client.is_closed
def test_environment_composition_requires_explicit_owner_adapter(monkeypatch):
configured = settings()
for field in configured.__dataclass_fields__:
monkeypatch.setenv('HUB_CORE_SECURITY_'+field.upper(),str(getattr(configured,field)))
closed = create_app(settings=RuntimeSettings(access_mode='enforce'))
with TestClient(closed) as client:
assert client.get('/docs',headers={'Authorization':'Bearer untrusted'}).status_code == 503
composed = create_app(settings=RuntimeSettings(access_mode='enforce'),access_facts=object())
with TestClient(composed) as client:
assert client.get('/healthz').status_code == 200
assert composed.state.access_controller is not None

View file

@ -157,3 +157,14 @@ def test_policy_client_rotates_workload_credentials_and_retains_no_user_token(tm
asyncio.run(run())
assert seen == ['Bearer first-workload-token', 'Bearer second-workload-token',
'Bearer second-workload-token']
def test_signed_decision_is_retained_exactly_and_sensitive_fields_are_refused():
request,envelope,now = case()
keys = sign(envelope)
result = verify_decision(envelope,request=request,keys=keys,caller='workload:hub',now=now)
assert result.signed_envelope == go_json(envelope).decode()
verify_signature(parse_json(result.signed_envelope),keys)
envelope['diagnostics'] = {'access_token':'must-not-be-archived'}
with pytest.raises(ValueError,match='sensitive decision field'):
verify_decision(envelope,request=request,keys=sign(envelope),caller='workload:hub',now=now)

View file

@ -0,0 +1,212 @@
"""Opt-in interoperability with Audit Core's actual receiver, not live custody.
HUB_CORE_AUDIT_CORE_SOURCE=/checkout/audit-core pytest -q tests/test_audit_core_owner_contract.py
"""
import asyncio
from dataclasses import replace
import json
import os
from pathlib import Path
import sys
import httpx
import pytest
source = os.getenv('HUB_CORE_AUDIT_CORE_SOURCE')
if not source:
pytest.skip('set HUB_CORE_AUDIT_CORE_SOURCE for owner-source interoperability', allow_module_level=True)
sys.path.insert(0, str(Path(source).resolve()))
from audit_core.ingestion import IngestionApplication
from audit_core.senders import SenderIdentity, SenderRegistry
from audit_core.sqlite_backend import SQLiteAuditBackend
from hub_core.security.audit import AuditCoreSink
from hub_core.security.identity import AccessFailure
class OperationalReceiptFixture(SQLiteAuditBackend):
"""Exercise production receipt parsing using synthetic local SQLite custody.
This override is test-only. It proves no operational/deployed custody claim.
The separate fallback test uses the unmodified owner development receipt.
"""
@property
def retention_policy(self):
return replace(super().retention_policy, custody_class='operational')
def receiver(backend):
return IngestionApplication(backend, SenderRegistry([
SenderIdentity(name='hub-core-fixture', tokens=('fixture-only',),
sources=frozenset({'hub-core'}), tenants=frozenset({'tenant:platform'}),
may_write=True, may_read=False, secret_policy='reject', evidence_kind='load-bearing')
]))
def emit(tmp_path, backend, *, credential='fixture-only', lost_receipt=False):
token = tmp_path/'sender'
token.write_text(credential)
sent = []
record = {'profile':'hub-core.access/1.0.0', 'correlation_id':'request:owner-contract',
'outcome':'authorized', 'subject':'fixture-root', 'actor_tenant':'tenant:platform',
'target_tenant':'tenant:platform', 'policy_caller':'workload:fixture'}
with httpx.Client(transport=httpx.WSGITransport(receiver(backend))) as native:
def handle(request):
response = native.request(request.method, str(request.url), content=request.content,
headers=request.headers)
if request.method == 'POST':
sent.append(json.loads(request.content))
if lost_receipt:
assert response.status_code == 202
raise httpx.ReadTimeout('simulated lost receipt after real receiver acceptance')
return httpx.Response(response.status_code, content=response.content, headers=response.headers)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
sink = AuditCoreSink(base_url='https://audit.fixture', token_file=token, client=client)
await sink.append(record)
try:
asyncio.run(run())
except AccessFailure:
return sent, False
return sent, True
def test_real_receiver_persists_exact_hub_envelope_and_reopens(tmp_path):
db = tmp_path/'audit.db'
backend = OperationalReceiptFixture(str(db))
sent, accepted = emit(tmp_path, backend)
assert accepted
backend.close()
reopened = SQLiteAuditBackend(str(db))
try:
stored = reopened.get(sent[0]['id'])
assert stored['details']['data'] == sent[0]['data']
assert stored['source'] == 'hub-core'
assert stored['actor'] is None # Actor attribution belongs in data, per owner contract.
finally:
reopened.close()
def test_real_development_receiver_is_rejected_before_ingestion(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path/'audit.db'))
try:
sent, accepted = emit(tmp_path, backend)
assert not accepted and sent == []
finally:
backend.close()
def test_lost_receipt_blocks_source_even_when_owner_has_durable_attempt(tmp_path):
backend = OperationalReceiptFixture(str(tmp_path/'audit.db'))
try:
sent, accepted = emit(tmp_path, backend, lost_receipt=True)
assert not accepted
assert backend.get(sent[0]['id']) is not None
finally:
backend.close()
def test_wrong_sender_credential_never_creates_a_record(tmp_path):
backend = OperationalReceiptFixture(str(tmp_path/'audit.db'))
try:
sent, accepted = emit(tmp_path, backend, credential='wrong')
assert not accepted
assert backend.get(sent[0]['id']) is None
finally:
backend.close()
def test_root_gate_retains_verifiable_decision_before_native_write(tmp_path):
import base64
import time
from datetime import datetime, timedelta, timezone
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from fastapi.testclient import TestClient
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.security.boundary import LiveFacts
from hub_core.security.config import SecuritySettings
from hub_core.security.policy import go_json, parse_json, submitted_digest, verify_signature
issuer = 'https://issuer.fixture'
now = int(time.time())
identity_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(identity_key.public_key()))
jwk.update(kid='identity-1', alg='RS256', use='sig')
claims = dict(iss=issuer, sub='immutable-root', aud='hub-core', iat=now, exp=now+300,
tenant='tenant:platform', principal_type='human', groups=[], roles=[], scope='openid',
assurance=dict(level='aal2', methods=['pwd','otp'], mfa=True, source='fixture', at=now))
token = jwt.encode(claims, identity_key, algorithm='RS256', headers={'kid':'identity-1','typ':'at+jwt'})
policy_key = Ed25519PrivateKey.generate()
encode = lambda v: base64.urlsafe_b64encode(v).decode().rstrip('=')
keys = {'keys':[{'kid':'policy-1','alg':'ed25519','public_key':encode(
policy_key.public_key().public_bytes(Encoding.Raw,PublicFormat.Raw))}]}
policy_token, audit_token, key_file = (tmp_path/name for name in ('policy-token','audit-token','keys.json'))
policy_token.write_text('policy-fixture')
audit_token.write_text('fixture-only')
key_file.write_text(json.dumps(keys))
caller = 'system:serviceaccount:hub-core:hub-core'
backend = OperationalReceiptFixture(str(tmp_path/'archive.db'))
archive_ids = []
class Facts:
entitled = True
async def resolve(self, actor, resource):
return LiveFacts(actor.issuer, actor.subject, actor.tenant, 'tenant:platform',
True, True, True, self.entitled, time.time(), 'owner:current',
frozenset({'agent:root'}))
facts = Facts()
native = httpx.Client(transport=httpx.WSGITransport(receiver(backend)))
def handle(request):
if request.url.host == 'issuer.fixture':
if request.url.path.endswith('openid-configuration'):
return httpx.Response(200,json={'issuer':issuer,'jwks_uri':issuer+'/keys'})
return httpx.Response(200,json={'keys':[jwk]})
if request.url.host == 'policy.fixture':
assert request.headers['authorization'] == 'Bearer policy-fixture'
check = json.loads(request.content)
timestamp = datetime.now(timezone.utc)
envelope = dict(id='decision:'+check['id'], contract_version='flex-auth.decision-record.v1',
request_id=check['id'], effect='allow', subject=check['subject'],resource=check['resource'],
binding={**{k:v for k,v in check.items() if k!='id'},
'submitted_request_digest':submitted_digest(check)},
lifetime={'kind':'ttl','not_before':timestamp.isoformat(),
'expires_at':(timestamp+timedelta(seconds=300)).isoformat()},
provenance={'policy_version':'fixture-v1','policy_package_digest':'sha256:'+'a'*64,
'decision_time':timestamp.isoformat(),
'caller':{'mode':'enforce','principal':caller,'audience':'flex-auth',
'not_after':(timestamp+timedelta(seconds=300)).isoformat()}})
envelope['signature'] = {'mode':'signed','alg':'ed25519','kid':'policy-1',
'value':encode(policy_key.sign(go_json(envelope)))}
return httpx.Response(200,content=go_json(envelope))
result = native.request(request.method,str(request.url),headers=request.headers,content=request.content)
if request.method == 'POST':
archive_ids.append(json.loads(request.content)['id'])
return httpx.Response(result.status_code,content=result.content,headers=result.headers)
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
security = SecuritySettings(issuer=issuer,audience='hub-core',root_subject='immutable-root',
policy_url='https://policy.fixture',policy_caller=caller,policy_token_file=policy_token,
policy_keys_file=key_file,audit_url='https://audit.fixture',audit_token_file=audit_token)
controller = security.compose(facts=facts,client=client)
app = create_app(settings=RuntimeSettings(access_mode='enforce'),access_controller=controller)
body = dict(schema_version='0.1.0',correlation_id='f7cffcab-4c02-419e-89e5-0b463f5b433a',
from_address='agent:root',to_addresses=['agent:reader'],body='private business content')
try:
with TestClient(app) as api:
result = api.post('/ports/messaging/messages',headers={'Authorization':'Bearer '+token},json=body)
assert result.status_code == 202, result.text
stored = backend.get(archive_ids[0])['details']['data']
verify_signature(parse_json(stored['signed_decision']), keys)
assert stored['subject'] == 'immutable-root'
assert 'private business content' not in json.dumps(stored)
assert token not in json.dumps(stored)
facts.entitled = False
result = api.post('/ports/messaging/messages',headers={'Authorization':'Bearer '+token},json=body)
assert result.status_code == 403
assert backend.get(archive_ids[-1])['details']['data']['reason'] == 'root_entitlement_required'
finally:
asyncio.run(client.aclose())
native.close()
backend.close()

View file

@ -272,6 +272,36 @@ themselves as `test`, since production no longer permits anonymous durable ports
Validation results are recorded in [implementation evidence](../docs/evidence/hub-wp-0012-source-20260928.md).
## Owner integration continuation — 2026-09-28
Committed and synced the initial foundation as `3e38614`. The follow-up
[owner integration review](../docs/owner-access-integration.md) documents source
contracts and delivers a real Audit Core sender plus explicit runtime composition.
The sender verifies operational durable custody and exact receiver acknowledgements,
rereads its own credential, and blocks execution on lost receipts or rejection.
Signed decisions survive archive serialization for independent verification.
The runtime owns the composed HTTP client's lifecycle; configuration alone cannot
activate missing authority adapters.
Owner-source tests exercise the actual Audit Core receiver/storage, a complete
fixture JWT→policy→audit→Hub write, and grant withdrawal denial. Their local SQLite
operational-readiness override is explicitly synthetic, not a live custody claim.
Two T01/T02 contract gaps now have concrete source evidence: User Engine's
`platform:root` requires an explicit mapping to canonical `tenant:platform`, and
`GET /api/v1/me` may provision an unknown account. It must not be used as Hub's
read-only account/entitlement oracle. Tenant Engine's live role endpoint also
needs an admitted authenticated Hub caller, not just an asserted actor query.
The existing `audit-core-senders` routing entry points to ops-mason/OpenBao and
is unresolved; no Hub sender credential was minted or retrieved.
Validation: **304 full-suite tests pass**, plus the final seven owner/denial checks;
wheel build and inventory validation pass.
[Continuation evidence](../docs/evidence/hub-wp-0012-owner-integration-20260928.md).
T01–T04 remain `progress`; live owner acceptance and the later milestones remain
open. No production deployment, entitlement or public listener changed.
## Acceptance checkpoints
- [x] Architecture/source/runtime review captured; new implementation owner is hub-core