diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 37b4a7d..04295a3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,10 +13,12 @@ | 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-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-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 | | 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 | @@ -58,15 +60,20 @@ | 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-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-T02 | todo | — | 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-T04 | 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 | done | — | 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 | 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-T02 | todo | — | 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-T04 | 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 | progress | — | 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-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 | diff --git a/docs/access-profile-v1.md b/docs/access-profile-v1.md new file mode 100644 index 0000000..4153178 --- /dev/null +++ b/docs/access-profile-v1.md @@ -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. diff --git a/docs/evidence/hub-wp-0012-source-20260928.md b/docs/evidence/hub-wp-0012-source-20260928.md new file mode 100644 index 0000000..90fe7a9 --- /dev/null +++ b/docs/evidence/hub-wp-0012-source-20260928.md @@ -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. diff --git a/docs/netkingdom-access-blueprint.md b/docs/netkingdom-access-blueprint.md index 5ba6311..a246535 100644 --- a/docs/netkingdom-access-blueprint.md +++ b/docs/netkingdom-access-blueprint.md @@ -1,12 +1,16 @@ # 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). Requested outcome: the person signing in as `platform-root` can access and administer the whole platform, including Hub Core, its extensions, and Railiance. Other human users are denied initially. Public exposure follows proven access 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 This is a source/configuration review plus read-only runtime observation, not diff --git a/docs/platform-access-inventory.json b/docs/platform-access-inventory.json index 5cc0199..e8172a8 100644 --- a/docs/platform-access-inventory.json +++ b/docs/platform-access-inventory.json @@ -174,7 +174,7 @@ "method": "GET", "path": "/annotation-categories", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "annotation_categories" @@ -185,7 +185,7 @@ "method": "GET", "path": "/annotations", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -196,7 +196,7 @@ "method": "GET", "path": "/api-consumers", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_consumers" @@ -207,7 +207,7 @@ "method": "GET", "path": "/api/v2/annotation-categories", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "annotation_categories" @@ -218,7 +218,7 @@ "method": "GET", "path": "/api/v2/annotations", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -229,7 +229,7 @@ "method": "GET", "path": "/api/v2/api-consumers", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_consumers" @@ -240,7 +240,7 @@ "method": "GET", "path": "/api/v2/decision-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -251,7 +251,7 @@ "method": "GET", "path": "/api/v2/deployment-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -262,7 +262,7 @@ "method": "GET", "path": "/api/v2/docs", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "docs" @@ -273,7 +273,7 @@ "method": "GET", "path": "/api/v2/event-types", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "event_types" @@ -284,7 +284,7 @@ "method": "GET", "path": "/api/v2/hub-capability-manifests", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_manifests" @@ -295,7 +295,7 @@ "method": "GET", "path": "/api/v2/hub-registry", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "hub_registry" @@ -306,7 +306,7 @@ "method": "GET", "path": "/api/v2/hubs", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_hubs" @@ -317,7 +317,7 @@ "method": "GET", "path": "/api/v2/interaction-events", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_interactions" @@ -328,7 +328,7 @@ "method": "GET", "path": "/api/v2/openapi.json", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "openapi_json" @@ -339,7 +339,7 @@ "method": "GET", "path": "/api/v2/openapi.yaml", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "openapi_yaml" @@ -350,7 +350,7 @@ "method": "GET", "path": "/api/v2/outcome-signals", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -361,7 +361,7 @@ "method": "GET", "path": "/api/v2/policy-scopes", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "policy_scopes" @@ -372,7 +372,7 @@ "method": "GET", "path": "/api/v2/requirement-candidates", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -383,7 +383,7 @@ "method": "GET", "path": "/api/v2/widget-types", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "widget_types" @@ -394,7 +394,7 @@ "method": "GET", "path": "/api/v2/widgets", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_widgets" @@ -405,7 +405,7 @@ "method": "GET", "path": "/console", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "console" @@ -416,7 +416,7 @@ "method": "GET", "path": "/decision-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -427,7 +427,7 @@ "method": "GET", "path": "/deployment-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -438,7 +438,7 @@ "method": "GET", "path": "/docs/oauth2-redirect", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "swagger_ui_redirect" @@ -449,7 +449,7 @@ "method": "GET", "path": "/docs", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "swagger_ui_html" @@ -460,7 +460,7 @@ "method": "GET", "path": "/docs", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "docs" @@ -471,7 +471,7 @@ "method": "GET", "path": "/event-types", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "event_types" @@ -493,7 +493,7 @@ "method": "GET", "path": "/hub-capability-manifests", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_manifests" @@ -504,7 +504,7 @@ "method": "GET", "path": "/hub-registry", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "hub_registry" @@ -515,7 +515,7 @@ "method": "GET", "path": "/hubs", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_hubs" @@ -526,7 +526,7 @@ "method": "GET", "path": "/interaction-events", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_interactions" @@ -537,7 +537,7 @@ "method": "GET", "path": "/openapi.json", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "openapi" @@ -548,7 +548,7 @@ "method": "GET", "path": "/openapi.json", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "openapi_json" @@ -559,7 +559,7 @@ "method": "GET", "path": "/openapi.yaml", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "openapi_yaml" @@ -570,7 +570,7 @@ "method": "GET", "path": "/outcome-signals", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -581,7 +581,7 @@ "method": "GET", "path": "/policy-scopes", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "policy_scopes" @@ -592,7 +592,7 @@ "method": "GET", "path": "/ports/messaging/messages", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "list_messages" @@ -603,7 +603,7 @@ "method": "GET", "path": "/ports/projections/repository-navigation/facets/{facet_kind}/{facet_value}", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.repository_navigation_routes", "conditional": false, "handler": "query_facet" @@ -614,7 +614,7 @@ "method": "GET", "path": "/ports/projections/repository-navigation/repositories", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.repository_navigation_routes", "conditional": false, "handler": "query_repositories" @@ -625,7 +625,7 @@ "method": "GET", "path": "/ports/projections/statehub-inbox", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.inbox_projection", "conditional": true, "handler": "inbox" @@ -636,7 +636,7 @@ "method": "GET", "path": "/ports/projections/workloads/resolve", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.workload_projection_routes", "conditional": false, "handler": "resolve_workload" @@ -647,7 +647,7 @@ "method": "GET", "path": "/ports/projections/workloads", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.workload_projection_routes", "conditional": false, "handler": "query_workloads" @@ -658,7 +658,7 @@ "method": "GET", "path": "/ports/projections/{projection_id}", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "query_projection" @@ -669,7 +669,7 @@ "method": "GET", "path": "/ports/registry/registrations/{hub_slug}/audit", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "registration_audit" @@ -680,7 +680,7 @@ "method": "GET", "path": "/ports/registry/registrations/{hub_slug}", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "resolve_registration" @@ -691,7 +691,7 @@ "method": "GET", "path": "/readyz", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.app", "conditional": false, "handler": "readyz" @@ -702,7 +702,7 @@ "method": "GET", "path": "/redoc", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "redoc_html" @@ -713,7 +713,7 @@ "method": "GET", "path": "/requirement-candidates", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "empty_collection" @@ -724,7 +724,7 @@ "method": "GET", "path": "/widget-types", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.compat", "conditional": false, "handler": "widget_types" @@ -735,7 +735,7 @@ "method": "GET", "path": "/widgets", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "list_widgets" @@ -746,7 +746,7 @@ "method": "HEAD", "path": "/docs/oauth2-redirect", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "swagger_ui_redirect" @@ -757,7 +757,7 @@ "method": "HEAD", "path": "/docs", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "swagger_ui_html" @@ -768,7 +768,7 @@ "method": "HEAD", "path": "/openapi.json", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "openapi" @@ -779,7 +779,7 @@ "method": "HEAD", "path": "/redoc", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "fastapi.applications", "conditional": false, "handler": "redoc_html" @@ -790,7 +790,7 @@ "method": "PATCH", "path": "/api/v2/hub-capability-manifests/{manifest_id}", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "patch_manifest" @@ -801,7 +801,7 @@ "method": "PATCH", "path": "/hub-capability-manifests/{manifest_id}", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "patch_manifest" @@ -812,7 +812,7 @@ "method": "POST", "path": "/annotations", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -823,7 +823,7 @@ "method": "POST", "path": "/api-consumers/{consumer_id}/api-keys", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_key" @@ -834,7 +834,7 @@ "method": "POST", "path": "/api-consumers", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_consumer" @@ -845,7 +845,7 @@ "method": "POST", "path": "/api/v2/annotations", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -856,7 +856,7 @@ "method": "POST", "path": "/api/v2/api-consumers/{consumer_id}/api-keys", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_key" @@ -867,7 +867,7 @@ "method": "POST", "path": "/api/v2/api-consumers", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_consumer" @@ -878,7 +878,7 @@ "method": "POST", "path": "/api/v2/decision-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -889,7 +889,7 @@ "method": "POST", "path": "/api/v2/deployment-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -900,7 +900,7 @@ "method": "POST", "path": "/api/v2/hub-capability-manifests/{manifest_id}/activate", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "activate_manifest" @@ -911,7 +911,7 @@ "method": "POST", "path": "/api/v2/hub-capability-manifests", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_manifest" @@ -922,7 +922,7 @@ "method": "POST", "path": "/api/v2/hubs", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_hub" @@ -933,7 +933,7 @@ "method": "POST", "path": "/api/v2/interaction-events", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_interaction" @@ -944,7 +944,7 @@ "method": "POST", "path": "/api/v2/outcome-signals", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -955,7 +955,7 @@ "method": "POST", "path": "/api/v2/requirement-candidates", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -966,7 +966,7 @@ "method": "POST", "path": "/api/v2/token", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "token" @@ -977,7 +977,7 @@ "method": "POST", "path": "/api/v2/widgets", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_widget" @@ -988,7 +988,7 @@ "method": "POST", "path": "/decision-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -999,7 +999,7 @@ "method": "POST", "path": "/deployment-records", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -1010,7 +1010,7 @@ "method": "POST", "path": "/hub-capability-manifests/{manifest_id}/activate", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "activate_manifest" @@ -1021,7 +1021,7 @@ "method": "POST", "path": "/hub-capability-manifests", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_manifest" @@ -1032,7 +1032,7 @@ "method": "POST", "path": "/hubs", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_hub" @@ -1043,7 +1043,7 @@ "method": "POST", "path": "/interaction-events", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_interaction" @@ -1054,7 +1054,7 @@ "method": "POST", "path": "/outcome-signals", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -1065,7 +1065,7 @@ "method": "POST", "path": "/ports/events/interaction", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "append_interaction" @@ -1076,7 +1076,7 @@ "method": "POST", "path": "/ports/events/progress", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "append_progress" @@ -1087,7 +1087,7 @@ "method": "POST", "path": "/ports/messaging/messages", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "send_message" @@ -1098,7 +1098,7 @@ "method": "POST", "path": "/ports/registry/registrations", "profile": "hub-api", - "current_gate": "no-identity-check-in-handler", + "current_gate": "access-profile-v1 in enforcement mode; development: no-identity-check-in-handler", "source": "hub_core.runtime.ports", "conditional": false, "handler": "register_extension" @@ -1109,7 +1109,7 @@ "method": "POST", "path": "/requirement-candidates", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "accept_deferred" @@ -1120,7 +1120,7 @@ "method": "POST", "path": "/token", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "token" @@ -1131,7 +1131,7 @@ "method": "POST", "path": "/widgets", "profile": "hub-api", - "current_gate": "shared-bearer", + "current_gate": "access-profile-v1 in enforcement mode; development: shared-bearer", "source": "hub_core.runtime.compat", "conditional": false, "handler": "create_widget" @@ -1142,14 +1142,14 @@ "tool": "accept_capability_request", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 225, + "line": 232, "target_calls": [ { "method": "POST", "path_expression": "f'/capability-requests/{request_id}/accept/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:append_progress", @@ -1157,14 +1157,14 @@ "tool": "append_progress", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 480, + "line": 487, "target_calls": [ { "method": "POST", "path_expression": "'/progress/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:check_repo_doi", @@ -1172,14 +1172,14 @@ "tool": "check_repo_doi", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 386, + "line": 393, "target_calls": [ { "method": "GET", "path_expression": "f'/repos/{repo_slug}/doi/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_alerts", @@ -1187,14 +1187,14 @@ "tool": "get_alerts", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 467, + "line": 474, "target_calls": [ { "method": "GET", "path_expression": "'/progress/alerts/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_capability_request", @@ -1202,14 +1202,14 @@ "tool": "get_capability_request", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 267, + "line": 274, "target_calls": [ { "method": "GET", "path_expression": "f'/capability-requests/{request_id}/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_doi_summary", @@ -1217,14 +1217,14 @@ "tool": "get_doi_summary", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 395, + "line": 402, "target_calls": [ { "method": "GET", "path_expression": "'/repos/doi/summary/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_domain", @@ -1232,14 +1232,14 @@ "tool": "get_domain", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 105, + "line": 112, "target_calls": [ { "method": "GET", "path_expression": "f'/domains/{domain_slug}/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_domain_summary", @@ -1247,14 +1247,14 @@ "tool": "get_domain_summary", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 101, + "line": 108, "target_calls": [ { "method": "GET", "path_expression": "f'/domains/{domain_slug}/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_gdpr_report", @@ -1262,14 +1262,14 @@ "tool": "get_gdpr_report", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 450, + "line": 457, "target_calls": [ { "method": "GET", "path_expression": "'/tpsc/report/gdpr/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_messages", @@ -1277,14 +1277,14 @@ "tool": "get_messages", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 130, + "line": 137, "target_calls": [ { "method": "GET", "path_expression": "'/messages/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_repository_navigation_facet", @@ -1292,14 +1292,14 @@ "tool": "get_repository_navigation_facet", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 334, + "line": 341, "target_calls": [ { "method": "GET", "path_expression": "f'/ports/projections/repository-navigation/facets/{facet_kind}/{facet_value}'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_risks", @@ -1307,14 +1307,14 @@ "tool": "get_risks", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 454, + "line": 461, "target_calls": [ { "method": "GET", "path_expression": "'/progress/risks/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:get_state_summary", @@ -1322,14 +1322,14 @@ "tool": "get_state_summary", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 93, + "line": 100, "target_calls": [ { "method": "GET", "path_expression": "'/state/summary/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:ingest_tpsc_tool", @@ -1337,14 +1337,14 @@ "tool": "ingest_tpsc_tool", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 441, + "line": 448, "target_calls": [ { "method": "POST", "path_expression": "'/tpsc/ingest/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:list_capabilities", @@ -1352,14 +1352,14 @@ "tool": "list_capabilities", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 185, + "line": 192, "target_calls": [ { "method": "GET", "path_expression": "'/capability-catalog/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:list_capability_requests", @@ -1367,14 +1367,14 @@ "tool": "list_capability_requests", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 254, + "line": 261, "target_calls": [ { "method": "GET", "path_expression": "'/capability-requests/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:list_domain_repos", @@ -1382,14 +1382,14 @@ "tool": "list_domain_repos", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 302, + "line": 309, "target_calls": [ { "method": "GET", "path_expression": "'/repos/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:list_domains", @@ -1397,14 +1397,14 @@ "tool": "list_domains", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 97, + "line": 104, "target_calls": [ { "method": "GET", "path_expression": "'/domains/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:list_services", @@ -1412,14 +1412,14 @@ "tool": "list_services", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 424, + "line": 431, "target_calls": [ { "method": "GET", "path_expression": "'/tpsc/catalog/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:mark_message_read", @@ -1427,14 +1427,14 @@ "tool": "mark_message_read", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 149, + "line": 156, "target_calls": [ { "method": "PATCH", "path_expression": "f'/messages/{message_id}/read/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:query_repository_navigation", @@ -1442,14 +1442,14 @@ "tool": "query_repository_navigation", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 306, + "line": 313, "target_calls": [ { "method": "GET", "path_expression": "'/ports/projections/repository-navigation/repositories'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:query_workloads", @@ -1457,14 +1457,14 @@ "tool": "query_workloads", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 350, + "line": 357, "target_calls": [ { "method": "GET", "path_expression": "'/ports/projections/workloads'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:register_capability", @@ -1472,14 +1472,14 @@ "tool": "register_capability", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 162, + "line": 169, "target_calls": [ { "method": "POST", "path_expression": "'/capability-catalog/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:register_repo", @@ -1487,14 +1487,14 @@ "tool": "register_repo", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 271, + "line": 278, "target_calls": [ { "method": "POST", "path_expression": "'/repos/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:register_service", @@ -1502,14 +1502,14 @@ "tool": "register_service", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 399, + "line": 406, "target_calls": [ { "method": "POST", "path_expression": "'/tpsc/catalog/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:reply_to_message", @@ -1517,14 +1517,14 @@ "tool": "reply_to_message", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 153, + "line": 160, "target_calls": [ { "method": "POST", "path_expression": "f'/messages/{message_id}/reply/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:request_capability", @@ -1532,14 +1532,14 @@ "tool": "request_capability", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 198, + "line": 205, "target_calls": [ { "method": "POST", "path_expression": "'/capability-requests/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:resolve_workload_reference", @@ -1547,14 +1547,14 @@ "tool": "resolve_workload_reference", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 372, + "line": 379, "target_calls": [ { "method": "GET", "path_expression": "'/ports/projections/workloads/resolve'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:send_message", @@ -1562,14 +1562,14 @@ "tool": "send_message", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 109, + "line": 116, "target_calls": [ { "method": "POST", "path_expression": "'/messages/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:update_capability_request_status", @@ -1577,14 +1577,14 @@ "tool": "update_capability_request_status", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 241, + "line": 248, "target_calls": [ { "method": "PATCH", "path_expression": "f'/capability-requests/{request_id}/status/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "mcp:update_repo_path", @@ -1592,14 +1592,14 @@ "tool": "update_repo_path", "profile": "mcp-client", "source": "hub_core/mcp/server.py", - "line": 296, + "line": 303, "target_calls": [ { "method": "POST", "path_expression": "f'/repos/{repo_slug}/paths/'" } ], - "current_gate": "no per-user credential forwarding in base wrapper" + "current_gate": "per-invocation token provider available; host adoption required" }, { "id": "sdk:create_capability_catalog_router:GET:/capability-catalog/", diff --git a/docs/platform-access-inventory.md b/docs/platform-access-inventory.md index 2070608..953a00c 100644 --- a/docs/platform-access-inventory.md +++ b/docs/platform-access-inventory.md @@ -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 diff. Cluster metadata is a dated reviewed input, not silently refreshed by this command. The checker detects source drift, missing profile/test references and -missing/duplicate cluster-object mappings. It does not test authorization. Actual -allow/deny cases are all marked `not-run`; implementation tasks must supply the +missing/duplicate cluster-object mappings. It does not test authorization. Live +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. ## Findings that affect implementation diff --git a/docs/runtime.md b/docs/runtime.md index 88d2336..931257a 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -144,3 +144,11 @@ it against an isolated runtime with `hub-core conformance --base-url `. `docs/core-hub-absorption-plan.md` defines the capability-sized `/api/v2` route and data move order, single-writer dual-run controls, evidence gates, 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. diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index 4e3ba10..de0a293 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from typing import Any +from collections.abc import Callable import httpx from fastmcp import FastMCP @@ -57,8 +58,14 @@ class HubCoreMCPServer: api_base: str, instructions: str | None = None, register_tools: bool = True, + token_provider: Callable[[], str] | None = None, + require_credentials: bool = False, + trailing_slash: bool = True, ) -> None: self.api_base = api_base.rstrip("/") + self.token_provider = token_provider + self.require_credentials = require_credentials + self.trailing_slash = trailing_slash self.mcp = FastMCP( name=name, instructions=instructions or "Generic FOS hub MCP server.", @@ -503,40 +510,51 @@ class HubCoreMCPServer: try: with self._client() as client: response = client.get( - normalize_trailing_slash(path), + normalize_trailing_slash(path, trailing=self.trailing_slash), params=self._clean(params or {}), ) response.raise_for_status() return response.json() except httpx.HTTPStatusError as exc: - return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"} - except Exception as exc: - return {"error": f"Request failed: {exc}"} + return {"error": f"API {exc.response.status_code}"} + except Exception: + return {"error": "Request failed"} def _post(self, path: str, body: dict[str, Any]) -> Any: try: 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() return response.json() except httpx.HTTPStatusError as exc: - return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"} - except Exception as exc: - return {"error": f"Request failed: {exc}"} + return {"error": f"API {exc.response.status_code}"} + except Exception: + return {"error": "Request failed"} def _patch(self, path: str, body: dict[str, Any]) -> Any: try: 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() return response.json() except httpx.HTTPStatusError as exc: - return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"} - except Exception as exc: - return {"error": f"Request failed: {exc}"} + return {"error": f"API {exc.response.status_code}"} + except Exception: + return {"error": "Request failed"} 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 def _clean(data: dict[str, Any]) -> dict[str, Any]: diff --git a/hub_core/runtime/app.py b/hub_core/runtime/app.py index 5f94b18..eb2f375 100644 --- a/hub_core/runtime/app.py +++ b/hub_core/runtime/app.py @@ -27,6 +27,7 @@ from hub_core.runtime.workload_projection import ( WorkloadProjectionService, ) from hub_core.runtime.workload_projection_routes import create_workload_projection_router +from hub_core.security.boundary import AccessBoundary, AccessController def create_app( @@ -35,6 +36,7 @@ def create_app( port_store: PortStore | None = None, repo_projection_client: RepoProjectionClient | None = None, workload_projection_client: WorkloadProjectionClient | None = None, + access_controller: AccessController | None = None, ) -> FastAPI: resolved_settings = settings or RuntimeSettings.from_env() resolved_store = port_store or _create_store(resolved_settings) @@ -108,6 +110,9 @@ def create_app( app.state.contract_validator = ContractValidator() app.state.repository_navigation = repository_navigation 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"]) async def healthz() -> HealthResponse: @@ -123,6 +128,8 @@ def create_app( **await repository_navigation.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( value in {"ok", "not_applicable"} for value in dependency_checks.values() ) diff --git a/hub_core/runtime/cli.py b/hub_core/runtime/cli.py index 9e6a3c6..31972fc 100644 --- a/hub_core/runtime/cli.py +++ b/hub_core/runtime/cli.py @@ -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: - 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) diff --git a/hub_core/runtime/compat.py b/hub_core/runtime/compat.py index 9fc8b7d..678b09f 100644 --- a/hub_core/runtime/compat.py +++ b/hub_core/runtime/compat.py @@ -548,6 +548,10 @@ async def _protected( _enabled(request, group) if write and group not in request.app.state.settings.v2_write_groups: 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 "): raise _unauthorized("Missing bearer token") token = authorization.removeprefix("Bearer ").strip() diff --git a/hub_core/runtime/config.py b/hub_core/runtime/config.py index a4ed940..e3981f2 100644 --- a/hub_core/runtime/config.py +++ b/hub_core/runtime/config.py @@ -38,9 +38,21 @@ class RuntimeSettings: legacy_health: bool = False statehub_inbox_reads: bool = False 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: - 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") if self.repo_manager_timeout_seconds <= 0: raise ValueError("Repo Manager timeout must be positive") @@ -87,6 +99,7 @@ class RuntimeSettings: legacy_health=_env_bool("HUB_CORE_LEGACY_HEALTH", False), statehub_inbox_reads=_env_bool("HUB_CORE_STATEHUB_INBOX_READS", False), 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]: @@ -99,7 +112,8 @@ class RuntimeSettings: "operator", } authorization_ready = ( - not protected_groups + self.enforce_access + or not protected_groups or bool(self.api_token) or store_backend == "postgresql" ) diff --git a/hub_core/runtime/inbox_projection.py b/hub_core/runtime/inbox_projection.py index d7ce9d2..f5f05bd 100644 --- a/hub_core/runtime/inbox_projection.py +++ b/hub_core/runtime/inbox_projection.py @@ -99,7 +99,10 @@ def create_inbox_projection_router() -> APIRouter: settings = request.app.state.settings token = settings.api_token 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", headers={"WWW-Authenticate": "Bearer"}) if to_agent != settings.statehub_inbox_agent: diff --git a/hub_core/runtime/ports.py b/hub_core/runtime/ports.py index 0433051..4bb5626 100644 --- a/hub_core/runtime/ports.py +++ b/hub_core/runtime/ports.py @@ -25,6 +25,21 @@ def get_contract_validator(request: Request) -> ContractValidator: 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: router = APIRouter(prefix="/ports") @@ -112,6 +127,7 @@ def create_ports_router() -> APIRouter: ) async def append_progress( body: EventCommand, + request: Request, store: PortStore = Depends(get_port_store), validator: ContractValidator = Depends(get_contract_validator), ) -> PortAccepted: @@ -119,7 +135,7 @@ def create_ports_router() -> APIRouter: validator.validate_event_family(body.event_type, "progress") except ValueError as 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( "/events/interaction", @@ -130,6 +146,7 @@ def create_ports_router() -> APIRouter: ) async def append_interaction( body: EventCommand, + request: Request, store: PortStore = Depends(get_port_store), validator: ContractValidator = Depends(get_contract_validator), ) -> PortAccepted: @@ -137,7 +154,7 @@ def create_ports_router() -> APIRouter: validator.validate_event_family(body.event_type, "interaction") except ValueError as 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( "/projections/{projection_id}", diff --git a/hub_core/security/__init__.py b/hub_core/security/__init__.py new file mode 100644 index 0000000..a36c941 --- /dev/null +++ b/hub_core/security/__init__.py @@ -0,0 +1 @@ +"""Hub access profile v1: deny by default, with owner-supplied trust adapters.""" diff --git a/hub_core/security/boundary.py b/hub_core/security/boundary.py new file mode 100644 index 0000000..077e3fc --- /dev/null +++ b/hub_core/security/boundary.py @@ -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) diff --git a/hub_core/security/identity.py b/hub_core/security/identity.py new file mode 100644 index 0000000..6121609 --- /dev/null +++ b/hub_core/security/identity.py @@ -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 diff --git a/hub_core/security/policy.py b/hub_core/security/policy.py new file mode 100644 index 0000000..1c4e32d --- /dev/null +++ b/hub_core/security/policy.py @@ -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 diff --git a/hub_core/security/routes.json b/hub_core/security/routes.json new file mode 100644 index 0000000..b99b5d3 --- /dev/null +++ b/hub_core/security/routes.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml index 07a2c57..0eda453 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "jsonschema>=4.23.0", "sqlalchemy[asyncio]>=2.0.0", "pydantic>=2.10.0", + "pyjwt[crypto]>=2.10.0", ] [project.optional-dependencies] diff --git a/tests/fixtures/flex-auth/README.md b/tests/fixtures/flex-auth/README.md new file mode 100644 index 0000000..2995a18 --- /dev/null +++ b/tests/fixtures/flex-auth/README.md @@ -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. diff --git a/tests/fixtures/flex-auth/check_request_allow_rotate.json b/tests/fixtures/flex-auth/check_request_allow_rotate.json new file mode 100644 index 0000000..7b859f0 --- /dev/null +++ b/tests/fixtures/flex-auth/check_request_allow_rotate.json @@ -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": {} +} diff --git a/tests/fixtures/flex-auth/decision_rotate_signed.json b/tests/fixtures/flex-auth/decision_rotate_signed.json new file mode 100644 index 0000000..a98dedb --- /dev/null +++ b/tests/fixtures/flex-auth/decision_rotate_signed.json @@ -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" + ] + } + ] + } +} diff --git a/tests/fixtures/flex-auth/decision_rotate_signed_tampered.json b/tests/fixtures/flex-auth/decision_rotate_signed_tampered.json new file mode 100644 index 0000000..c8a6070 --- /dev/null +++ b/tests/fixtures/flex-auth/decision_rotate_signed_tampered.json @@ -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" + ] + } + ] + } +} diff --git a/tests/fixtures/flex-auth/keys.json b/tests/fixtures/flex-auth/keys.json new file mode 100644 index 0000000..f902928 --- /dev/null +++ b/tests/fixtures/flex-auth/keys.json @@ -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." + } + ] +} diff --git a/tests/test_access_boundary.py b/tests/test_access_boundary.py new file mode 100644 index 0000000..c781aa1 --- /dev/null +++ b/tests/test_access_boundary.py @@ -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' diff --git a/tests/test_access_identity.py b/tests/test_access_identity.py new file mode 100644 index 0000000..e8be0a0 --- /dev/null +++ b/tests/test_access_identity.py @@ -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) diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py new file mode 100644 index 0000000..f3a02e7 --- /dev/null +++ b/tests/test_access_policy.py @@ -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'] diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 470e92a..f4532f4 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -26,7 +26,7 @@ def compatibility_client(tmp_path, *, write_groups: frozenset[str] = GROUPS) -> asyncio.run(create_schema()) store = PostgresPortStore.from_url(database_url) settings = RuntimeSettings( - environment="production", + environment="test", # Exercise the retained development compatibility lane. backend="postgresql", allow_ephemeral=False, database_url=database_url, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 163de66..61f2cfc 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -69,3 +69,34 @@ def test_repository_navigation_mcp_tool_exposes_all_six_facets() -> None: "business_stake", "business_mechanic", } <= set(schema["properties"]) + + +def test_mcp_credentials_are_per_invocation_and_redirects_do_not_relay_them(): + from contextvars import ContextVar + import pytest + credential = ContextVar('hub_credential') + server = HubCoreMCPServer(name='secure', api_base='https://hub.example', register_tools=False, + token_provider=credential.get, require_credentials=True) + async def invoke(token): + credential.set(token) + await asyncio.sleep(0) + with server._client() as client: + assert client.headers['authorization'] == f'Bearer {token}' + assert not client.follow_redirects + async def run(): + await asyncio.gather(invoke('caller-a'), invoke('caller-b')) + asyncio.run(run()) + with pytest.raises(LookupError): + server._client() + missing = HubCoreMCPServer(name='missing', api_base='https://hub.example', register_tools=False, + require_credentials=True) + with pytest.raises(ValueError, match='current Hub credential'): + missing._client() + + +def test_mcp_provider_errors_do_not_echo_credentials(): + def failed_provider(): + raise RuntimeError('secret-value-must-not-escape') + server = HubCoreMCPServer(name='failing', api_base='https://hub.example', register_tools=False, + token_provider=failed_provider, require_credentials=True) + assert server._get('/docs') == {'error': 'Request failed'} diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py index 7da15ed..1b76f37 100644 --- a/tests/test_postgres_store.py +++ b/tests/test_postgres_store.py @@ -31,7 +31,7 @@ def test_durable_store_survives_reopen_and_keeps_event_families_separate(tmp_pat .read_text(encoding="utf-8") ) settings = RuntimeSettings( - environment="production", + environment="test", backend="postgresql", allow_ephemeral=False, database_url=database_url, @@ -80,7 +80,7 @@ def test_postgresql_readiness_fails_when_database_is_unavailable() -> None: database_url = "sqlite+aiosqlite:////definitely-missing-parent/runtime.db" store = PostgresPortStore.from_url(database_url) settings = RuntimeSettings( - environment="production", + environment="test", backend="postgresql", allow_ephemeral=False, database_url=database_url, @@ -96,7 +96,7 @@ def test_postgresql_readiness_fails_when_runtime_tables_are_unavailable(tmp_path database_url = f"sqlite+aiosqlite:///{tmp_path / 'empty.db'}" store = PostgresPortStore.from_url(database_url) settings = RuntimeSettings( - environment="production", + environment="test", backend="postgresql", allow_ephemeral=False, database_url=database_url, diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 04da178..c8e4687 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -51,7 +51,7 @@ def test_health_and_ephemeral_readiness() -> None: def test_production_readiness_fails_closed_for_ephemeral_backend() -> None: - response = client(allow_ephemeral=False, environment="production").get("/readyz") + response = client(allow_ephemeral=False).get("/readyz") assert response.status_code == 503 assert response.json()["status"] == "degraded" diff --git a/tools/build_access_inventory.py b/tools/build_access_inventory.py index d5e2e01..2304a13 100644 --- a/tools/build_access_inventory.py +++ b/tools/build_access_inventory.py @@ -37,6 +37,8 @@ def discover(root): module = endpoint.__module__ gate = ('shared-bearer' if '_protected(' in source or module.endswith('inbox_projection') 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): 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'), @@ -94,7 +96,7 @@ def discover(root): 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', 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 len(rows) == len({r['id'] for r in rows}), 'Duplicate surface identity' return sorted(rows, key=lambda r:r['id']) diff --git a/uv.lock b/uv.lock index c8653e5..eab89be 100644 --- a/uv.lock +++ b/uv.lock @@ -644,6 +644,7 @@ dependencies = [ { name = "httpx" }, { name = "jsonschema" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "sqlalchemy", extra = ["asyncio"] }, ] @@ -671,6 +672,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.23.0" }, { name = "psycopg2-binary", marker = "extra == 'runtime'", specifier = ">=2.9.0" }, { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'runtime'", specifier = ">=0.30.0" }, ] diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index b4e7b00..54ddff2 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -40,10 +40,11 @@ exposure waits for access-control evidence and a separate approved rollout. [Architecture blueprint](../docs/netkingdom-access-blueprint.md) defines the contract and reviewed baseline. This is the single new integration workplan; existing retirement and rollout plans retain their tasks. Core Hub receives no -new product feature work. This planning session does not implement or activate -grants, enroll factors, deploy policies, expose services or retire State Hub. +new product feature work. The 2026-09-28 implementation session is authorized +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; 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. @@ -84,12 +85,12 @@ platform-root login or enforcement test is claimed by inventory validation. ```task id: HUB-WP-0012-T02 -status: todo +status: progress priority: high 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)` without recording credentials. Establish its explicit platform entitlement, map existing platform-operator vocabulary, register Hub clients/audiences and @@ -109,7 +110,7 @@ step-up implementation is actually required. ```task id: HUB-WP-0012-T03 -status: todo +status: progress priority: high state_hub_task_id: "feea8f20-aad4-583b-958a-a8efeb9c133c" ``` @@ -133,12 +134,12 @@ substituted for evidence of production custody and delivery. ```task id: HUB-WP-0012-T04 -status: todo +status: progress priority: high 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, catalogs/docs, browser, MCP and embedded router paths. Minimal liveness and 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 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 - [x] Architecture/source/runtime review captured; new implementation owner is hub-core