feat: integrate durable authorization audit and runtime composition
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
3e386147fd
commit
c9b6916dac
14 changed files with 767 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
46
docs/evidence/hub-wp-0012-owner-integration-20260928.md
Normal file
46
docs/evidence/hub-wp-0012-owner-integration-20260928.md
Normal 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.
|
||||
101
docs/owner-access-integration.md
Normal file
101
docs/owner-access-integration.md
Normal 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.
|
||||
|
|
@ -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)):
|
||||
|
|
|
|||
77
hub_core/security/audit.py
Normal file
77
hub_core/security/audit.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
62
hub_core/security/config.py
Normal file
62
hub_core/security/config.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
94
tests/test_access_audit.py
Normal file
94
tests/test_access_audit.py
Normal 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']
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
64
tests/test_access_config.py
Normal file
64
tests/test_access_config.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
212
tests/test_audit_core_owner_contract.py
Normal file
212
tests/test_audit_core_owner_contract.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue