diff --git a/docs/access-profile-v1.md b/docs/access-profile-v1.md index 4153178..20e0151 100644 --- a/docs/access-profile-v1.md +++ b/docs/access-profile-v1.md @@ -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. diff --git a/docs/evidence/hub-wp-0012-owner-integration-20260928.md b/docs/evidence/hub-wp-0012-owner-integration-20260928.md new file mode 100644 index 0000000..aef5f25 --- /dev/null +++ b/docs/evidence/hub-wp-0012-owner-integration-20260928.md @@ -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. diff --git a/docs/owner-access-integration.md b/docs/owner-access-integration.md new file mode 100644 index 0000000..3bc5b7f --- /dev/null +++ b/docs/owner-access-integration.md @@ -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. diff --git a/hub_core/runtime/app.py b/hub_core/runtime/app.py index eb2f375..ba6f850 100644 --- a/hub_core/runtime/app.py +++ b/hub_core/runtime/app.py @@ -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,15 +104,19 @@ def create_app( await workload_projection.refresh() except WorkloadProjectionRejected: pass - yield - if refresh_task is not None: - refresh_task.cancel() - with suppress(asyncio.CancelledError): - await refresh_task - 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)): - await closer() + 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)): + await closer() app = FastAPI( title="Hub Core Runtime", diff --git a/hub_core/security/audit.py b/hub_core/security/audit.py new file mode 100644 index 0000000..2b69e13 --- /dev/null +++ b/hub_core/security/audit.py @@ -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 diff --git a/hub_core/security/boundary.py b/hub_core/security/boundary.py index 077e3fc..63eee0a 100644 --- a/hub_core/security/boundary.py +++ b/hub_core/security/boundary.py @@ -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") diff --git a/hub_core/security/config.py b/hub_core/security/config.py new file mode 100644 index 0000000..f4267a2 --- /dev/null +++ b/hub_core/security/config.py @@ -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, + ) diff --git a/hub_core/security/policy.py b/hub_core/security/policy.py index 1c4e32d..b921564 100644 --- a/hub_core/security/policy.py +++ b/hub_core/security/policy.py @@ -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: diff --git a/tests/test_access_audit.py b/tests/test_access_audit.py new file mode 100644 index 0000000..1609908 --- /dev/null +++ b/tests/test_access_audit.py @@ -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'] diff --git a/tests/test_access_boundary.py b/tests/test_access_boundary.py index c781aa1..3340a62 100644 --- a/tests/test_access_boundary.py +++ b/tests/test_access_boundary.py @@ -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'] diff --git a/tests/test_access_config.py b/tests/test_access_config.py new file mode 100644 index 0000000..da74b35 --- /dev/null +++ b/tests/test_access_config.py @@ -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 diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index f3a02e7..3ad3332 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -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) diff --git a/tests/test_audit_core_owner_contract.py b/tests/test_audit_core_owner_contract.py new file mode 100644 index 0000000..bea6e15 --- /dev/null +++ b/tests/test_audit_core_owner_contract.py @@ -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() diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index 54ddff2..d851c69 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -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