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

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

View file

@ -13,10 +13,12 @@
| workplan | HUB-WP-0003 | finished | — | workplans/HUB-WP-0003-ecosystem-consolidation-library-lane.md | | workplan | HUB-WP-0003 | finished | — | workplans/HUB-WP-0003-ecosystem-consolidation-library-lane.md |
| workplan | HUB-WP-0004 | finished | — | workplans/HUB-WP-0004-runtime-and-extension-contract.md | | workplan | HUB-WP-0004 | finished | — | workplans/HUB-WP-0004-runtime-and-extension-contract.md |
| workplan | HUB-WP-0005 | finished | — | workplans/HUB-WP-0005-core-hub-absorption-execution.md | | workplan | HUB-WP-0005 | finished | — | workplans/HUB-WP-0005-core-hub-absorption-execution.md |
| workplan | HUB-WP-0006 | active | — | workplans/HUB-WP-0006-repository-classification-navigation.md | | workplan | HUB-WP-0006 | blocked | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
| workplan | HUB-WP-0007 | finished | — | workplans/HUB-WP-0007-workload-projection-transport.md | | workplan | HUB-WP-0007 | finished | — | workplans/HUB-WP-0007-workload-projection-transport.md |
| workplan | HUB-WP-0008 | finished | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | | workplan | HUB-WP-0008 | finished | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md |
| workplan | HUB-WP-0009 | proposed | — | workplans/HUB-WP-0009-extension-conformance-gaps.md | | workplan | HUB-WP-0009 | finished | — | workplans/HUB-WP-0009-extension-conformance-gaps.md |
| workplan | HUB-WP-0010 | finished | — | workplans/HUB-WP-0010-statehub-inbox-read-pilot.md |
| workplan | HUB-WP-0011 | blocked | — | workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md |
| workplan | HUB-WP-0012 | active | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | workplan | HUB-WP-0012 | active | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0001-T01 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md | | task | HUB-WP-0001-T01 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
| task | HUB-WP-0001-T02 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md | | task | HUB-WP-0001-T02 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
@ -58,15 +60,20 @@
| task | HUB-WP-0008-T03 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | | task | HUB-WP-0008-T03 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md |
| task | HUB-WP-0008-T04 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | | task | HUB-WP-0008-T04 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md |
| task | HUB-WP-0008-T05 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md | | task | HUB-WP-0008-T05 | done | — | workplans/HUB-WP-0008-legacy-message-identity-reconciliation.md |
| task | HUB-WP-0009-T01 | todo | — | workplans/HUB-WP-0009-extension-conformance-gaps.md | | task | HUB-WP-0009-T01 | done | — | workplans/HUB-WP-0009-extension-conformance-gaps.md |
| task | HUB-WP-0009-T02 | todo | — | workplans/HUB-WP-0009-extension-conformance-gaps.md | | task | HUB-WP-0009-T02 | done | — | workplans/HUB-WP-0009-extension-conformance-gaps.md |
| task | HUB-WP-0009-T03 | todo | — | workplans/HUB-WP-0009-extension-conformance-gaps.md | | task | HUB-WP-0009-T03 | done | — | workplans/HUB-WP-0009-extension-conformance-gaps.md |
| task | HUB-WP-0009-T04 | todo | — | workplans/HUB-WP-0009-extension-conformance-gaps.md | | task | HUB-WP-0009-T04 | done | — | workplans/HUB-WP-0009-extension-conformance-gaps.md |
| task | HUB-WP-0010-T01 | done | — | workplans/HUB-WP-0010-statehub-inbox-read-pilot.md |
| task | HUB-WP-0010-T02 | done | — | workplans/HUB-WP-0010-statehub-inbox-read-pilot.md |
| task | HUB-WP-0011-T01 | todo | — | workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md |
| task | HUB-WP-0011-T02 | wait | — | workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md |
| task | HUB-WP-0011-T03 | wait | — | workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md |
| task | HUB-WP-0012-T01 | progress | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T01 | progress | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T02 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T02 | progress | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T03 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T03 | progress | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T04 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T04 | progress | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T05 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T05 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T06 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T06 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T07 | wait | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T07 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |
| task | HUB-WP-0012-T08 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md | | task | HUB-WP-0012-T08 | todo | — | workplans/HUB-WP-0012-netkingdom-platform-root-access.md |

131
docs/access-profile-v1.md Normal file
View file

@ -0,0 +1,131 @@
# Hub access profile 1.0.0 — implementation candidate
HUB-WP-0012 source implementation, 2026-09-28. Owner review and live acceptance
remain open. This profile does not grant platform access or enable public exposure.
## Runtime behavior
`HUB_CORE_ACCESS_MODE=auto` enables enforcement whenever `HUB_CORE_ENV` is not
`development` or `test`. `enforce` also enables it locally. `development` is
rejected in other environments. **Do not deploy this candidate as a routine
upgrade:** the default production factory has no admitted owner adapters yet and
returns 401 for missing credentials and 503 for credential-bearing requests.
The current deployed image and its release configuration have not been changed.
Only exact `GET /healthz` is public, returning `{"status":"ok"}`. The shared
ASGI boundary protects docs, readiness, native ports, projections, compatibility
aliases and subsequently attached routes. Unknown method/route/handler combinations,
WebSockets, mounts without admission, and slash redirects without catalog entries
fail closed. Clients must use exact paths. Catalog admission does not override
compatibility feature flags or single-writer/read-only gates. Legacy bearer checks
remain in the development lane; production enforcement never falls back to them.
`hub_core/security/routes.json` is the candidate action catalog. It names each
runtime method, path template and handler, with a stable action per handler/method.
It is packaged in the wheel and tested against actual route construction. Source
inventory generation does **not** auto-admit a new route. Duplicate docs handlers
remain separately inventoried; the boundary selects the first effective route.
These technical action names require flex-auth/owner review before policy delivery.
## Composition and trust
A host composes `create_app(access_controller=AccessController(...))` with:
- `OIDCVerifier`: an explicitly trusted HTTPS issuer and Hub audience, an owned
`httpx.AsyncClient`, RS256 discovery/JWKS, token and assurance lifetime at most
300 seconds, a 60-second key cache, and unknown-key rotation refresh (rate limited
to once per second). Access tokens require `at+jwt` or the KeyCape `typ: Bearer`
payload marker. ID tokens, local issuers/profiles, weak keys, AAL0 and delegated
agents are refused. Strict integer NumericDates and assurance time are required.
- `FactSource.resolve`: an **owner implementation still owed** that queries
authoritative account, root entitlement, actor tenant and target tenant state.
Its result binds issuer, subject, actor tenant, evidence reference and permitted
producer addresses; its age may not exceed five seconds, including after policy
and audit finish. No token role or incoming tenant/identity header supplies facts.
- `FlexPolicy`: a dedicated HTTPS Hub PDP, exact admitted ServiceAccount principal,
a rotating projected caller-token file and an owner-delivered trusted public-key
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;
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.
- The root's existing immutable issuer and subject, supplied after owner resolution.
No username, email, first-login promotion or generic role establishes root.
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.
For this candidate all Hub resources are explicitly **platform-owned**. Other
target tenants are refused. Root requires AAL2/3, active account and tenants,
current root entitlement, and a fresh policy allow for every action. Workloads
receive no root shortcut: a policy grant and current authoritative facts are always
required. Real workload admission remains unproven. Cross-tenant owner administration
and Phase 2 storage/query isolation are not implemented by this classification.
## Policy interoperability and failure handling
The PDP request carries actor, target tenant, action, concrete resource path,
assurance, root entitlement, authoritative evidence reference, and a digest of
HTTP method/path/query/body. Client bodies and bearer tokens are not sent to the
PDP or audit sink. Body size is bounded at 1 MiB. The controller's identity/facts/
policy/audit chain has a ten-second timeout; individual HTTP calls have three seconds.
A separate refusal-audit attempt is bounded at three seconds.
The verifier requires Ed25519 signing, the submitted request digest, matching
request ID and structured actor/action/resource/tenant/context, enforced caller
provenance, policy version/digest, a decision age at most 30 seconds and a valid
allow lifetime. It never caches decisions. Every unimplemented obligation and
non-allow/non-deny effect fails closed; approval requirements cannot be waived.
A malformed/untrusted/unavailable decision returns 503, a verified denial 403,
and invalid authentication 401. Responses are `no-store` and do not expose backend
exceptions. There is no local allow fallback.
Interoperability tests retain flex-auth's real Go-signed fixture, tampered pair,
public test key and original submitted request. Go `encoding/json` emits struct
fields in declaration order and map keys in sorted order. The verifier retains
wire order and HTML escaping for signatures and reproduces the request structs
for `submitted_request_digest`. This is **not** RFC 8785. Duplicate JSON keys and
non-integer decision numbers are outside this candidate profile and fail closed;
a reordered envelope also fails signature verification. Agree broader canonical
encoding with flex-auth before expanding this profile.
## Producers, MCP and embedded hosts
`from_address`, `from_agent` and `author`, when present in a top-level JSON command,
must match the live owner's producer-address set. Native events get a reserved
`payload._hub_access` record containing verified actor/tenant/correlation identity;
client assertions under that key are overwritten. Domain `subject_refs` remain
business data, never proof of origin. Compatibility event implementations and
external publishers still need owner acceptance of equivalent attribution.
Embedded hosts can install `AccessBoundary` and an explicit host route catalog.
Tests prove the common seam on an embedded host; each real host's mounted paths,
features and routers still require inventory and admission. Do not treat the
standalone runtime catalog as admission for every embedded API.
MCP hosts may supply `token_provider`, a callable resolving a **Hub-audience**
credential from the current invocation, with `require_credentials=True`. Credentials
are never retained on the server; requests do not follow redirects when carrying
one. `trailing_slash=False` targets the standalone runtime's native paths. The
standalone production MCP has no human flow/provider yet and fails closed.
Many legacy tools target APIs the standalone Hub does not implement. They remain
unsupported, not silently translated or authorized. Cross-audience delegation,
browser PKCE sessions/logout and real MCP root login remain open.
## Remaining release gates
1. T01: cross-owner contract review, concrete policy vocabulary, effective host and
service-route expansion; the 250-object snapshot is not full route discovery.
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
migrations, domain outcome audit, legacy lane rollback and full root journeys.
5. T06–T08: every platform/Railiance receipt, separate public-enable approval and
later role/delegation/tenant isolation. No milestone is closed by local fixtures.

View file

@ -0,0 +1,42 @@
# HUB-WP-0012 source implementation evidence — 2026-09-28
This is local source evidence, not attended login, deployed policy, extension or
Railiance acceptance. The workplan remains active with T01–T04 in progress.
Validation:
- `.venv/bin/python -m pytest -q --disable-warnings`: **271 passed** in 50.43s.
One existing FastAPI/Starlette TestClient deprecation warning.
- `tools/build_access_inventory.py --inventory docs/platform-access-inventory.json
--check`: **161 Hub surfaces, 48 platform rows, 250 cluster objects**; checks pass.
- `uv build`: source distribution and wheel build successfully; wheel contains
`hub_core/security/routes.json`.
- `git diff --check`: passes.
Tests cover catalog-wide anonymous denial, exact minimal health exception,
production default denial without owner adapters, immutable root/assurance checks,
live fact freshness and suspension/entitlement withdrawal, denied/unavailable
policy, durable-audit failure, body replay and producer binding, event provenance,
concurrent request contexts, an embedded host, MCP invocation credential isolation
and error redaction, signed JWT claim validation and issuer-key rotation, signed
PDP binding/lifetime/caller/obligation rejection, and projected caller-token rotation.
Interoperability uses flex-auth's original public test fixtures (signed, tampered,
public verification key and original request). Both signature verification and
`submitted_request_digest` reproduction pass against the Go-generated artifacts.
Synthetic current decisions exercise the live-time checks; the historical fixture
is never treated as an active authorization grant.
The [profile candidate](../access-profile-v1.md) states configuration, bounded
lifetimes, serialization limits, extension/host responsibilities, and release gates.
No live credential, grant, policy, workload, public listener or retirement state
was changed. No private production signing key or root subject was invented.
State Hub implementation decision:
`6edd5720-c894-46e3-8130-fd6c09b9f311`.
Remaining requirements include owner review and per-service route expansion;
attended root binding/PKCE/MFA/logout; real account/tenant and durable audit adapters;
a dedicated Hub PDP with authenticated caller and signing-key delivery; deployment
composition and owner health checks; all client/extension/platform receipts;
separately approved exposure; and Phase 2 tenant isolation/delegation.

View file

@ -1,12 +1,16 @@
# Hub Core and extension access through NetKingdom # Hub Core and extension access through NetKingdom
Status: proposed implementation blueprint, 2026-09-28. Owner: `hub-core`. Status: reviewed source blueprint; owner/live acceptance pending, 2026-09-28. Owner: `hub-core`.
Execution record: [HUB-WP-0012](../workplans/HUB-WP-0012-netkingdom-platform-root-access.md). Execution record: [HUB-WP-0012](../workplans/HUB-WP-0012-netkingdom-platform-root-access.md).
Requested outcome: the person signing in as `platform-root` can access and Requested outcome: the person signing in as `platform-root` can access and
administer the whole platform, including Hub Core, its extensions, and Railiance. administer the whole platform, including Hub Core, its extensions, and Railiance.
Other human users are denied initially. Public exposure follows proven access Other human users are denied initially. Public exposure follows proven access
enforcement. Fine-grained delegation is a later phase of the same workplan. enforcement. Fine-grained delegation is a later phase of the same workplan.
The [access profile candidate](access-profile-v1.md) records the subsequent source
implementation and remaining integration gates. The table below is the original
pre-implementation baseline, not a claim that the new enforcement is deployed.
## Findings and evidence boundary ## Findings and evidence boundary
This is a source/configuration review plus read-only runtime observation, not This is a source/configuration review plus read-only runtime observation, not

File diff suppressed because it is too large Load diff

View file

@ -44,8 +44,9 @@ PYTHONDONTWRITEBYTECODE=1 .venv/bin/python tools/build_access_inventory.py \
Omit `--check` to refresh source rows after intentional changes, then review the Omit `--check` to refresh source rows after intentional changes, then review the
diff. Cluster metadata is a dated reviewed input, not silently refreshed by this diff. Cluster metadata is a dated reviewed input, not silently refreshed by this
command. The checker detects source drift, missing profile/test references and command. The checker detects source drift, missing profile/test references and
missing/duplicate cluster-object mappings. It does not test authorization. Actual missing/duplicate cluster-object mappings. It does not test authorization. Live
allow/deny cases are all marked `not-run`; implementation tasks must supply the allow/deny cases remain marked `not-run`; the [source candidate](access-profile-v1.md)
adds local enforcement tests. Implementation tasks must still supply the
client fixtures, isolated mutations, independent readbacks and live receipts. client fixtures, isolated mutations, independent readbacks and live receipts.
## Findings that affect implementation ## Findings that affect implementation

View file

@ -144,3 +144,11 @@ it against an isolated runtime with `hub-core conformance --base-url <url>`.
`docs/core-hub-absorption-plan.md` defines the capability-sized `/api/v2` `docs/core-hub-absorption-plan.md` defines the capability-sized `/api/v2`
route and data move order, single-writer dual-run controls, evidence gates, route and data move order, single-writer dual-run controls, evidence gates,
rollback, and final cutover criteria shared with `CORE-WP-0010`. rollback, and final cutover criteria shared with `CORE-WP-0010`.
## Access enforcement candidate
See [access profile v1](access-profile-v1.md). Production now defaults to the shared
access boundary; the default factory fails closed until real identity/facts/policy/
audit adapters are composed. Only exact GET `/healthz` is public. Do not deploy this
source candidate over the current release before the HUB-WP-0012 admission gates.
Development/test retains the existing unauthenticated/native and legacy-key lanes.

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
from typing import Any from typing import Any
from collections.abc import Callable
import httpx import httpx
from fastmcp import FastMCP from fastmcp import FastMCP
@ -57,8 +58,14 @@ class HubCoreMCPServer:
api_base: str, api_base: str,
instructions: str | None = None, instructions: str | None = None,
register_tools: bool = True, register_tools: bool = True,
token_provider: Callable[[], str] | None = None,
require_credentials: bool = False,
trailing_slash: bool = True,
) -> None: ) -> None:
self.api_base = api_base.rstrip("/") self.api_base = api_base.rstrip("/")
self.token_provider = token_provider
self.require_credentials = require_credentials
self.trailing_slash = trailing_slash
self.mcp = FastMCP( self.mcp = FastMCP(
name=name, name=name,
instructions=instructions or "Generic FOS hub MCP server.", instructions=instructions or "Generic FOS hub MCP server.",
@ -503,40 +510,51 @@ class HubCoreMCPServer:
try: try:
with self._client() as client: with self._client() as client:
response = client.get( response = client.get(
normalize_trailing_slash(path), normalize_trailing_slash(path, trailing=self.trailing_slash),
params=self._clean(params or {}), params=self._clean(params or {}),
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"} return {"error": f"API {exc.response.status_code}"}
except Exception as exc: except Exception:
return {"error": f"Request failed: {exc}"} return {"error": "Request failed"}
def _post(self, path: str, body: dict[str, Any]) -> Any: def _post(self, path: str, body: dict[str, Any]) -> Any:
try: try:
with self._client() as client: with self._client() as client:
response = client.post(normalize_trailing_slash(path), json=self._clean(body)) response = client.post(normalize_trailing_slash(path, trailing=self.trailing_slash), json=self._clean(body))
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"} return {"error": f"API {exc.response.status_code}"}
except Exception as exc: except Exception:
return {"error": f"Request failed: {exc}"} return {"error": "Request failed"}
def _patch(self, path: str, body: dict[str, Any]) -> Any: def _patch(self, path: str, body: dict[str, Any]) -> Any:
try: try:
with self._client() as client: with self._client() as client:
response = client.patch(normalize_trailing_slash(path), json=self._clean(body)) response = client.patch(normalize_trailing_slash(path, trailing=self.trailing_slash), json=self._clean(body))
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"} return {"error": f"API {exc.response.status_code}"}
except Exception as exc: except Exception:
return {"error": f"Request failed: {exc}"} return {"error": "Request failed"}
def _client(self) -> httpx.Client: def _client(self) -> httpx.Client:
return httpx.Client(base_url=self.api_base, timeout=30.0, follow_redirects=True) # The host resolves a Hub-audience credential from the current invocation.
# Never retain it on the MCP server or fall back to a shared root token.
headers = {}
if self.token_provider is not None:
token = self.token_provider()
if not token or any(c.isspace() for c in token):
raise ValueError("current invocation has no Hub credential")
headers["Authorization"] = f"Bearer {token}"
elif self.require_credentials:
raise ValueError("MCP host must provide a current Hub credential")
return httpx.Client(base_url=self.api_base, timeout=30.0,
headers=headers, follow_redirects=not bool(headers))
@staticmethod @staticmethod
def _clean(data: dict[str, Any]) -> dict[str, Any]: def _clean(data: dict[str, Any]) -> dict[str, Any]:

View file

@ -27,6 +27,7 @@ from hub_core.runtime.workload_projection import (
WorkloadProjectionService, WorkloadProjectionService,
) )
from hub_core.runtime.workload_projection_routes import create_workload_projection_router from hub_core.runtime.workload_projection_routes import create_workload_projection_router
from hub_core.security.boundary import AccessBoundary, AccessController
def create_app( def create_app(
@ -35,6 +36,7 @@ def create_app(
port_store: PortStore | None = None, port_store: PortStore | None = None,
repo_projection_client: RepoProjectionClient | None = None, repo_projection_client: RepoProjectionClient | None = None,
workload_projection_client: WorkloadProjectionClient | None = None, workload_projection_client: WorkloadProjectionClient | None = None,
access_controller: AccessController | None = None,
) -> FastAPI: ) -> FastAPI:
resolved_settings = settings or RuntimeSettings.from_env() resolved_settings = settings or RuntimeSettings.from_env()
resolved_store = port_store or _create_store(resolved_settings) resolved_store = port_store or _create_store(resolved_settings)
@ -108,6 +110,9 @@ def create_app(
app.state.contract_validator = ContractValidator() app.state.contract_validator = ContractValidator()
app.state.repository_navigation = repository_navigation app.state.repository_navigation = repository_navigation
app.state.workload_projection = workload_projection app.state.workload_projection = workload_projection
app.state.access_controller = access_controller
if resolved_settings.enforce_access:
app.add_middleware(AccessBoundary, host=app, controller=access_controller)
@app.get("/healthz", response_model=HealthResponse, tags=["system"]) @app.get("/healthz", response_model=HealthResponse, tags=["system"])
async def healthz() -> HealthResponse: async def healthz() -> HealthResponse:
@ -123,6 +128,8 @@ def create_app(
**await repository_navigation.readiness_checks(), **await repository_navigation.readiness_checks(),
**await workload_projection.readiness_checks(), **await workload_projection.readiness_checks(),
} }
if resolved_settings.enforce_access:
dependency_checks["access_profile"] = "ok" if access_controller else "unavailable"
ready = resolved_settings.is_ready(resolved_store.backend_name) and all( ready = resolved_settings.is_ready(resolved_store.backend_name) and all(
value in {"ok", "not_applicable"} for value in dependency_checks.values() value in {"ok", "not_applicable"} for value in dependency_checks.values()
) )

View file

@ -110,7 +110,9 @@ def _run_api(host: str, port: int) -> None:
def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None: def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None:
server = HubCoreMCPServer(name="hub-core", api_base=api_base) server = HubCoreMCPServer(name="hub-core", api_base=api_base,
require_credentials=RuntimeSettings.from_env().enforce_access,
trailing_slash=False)
server.mcp.run(transport=transport, host=host, port=port) server.mcp.run(transport=transport, host=host, port=port)

View file

@ -548,6 +548,10 @@ async def _protected(
_enabled(request, group) _enabled(request, group)
if write and group not in request.app.state.settings.v2_write_groups: if write and group not in request.app.state.settings.v2_write_groups:
raise HTTPException(status_code=503, detail="compatibility group is read-only") raise HTTPException(status_code=503, detail="compatibility group is read-only")
if request.app.state.settings.enforce_access:
if getattr(request.state, "hub_access", None) is None:
raise HTTPException(status_code=503, detail="access boundary unavailable")
return
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise _unauthorized("Missing bearer token") raise _unauthorized("Missing bearer token")
token = authorization.removeprefix("Bearer ").strip() token = authorization.removeprefix("Bearer ").strip()

View file

@ -38,9 +38,21 @@ class RuntimeSettings:
legacy_health: bool = False legacy_health: bool = False
statehub_inbox_reads: bool = False statehub_inbox_reads: bool = False
statehub_inbox_agent: str = "state-hub" statehub_inbox_agent: str = "state-hub"
access_mode: str = "auto"
@property
def enforce_access(self) -> bool:
return self.access_mode == "enforce" or (
self.access_mode == "auto" and self.environment not in {"development", "test"}
)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.statehub_inbox_reads and (self.backend != "postgresql" or not self.api_token): if self.access_mode not in {"auto", "enforce", "development"}:
raise ValueError("unsupported access mode")
if self.access_mode == "development" and self.environment not in {"development", "test"}:
raise ValueError("development access is forbidden outside development/test")
if self.statehub_inbox_reads and (self.backend != "postgresql" or
(not self.enforce_access and not self.api_token)):
raise ValueError("State Hub inbox reads require PostgreSQL and operator token") raise ValueError("State Hub inbox reads require PostgreSQL and operator token")
if self.repo_manager_timeout_seconds <= 0: if self.repo_manager_timeout_seconds <= 0:
raise ValueError("Repo Manager timeout must be positive") raise ValueError("Repo Manager timeout must be positive")
@ -87,6 +99,7 @@ class RuntimeSettings:
legacy_health=_env_bool("HUB_CORE_LEGACY_HEALTH", False), legacy_health=_env_bool("HUB_CORE_LEGACY_HEALTH", False),
statehub_inbox_reads=_env_bool("HUB_CORE_STATEHUB_INBOX_READS", False), statehub_inbox_reads=_env_bool("HUB_CORE_STATEHUB_INBOX_READS", False),
statehub_inbox_agent=os.getenv("HUB_CORE_STATEHUB_INBOX_AGENT", "state-hub"), statehub_inbox_agent=os.getenv("HUB_CORE_STATEHUB_INBOX_AGENT", "state-hub"),
access_mode=os.getenv("HUB_CORE_ACCESS_MODE", "auto"),
) )
def readiness_checks(self, store_backend: str) -> dict[str, str]: def readiness_checks(self, store_backend: str) -> dict[str, str]:
@ -99,7 +112,8 @@ class RuntimeSettings:
"operator", "operator",
} }
authorization_ready = ( authorization_ready = (
not protected_groups self.enforce_access
or not protected_groups
or bool(self.api_token) or bool(self.api_token)
or store_backend == "postgresql" or store_backend == "postgresql"
) )

View file

@ -99,7 +99,10 @@ def create_inbox_projection_router() -> APIRouter:
settings = request.app.state.settings settings = request.app.state.settings
token = settings.api_token token = settings.api_token
supplied = (authorization or "").removeprefix("Bearer ") supplied = (authorization or "").removeprefix("Bearer ")
if not token or not (authorization or "").startswith("Bearer ") or not hmac.compare_digest(supplied, token): if settings.enforce_access:
if getattr(request.state, "hub_access", None) is None:
raise HTTPException(503, "access boundary unavailable")
elif not token or not (authorization or "").startswith("Bearer ") or not hmac.compare_digest(supplied, token):
raise HTTPException(401, "inbox pilot requires operator bearer authentication", raise HTTPException(401, "inbox pilot requires operator bearer authentication",
headers={"WWW-Authenticate": "Bearer"}) headers={"WWW-Authenticate": "Bearer"})
if to_agent != settings.statehub_inbox_agent: if to_agent != settings.statehub_inbox_agent:

View file

@ -25,6 +25,21 @@ def get_contract_validator(request: Request) -> ContractValidator:
return request.app.state.contract_validator return request.app.state.contract_validator
def _attribute_event(body: EventCommand, request: Request) -> EventCommand:
context = getattr(request.state, "hub_access", None)
if context is None:
return body
# Reserved server provenance overrides any payload assertion. Domain
# subject_refs remain business data and are never authentication evidence.
return body.model_copy(update={"payload": {**body.payload, "_hub_access": {
"issuer": context.actor.issuer, "subject": context.actor.subject,
"principal_type": context.actor.principal_type,
"actor_tenant": context.actor.tenant,
"target_tenant": context.facts.target_tenant,
"correlation_id": context.correlation_id,
}}})
def create_ports_router() -> APIRouter: def create_ports_router() -> APIRouter:
router = APIRouter(prefix="/ports") router = APIRouter(prefix="/ports")
@ -112,6 +127,7 @@ def create_ports_router() -> APIRouter:
) )
async def append_progress( async def append_progress(
body: EventCommand, body: EventCommand,
request: Request,
store: PortStore = Depends(get_port_store), store: PortStore = Depends(get_port_store),
validator: ContractValidator = Depends(get_contract_validator), validator: ContractValidator = Depends(get_contract_validator),
) -> PortAccepted: ) -> PortAccepted:
@ -119,7 +135,7 @@ def create_ports_router() -> APIRouter:
validator.validate_event_family(body.event_type, "progress") validator.validate_event_family(body.event_type, "progress")
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
return await store.append_progress(body) return await store.append_progress(_attribute_event(body, request))
@router.post( @router.post(
"/events/interaction", "/events/interaction",
@ -130,6 +146,7 @@ def create_ports_router() -> APIRouter:
) )
async def append_interaction( async def append_interaction(
body: EventCommand, body: EventCommand,
request: Request,
store: PortStore = Depends(get_port_store), store: PortStore = Depends(get_port_store),
validator: ContractValidator = Depends(get_contract_validator), validator: ContractValidator = Depends(get_contract_validator),
) -> PortAccepted: ) -> PortAccepted:
@ -137,7 +154,7 @@ def create_ports_router() -> APIRouter:
validator.validate_event_family(body.event_type, "interaction") validator.validate_event_family(body.event_type, "interaction")
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
return await store.append_interaction(body) return await store.append_interaction(_attribute_event(body, request))
@router.get( @router.get(
"/projections/{projection_id}", "/projections/{projection_id}",

View file

@ -0,0 +1 @@
"""Hub access profile v1: deny by default, with owner-supplied trust adapters."""

View file

@ -0,0 +1,280 @@
"""Reusable HTTP enforcement for the private platform-root milestone.
Owner adapters establish identity, live account/tenant facts, signed policy
decisions and durable audit. An absent adapter never grants access.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import time
from dataclasses import dataclass
from importlib.resources import files
from typing import Protocol
from uuid import uuid4
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Match
from hub_core.security.identity import AccessFailure, Actor
PROFILE = "hub-core.access/1.0.0"
@dataclass(frozen=True)
class LiveFacts:
issuer: str
subject: str
actor_tenant: str
target_tenant: str
account_active: bool
actor_tenant_active: bool
target_tenant_active: bool
root_entitled: bool
checked_at: float
evidence_id: str
producer_addresses: frozenset[str] = frozenset()
def __post_init__(self):
for value in (self.account_active, self.actor_tenant_active,
self.target_tenant_active, self.root_entitled):
if type(value) is not bool:
raise ValueError("authoritative state must be boolean")
if type(self.checked_at) not in {int, float} or not math.isfinite(self.checked_at):
raise ValueError("finite fact observation time required")
for value in (self.issuer, self.subject, self.actor_tenant,
self.target_tenant, self.evidence_id, *self.producer_addresses):
if not isinstance(value, str) or not value:
raise ValueError("nonempty authoritative references required")
@dataclass(frozen=True)
class Authorization:
actor: Actor
action: str
resource: str
facts: LiveFacts
correlation_id: str
request_digest: str
@dataclass(frozen=True)
class Decision:
allowed: bool
decision_id: str
policy_version: str
caller: str = ""
def __post_init__(self):
if type(self.allowed) is not bool or not self.decision_id or not self.policy_version:
raise ValueError("explicit boolean decision and provenance required")
class Identity(Protocol):
async def authenticate(self, token: str) -> Actor: ...
class FactSource(Protocol):
async def resolve(self, actor: Actor, resource: str) -> LiveFacts:
"""Query authoritative account/tenant state, without cached root grants."""
...
class Policy(Protocol):
async def evaluate(self, request: Authorization) -> Decision:
"""Verify signed origin, binding, lifetime, caller and obligations."""
...
class Audit(Protocol):
async def append(self, record: dict) -> None:
"""Return only after durable acceptance; raise on delivery failure."""
...
class AccessController:
def __init__(self, *, identity: Identity, facts: FactSource, policy: Policy,
audit: Audit, root_issuer: str, root_subject: str):
if not root_issuer or not root_subject:
raise ValueError("immutable root identity is required")
self.identity, self.facts, self.policy, self.audit = identity, facts, policy, audit
self.root_identity = (root_issuer, root_subject)
async def authorize(self, token: str, action: str, resource: str,
correlation_id: str, request_digest: str) -> Authorization:
actor = await self.identity.authenticate(token)
try:
return await self._authorize_actor(actor, action, resource, correlation_id, request_digest)
except Exception as exc:
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")
failure.actor = actor
raise failure
async def _authorize_actor(self, actor: Actor, action: str, resource: str,
correlation_id: str, request_digest: str) -> Authorization:
if actor.expires_at <= time.time():
raise AccessFailure(401, "expired_access_token")
if actor.principal_type == "human" and (
(actor.issuer, actor.subject) != self.root_identity
or actor.tenant != "tenant:platform" or actor.assurance not in {"aal2", "aal3"}
):
raise AccessFailure(403, "root_required")
facts = await self.facts.resolve(actor, resource)
if (facts.issuer, facts.subject, facts.actor_tenant) != (
actor.issuer, actor.subject, actor.tenant
) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id:
raise AccessFailure(503, "untrusted_or_stale_facts")
# v1 explicitly classifies Hub records as platform-owned. Other tenants
# require the Phase 2 resource resolver/storage contract, not a header.
if facts.target_tenant != "tenant:platform":
raise AccessFailure(403, "unsupported_target_tenant")
if not (facts.account_active and facts.actor_tenant_active and facts.target_tenant_active):
raise AccessFailure(403, "inactive_identity_or_tenant")
if actor.principal_type == "human" and not facts.root_entitled:
raise AccessFailure(403, "root_entitlement_required")
context = Authorization(actor, action, resource, facts, correlation_id, request_digest)
decision = await self.policy.evaluate(context)
await self.audit.append({
"profile": PROFILE, "correlation_id": correlation_id,
"issuer": actor.issuer, "subject": actor.subject,
"principal_type": actor.principal_type, "actor_tenant": actor.tenant,
"target_tenant": facts.target_tenant, "action": action,
"resource_digest": hashlib.sha256(resource.encode()).hexdigest(),
"request_digest": request_digest, "facts_evidence": facts.evidence_id,
"decision_id": decision.decision_id, "policy_version": decision.policy_version,
"policy_caller": decision.caller,
"outcome": "authorized" if decision.allowed else "denied",
})
if not decision.allowed:
raise AccessFailure(403, "policy_denied")
if actor.expires_at <= time.time():
raise AccessFailure(401, "expired_access_token")
if time.time() - facts.checked_at > 5:
raise AccessFailure(503, "facts_expired_during_authorization")
return context
def route_key(route, method: str) -> str:
endpoint = route.endpoint
return f"{method}:{route.path}:{endpoint.__module__}.{endpoint.__name__}"
def iter_routes(router):
for route in router.routes:
if hasattr(route, "original_router"):
yield from iter_routes(route.original_router)
else:
yield route
class AccessBoundary:
"""ASGI boundary also covers docs, redirects, unknown routes and WebSockets.
Install on an embedded host with its own explicit catalog to protect SDK
routers. Routes added without catalog admission remain denied.
"""
def __init__(self, app, *, host, controller: AccessController | None,
catalog: dict[str, str] | None = None):
self.app, self.host, self.controller = app, host, controller
self.catalog = catalog if catalog is not None else json.loads(
files("hub_core.security").joinpath("routes.json").read_text()
)["routes"]
async def __call__(self, scope, receive, send):
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1008})
return
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# No prefix or trailing-slash exception. Detailed readiness is protected.
if scope["method"] == "GET" and scope["path"] == "/healthz":
await JSONResponse({"status": "ok"})(scope, receive, send)
return
correlation = str(uuid4())
context = None
try:
headers = Request(scope).headers.getlist("authorization")
if len(headers) != 1 or not headers[0].startswith("Bearer "):
raise AccessFailure(401, "bearer_required")
token = headers[0][7:]
if not token or len(token) > 16384 or any(c.isspace() for c in token):
raise AccessFailure(401, "invalid_access_token")
if self.controller is None:
raise AccessFailure(503, "access_dependencies_unavailable")
route = next((r for r in iter_routes(self.host)
if r.matches(scope)[0] == Match.FULL), None)
key = route_key(route, scope["method"]) if route and hasattr(route, "endpoint") else None
action = self.catalog.get(key)
if not action:
raise AccessFailure(403, "surface_not_admitted")
# Bind policy to the exact request without exposing content to PDP/audit.
request = Request(scope, receive)
chunks, size = [], 0
async for chunk in request.stream():
size += len(chunk)
if size > 1024 * 1024:
raise AccessFailure(413, "request_too_large")
chunks.append(chunk)
body = b"".join(chunks)
digest = hashlib.sha256(b"\0".join([
scope["method"].encode(), scope["path"].encode(),
scope.get("query_string", b""), body,
])).hexdigest()
async with asyncio.timeout(10):
context = await self.controller.authorize(
token, action, scope["path"], correlation, digest,
)
if body:
try:
payload = json.loads(body)
except (ValueError, UnicodeError):
payload = None # Handler owns content validation.
if isinstance(payload, dict):
for field in ("from_address", "from_agent", "author"):
if field in payload and payload[field] not in context.facts.producer_addresses:
raise AccessFailure(403, "producer_identity_mismatch")
scope.setdefault("state", {})["hub_access"] = context
except Exception as exc:
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")
actor = context.actor if context else getattr(failure, "actor", None)
if self.controller is not None:
try:
async with asyncio.timeout(3):
await self.controller.audit.append({
"profile": PROFILE, "correlation_id": correlation,
"outcome": "refused", "reason": failure.code,
"subject": actor.subject if actor else None,
"issuer": actor.issuer if actor else None,
"actor_tenant": actor.tenant if actor else None,
})
except Exception:
failure = AccessFailure(503, "audit_unavailable")
headers = {"Cache-Control": "no-store", "X-Correlation-ID": correlation}
if failure.status == 401:
headers["WWW-Authenticate"] = "Bearer"
await JSONResponse({"detail": failure.code}, status_code=failure.status,
headers=headers)(scope, receive, send)
return
delivered = False
async def replay():
nonlocal delivered
if not delivered:
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return await receive()
async def protected_send(message):
if message["type"] == "http.response.start":
message["headers"] = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in {b"cache-control", b"x-correlation-id"}]
message["headers"].extend([(b"cache-control", b"no-store"),
(b"x-correlation-id", correlation.encode())])
await send(message)
await self.app(scope, replay, protected_send)

View file

@ -0,0 +1,168 @@
"""IAM v0.3 access-token verification. No username-based authority."""
from __future__ import annotations
import asyncio
import ipaddress
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
import jwt
class AccessFailure(Exception):
def __init__(self, status: int, code: str):
self.status, self.code = status, code
super().__init__(code)
@dataclass(frozen=True)
class Actor:
issuer: str
subject: str
tenant: str
principal_type: str
assurance: str
authenticated_at: int
expires_at: int
def require_https(url: str) -> None:
parsed = urlsplit(url)
hostname = (parsed.hostname or "").rstrip(".").lower()
try:
local = ipaddress.ip_address(hostname).is_loopback
except ValueError:
local = hostname == "localhost" or hostname.endswith(".localhost")
if (parsed.scheme != "https" or not parsed.hostname or parsed.username
or parsed.password or parsed.fragment or parsed.query
or local):
raise ValueError("a non-local HTTPS trust endpoint is required")
class OIDCVerifier:
"""Discover keys at an explicitly trusted issuer; bounded, rotation-aware cache.
Supports RFC 9068 at+jwt tokens or the admitted KeyCape Bearer payload type.
ID tokens without either access-token marker are rejected.
"""
def __init__(self, *, issuer: str, audience: str, client: httpx.AsyncClient,
key_ttl: int = 60, max_token_age: int = 300):
require_https(issuer)
if not audience or not 1 <= key_ttl <= 300 or not 1 <= max_token_age <= 300:
raise ValueError("audience and bounded key/token lifetimes are required")
self.issuer, self.audience, self.client = issuer, audience, client
self.key_ttl, self.max_token_age = key_ttl, max_token_age
self._keys: dict = {}
self._loaded = 0.0
self._lock = asyncio.Lock()
async def _refresh(self) -> None:
try:
response = await self.client.get(
self.issuer.rstrip("/") + "/.well-known/openid-configuration",
timeout=3, follow_redirects=False,
)
response.raise_for_status()
discovery = response.json()
if discovery["issuer"] != self.issuer:
raise ValueError("issuer mismatch")
require_https(discovery["jwks_uri"])
response = await self.client.get(discovery["jwks_uri"], timeout=3,
follow_redirects=False)
response.raise_for_status()
keys = {}
for value in response.json()["keys"]:
if value.get("kty") != "RSA" or value.get("use", "sig") != "sig":
continue
if value.get("alg", "RS256") != "RS256":
continue
if "verify" not in value.get("key_ops", ["verify"]):
continue
kid = value["kid"]
if not isinstance(kid, str) or not kid or kid in keys:
raise ValueError("invalid key IDs")
key = jwt.PyJWK.from_dict(value, algorithm="RS256").key
if key.key_size < 2048:
raise ValueError("weak issuer key")
keys[kid] = key
if not keys:
raise ValueError("no signing keys")
self._keys, self._loaded = keys, time.monotonic()
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as exc:
raise AccessFailure(503, "identity_unavailable") from exc
async def authenticate(self, token: str) -> Actor:
try:
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
raise ValueError("unsupported token")
async with self._lock:
# At most one unknown-key refresh per second, to bound random-kid traffic.
age = time.monotonic() - self._loaded
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
await self._refresh()
key = self._keys.get(header["kid"])
if key is None:
raise ValueError("unknown key")
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
audience=self.audience, leeway=0,
options={"require": ["iss", "sub", "aud", "exp", "iat",
"tenant", "principal_type", "groups",
"roles", "assurance"]})
if header.get("typ") != "at+jwt" and claims.get("typ") != "Bearer":
raise ValueError("not an access token")
if claims.get("typ", "Bearer") != "Bearer" or claims.get("environment") in {
"local", "development", "test",
}:
raise ValueError("unsupported token profile")
for name in ("sub", "tenant"):
if not isinstance(claims[name], str) or not claims[name]:
raise ValueError("invalid identity")
for name in ("groups", "roles"):
if not isinstance(claims[name], list) or any(
not isinstance(item, str) for item in claims[name]
):
raise ValueError("invalid IAM array")
scope = claims.get("scope", claims.get("scp"))
if not isinstance(scope, (str, list)) or (
isinstance(scope, list) and any(not isinstance(item, str) for item in scope)
):
raise ValueError("invalid scope")
assurance = claims["assurance"]
if (not isinstance(assurance, dict)
or assurance.get("level") not in {"aal1", "aal2", "aal3"}
or type(assurance.get("mfa")) is not bool
or not isinstance(assurance.get("source"), str)
or not assurance["source"]
or not isinstance(assurance.get("methods"), list)
or not all(isinstance(x, str) for x in assurance["methods"])):
raise ValueError("invalid assurance")
for value in (claims["iat"], claims["exp"], assurance.get("at")):
if type(value) is not int:
raise ValueError("integer timestamps required")
if "nbf" in claims and type(claims["nbf"]) is not int:
raise ValueError("integer not-before required")
if assurance["level"] in {"aal2", "aal3"} and not assurance["mfa"]:
raise ValueError("missing MFA evidence")
now = time.time()
if (not claims["iat"] <= now < claims["exp"]
or claims["exp"] - claims["iat"] > self.max_token_age
or not 0 <= now - assurance["at"] <= self.max_token_age):
raise ValueError("stale identity or assurance")
principal = claims["principal_type"]
if principal not in {"human", "service", "agent"}:
raise ValueError("invalid principal type")
if principal == "agent":
agent = claims.get("agent", {})
if not agent.get("id") or agent.get("mode") != "autonomous":
# Delegation needs a separately admitted actor/workload contract.
raise ValueError("unsupported delegation")
return Actor(self.issuer, claims["sub"], claims["tenant"], principal,
assurance["level"], assurance["at"], claims["exp"])
except AccessFailure:
raise
except (jwt.PyJWTError, ValueError, KeyError, TypeError, AttributeError) as exc:
raise AccessFailure(401, "invalid_access_token") from exc

177
hub_core/security/policy.py Normal file
View file

@ -0,0 +1,177 @@
"""Authenticated flex-auth client; verification precedes every allow/deny."""
from __future__ import annotations
import base64
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import httpx
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from hub_core.security.boundary import Authorization, Decision
from hub_core.security.identity import AccessFailure, require_https
def _object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def parse_json(raw: bytes | str):
def reject(_):
raise ValueError("non-integer numbers are outside this profile")
return json.loads(raw, object_pairs_hook=_object, parse_float=reject, parse_constant=reject)
def go_json(value) -> bytes:
"""Preserve Go struct/wire order and escape HTML as encoding/json does.
This deliberately does not sort struct fields. Maps sent in CheckRequest
are sorted separately. Unsupported floating point input fails closed.
"""
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
for char, escaped in (("<", "\\u003c"), (">", "\\u003e"), ("&", "\\u0026"),
("\u2028", "\\u2028"), ("\u2029", "\\u2029")):
encoded = encoded.replace(char, escaped)
return encoded.encode()
def _sorted_maps(value):
if isinstance(value, dict):
return {k: _sorted_maps(value[k]) for k in sorted(value)}
if isinstance(value, list):
return [_sorted_maps(x) for x in value]
return value
def submitted_digest(request: dict) -> str:
# requestDigestMaterial, SubjectRef and ResourceRef are Go structs, whose
# declaration order (unlike maps) participates in the current wire contract.
material = {}
if request.get("tenant"):
material["tenant"] = request["tenant"]
for field, order in (("subject", ("id", "type", "tenant", "attributes")),
("resource", ("id", "type", "system", "tenant", "attributes"))):
if field == "resource":
material["action"] = request["action"]
material[field] = {k: _sorted_maps(request[field][k]) for k in order
if request[field].get(k)}
if request.get("context"):
material["context"] = _sorted_maps(request["context"])
return "sha256:" + hashlib.sha256(go_json(material)).hexdigest()
def verify_signature(envelope: dict, keys: dict) -> None:
signature = envelope["signature"]
if signature["mode"] != "signed" or signature["alg"] != "ed25519":
raise ValueError("signed Ed25519 decision required")
candidates = [key for key in keys["keys"] if key["kid"] == signature["kid"]]
if len(candidates) != 1 or candidates[0]["alg"] != "ed25519":
raise ValueError("untrusted signing key")
def decode(value):
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
key = Ed25519PublicKey.from_public_bytes(decode(candidates[0]["public_key"]))
key.verify(decode(signature["value"]), go_json({
k: v for k, v in envelope.items() if k != "signature"
}))
def _time(value: str) -> datetime:
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
if result.tzinfo is None:
raise ValueError("timezone required")
return result
def verify_decision(envelope: dict, *, request: dict, keys: dict,
caller: str, now: datetime | None = None) -> Decision:
verify_signature(envelope, keys)
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"]):
raise ValueError("invalid decision contract or correlation")
binding = envelope["binding"]
if binding["submitted_request_digest"] != submitted_digest(request):
raise ValueError("submitted request mismatch")
if binding["action"] != request["action"] or binding.get("tenant") != request["tenant"]:
raise ValueError("action or tenant mismatch")
for field, fields in (("subject", ("id", "type", "tenant")),
("resource", ("id", "type", "system", "tenant"))):
for key in fields:
if binding[field].get(key) != request[field].get(key):
raise ValueError("evaluated identity or resource mismatch")
if envelope[field] != binding[field]:
raise ValueError("inconsistent binding")
if binding.get("context", {}) != request.get("context", {}):
raise ValueError("context mismatch")
provenance = envelope["provenance"]
caller_record = provenance["caller"]
if (caller_record["mode"] != "enforce" or caller_record["principal"] != caller
or caller_record["audience"] != "flex-auth"
or _time(caller_record["not_after"]) <= now):
raise ValueError("untrusted workload caller")
if not provenance["policy_version"] or not provenance["policy_package_digest"]:
raise ValueError("missing policy provenance")
age = (now - _time(provenance["decision_time"])).total_seconds()
if not 0 <= age <= 30:
raise ValueError("stale decision")
if envelope.get("obligations"):
# No obligation is silently treated as satisfied. Owner-specific
# approval/redaction/audit handlers require a later profile revision.
raise ValueError("unsupported decision obligations")
if envelope["effect"] not in {"allow", "deny"}:
raise ValueError("unsupported effect")
if envelope["effect"] == "allow":
lifetime = envelope["lifetime"]
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)
class FlexPolicy:
def __init__(self, *, base_url: str, client: httpx.AsyncClient,
caller_token_file: Path, trusted_keys_file: Path, caller: str):
require_https(base_url)
if not caller.startswith("system:serviceaccount:"):
raise ValueError("explicit admitted workload caller required")
self.base_url, self.client = base_url.rstrip("/"), client
self.caller_token_file, self.trusted_keys_file = caller_token_file, trusted_keys_file
self.caller = caller
async def evaluate(self, request: Authorization) -> Decision:
actor, facts = request.actor, request.facts
check = {
"id": request.correlation_id, "tenant": facts.target_tenant,
"subject": {"id": actor.subject, "type": actor.principal_type,
"tenant": actor.tenant,
"attributes": {"issuer": actor.issuer, "assurance": actor.assurance}},
"action": request.action,
"resource": {"id": request.resource, "type": "hub-route", "system": "hub-core",
"tenant": facts.target_tenant},
"context": {"http_request_digest": request.request_digest,
"facts_evidence": facts.evidence_id,
"root_entitled": facts.root_entitled},
}
try:
# Reread projected credentials and owner-delivered public trust at
# every check. No remote key response can bootstrap its own trust.
token = self.caller_token_file.read_text().strip()
if not token or any(c.isspace() for c in token):
raise ValueError("invalid caller credential")
keys = parse_json(self.trusted_keys_file.read_bytes())
response = await self.client.post(self.base_url + "/v1/check", json=check,
headers={"Authorization": f"Bearer {token}"},
timeout=3, follow_redirects=False)
response.raise_for_status()
return verify_decision(parse_json(response.content), request=check,
keys=keys, caller=self.caller)
except Exception as exc:
# Neither response body nor credentials appear in the public error.
raise AccessFailure(503, "policy_unavailable_or_untrusted") from exc

View file

@ -0,0 +1,93 @@
{
"profile": "hub-core.access/1.0.0",
"status": "candidate-owner-review-required",
"routes": {
"GET:/annotation-categories:hub_core.runtime.compat.annotation_categories": "hub.hub_core.runtime.compat.annotation_categories.get",
"GET:/annotations:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/api-consumers:hub_core.runtime.compat.list_consumers": "hub.hub_core.runtime.compat.list_consumers.get",
"GET:/api/v2/annotation-categories:hub_core.runtime.compat.annotation_categories": "hub.hub_core.runtime.compat.annotation_categories.get",
"GET:/api/v2/annotations:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/api/v2/api-consumers:hub_core.runtime.compat.list_consumers": "hub.hub_core.runtime.compat.list_consumers.get",
"GET:/api/v2/decision-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/api/v2/deployment-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/api/v2/docs:hub_core.runtime.compat.docs": "hub.hub_core.runtime.compat.docs.get",
"GET:/api/v2/event-types:hub_core.runtime.compat.event_types": "hub.hub_core.runtime.compat.event_types.get",
"GET:/api/v2/hub-capability-manifests:hub_core.runtime.compat.list_manifests": "hub.hub_core.runtime.compat.list_manifests.get",
"GET:/api/v2/hub-registry:hub_core.runtime.compat.hub_registry": "hub.hub_core.runtime.compat.hub_registry.get",
"GET:/api/v2/hubs:hub_core.runtime.compat.list_hubs": "hub.hub_core.runtime.compat.list_hubs.get",
"GET:/api/v2/interaction-events:hub_core.runtime.compat.list_interactions": "hub.hub_core.runtime.compat.list_interactions.get",
"GET:/api/v2/openapi.json:hub_core.runtime.compat.openapi_json": "hub.hub_core.runtime.compat.openapi_json.get",
"GET:/api/v2/openapi.yaml:hub_core.runtime.compat.openapi_yaml": "hub.hub_core.runtime.compat.openapi_yaml.get",
"GET:/api/v2/outcome-signals:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/api/v2/policy-scopes:hub_core.runtime.compat.policy_scopes": "hub.hub_core.runtime.compat.policy_scopes.get",
"GET:/api/v2/requirement-candidates:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/api/v2/widget-types:hub_core.runtime.compat.widget_types": "hub.hub_core.runtime.compat.widget_types.get",
"GET:/api/v2/widgets:hub_core.runtime.compat.list_widgets": "hub.hub_core.runtime.compat.list_widgets.get",
"GET:/console:hub_core.runtime.compat.console": "hub.hub_core.runtime.compat.console.get",
"GET:/decision-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/deployment-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/docs/oauth2-redirect:fastapi.applications.swagger_ui_redirect": "hub.fastapi.applications.swagger_ui_redirect.get",
"GET:/docs:fastapi.applications.swagger_ui_html": "hub.fastapi.applications.swagger_ui_html.get",
"GET:/docs:hub_core.runtime.compat.docs": "hub.hub_core.runtime.compat.docs.get",
"GET:/event-types:hub_core.runtime.compat.event_types": "hub.hub_core.runtime.compat.event_types.get",
"GET:/hub-capability-manifests:hub_core.runtime.compat.list_manifests": "hub.hub_core.runtime.compat.list_manifests.get",
"GET:/hub-registry:hub_core.runtime.compat.hub_registry": "hub.hub_core.runtime.compat.hub_registry.get",
"GET:/hubs:hub_core.runtime.compat.list_hubs": "hub.hub_core.runtime.compat.list_hubs.get",
"GET:/interaction-events:hub_core.runtime.compat.list_interactions": "hub.hub_core.runtime.compat.list_interactions.get",
"GET:/openapi.json:fastapi.applications.openapi": "hub.fastapi.applications.openapi.get",
"GET:/openapi.json:hub_core.runtime.compat.openapi_json": "hub.hub_core.runtime.compat.openapi_json.get",
"GET:/openapi.yaml:hub_core.runtime.compat.openapi_yaml": "hub.hub_core.runtime.compat.openapi_yaml.get",
"GET:/outcome-signals:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/policy-scopes:hub_core.runtime.compat.policy_scopes": "hub.hub_core.runtime.compat.policy_scopes.get",
"GET:/ports/messaging/messages:hub_core.runtime.ports.list_messages": "hub.hub_core.runtime.ports.list_messages.get",
"GET:/ports/projections/repository-navigation/facets/{facet_kind}/{facet_value}:hub_core.runtime.repository_navigation_routes.query_facet": "hub.hub_core.runtime.repository_navigation_routes.query_facet.get",
"GET:/ports/projections/repository-navigation/repositories:hub_core.runtime.repository_navigation_routes.query_repositories": "hub.hub_core.runtime.repository_navigation_routes.query_repositories.get",
"GET:/ports/projections/statehub-inbox:hub_core.runtime.inbox_projection.inbox": "hub.hub_core.runtime.inbox_projection.inbox.get",
"GET:/ports/projections/workloads/resolve:hub_core.runtime.workload_projection_routes.resolve_workload": "hub.hub_core.runtime.workload_projection_routes.resolve_workload.get",
"GET:/ports/projections/workloads:hub_core.runtime.workload_projection_routes.query_workloads": "hub.hub_core.runtime.workload_projection_routes.query_workloads.get",
"GET:/ports/projections/{projection_id}:hub_core.runtime.ports.query_projection": "hub.hub_core.runtime.ports.query_projection.get",
"GET:/ports/registry/registrations/{hub_slug}/audit:hub_core.runtime.ports.registration_audit": "hub.hub_core.runtime.ports.registration_audit.get",
"GET:/ports/registry/registrations/{hub_slug}:hub_core.runtime.ports.resolve_registration": "hub.hub_core.runtime.ports.resolve_registration.get",
"GET:/readyz:hub_core.runtime.app.readyz": "hub.hub_core.runtime.app.readyz.get",
"GET:/redoc:fastapi.applications.redoc_html": "hub.fastapi.applications.redoc_html.get",
"GET:/requirement-candidates:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
"GET:/widget-types:hub_core.runtime.compat.widget_types": "hub.hub_core.runtime.compat.widget_types.get",
"GET:/widgets:hub_core.runtime.compat.list_widgets": "hub.hub_core.runtime.compat.list_widgets.get",
"HEAD:/docs/oauth2-redirect:fastapi.applications.swagger_ui_redirect": "hub.fastapi.applications.swagger_ui_redirect.head",
"HEAD:/docs:fastapi.applications.swagger_ui_html": "hub.fastapi.applications.swagger_ui_html.head",
"HEAD:/openapi.json:fastapi.applications.openapi": "hub.fastapi.applications.openapi.head",
"HEAD:/redoc:fastapi.applications.redoc_html": "hub.fastapi.applications.redoc_html.head",
"PATCH:/api/v2/hub-capability-manifests/{manifest_id}:hub_core.runtime.compat.patch_manifest": "hub.hub_core.runtime.compat.patch_manifest.patch",
"PATCH:/hub-capability-manifests/{manifest_id}:hub_core.runtime.compat.patch_manifest": "hub.hub_core.runtime.compat.patch_manifest.patch",
"POST:/annotations:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/api-consumers/{consumer_id}/api-keys:hub_core.runtime.compat.create_key": "hub.hub_core.runtime.compat.create_key.post",
"POST:/api-consumers:hub_core.runtime.compat.create_consumer": "hub.hub_core.runtime.compat.create_consumer.post",
"POST:/api/v2/annotations:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/api/v2/api-consumers/{consumer_id}/api-keys:hub_core.runtime.compat.create_key": "hub.hub_core.runtime.compat.create_key.post",
"POST:/api/v2/api-consumers:hub_core.runtime.compat.create_consumer": "hub.hub_core.runtime.compat.create_consumer.post",
"POST:/api/v2/decision-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/api/v2/deployment-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/api/v2/hub-capability-manifests/{manifest_id}/activate:hub_core.runtime.compat.activate_manifest": "hub.hub_core.runtime.compat.activate_manifest.post",
"POST:/api/v2/hub-capability-manifests:hub_core.runtime.compat.create_manifest": "hub.hub_core.runtime.compat.create_manifest.post",
"POST:/api/v2/hubs:hub_core.runtime.compat.create_hub": "hub.hub_core.runtime.compat.create_hub.post",
"POST:/api/v2/interaction-events:hub_core.runtime.compat.create_interaction": "hub.hub_core.runtime.compat.create_interaction.post",
"POST:/api/v2/outcome-signals:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/api/v2/requirement-candidates:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/api/v2/token:hub_core.runtime.compat.token": "hub.hub_core.runtime.compat.token.post",
"POST:/api/v2/widgets:hub_core.runtime.compat.create_widget": "hub.hub_core.runtime.compat.create_widget.post",
"POST:/decision-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/deployment-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/hub-capability-manifests/{manifest_id}/activate:hub_core.runtime.compat.activate_manifest": "hub.hub_core.runtime.compat.activate_manifest.post",
"POST:/hub-capability-manifests:hub_core.runtime.compat.create_manifest": "hub.hub_core.runtime.compat.create_manifest.post",
"POST:/hubs:hub_core.runtime.compat.create_hub": "hub.hub_core.runtime.compat.create_hub.post",
"POST:/interaction-events:hub_core.runtime.compat.create_interaction": "hub.hub_core.runtime.compat.create_interaction.post",
"POST:/outcome-signals:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/ports/events/interaction:hub_core.runtime.ports.append_interaction": "hub.hub_core.runtime.ports.append_interaction.post",
"POST:/ports/events/progress:hub_core.runtime.ports.append_progress": "hub.hub_core.runtime.ports.append_progress.post",
"POST:/ports/messaging/messages:hub_core.runtime.ports.send_message": "hub.hub_core.runtime.ports.send_message.post",
"POST:/ports/registry/registrations:hub_core.runtime.ports.register_extension": "hub.hub_core.runtime.ports.register_extension.post",
"POST:/requirement-candidates:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
"POST:/token:hub_core.runtime.compat.token": "hub.hub_core.runtime.compat.token.post",
"POST:/widgets:hub_core.runtime.compat.create_widget": "hub.hub_core.runtime.compat.create_widget.post"
}
}

View file

@ -10,6 +10,7 @@ dependencies = [
"jsonschema>=4.23.0", "jsonschema>=4.23.0",
"sqlalchemy[asyncio]>=2.0.0", "sqlalchemy[asyncio]>=2.0.0",
"pydantic>=2.10.0", "pydantic>=2.10.0",
"pyjwt[crypto]>=2.10.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

7
tests/fixtures/flex-auth/README.md vendored Normal file
View file

@ -0,0 +1,7 @@
These public conformance fixtures were copied from flex-auth
`examples/secrets-engine/replay/` and `check_request_allow_rotate.json` on
2026-09-28. They retain the owner's Go-generated signature and submitted digest.
`keys.json` contains a well-known **test-only public key**, not production trust.
The old decision is used only to test cryptographic interoperability, never as
an active authorization decision. Hub policy lifetime/caller tests use fresh
synthetic decisions with ephemeral test keys.

View file

@ -0,0 +1,23 @@
{
"id": "check:secrets-engine-rotate",
"tenant": "tenant:platform",
"subject": {
"id": "secrets-engine",
"type": "service"
},
"action": "rotate",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "prod",
"fields": [
"password"
],
"policy_targets": [],
"auth_targets": []
}
},
"context": {}
}

View file

@ -0,0 +1,118 @@
{
"id": "decision:414734bb30381ff7",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-rotate",
"effect": "allow",
"reason": "catalog_lane_policy_matched",
"matched_policy_version": "v2",
"matched_rule": "catalog_lane_policy_matched",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"tenant": "tenant:platform",
"attributes": {
"auth_targets": [],
"fields": [
"password"
],
"policy_targets": [],
"stage": "prod"
}
},
"subject": {
"id": "secrets-engine",
"type": "service",
"tenant": "tenant:platform",
"attributes": {
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
"display_name": "secrets-engine service principal",
"groups": [
"group:secrets-engine-lane-operators"
],
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
]
}
},
"binding": {
"tenant": "tenant:platform",
"subject": {
"id": "secrets-engine",
"type": "service",
"tenant": "tenant:platform",
"attributes": {
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
"display_name": "secrets-engine service principal",
"groups": [
"group:secrets-engine-lane-operators"
],
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
]
}
},
"action": "rotate",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"tenant": "tenant:platform",
"attributes": {
"auth_targets": [],
"fields": [
"password"
],
"policy_targets": [],
"stage": "prod"
}
},
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-09-07T07:10:23Z",
"expires_at": "2026-09-07T07:25:23Z"
},
"diagnostics": {
"action": "rotate",
"matched_relationship": "",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_status": "ready",
"registry_overrode": [],
"registry_resource": false,
"registry_subject": true
},
"provenance": {
"evaluator": "flex-auth/local",
"mode": "standalone",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_version": "v2",
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"decision_time": "2026-09-07T07:10:23Z"
},
"signature": {
"mode": "signed",
"alg": "ed25519",
"kid": "testdata-ed25519",
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
},
"caring": {
"profile": "caring-0.4.0-rc2",
"conformance_findings": [
{
"code": "CARING-DESCRIPTOR-MISSING",
"severity": "warning",
"message": "no CARING descriptor matched the request",
"fields": [
"caring_context"
]
}
]
}
}

View file

@ -0,0 +1,118 @@
{
"id": "decision:414734bb30381ff7",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-rotate",
"effect": "deny",
"reason": "tampered_after_signing",
"matched_policy_version": "v2",
"matched_rule": "catalog_lane_policy_matched",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"tenant": "tenant:platform",
"attributes": {
"auth_targets": [],
"fields": [
"password"
],
"policy_targets": [],
"stage": "prod"
}
},
"subject": {
"id": "secrets-engine",
"type": "service",
"tenant": "tenant:platform",
"attributes": {
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
"display_name": "secrets-engine service principal",
"groups": [
"group:secrets-engine-lane-operators"
],
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
]
}
},
"binding": {
"tenant": "tenant:platform",
"subject": {
"id": "secrets-engine",
"type": "service",
"tenant": "tenant:platform",
"attributes": {
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
"display_name": "secrets-engine service principal",
"groups": [
"group:secrets-engine-lane-operators"
],
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
]
}
},
"action": "rotate",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"tenant": "tenant:platform",
"attributes": {
"auth_targets": [],
"fields": [
"password"
],
"policy_targets": [],
"stage": "prod"
}
},
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-09-07T07:10:23Z",
"expires_at": "2026-09-07T07:25:23Z"
},
"diagnostics": {
"action": "rotate",
"matched_relationship": "",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_status": "ready",
"registry_overrode": [],
"registry_resource": false,
"registry_subject": true
},
"provenance": {
"evaluator": "flex-auth/local",
"mode": "standalone",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_version": "v2",
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"decision_time": "2026-09-07T07:10:23Z"
},
"signature": {
"mode": "signed",
"alg": "ed25519",
"kid": "testdata-ed25519",
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
},
"caring": {
"profile": "caring-0.4.0-rc2",
"conformance_findings": [
{
"code": "CARING-DESCRIPTOR-MISSING",
"severity": "warning",
"message": "no CARING descriptor matched the request",
"fields": [
"caring_context"
]
}
]
}
}

11
tests/fixtures/flex-auth/keys.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"algorithm": "ed25519",
"keys": [
{
"kid": "testdata-ed25519",
"alg": "ed25519",
"public_key": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI",
"note": "Well-known non-production seed 0x42 repeated. Not a custody path. FLEX-WP-0024-T03 fixtures only."
}
]
}

View file

@ -0,0 +1,227 @@
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import replace
from pathlib import Path
import httpx
import pytest
from fastapi.testclient import TestClient
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.store import InMemoryPortStore
from hub_core.security.boundary import (
AccessController, Actor, Decision, LiveFacts, iter_routes, route_key,
)
from hub_core.security.identity import AccessFailure
class Owners:
def __init__(self):
self.actor = Actor('https://issuer.example', 'immutable-root', 'tenant:platform',
'human', 'aal2', int(time.time()), int(time.time()) + 300)
self.facts = LiveFacts(self.actor.issuer, self.actor.subject, self.actor.tenant,
'tenant:platform', True, True, True, True, time.time(),
'owner-receipt', frozenset({'agent:root'}))
self.records, self.requests = [], []
self.allow = True
self.audit_down = False
self.policy_down = False
async def authenticate(self, token):
if token != 'verified-root':
raise AccessFailure(401, 'invalid_access_token')
return self.actor
async def resolve(self, actor, resource):
return self.facts
async def evaluate(self, request):
self.requests.append(request)
if self.policy_down:
raise ConnectionError('private backend details')
return Decision(self.allow, 'decision:1', 'policy:v1')
async def append(self, record):
if self.audit_down:
raise ConnectionError('private audit details')
self.records.append(record)
def controller(self):
return AccessController(identity=self, facts=self, policy=self, audit=self,
root_issuer='https://issuer.example', root_subject='immutable-root')
def runtime(owners=None):
return create_app(settings=RuntimeSettings(environment='test', access_mode='enforce'),
port_store=InMemoryPortStore(),
access_controller=owners.controller() if owners else None)
HEADERS = {'Authorization': 'Bearer verified-root'}
CATALOG = json.loads(Path('hub_core/security/routes.json').read_text())['routes']
SURFACES = [(key.split(':', 2)[0], key.split(':', 2)[1]) for key in CATALOG]
@pytest.mark.parametrize('method,path', SURFACES)
def test_every_catalog_surface_denies_anonymous(method, path):
with TestClient(runtime()) as client:
response = client.request(method, path)
assert response.status_code == 401
def test_production_is_closed_without_owner_adapters():
app = create_app(settings=RuntimeSettings(environment='production'))
with TestClient(app) as client:
assert client.get('/healthz').json() == {'status': 'ok'}
assert client.get('/readyz').status_code == 401
assert client.get('/ports/projections/hub_registry', headers=HEADERS).status_code == 503
assert client.get('/healthz/').status_code == 401
with pytest.raises(ValueError):
RuntimeSettings(environment='production', access_mode='development')
def test_root_access_requires_fresh_facts_and_audit_on_every_request():
owners = Owners()
with TestClient(runtime(owners)) as client:
first = client.get('/ports/projections/hub_registry', headers=HEADERS)
assert first.status_code == 200
assert first.headers['cache-control'] == 'no-store'
assert owners.requests[0].facts.root_entitled
owners.facts = replace(owners.facts, root_entitled=False)
assert client.get('/ports/projections/hub_registry', headers=HEADERS).status_code == 403
assert len(owners.requests) == 1
assert owners.records[0]['outcome'] == 'authorized'
@pytest.mark.parametrize('change,status', [
({'subject': 'ordinary'}, 403), ({'issuer': 'https://other.example'}, 403),
({'assurance': 'aal1'}, 403), ({'tenant': 'tenant:other'}, 403),
({'expires_at': 1}, 401),
])
def test_root_cannot_be_claimed_by_name_or_role(change, status):
owners = Owners()
owners.actor = replace(owners.actor, **change)
with TestClient(runtime(owners)) as client:
assert client.get('/docs', headers=HEADERS).status_code == status
@pytest.mark.parametrize('change,status', [
({'checked_at': 1}, 503), ({'subject': 'different'}, 503),
({'account_active': False}, 403), ({'actor_tenant_active': False}, 403),
({'target_tenant_active': False}, 403), ({'target_tenant': 'tenant:other'}, 403),
])
def test_authoritative_account_and_tenant_checks(change, status):
owners = Owners()
owners.facts = replace(owners.facts, **change)
with TestClient(runtime(owners)) as client:
assert client.get('/openapi.json', headers=HEADERS).status_code == status
@pytest.mark.parametrize('attribute,status', [('allow', 403), ('policy_down', 503), ('audit_down', 503)])
def test_denial_and_dependency_failure_never_reach_handler(attribute, status):
owners = Owners()
setattr(owners, attribute, attribute != 'allow')
with TestClient(runtime(owners)) as client:
result = client.post('/ports/messaging/messages', headers=HEADERS, json={})
assert result.status_code == status
assert 'private' not in result.text
def test_new_route_and_wrong_method_remain_denied():
owners = Owners()
app = runtime(owners)
calls = []
@app.get('/newly-added')
def new_route():
calls.append(True)
with TestClient(app) as client:
for path in ['/newly-added', '/unknown', '/ports/projections/hub_registry/']:
assert client.get(path, headers=HEADERS).status_code == 403
assert client.delete('/docs', headers=HEADERS).status_code == 403
assert calls == []
def test_native_sender_is_bound_and_body_reaches_handler():
owners = Owners()
body = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a',
'from_address': 'agent:root', 'to_addresses': ['agent:reader'], 'body': 'private text'}
with TestClient(runtime(owners)) as client:
assert client.post('/ports/messaging/messages', headers=HEADERS, json=body).status_code == 202
body['from_address'] = 'agent:someone-else'
assert client.post('/ports/messaging/messages', headers=HEADERS, json=body).status_code == 403
assert 'private text' not in json.dumps(owners.records)
def test_catalog_covers_current_routes_and_does_not_auto_admit():
app = runtime()
missing = [route_key(r, method) for r in iter_routes(app) if hasattr(r, 'methods')
for method in r.methods if r.path != '/healthz' and route_key(r, method) not in CATALOG]
assert missing == []
def test_concurrent_requests_keep_separate_contexts():
owners = Owners()
app = runtime(owners)
async def run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
return await asyncio.gather(*[
client.get('/ports/projections/hub_registry', headers=HEADERS,
params={'n': n}) for n in range(10)
])
results = asyncio.run(run())
assert all(r.status_code == 200 for r in results)
assert len({r.correlation_id for r in owners.requests}) == 10
assert len({r.request_digest for r in owners.requests}) == 10
def test_event_provenance_overrides_asserted_producer():
from datetime import datetime, timezone
owners = Owners()
event = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a',
'event_type': 'hub.progress.recorded', 'occurred_at': datetime.now(timezone.utc).isoformat(),
'subject_refs': {'hub': 'untrusted-business-reference'},
'payload': {'_hub_access': {'subject': 'forged'}}}
with TestClient(runtime(owners)) as client:
assert client.post('/ports/events/progress', headers=HEADERS, json=event).status_code == 202
record = client.get('/ports/projections/progress_events', headers=HEADERS).json()
item = record['data']['items'][0]
assert item['payload']['_hub_access']['subject'] == 'immutable-root'
def test_embedded_router_uses_the_same_boundary():
from fastapi import FastAPI
from hub_core.security.boundary import AccessBoundary
owners = Owners()
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
calls = []
@app.get('/embedded')
def embedded():
calls.append(True)
return {'ok': True}
route = next(iter(iter_routes(app)))
app.add_middleware(AccessBoundary, host=app, controller=owners.controller(),
catalog={route_key(route, 'GET'): 'extension.read'})
with TestClient(app) as client:
assert client.get('/embedded').status_code == 401
assert client.get('/embedded', headers=HEADERS).status_code == 200
assert calls == [True]
def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor():
owners = Owners()
with pytest.raises(ValueError):
replace(owners.facts, root_entitled='false')
with pytest.raises(ValueError):
Decision('allow', 'id', 'v1')
owners.actor = replace(owners.actor, subject='ordinary')
with TestClient(runtime(owners)) as client:
assert client.get('/docs', headers=HEADERS).status_code == 403
assert owners.records[-1]['subject'] == 'ordinary'

View file

@ -0,0 +1,102 @@
import asyncio
import json
import time
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from hub_core.security.identity import AccessFailure, OIDCVerifier
@pytest.fixture(scope='module')
def signing_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def setup(signing_key, changes=None, header_changes=None):
now = int(time.time())
claims = dict(iss='https://issuer.example', sub='immutable-root', aud='hub-core',
iat=now, exp=now+300, nbf=now, tenant='tenant:platform',
principal_type='human', groups=[], roles=[], scope='openid',
assurance=dict(level='aal2', methods=['pwd', 'otp'], mfa=True,
source='key-cape', at=now))
claims.update(changes or {})
headers = {'kid': 'key-1', 'typ': 'at+jwt', **(header_changes or {})}
token = jwt.encode(claims, signing_key, algorithm='RS256', headers=headers)
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))
jwk.update(kid='key-1', use='sig', alg='RS256')
responses = {'discovery': 200, 'keys': [jwk]}
def handle(request):
if request.url.path.endswith('openid-configuration'):
return httpx.Response(responses['discovery'], json={
'issuer': 'https://issuer.example', 'jwks_uri': 'https://issuer.example/keys',
})
return httpx.Response(200, json={'keys': responses['keys']})
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
verifier = OIDCVerifier(issuer='https://issuer.example', audience='hub-core', client=client)
return token, verifier, responses, client
def test_accepts_valid_iam_access_token(signing_key):
token, verifier, _, client = setup(signing_key)
async def run():
async with client:
actor = await verifier.authenticate(token)
assert actor.subject == 'immutable-root'
assert actor.tenant == 'tenant:platform'
asyncio.run(run())
@pytest.mark.parametrize('claims,headers', [
({'iss': 'https://evil.example'}, {}), ({'aud': 'different'}, {}),
({'exp': 1}, {}), ({'nbf': int(time.time())+3600}, {}),
({'iat': int(time.time())+3600}, {}), ({'sub': ''}, {}),
({'roles': 'platform-root'}, {}), ({'groups': {}}, {}),
({'tenant': None}, {}), ({'scope': None}, {}),
({'assurance': {'level': 'aal2'}}, {}), ({'principal_type': 'root'}, {}),
({'exp': int(time.time())+3600}, {}), ({}, {'typ': 'JWT'}),
({}, {'kid': 'unknown'}),
({'principal_type': 'agent', 'agent': {'id': 'a', 'mode': 'delegated'}}, {}),
])
def test_invalid_tokens_are_401(signing_key, claims, headers):
token, verifier, _, client = setup(signing_key, claims, headers)
async def run():
async with client:
with pytest.raises(AccessFailure) as result:
await verifier.authenticate(token)
assert result.value.status == 401
asyncio.run(run())
def test_key_rotation_removes_old_trust_and_outage_fails_closed(signing_key):
token, verifier, responses, client = setup(signing_key)
second = rsa.generate_private_key(public_exponent=65537, key_size=2048)
next_jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(second.public_key()))
next_jwk.update(kid='key-2', alg='RS256', use='sig')
claims = jwt.decode(token, options={'verify_signature': False})
rotated = jwt.encode(claims, second, algorithm='RS256', headers={'kid': 'key-2', 'typ': 'at+jwt'})
async def run():
async with client:
await verifier.authenticate(token)
responses['keys'] = [next_jwk]
verifier._loaded -= 2
await verifier.authenticate(rotated)
with pytest.raises(AccessFailure) as result:
await verifier.authenticate(token)
assert result.value.status == 401
responses['discovery'] = 503
verifier._loaded = 0
with pytest.raises(AccessFailure) as result:
await verifier.authenticate(rotated)
assert result.value.status == 503
asyncio.run(run())
def test_untrusted_issuer_configuration_rejected():
for issuer in ['http://issuer.example', 'https://localhost', 'https://u:p@example.com']:
with pytest.raises(ValueError):
OIDCVerifier(issuer=issuer, audience='hub-core', client=None)

159
tests/test_access_policy.py Normal file
View file

@ -0,0 +1,159 @@
import base64
import copy
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from hub_core.security.policy import go_json, parse_json, submitted_digest, verify_decision, verify_signature
FIXTURES = Path(__file__).parent / 'fixtures/flex-auth'
def test_real_go_signer_fixture_and_tampered_pair():
keys = parse_json((FIXTURES / 'keys.json').read_bytes())
verify_signature(parse_json((FIXTURES / 'decision_rotate_signed.json').read_bytes()), keys)
with pytest.raises(InvalidSignature):
verify_signature(parse_json((FIXTURES / 'decision_rotate_signed_tampered.json').read_bytes()), keys)
def test_go_submitted_digest_known_answer():
request = parse_json((FIXTURES / 'check_request_allow_rotate.json').read_bytes())
envelope = parse_json((FIXTURES / 'decision_rotate_signed.json').read_bytes())
assert submitted_digest(request) == envelope['binding']['submitted_request_digest']
def case():
now = datetime.now(timezone.utc)
request = {'id': 'request:1', 'tenant': 'tenant:platform',
'subject': {'id': 'root-sub', 'type': 'human', 'tenant': 'tenant:platform'},
'action': 'hub.read', 'resource': {'id': '/docs', 'type': 'hub-route',
'system': 'hub-core', 'tenant': 'tenant:platform'},
'context': {'http_request_digest': 'request-hash', 'root_entitled': True}}
envelope = {'id': 'decision:1', 'contract_version': 'flex-auth.decision-record.v1',
'request_id': request['id'], 'effect': 'allow',
'resource': copy.deepcopy(request['resource']), 'subject': copy.deepcopy(request['subject']),
'binding': {**copy.deepcopy(request), 'submitted_request_digest': submitted_digest(request)},
'lifetime': {'kind': 'ttl', 'not_before': now.isoformat(),
'expires_at': (now+timedelta(seconds=300)).isoformat()},
'provenance': {'policy_version': 'v1', 'policy_package_digest': 'sha256:'+'a'*64,
'decision_time': now.isoformat(), 'caller': {
'mode': 'enforce', 'principal': 'workload:hub', 'audience': 'flex-auth',
'not_after': (now+timedelta(seconds=300)).isoformat()}}}
return request, envelope, now
def sign(envelope):
key = Ed25519PrivateKey.generate()
encode = lambda value: base64.urlsafe_b64encode(value).decode().rstrip('=')
envelope.pop('signature', None)
signature = key.sign(go_json(envelope))
envelope['signature'] = {'mode': 'signed', 'alg': 'ed25519', 'kid': 'test', 'value': encode(signature)}
return {'keys': [{'kid': 'test', 'alg': 'ed25519',
'public_key': encode(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))}]}
def test_signed_bound_allow():
request, envelope, now = case()
decision = verify_decision(envelope, request=request, keys=sign(envelope), caller='workload:hub', now=now)
assert decision.allowed
@pytest.mark.parametrize('mutation', [
lambda d: d.update(effect='redact'), lambda d: d.update(request_id='other'),
lambda d: d['binding'].update(submitted_request_digest='sha256:wrong'),
lambda d: d['binding'].update(action='destroy'),
lambda d: d['binding']['subject'].update(id='other'),
lambda d: d['binding']['resource'].update(tenant='tenant:other'),
lambda d: d['binding']['resource'].update(id='/secrets'),
lambda d: d['binding']['context'].update(root_entitled=False),
lambda d: d['lifetime'].update(expires_at='2000-01-01T00:00:00Z'),
lambda d: d['lifetime'].update(not_before='2099-01-01T00:00:00Z'),
lambda d: d['provenance'].update(decision_time='2000-01-01T00:00:00Z'),
lambda d: d['provenance']['caller'].update(mode='warn'),
lambda d: d['provenance']['caller'].update(principal='other'),
lambda d: d.update(obligations=[{'type': 'approval'}]),
])
def test_even_authentically_signed_wrong_decisions_are_rejected(mutation):
request, envelope, now = case()
mutation(envelope)
with pytest.raises(ValueError):
verify_decision(envelope, request=request, keys=sign(envelope), caller='workload:hub', now=now)
def test_unsigned_untrusted_and_ambiguous_inputs_fail():
request, envelope, now = case()
keys = sign(envelope)
for wrong in ({'keys': []}, {'keys': keys['keys']*2}):
with pytest.raises(ValueError):
verify_decision(envelope, request=request, keys=wrong, caller='workload:hub', now=now)
envelope['signature'] = {'mode': 'unsigned'}
with pytest.raises(ValueError):
verify_signature(envelope, keys)
for raw in ['{"effect":"deny","effect":"allow"}', '{"x":NaN}', '{"x":1.1}']:
with pytest.raises(ValueError):
parse_json(raw)
def test_policy_client_rotates_workload_credentials_and_retains_no_user_token(tmp_path):
import asyncio
import time
import httpx
from hub_core.security.boundary import Actor, Authorization, LiveFacts
from hub_core.security.policy import FlexPolicy
from hub_core.security.identity import AccessFailure
token_file, keys_file = tmp_path/'caller', tmp_path/'keys'
token_file.write_text('first-workload-token')
seen = []
unavailable = False
caller = 'system:serviceaccount:hub-core:hub-core'
actor = Actor('https://issuer.example', 'root-sub', 'tenant:platform', 'human', 'aal2',
int(time.time()), int(time.time())+300)
facts = LiveFacts(actor.issuer, actor.subject, actor.tenant, 'tenant:platform',
True, True, True, True, time.time(), 'owner:receipt')
authorization = Authorization(actor, 'hub.read', '/docs', facts, 'request:1', 'body-hash')
def handle(request):
seen.append(request.headers['authorization'])
if unavailable:
return httpx.Response(503, text='sensitive backend details')
check = json.loads(request.content)
_, envelope, _ = case()
envelope['subject'], envelope['resource'] = check['subject'], check['resource']
envelope['binding'] = {k: v for k, v in check.items() if k != 'id'}
envelope['binding']['submitted_request_digest'] = submitted_digest(check)
envelope['provenance']['caller']['principal'] = caller
# Key publication precedes this call in reality; write the fixture before
# evaluation and sign with the corresponding ephemeral test key below.
envelope.pop('signature', None)
encode = lambda v: base64.urlsafe_b64encode(v).decode().rstrip('=')
envelope['signature'] = {'mode': 'signed', 'alg': 'ed25519', 'kid': 'test',
'value': encode(signing_key.sign(go_json(envelope)))}
assert 'token' not in request.content.decode()
return httpx.Response(200, content=go_json(envelope))
signing_key = Ed25519PrivateKey.generate()
keys_file.write_text(json.dumps({'keys': [{'kid': 'test', 'alg': 'ed25519',
'public_key': base64.urlsafe_b64encode(signing_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw)).decode().rstrip('=')}]}))
async def run():
nonlocal unavailable
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
policy = FlexPolicy(base_url='https://policy.example', client=client, caller=caller,
caller_token_file=token_file, trusted_keys_file=keys_file)
assert (await policy.evaluate(authorization)).allowed
token_file.write_text('second-workload-token')
assert (await policy.evaluate(authorization)).allowed
unavailable = True
with pytest.raises(AccessFailure) as error:
await policy.evaluate(authorization)
assert error.value.status == 503
assert 'sensitive' not in str(error.value)
asyncio.run(run())
assert seen == ['Bearer first-workload-token', 'Bearer second-workload-token',
'Bearer second-workload-token']

View file

@ -26,7 +26,7 @@ def compatibility_client(tmp_path, *, write_groups: frozenset[str] = GROUPS) ->
asyncio.run(create_schema()) asyncio.run(create_schema())
store = PostgresPortStore.from_url(database_url) store = PostgresPortStore.from_url(database_url)
settings = RuntimeSettings( settings = RuntimeSettings(
environment="production", environment="test", # Exercise the retained development compatibility lane.
backend="postgresql", backend="postgresql",
allow_ephemeral=False, allow_ephemeral=False,
database_url=database_url, database_url=database_url,

View file

@ -69,3 +69,34 @@ def test_repository_navigation_mcp_tool_exposes_all_six_facets() -> None:
"business_stake", "business_stake",
"business_mechanic", "business_mechanic",
} <= set(schema["properties"]) } <= set(schema["properties"])
def test_mcp_credentials_are_per_invocation_and_redirects_do_not_relay_them():
from contextvars import ContextVar
import pytest
credential = ContextVar('hub_credential')
server = HubCoreMCPServer(name='secure', api_base='https://hub.example', register_tools=False,
token_provider=credential.get, require_credentials=True)
async def invoke(token):
credential.set(token)
await asyncio.sleep(0)
with server._client() as client:
assert client.headers['authorization'] == f'Bearer {token}'
assert not client.follow_redirects
async def run():
await asyncio.gather(invoke('caller-a'), invoke('caller-b'))
asyncio.run(run())
with pytest.raises(LookupError):
server._client()
missing = HubCoreMCPServer(name='missing', api_base='https://hub.example', register_tools=False,
require_credentials=True)
with pytest.raises(ValueError, match='current Hub credential'):
missing._client()
def test_mcp_provider_errors_do_not_echo_credentials():
def failed_provider():
raise RuntimeError('secret-value-must-not-escape')
server = HubCoreMCPServer(name='failing', api_base='https://hub.example', register_tools=False,
token_provider=failed_provider, require_credentials=True)
assert server._get('/docs') == {'error': 'Request failed'}

View file

@ -31,7 +31,7 @@ def test_durable_store_survives_reopen_and_keeps_event_families_separate(tmp_pat
.read_text(encoding="utf-8") .read_text(encoding="utf-8")
) )
settings = RuntimeSettings( settings = RuntimeSettings(
environment="production", environment="test",
backend="postgresql", backend="postgresql",
allow_ephemeral=False, allow_ephemeral=False,
database_url=database_url, database_url=database_url,
@ -80,7 +80,7 @@ def test_postgresql_readiness_fails_when_database_is_unavailable() -> None:
database_url = "sqlite+aiosqlite:////definitely-missing-parent/runtime.db" database_url = "sqlite+aiosqlite:////definitely-missing-parent/runtime.db"
store = PostgresPortStore.from_url(database_url) store = PostgresPortStore.from_url(database_url)
settings = RuntimeSettings( settings = RuntimeSettings(
environment="production", environment="test",
backend="postgresql", backend="postgresql",
allow_ephemeral=False, allow_ephemeral=False,
database_url=database_url, database_url=database_url,
@ -96,7 +96,7 @@ def test_postgresql_readiness_fails_when_runtime_tables_are_unavailable(tmp_path
database_url = f"sqlite+aiosqlite:///{tmp_path / 'empty.db'}" database_url = f"sqlite+aiosqlite:///{tmp_path / 'empty.db'}"
store = PostgresPortStore.from_url(database_url) store = PostgresPortStore.from_url(database_url)
settings = RuntimeSettings( settings = RuntimeSettings(
environment="production", environment="test",
backend="postgresql", backend="postgresql",
allow_ephemeral=False, allow_ephemeral=False,
database_url=database_url, database_url=database_url,

View file

@ -51,7 +51,7 @@ def test_health_and_ephemeral_readiness() -> None:
def test_production_readiness_fails_closed_for_ephemeral_backend() -> None: def test_production_readiness_fails_closed_for_ephemeral_backend() -> None:
response = client(allow_ephemeral=False, environment="production").get("/readyz") response = client(allow_ephemeral=False).get("/readyz")
assert response.status_code == 503 assert response.status_code == 503
assert response.json()["status"] == "degraded" assert response.json()["status"] == "degraded"

View file

@ -37,6 +37,8 @@ def discover(root):
module = endpoint.__module__ module = endpoint.__module__
gate = ('shared-bearer' if '_protected(' in source or module.endswith('inbox_projection') gate = ('shared-bearer' if '_protected(' in source or module.endswith('inbox_projection')
else 'no-identity-check-in-handler') else 'no-identity-check-in-handler')
if route.path != '/healthz':
gate = 'access-profile-v1 in enforcement mode; development: ' + gate
for method in sorted(route.methods): for method in sorted(route.methods):
rows.append(dict(id=f'http:{method}:{route.path}:{module}.{endpoint.__name__}', kind='runtime-http', rows.append(dict(id=f'http:{method}:{route.path}:{module}.{endpoint.__name__}', kind='runtime-http',
method=method, path=route.path, profile=('minimal-health' if route.path == '/healthz' else 'hub-api'), method=method, path=route.path, profile=('minimal-health' if route.path == '/healthz' else 'hub-api'),
@ -94,7 +96,7 @@ def discover(root):
calls.append(dict(method=call.func.attr[1:].upper(), path_expression=ast.unparse(call.args[0]))) calls.append(dict(method=call.func.attr[1:].upper(), path_expression=ast.unparse(call.args[0])))
rows.append(dict(id=f'mcp:{name}', kind='mcp', tool=name, profile='mcp-client', rows.append(dict(id=f'mcp:{name}', kind='mcp', tool=name, profile='mcp-client',
source=str(path.relative_to(root)), line=node.lineno, source=str(path.relative_to(root)), line=node.lineno,
target_calls=calls, current_gate='no per-user credential forwarding in base wrapper')) target_calls=calls, current_gate='per-invocation token provider available; host adoption required'))
assert expected == {r['tool'] for r in rows if r['kind'] == 'mcp'} assert expected == {r['tool'] for r in rows if r['kind'] == 'mcp'}
assert len(rows) == len({r['id'] for r in rows}), 'Duplicate surface identity' assert len(rows) == len({r['id'] for r in rows}), 'Duplicate surface identity'
return sorted(rows, key=lambda r:r['id']) return sorted(rows, key=lambda r:r['id'])

2
uv.lock generated
View file

@ -644,6 +644,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
] ]
@ -671,6 +672,7 @@ requires-dist = [
{ name = "jsonschema", specifier = ">=4.23.0" }, { name = "jsonschema", specifier = ">=4.23.0" },
{ name = "psycopg2-binary", marker = "extra == 'runtime'", specifier = ">=2.9.0" }, { name = "psycopg2-binary", marker = "extra == 'runtime'", specifier = ">=2.9.0" },
{ name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic", specifier = ">=2.10.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'runtime'", specifier = ">=0.30.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'runtime'", specifier = ">=0.30.0" },
] ]

View file

@ -40,10 +40,11 @@ exposure waits for access-control evidence and a separate approved rollout.
[Architecture blueprint](../docs/netkingdom-access-blueprint.md) defines the [Architecture blueprint](../docs/netkingdom-access-blueprint.md) defines the
contract and reviewed baseline. This is the single new integration workplan; contract and reviewed baseline. This is the single new integration workplan;
existing retirement and rollout plans retain their tasks. Core Hub receives no existing retirement and rollout plans retain their tasks. Core Hub receives no
new product feature work. This planning session does not implement or activate new product feature work. The 2026-09-28 implementation session is authorized
grants, enroll factors, deploy policies, expose services or retire State Hub. for source implementation and verification. Live grant/factor/policy delivery,
public exposure and retirement retain their concrete owner acceptance gates.
Inventory work is active. Cross-owner policy, root identity binding and live Inventory and local enforcement implementation are active. Cross-owner policy, root identity binding and live
acceptance are not yet reviewed. The user has selected the root-first scope; acceptance are not yet reviewed. The user has selected the root-first scope;
there is no need to reopen that product decision. Dependencies below are there is no need to reopen that product decision. Dependencies below are
per-task sequencing, not a blanket wait for every related workplan to finish. per-task sequencing, not a blanket wait for every related workplan to finish.
@ -84,12 +85,12 @@ platform-root login or enforcement test is claimed by inventory validation.
```task ```task
id: HUB-WP-0012-T02 id: HUB-WP-0012-T02
status: todo status: progress
priority: high priority: high
state_hub_task_id: "9a955fbf-f289-51b7-9682-bbe471694595" state_hub_task_id: "9a955fbf-f289-51b7-9682-bbe471694595"
``` ```
Depends on T01. Owners: NetKingdom/KeyCape, user-engine and tenant-engine; Live admission depends on T01; independently testable source may proceed. Owners: NetKingdom/KeyCape, user-engine and tenant-engine;
hub-core owns consumption. Resolve the existing root account to `(iss, sub)` hub-core owns consumption. Resolve the existing root account to `(iss, sub)`
without recording credentials. Establish its explicit platform entitlement, without recording credentials. Establish its explicit platform entitlement,
map existing platform-operator vocabulary, register Hub clients/audiences and map existing platform-operator vocabulary, register Hub clients/audiences and
@ -109,7 +110,7 @@ step-up implementation is actually required.
```task ```task
id: HUB-WP-0012-T03 id: HUB-WP-0012-T03
status: todo status: progress
priority: high priority: high
state_hub_task_id: "feea8f20-aad4-583b-958a-a8efeb9c133c" state_hub_task_id: "feea8f20-aad4-583b-958a-a8efeb9c133c"
``` ```
@ -133,12 +134,12 @@ substituted for evidence of production custody and delivery.
```task ```task
id: HUB-WP-0012-T04 id: HUB-WP-0012-T04
status: todo status: progress
priority: high priority: high
state_hub_task_id: "15bc3cae-4575-56c9-afd2-e1e348109ca9" state_hub_task_id: "15bc3cae-4575-56c9-afd2-e1e348109ca9"
``` ```
Depends on T02/T03. Add the reusable verified actor/tenant context and local Live admission depends on T02/T03; the default-deny source seam may proceed. Add the reusable verified actor/tenant context and local
enforcement seam to all native ports, projections, compatibility routes/aliases, enforcement seam to all native ports, projections, compatibility routes/aliases,
catalogs/docs, browser, MCP and embedded router paths. Minimal liveness and catalogs/docs, browser, MCP and embedded router paths. Minimal liveness and
login mechanics are the only public exceptions. Bind messaging/event producer login mechanics are the only public exceptions. Bind messaging/event producer
@ -236,6 +237,41 @@ remains explicit and auditable. Do not create a second workplan merely to defer
this task; this plan stays open after M1 until Phase 2 is completed or explicitly this task; this plan stays open after M1 until Phase 2 is completed or explicitly
re-scoped with a durable owner. re-scoped with a durable owner.
## Implementation review — 2026-09-28
The [candidate security profile](../docs/access-profile-v1.md) records the concrete
contract, configuration, source evidence and owner integration gaps. Source changes
are executable preparation; dependencies above gate live admission, not isolated
implementation against explicit test doubles. No root subject or entitlement was
invented and no live service, grant or public listener was changed.
- T01: retained all 161 source surfaces/48 platform rows/250 objects; added a
packaged runtime action catalog and drift/anonymous-denial tests. This does not
complete per-service routes or substitute for the named owners' review.
- T02: implemented IAM v0.3 access-token verification with discovery, signature,
audience/type/lifetime/assurance validation and rotating keys. Live root binding,
PKCE sessions/MFA/logout and authoritative account/tenant adapters remain open.
- T03: implemented authenticated workload PDP calls, trusted rotating public keys,
signed envelope, submitted request digest, caller/structured binding/lifetime
checks and fail-closed obligations. Real Go fixtures prove interoperability.
Hub policy, fact provenance approval, key delivery and durable audit remain open.
- T04: production/enforce defaults protect runtime routes and refuse missing
dependencies. Shared-key fallback is removed in that mode. Added verified event
attribution, sender binding, per-invocation MCP credentials and an embedded-host
seam. Normal production CLI requests remain closed until an admitted composition
factory supplies real owners. Do not promote this candidate as an ordinary upgrade.
- T05–T08 remain open: no actual extension, full platform/Railiance, public or
multi-tenant acceptance receipt exists. Source-only tests cannot close them.
Review corrections: flex-auth's consumer join is `submitted_request_digest`;
its Go serializer preserves struct declaration order (sorting every JSON key is
incorrect). Root decisions need live tenant/account facts on **every** access,
including reads, rather than a five-minute cached root grant. Unsupported decision
obligations refuse access. Existing test fixtures for legacy behavior now identify
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).
## Acceptance checkpoints ## Acceptance checkpoints
- [x] Architecture/source/runtime review captured; new implementation owner is hub-core - [x] Architecture/source/runtime review captured; new implementation owner is hub-core