feat: add fail-closed Hub access profile foundation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
df39fd5f43
commit
3e386147fd
35 changed files with 2009 additions and 195 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
131
docs/access-profile-v1.md
Normal file
131
docs/access-profile-v1.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Hub access profile 1.0.0 — implementation candidate
|
||||
|
||||
HUB-WP-0012 source implementation, 2026-09-28. Owner review and live acceptance
|
||||
remain open. This profile does not grant platform access or enable public exposure.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
`HUB_CORE_ACCESS_MODE=auto` enables enforcement whenever `HUB_CORE_ENV` is not
|
||||
`development` or `test`. `enforce` also enables it locally. `development` is
|
||||
rejected in other environments. **Do not deploy this candidate as a routine
|
||||
upgrade:** the default production factory has no admitted owner adapters yet and
|
||||
returns 401 for missing credentials and 503 for credential-bearing requests.
|
||||
The current deployed image and its release configuration have not been changed.
|
||||
|
||||
Only exact `GET /healthz` is public, returning `{"status":"ok"}`. The shared
|
||||
ASGI boundary protects docs, readiness, native ports, projections, compatibility
|
||||
aliases and subsequently attached routes. Unknown method/route/handler combinations,
|
||||
WebSockets, mounts without admission, and slash redirects without catalog entries
|
||||
fail closed. Clients must use exact paths. Catalog admission does not override
|
||||
compatibility feature flags or single-writer/read-only gates. Legacy bearer checks
|
||||
remain in the development lane; production enforcement never falls back to them.
|
||||
|
||||
`hub_core/security/routes.json` is the candidate action catalog. It names each
|
||||
runtime method, path template and handler, with a stable action per handler/method.
|
||||
It is packaged in the wheel and tested against actual route construction. Source
|
||||
inventory generation does **not** auto-admit a new route. Duplicate docs handlers
|
||||
remain separately inventoried; the boundary selects the first effective route.
|
||||
These technical action names require flex-auth/owner review before policy delivery.
|
||||
|
||||
## Composition and trust
|
||||
|
||||
A host composes `create_app(access_controller=AccessController(...))` with:
|
||||
|
||||
- `OIDCVerifier`: an explicitly trusted HTTPS issuer and Hub audience, an owned
|
||||
`httpx.AsyncClient`, RS256 discovery/JWKS, token and assurance lifetime at most
|
||||
300 seconds, a 60-second key cache, and unknown-key rotation refresh (rate limited
|
||||
to once per second). Access tokens require `at+jwt` or the KeyCape `typ: Bearer`
|
||||
payload marker. ID tokens, local issuers/profiles, weak keys, AAL0 and delegated
|
||||
agents are refused. Strict integer NumericDates and assurance time are required.
|
||||
- `FactSource.resolve`: an **owner implementation still owed** that queries
|
||||
authoritative account, root entitlement, actor tenant and target tenant state.
|
||||
Its result binds issuer, subject, actor tenant, evidence reference and permitted
|
||||
producer addresses; its age may not exceed five seconds, including after policy
|
||||
and audit finish. No token role or incoming tenant/identity header supplies facts.
|
||||
- `FlexPolicy`: a dedicated HTTPS Hub PDP, exact admitted ServiceAccount principal,
|
||||
a rotating projected caller-token file and an owner-delivered trusted public-key
|
||||
set. The token is reread on every call and is separate from the end-user token.
|
||||
Remote `/v1/keys` responses never establish their own trust. Current/previous keys
|
||||
can coexist in the mounted trust file; removing a key takes effect next call.
|
||||
- `Audit.append`: an **owner implementation still owed** that returns only after
|
||||
durable acceptance. Every allow must reach this sink before handler execution;
|
||||
a failed sink blocks reads as well as writes. Authorization receipts say
|
||||
`authorized`, not “operation completed.” Domain commit/outcome audit remains a
|
||||
separate requirement; this source seam does not claim transactional audit.
|
||||
- The root's existing immutable issuer and subject, supplied after owner resolution.
|
||||
No username, email, first-login promotion or generic role establishes root.
|
||||
|
||||
Do not implement these missing adapters as a constant allow, in-memory audit sink,
|
||||
or an assertion copied from token claims. Tests use synthetic owners explicitly.
|
||||
The current CLI deliberately provides no fixture adapter or production bypass.
|
||||
A deployment composition factory and dependency health probes remain T02–T04 work.
|
||||
|
||||
For this candidate all Hub resources are explicitly **platform-owned**. Other
|
||||
target tenants are refused. Root requires AAL2/3, active account and tenants,
|
||||
current root entitlement, and a fresh policy allow for every action. Workloads
|
||||
receive no root shortcut: a policy grant and current authoritative facts are always
|
||||
required. Real workload admission remains unproven. Cross-tenant owner administration
|
||||
and Phase 2 storage/query isolation are not implemented by this classification.
|
||||
|
||||
## Policy interoperability and failure handling
|
||||
|
||||
The PDP request carries actor, target tenant, action, concrete resource path,
|
||||
assurance, root entitlement, authoritative evidence reference, and a digest of
|
||||
HTTP method/path/query/body. Client bodies and bearer tokens are not sent to the
|
||||
PDP or audit sink. Body size is bounded at 1 MiB. The controller's identity/facts/
|
||||
policy/audit chain has a ten-second timeout; individual HTTP calls have three seconds.
|
||||
A separate refusal-audit attempt is bounded at three seconds.
|
||||
|
||||
The verifier requires Ed25519 signing, the submitted request digest, matching
|
||||
request ID and structured actor/action/resource/tenant/context, enforced caller
|
||||
provenance, policy version/digest, a decision age at most 30 seconds and a valid
|
||||
allow lifetime. It never caches decisions. Every unimplemented obligation and
|
||||
non-allow/non-deny effect fails closed; approval requirements cannot be waived.
|
||||
A malformed/untrusted/unavailable decision returns 503, a verified denial 403,
|
||||
and invalid authentication 401. Responses are `no-store` and do not expose backend
|
||||
exceptions. There is no local allow fallback.
|
||||
|
||||
Interoperability tests retain flex-auth's real Go-signed fixture, tampered pair,
|
||||
public test key and original submitted request. Go `encoding/json` emits struct
|
||||
fields in declaration order and map keys in sorted order. The verifier retains
|
||||
wire order and HTML escaping for signatures and reproduces the request structs
|
||||
for `submitted_request_digest`. This is **not** RFC 8785. Duplicate JSON keys and
|
||||
non-integer decision numbers are outside this candidate profile and fail closed;
|
||||
a reordered envelope also fails signature verification. Agree broader canonical
|
||||
encoding with flex-auth before expanding this profile.
|
||||
|
||||
## Producers, MCP and embedded hosts
|
||||
|
||||
`from_address`, `from_agent` and `author`, when present in a top-level JSON command,
|
||||
must match the live owner's producer-address set. Native events get a reserved
|
||||
`payload._hub_access` record containing verified actor/tenant/correlation identity;
|
||||
client assertions under that key are overwritten. Domain `subject_refs` remain
|
||||
business data, never proof of origin. Compatibility event implementations and
|
||||
external publishers still need owner acceptance of equivalent attribution.
|
||||
|
||||
Embedded hosts can install `AccessBoundary` and an explicit host route catalog.
|
||||
Tests prove the common seam on an embedded host; each real host's mounted paths,
|
||||
features and routers still require inventory and admission. Do not treat the
|
||||
standalone runtime catalog as admission for every embedded API.
|
||||
|
||||
MCP hosts may supply `token_provider`, a callable resolving a **Hub-audience**
|
||||
credential from the current invocation, with `require_credentials=True`. Credentials
|
||||
are never retained on the server; requests do not follow redirects when carrying
|
||||
one. `trailing_slash=False` targets the standalone runtime's native paths. The
|
||||
standalone production MCP has no human flow/provider yet and fails closed.
|
||||
Many legacy tools target APIs the standalone Hub does not implement. They remain
|
||||
unsupported, not silently translated or authorized. Cross-audience delegation,
|
||||
browser PKCE sessions/logout and real MCP root login remain open.
|
||||
|
||||
## Remaining release gates
|
||||
|
||||
1. T01: cross-owner contract review, concrete policy vocabulary, effective host and
|
||||
service-route expansion; the 250-object snapshot is not full route discovery.
|
||||
2. T02: immutable root binding, registered audience/redirects, PKCE/MFA/recovery,
|
||||
admitted live facts adapters, attended login/logout and revocation receipts.
|
||||
3. T03: dedicated Hub policy and fact provenance review; authenticated deployment,
|
||||
credential/key custody, durable audit implementation and native rotation probes.
|
||||
4. T04–T05: composed deployable runtime, dependency health probes, all client/extension
|
||||
migrations, domain outcome audit, legacy lane rollback and full root journeys.
|
||||
5. T06–T08: every platform/Railiance receipt, separate public-enable approval and
|
||||
later role/delegation/tenant isolation. No milestone is closed by local fixtures.
|
||||
42
docs/evidence/hub-wp-0012-source-20260928.md
Normal file
42
docs/evidence/hub-wp-0012-source-20260928.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# HUB-WP-0012 source implementation evidence — 2026-09-28
|
||||
|
||||
This is local source evidence, not attended login, deployed policy, extension or
|
||||
Railiance acceptance. The workplan remains active with T01–T04 in progress.
|
||||
|
||||
Validation:
|
||||
|
||||
- `.venv/bin/python -m pytest -q --disable-warnings`: **271 passed** in 50.43s.
|
||||
One existing FastAPI/Starlette TestClient deprecation warning.
|
||||
- `tools/build_access_inventory.py --inventory docs/platform-access-inventory.json
|
||||
--check`: **161 Hub surfaces, 48 platform rows, 250 cluster objects**; checks pass.
|
||||
- `uv build`: source distribution and wheel build successfully; wheel contains
|
||||
`hub_core/security/routes.json`.
|
||||
- `git diff --check`: passes.
|
||||
|
||||
Tests cover catalog-wide anonymous denial, exact minimal health exception,
|
||||
production default denial without owner adapters, immutable root/assurance checks,
|
||||
live fact freshness and suspension/entitlement withdrawal, denied/unavailable
|
||||
policy, durable-audit failure, body replay and producer binding, event provenance,
|
||||
concurrent request contexts, an embedded host, MCP invocation credential isolation
|
||||
and error redaction, signed JWT claim validation and issuer-key rotation, signed
|
||||
PDP binding/lifetime/caller/obligation rejection, and projected caller-token rotation.
|
||||
|
||||
Interoperability uses flex-auth's original public test fixtures (signed, tampered,
|
||||
public verification key and original request). Both signature verification and
|
||||
`submitted_request_digest` reproduction pass against the Go-generated artifacts.
|
||||
Synthetic current decisions exercise the live-time checks; the historical fixture
|
||||
is never treated as an active authorization grant.
|
||||
|
||||
The [profile candidate](../access-profile-v1.md) states configuration, bounded
|
||||
lifetimes, serialization limits, extension/host responsibilities, and release gates.
|
||||
No live credential, grant, policy, workload, public listener or retirement state
|
||||
was changed. No private production signing key or root subject was invented.
|
||||
|
||||
State Hub implementation decision:
|
||||
`6edd5720-c894-46e3-8130-fd6c09b9f311`.
|
||||
|
||||
Remaining requirements include owner review and per-service route expansion;
|
||||
attended root binding/PKCE/MFA/logout; real account/tenant and durable audit adapters;
|
||||
a dedicated Hub PDP with authenticated caller and signing-key delivery; deployment
|
||||
composition and owner health checks; all client/extension/platform receipts;
|
||||
separately approved exposure; and Phase 2 tenant isolation/delegation.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -144,3 +144,11 @@ it against an isolated runtime with `hub-core conformance --base-url <url>`.
|
|||
`docs/core-hub-absorption-plan.md` defines the capability-sized `/api/v2`
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
1
hub_core/security/__init__.py
Normal file
1
hub_core/security/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Hub access profile v1: deny by default, with owner-supplied trust adapters."""
|
||||
280
hub_core/security/boundary.py
Normal file
280
hub_core/security/boundary.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Reusable HTTP enforcement for the private platform-root milestone.
|
||||
|
||||
Owner adapters establish identity, live account/tenant facts, signed policy
|
||||
decisions and durable audit. An absent adapter never grants access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import files
|
||||
from typing import Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Match
|
||||
|
||||
from hub_core.security.identity import AccessFailure, Actor
|
||||
|
||||
PROFILE = "hub-core.access/1.0.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveFacts:
|
||||
issuer: str
|
||||
subject: str
|
||||
actor_tenant: str
|
||||
target_tenant: str
|
||||
account_active: bool
|
||||
actor_tenant_active: bool
|
||||
target_tenant_active: bool
|
||||
root_entitled: bool
|
||||
checked_at: float
|
||||
evidence_id: str
|
||||
producer_addresses: frozenset[str] = frozenset()
|
||||
|
||||
def __post_init__(self):
|
||||
for value in (self.account_active, self.actor_tenant_active,
|
||||
self.target_tenant_active, self.root_entitled):
|
||||
if type(value) is not bool:
|
||||
raise ValueError("authoritative state must be boolean")
|
||||
if type(self.checked_at) not in {int, float} or not math.isfinite(self.checked_at):
|
||||
raise ValueError("finite fact observation time required")
|
||||
for value in (self.issuer, self.subject, self.actor_tenant,
|
||||
self.target_tenant, self.evidence_id, *self.producer_addresses):
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("nonempty authoritative references required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Authorization:
|
||||
actor: Actor
|
||||
action: str
|
||||
resource: str
|
||||
facts: LiveFacts
|
||||
correlation_id: str
|
||||
request_digest: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
allowed: bool
|
||||
decision_id: str
|
||||
policy_version: str
|
||||
caller: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if type(self.allowed) is not bool or not self.decision_id or not self.policy_version:
|
||||
raise ValueError("explicit boolean decision and provenance required")
|
||||
|
||||
|
||||
class Identity(Protocol):
|
||||
async def authenticate(self, token: str) -> Actor: ...
|
||||
|
||||
|
||||
class FactSource(Protocol):
|
||||
async def resolve(self, actor: Actor, resource: str) -> LiveFacts:
|
||||
"""Query authoritative account/tenant state, without cached root grants."""
|
||||
...
|
||||
|
||||
|
||||
class Policy(Protocol):
|
||||
async def evaluate(self, request: Authorization) -> Decision:
|
||||
"""Verify signed origin, binding, lifetime, caller and obligations."""
|
||||
...
|
||||
|
||||
|
||||
class Audit(Protocol):
|
||||
async def append(self, record: dict) -> None:
|
||||
"""Return only after durable acceptance; raise on delivery failure."""
|
||||
...
|
||||
|
||||
|
||||
class AccessController:
|
||||
def __init__(self, *, identity: Identity, facts: FactSource, policy: Policy,
|
||||
audit: Audit, root_issuer: str, root_subject: str):
|
||||
if not root_issuer or not root_subject:
|
||||
raise ValueError("immutable root identity is required")
|
||||
self.identity, self.facts, self.policy, self.audit = identity, facts, policy, audit
|
||||
self.root_identity = (root_issuer, root_subject)
|
||||
|
||||
async def authorize(self, token: str, action: str, resource: str,
|
||||
correlation_id: str, request_digest: str) -> Authorization:
|
||||
actor = await self.identity.authenticate(token)
|
||||
try:
|
||||
return await self._authorize_actor(actor, action, resource, correlation_id, request_digest)
|
||||
except Exception as exc:
|
||||
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")
|
||||
failure.actor = actor
|
||||
raise failure
|
||||
|
||||
async def _authorize_actor(self, actor: Actor, action: str, resource: str,
|
||||
correlation_id: str, request_digest: str) -> Authorization:
|
||||
if actor.expires_at <= time.time():
|
||||
raise AccessFailure(401, "expired_access_token")
|
||||
if actor.principal_type == "human" and (
|
||||
(actor.issuer, actor.subject) != self.root_identity
|
||||
or actor.tenant != "tenant:platform" or actor.assurance not in {"aal2", "aal3"}
|
||||
):
|
||||
raise AccessFailure(403, "root_required")
|
||||
facts = await self.facts.resolve(actor, resource)
|
||||
if (facts.issuer, facts.subject, facts.actor_tenant) != (
|
||||
actor.issuer, actor.subject, actor.tenant
|
||||
) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id:
|
||||
raise AccessFailure(503, "untrusted_or_stale_facts")
|
||||
# v1 explicitly classifies Hub records as platform-owned. Other tenants
|
||||
# require the Phase 2 resource resolver/storage contract, not a header.
|
||||
if facts.target_tenant != "tenant:platform":
|
||||
raise AccessFailure(403, "unsupported_target_tenant")
|
||||
if not (facts.account_active and facts.actor_tenant_active and facts.target_tenant_active):
|
||||
raise AccessFailure(403, "inactive_identity_or_tenant")
|
||||
if actor.principal_type == "human" and not facts.root_entitled:
|
||||
raise AccessFailure(403, "root_entitlement_required")
|
||||
context = Authorization(actor, action, resource, facts, correlation_id, request_digest)
|
||||
decision = await self.policy.evaluate(context)
|
||||
await self.audit.append({
|
||||
"profile": PROFILE, "correlation_id": correlation_id,
|
||||
"issuer": actor.issuer, "subject": actor.subject,
|
||||
"principal_type": actor.principal_type, "actor_tenant": actor.tenant,
|
||||
"target_tenant": facts.target_tenant, "action": action,
|
||||
"resource_digest": hashlib.sha256(resource.encode()).hexdigest(),
|
||||
"request_digest": request_digest, "facts_evidence": facts.evidence_id,
|
||||
"decision_id": decision.decision_id, "policy_version": decision.policy_version,
|
||||
"policy_caller": decision.caller,
|
||||
"outcome": "authorized" if decision.allowed else "denied",
|
||||
})
|
||||
if not decision.allowed:
|
||||
raise AccessFailure(403, "policy_denied")
|
||||
if actor.expires_at <= time.time():
|
||||
raise AccessFailure(401, "expired_access_token")
|
||||
if time.time() - facts.checked_at > 5:
|
||||
raise AccessFailure(503, "facts_expired_during_authorization")
|
||||
return context
|
||||
|
||||
|
||||
def route_key(route, method: str) -> str:
|
||||
endpoint = route.endpoint
|
||||
return f"{method}:{route.path}:{endpoint.__module__}.{endpoint.__name__}"
|
||||
|
||||
|
||||
def iter_routes(router):
|
||||
for route in router.routes:
|
||||
if hasattr(route, "original_router"):
|
||||
yield from iter_routes(route.original_router)
|
||||
else:
|
||||
yield route
|
||||
|
||||
|
||||
class AccessBoundary:
|
||||
"""ASGI boundary also covers docs, redirects, unknown routes and WebSockets.
|
||||
|
||||
Install on an embedded host with its own explicit catalog to protect SDK
|
||||
routers. Routes added without catalog admission remain denied.
|
||||
"""
|
||||
def __init__(self, app, *, host, controller: AccessController | None,
|
||||
catalog: dict[str, str] | None = None):
|
||||
self.app, self.host, self.controller = app, host, controller
|
||||
self.catalog = catalog if catalog is not None else json.loads(
|
||||
files("hub_core.security").joinpath("routes.json").read_text()
|
||||
)["routes"]
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
return
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
# No prefix or trailing-slash exception. Detailed readiness is protected.
|
||||
if scope["method"] == "GET" and scope["path"] == "/healthz":
|
||||
await JSONResponse({"status": "ok"})(scope, receive, send)
|
||||
return
|
||||
correlation = str(uuid4())
|
||||
context = None
|
||||
try:
|
||||
headers = Request(scope).headers.getlist("authorization")
|
||||
if len(headers) != 1 or not headers[0].startswith("Bearer "):
|
||||
raise AccessFailure(401, "bearer_required")
|
||||
token = headers[0][7:]
|
||||
if not token or len(token) > 16384 or any(c.isspace() for c in token):
|
||||
raise AccessFailure(401, "invalid_access_token")
|
||||
if self.controller is None:
|
||||
raise AccessFailure(503, "access_dependencies_unavailable")
|
||||
route = next((r for r in iter_routes(self.host)
|
||||
if r.matches(scope)[0] == Match.FULL), None)
|
||||
key = route_key(route, scope["method"]) if route and hasattr(route, "endpoint") else None
|
||||
action = self.catalog.get(key)
|
||||
if not action:
|
||||
raise AccessFailure(403, "surface_not_admitted")
|
||||
# Bind policy to the exact request without exposing content to PDP/audit.
|
||||
request = Request(scope, receive)
|
||||
chunks, size = [], 0
|
||||
async for chunk in request.stream():
|
||||
size += len(chunk)
|
||||
if size > 1024 * 1024:
|
||||
raise AccessFailure(413, "request_too_large")
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
digest = hashlib.sha256(b"\0".join([
|
||||
scope["method"].encode(), scope["path"].encode(),
|
||||
scope.get("query_string", b""), body,
|
||||
])).hexdigest()
|
||||
async with asyncio.timeout(10):
|
||||
context = await self.controller.authorize(
|
||||
token, action, scope["path"], correlation, digest,
|
||||
)
|
||||
if body:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (ValueError, UnicodeError):
|
||||
payload = None # Handler owns content validation.
|
||||
if isinstance(payload, dict):
|
||||
for field in ("from_address", "from_agent", "author"):
|
||||
if field in payload and payload[field] not in context.facts.producer_addresses:
|
||||
raise AccessFailure(403, "producer_identity_mismatch")
|
||||
scope.setdefault("state", {})["hub_access"] = context
|
||||
except Exception as exc:
|
||||
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")
|
||||
actor = context.actor if context else getattr(failure, "actor", None)
|
||||
if self.controller is not None:
|
||||
try:
|
||||
async with asyncio.timeout(3):
|
||||
await self.controller.audit.append({
|
||||
"profile": PROFILE, "correlation_id": correlation,
|
||||
"outcome": "refused", "reason": failure.code,
|
||||
"subject": actor.subject if actor else None,
|
||||
"issuer": actor.issuer if actor else None,
|
||||
"actor_tenant": actor.tenant if actor else None,
|
||||
})
|
||||
except Exception:
|
||||
failure = AccessFailure(503, "audit_unavailable")
|
||||
headers = {"Cache-Control": "no-store", "X-Correlation-ID": correlation}
|
||||
if failure.status == 401:
|
||||
headers["WWW-Authenticate"] = "Bearer"
|
||||
await JSONResponse({"detail": failure.code}, status_code=failure.status,
|
||||
headers=headers)(scope, receive, send)
|
||||
return
|
||||
|
||||
delivered = False
|
||||
|
||||
async def replay():
|
||||
nonlocal delivered
|
||||
if not delivered:
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
return await receive()
|
||||
|
||||
async def protected_send(message):
|
||||
if message["type"] == "http.response.start":
|
||||
message["headers"] = [(k, v) for k, v in message.get("headers", [])
|
||||
if k.lower() not in {b"cache-control", b"x-correlation-id"}]
|
||||
message["headers"].extend([(b"cache-control", b"no-store"),
|
||||
(b"x-correlation-id", correlation.encode())])
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, replay, protected_send)
|
||||
168
hub_core/security/identity.py
Normal file
168
hub_core/security/identity.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""IAM v0.3 access-token verification. No username-based authority."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
|
||||
class AccessFailure(Exception):
|
||||
def __init__(self, status: int, code: str):
|
||||
self.status, self.code = status, code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Actor:
|
||||
issuer: str
|
||||
subject: str
|
||||
tenant: str
|
||||
principal_type: str
|
||||
assurance: str
|
||||
authenticated_at: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
def require_https(url: str) -> None:
|
||||
parsed = urlsplit(url)
|
||||
hostname = (parsed.hostname or "").rstrip(".").lower()
|
||||
try:
|
||||
local = ipaddress.ip_address(hostname).is_loopback
|
||||
except ValueError:
|
||||
local = hostname == "localhost" or hostname.endswith(".localhost")
|
||||
if (parsed.scheme != "https" or not parsed.hostname or parsed.username
|
||||
or parsed.password or parsed.fragment or parsed.query
|
||||
or local):
|
||||
raise ValueError("a non-local HTTPS trust endpoint is required")
|
||||
|
||||
|
||||
class OIDCVerifier:
|
||||
"""Discover keys at an explicitly trusted issuer; bounded, rotation-aware cache.
|
||||
|
||||
Supports RFC 9068 at+jwt tokens or the admitted KeyCape Bearer payload type.
|
||||
ID tokens without either access-token marker are rejected.
|
||||
"""
|
||||
|
||||
def __init__(self, *, issuer: str, audience: str, client: httpx.AsyncClient,
|
||||
key_ttl: int = 60, max_token_age: int = 300):
|
||||
require_https(issuer)
|
||||
if not audience or not 1 <= key_ttl <= 300 or not 1 <= max_token_age <= 300:
|
||||
raise ValueError("audience and bounded key/token lifetimes are required")
|
||||
self.issuer, self.audience, self.client = issuer, audience, client
|
||||
self.key_ttl, self.max_token_age = key_ttl, max_token_age
|
||||
self._keys: dict = {}
|
||||
self._loaded = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _refresh(self) -> None:
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.issuer.rstrip("/") + "/.well-known/openid-configuration",
|
||||
timeout=3, follow_redirects=False,
|
||||
)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
if discovery["issuer"] != self.issuer:
|
||||
raise ValueError("issuer mismatch")
|
||||
require_https(discovery["jwks_uri"])
|
||||
response = await self.client.get(discovery["jwks_uri"], timeout=3,
|
||||
follow_redirects=False)
|
||||
response.raise_for_status()
|
||||
keys = {}
|
||||
for value in response.json()["keys"]:
|
||||
if value.get("kty") != "RSA" or value.get("use", "sig") != "sig":
|
||||
continue
|
||||
if value.get("alg", "RS256") != "RS256":
|
||||
continue
|
||||
if "verify" not in value.get("key_ops", ["verify"]):
|
||||
continue
|
||||
kid = value["kid"]
|
||||
if not isinstance(kid, str) or not kid or kid in keys:
|
||||
raise ValueError("invalid key IDs")
|
||||
key = jwt.PyJWK.from_dict(value, algorithm="RS256").key
|
||||
if key.key_size < 2048:
|
||||
raise ValueError("weak issuer key")
|
||||
keys[kid] = key
|
||||
if not keys:
|
||||
raise ValueError("no signing keys")
|
||||
self._keys, self._loaded = keys, time.monotonic()
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as exc:
|
||||
raise AccessFailure(503, "identity_unavailable") from exc
|
||||
|
||||
async def authenticate(self, token: str) -> Actor:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
|
||||
raise ValueError("unsupported token")
|
||||
async with self._lock:
|
||||
# At most one unknown-key refresh per second, to bound random-kid traffic.
|
||||
age = time.monotonic() - self._loaded
|
||||
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
|
||||
await self._refresh()
|
||||
key = self._keys.get(header["kid"])
|
||||
if key is None:
|
||||
raise ValueError("unknown key")
|
||||
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
|
||||
audience=self.audience, leeway=0,
|
||||
options={"require": ["iss", "sub", "aud", "exp", "iat",
|
||||
"tenant", "principal_type", "groups",
|
||||
"roles", "assurance"]})
|
||||
if header.get("typ") != "at+jwt" and claims.get("typ") != "Bearer":
|
||||
raise ValueError("not an access token")
|
||||
if claims.get("typ", "Bearer") != "Bearer" or claims.get("environment") in {
|
||||
"local", "development", "test",
|
||||
}:
|
||||
raise ValueError("unsupported token profile")
|
||||
for name in ("sub", "tenant"):
|
||||
if not isinstance(claims[name], str) or not claims[name]:
|
||||
raise ValueError("invalid identity")
|
||||
for name in ("groups", "roles"):
|
||||
if not isinstance(claims[name], list) or any(
|
||||
not isinstance(item, str) for item in claims[name]
|
||||
):
|
||||
raise ValueError("invalid IAM array")
|
||||
scope = claims.get("scope", claims.get("scp"))
|
||||
if not isinstance(scope, (str, list)) or (
|
||||
isinstance(scope, list) and any(not isinstance(item, str) for item in scope)
|
||||
):
|
||||
raise ValueError("invalid scope")
|
||||
assurance = claims["assurance"]
|
||||
if (not isinstance(assurance, dict)
|
||||
or assurance.get("level") not in {"aal1", "aal2", "aal3"}
|
||||
or type(assurance.get("mfa")) is not bool
|
||||
or not isinstance(assurance.get("source"), str)
|
||||
or not assurance["source"]
|
||||
or not isinstance(assurance.get("methods"), list)
|
||||
or not all(isinstance(x, str) for x in assurance["methods"])):
|
||||
raise ValueError("invalid assurance")
|
||||
for value in (claims["iat"], claims["exp"], assurance.get("at")):
|
||||
if type(value) is not int:
|
||||
raise ValueError("integer timestamps required")
|
||||
if "nbf" in claims and type(claims["nbf"]) is not int:
|
||||
raise ValueError("integer not-before required")
|
||||
if assurance["level"] in {"aal2", "aal3"} and not assurance["mfa"]:
|
||||
raise ValueError("missing MFA evidence")
|
||||
now = time.time()
|
||||
if (not claims["iat"] <= now < claims["exp"]
|
||||
or claims["exp"] - claims["iat"] > self.max_token_age
|
||||
or not 0 <= now - assurance["at"] <= self.max_token_age):
|
||||
raise ValueError("stale identity or assurance")
|
||||
principal = claims["principal_type"]
|
||||
if principal not in {"human", "service", "agent"}:
|
||||
raise ValueError("invalid principal type")
|
||||
if principal == "agent":
|
||||
agent = claims.get("agent", {})
|
||||
if not agent.get("id") or agent.get("mode") != "autonomous":
|
||||
# Delegation needs a separately admitted actor/workload contract.
|
||||
raise ValueError("unsupported delegation")
|
||||
return Actor(self.issuer, claims["sub"], claims["tenant"], principal,
|
||||
assurance["level"], assurance["at"], claims["exp"])
|
||||
except AccessFailure:
|
||||
raise
|
||||
except (jwt.PyJWTError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||
raise AccessFailure(401, "invalid_access_token") from exc
|
||||
177
hub_core/security/policy.py
Normal file
177
hub_core/security/policy.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""Authenticated flex-auth client; verification precedes every allow/deny."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from hub_core.security.boundary import Authorization, Decision
|
||||
from hub_core.security.identity import AccessFailure, require_https
|
||||
|
||||
|
||||
def _object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def parse_json(raw: bytes | str):
|
||||
def reject(_):
|
||||
raise ValueError("non-integer numbers are outside this profile")
|
||||
return json.loads(raw, object_pairs_hook=_object, parse_float=reject, parse_constant=reject)
|
||||
|
||||
|
||||
def go_json(value) -> bytes:
|
||||
"""Preserve Go struct/wire order and escape HTML as encoding/json does.
|
||||
|
||||
This deliberately does not sort struct fields. Maps sent in CheckRequest
|
||||
are sorted separately. Unsupported floating point input fails closed.
|
||||
"""
|
||||
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||
for char, escaped in (("<", "\\u003c"), (">", "\\u003e"), ("&", "\\u0026"),
|
||||
("\u2028", "\\u2028"), ("\u2029", "\\u2029")):
|
||||
encoded = encoded.replace(char, escaped)
|
||||
return encoded.encode()
|
||||
|
||||
|
||||
def _sorted_maps(value):
|
||||
if isinstance(value, dict):
|
||||
return {k: _sorted_maps(value[k]) for k in sorted(value)}
|
||||
if isinstance(value, list):
|
||||
return [_sorted_maps(x) for x in value]
|
||||
return value
|
||||
|
||||
|
||||
def submitted_digest(request: dict) -> str:
|
||||
# requestDigestMaterial, SubjectRef and ResourceRef are Go structs, whose
|
||||
# declaration order (unlike maps) participates in the current wire contract.
|
||||
material = {}
|
||||
if request.get("tenant"):
|
||||
material["tenant"] = request["tenant"]
|
||||
for field, order in (("subject", ("id", "type", "tenant", "attributes")),
|
||||
("resource", ("id", "type", "system", "tenant", "attributes"))):
|
||||
if field == "resource":
|
||||
material["action"] = request["action"]
|
||||
material[field] = {k: _sorted_maps(request[field][k]) for k in order
|
||||
if request[field].get(k)}
|
||||
if request.get("context"):
|
||||
material["context"] = _sorted_maps(request["context"])
|
||||
return "sha256:" + hashlib.sha256(go_json(material)).hexdigest()
|
||||
|
||||
|
||||
def verify_signature(envelope: dict, keys: dict) -> None:
|
||||
signature = envelope["signature"]
|
||||
if signature["mode"] != "signed" or signature["alg"] != "ed25519":
|
||||
raise ValueError("signed Ed25519 decision required")
|
||||
candidates = [key for key in keys["keys"] if key["kid"] == signature["kid"]]
|
||||
if len(candidates) != 1 or candidates[0]["alg"] != "ed25519":
|
||||
raise ValueError("untrusted signing key")
|
||||
def decode(value):
|
||||
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||
key = Ed25519PublicKey.from_public_bytes(decode(candidates[0]["public_key"]))
|
||||
key.verify(decode(signature["value"]), go_json({
|
||||
k: v for k, v in envelope.items() if k != "signature"
|
||||
}))
|
||||
|
||||
|
||||
def _time(value: str) -> datetime:
|
||||
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if result.tzinfo is None:
|
||||
raise ValueError("timezone required")
|
||||
return result
|
||||
|
||||
|
||||
def verify_decision(envelope: dict, *, request: dict, keys: dict,
|
||||
caller: str, now: datetime | None = None) -> Decision:
|
||||
verify_signature(envelope, keys)
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if (envelope["contract_version"] != "flex-auth.decision-record.v1"
|
||||
or envelope["request_id"] != request["id"] or not envelope["id"]):
|
||||
raise ValueError("invalid decision contract or correlation")
|
||||
binding = envelope["binding"]
|
||||
if binding["submitted_request_digest"] != submitted_digest(request):
|
||||
raise ValueError("submitted request mismatch")
|
||||
if binding["action"] != request["action"] or binding.get("tenant") != request["tenant"]:
|
||||
raise ValueError("action or tenant mismatch")
|
||||
for field, fields in (("subject", ("id", "type", "tenant")),
|
||||
("resource", ("id", "type", "system", "tenant"))):
|
||||
for key in fields:
|
||||
if binding[field].get(key) != request[field].get(key):
|
||||
raise ValueError("evaluated identity or resource mismatch")
|
||||
if envelope[field] != binding[field]:
|
||||
raise ValueError("inconsistent binding")
|
||||
if binding.get("context", {}) != request.get("context", {}):
|
||||
raise ValueError("context mismatch")
|
||||
provenance = envelope["provenance"]
|
||||
caller_record = provenance["caller"]
|
||||
if (caller_record["mode"] != "enforce" or caller_record["principal"] != caller
|
||||
or caller_record["audience"] != "flex-auth"
|
||||
or _time(caller_record["not_after"]) <= now):
|
||||
raise ValueError("untrusted workload caller")
|
||||
if not provenance["policy_version"] or not provenance["policy_package_digest"]:
|
||||
raise ValueError("missing policy provenance")
|
||||
age = (now - _time(provenance["decision_time"])).total_seconds()
|
||||
if not 0 <= age <= 30:
|
||||
raise ValueError("stale decision")
|
||||
if envelope.get("obligations"):
|
||||
# No obligation is silently treated as satisfied. Owner-specific
|
||||
# approval/redaction/audit handlers require a later profile revision.
|
||||
raise ValueError("unsupported decision obligations")
|
||||
if envelope["effect"] not in {"allow", "deny"}:
|
||||
raise ValueError("unsupported effect")
|
||||
if envelope["effect"] == "allow":
|
||||
lifetime = envelope["lifetime"]
|
||||
if (lifetime["kind"] != "ttl" or not
|
||||
_time(lifetime["not_before"]) <= now < _time(lifetime["expires_at"])):
|
||||
raise ValueError("invalid decision lifetime")
|
||||
return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"], caller)
|
||||
|
||||
|
||||
class FlexPolicy:
|
||||
def __init__(self, *, base_url: str, client: httpx.AsyncClient,
|
||||
caller_token_file: Path, trusted_keys_file: Path, caller: str):
|
||||
require_https(base_url)
|
||||
if not caller.startswith("system:serviceaccount:"):
|
||||
raise ValueError("explicit admitted workload caller required")
|
||||
self.base_url, self.client = base_url.rstrip("/"), client
|
||||
self.caller_token_file, self.trusted_keys_file = caller_token_file, trusted_keys_file
|
||||
self.caller = caller
|
||||
|
||||
async def evaluate(self, request: Authorization) -> Decision:
|
||||
actor, facts = request.actor, request.facts
|
||||
check = {
|
||||
"id": request.correlation_id, "tenant": facts.target_tenant,
|
||||
"subject": {"id": actor.subject, "type": actor.principal_type,
|
||||
"tenant": actor.tenant,
|
||||
"attributes": {"issuer": actor.issuer, "assurance": actor.assurance}},
|
||||
"action": request.action,
|
||||
"resource": {"id": request.resource, "type": "hub-route", "system": "hub-core",
|
||||
"tenant": facts.target_tenant},
|
||||
"context": {"http_request_digest": request.request_digest,
|
||||
"facts_evidence": facts.evidence_id,
|
||||
"root_entitled": facts.root_entitled},
|
||||
}
|
||||
try:
|
||||
# Reread projected credentials and owner-delivered public trust at
|
||||
# every check. No remote key response can bootstrap its own trust.
|
||||
token = self.caller_token_file.read_text().strip()
|
||||
if not token or any(c.isspace() for c in token):
|
||||
raise ValueError("invalid caller credential")
|
||||
keys = parse_json(self.trusted_keys_file.read_bytes())
|
||||
response = await self.client.post(self.base_url + "/v1/check", json=check,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=3, follow_redirects=False)
|
||||
response.raise_for_status()
|
||||
return verify_decision(parse_json(response.content), request=check,
|
||||
keys=keys, caller=self.caller)
|
||||
except Exception as exc:
|
||||
# Neither response body nor credentials appear in the public error.
|
||||
raise AccessFailure(503, "policy_unavailable_or_untrusted") from exc
|
||||
93
hub_core/security/routes.json
Normal file
93
hub_core/security/routes.json
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"profile": "hub-core.access/1.0.0",
|
||||
"status": "candidate-owner-review-required",
|
||||
"routes": {
|
||||
"GET:/annotation-categories:hub_core.runtime.compat.annotation_categories": "hub.hub_core.runtime.compat.annotation_categories.get",
|
||||
"GET:/annotations:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/api-consumers:hub_core.runtime.compat.list_consumers": "hub.hub_core.runtime.compat.list_consumers.get",
|
||||
"GET:/api/v2/annotation-categories:hub_core.runtime.compat.annotation_categories": "hub.hub_core.runtime.compat.annotation_categories.get",
|
||||
"GET:/api/v2/annotations:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/api/v2/api-consumers:hub_core.runtime.compat.list_consumers": "hub.hub_core.runtime.compat.list_consumers.get",
|
||||
"GET:/api/v2/decision-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/api/v2/deployment-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/api/v2/docs:hub_core.runtime.compat.docs": "hub.hub_core.runtime.compat.docs.get",
|
||||
"GET:/api/v2/event-types:hub_core.runtime.compat.event_types": "hub.hub_core.runtime.compat.event_types.get",
|
||||
"GET:/api/v2/hub-capability-manifests:hub_core.runtime.compat.list_manifests": "hub.hub_core.runtime.compat.list_manifests.get",
|
||||
"GET:/api/v2/hub-registry:hub_core.runtime.compat.hub_registry": "hub.hub_core.runtime.compat.hub_registry.get",
|
||||
"GET:/api/v2/hubs:hub_core.runtime.compat.list_hubs": "hub.hub_core.runtime.compat.list_hubs.get",
|
||||
"GET:/api/v2/interaction-events:hub_core.runtime.compat.list_interactions": "hub.hub_core.runtime.compat.list_interactions.get",
|
||||
"GET:/api/v2/openapi.json:hub_core.runtime.compat.openapi_json": "hub.hub_core.runtime.compat.openapi_json.get",
|
||||
"GET:/api/v2/openapi.yaml:hub_core.runtime.compat.openapi_yaml": "hub.hub_core.runtime.compat.openapi_yaml.get",
|
||||
"GET:/api/v2/outcome-signals:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/api/v2/policy-scopes:hub_core.runtime.compat.policy_scopes": "hub.hub_core.runtime.compat.policy_scopes.get",
|
||||
"GET:/api/v2/requirement-candidates:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/api/v2/widget-types:hub_core.runtime.compat.widget_types": "hub.hub_core.runtime.compat.widget_types.get",
|
||||
"GET:/api/v2/widgets:hub_core.runtime.compat.list_widgets": "hub.hub_core.runtime.compat.list_widgets.get",
|
||||
"GET:/console:hub_core.runtime.compat.console": "hub.hub_core.runtime.compat.console.get",
|
||||
"GET:/decision-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/deployment-records:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/docs/oauth2-redirect:fastapi.applications.swagger_ui_redirect": "hub.fastapi.applications.swagger_ui_redirect.get",
|
||||
"GET:/docs:fastapi.applications.swagger_ui_html": "hub.fastapi.applications.swagger_ui_html.get",
|
||||
"GET:/docs:hub_core.runtime.compat.docs": "hub.hub_core.runtime.compat.docs.get",
|
||||
"GET:/event-types:hub_core.runtime.compat.event_types": "hub.hub_core.runtime.compat.event_types.get",
|
||||
"GET:/hub-capability-manifests:hub_core.runtime.compat.list_manifests": "hub.hub_core.runtime.compat.list_manifests.get",
|
||||
"GET:/hub-registry:hub_core.runtime.compat.hub_registry": "hub.hub_core.runtime.compat.hub_registry.get",
|
||||
"GET:/hubs:hub_core.runtime.compat.list_hubs": "hub.hub_core.runtime.compat.list_hubs.get",
|
||||
"GET:/interaction-events:hub_core.runtime.compat.list_interactions": "hub.hub_core.runtime.compat.list_interactions.get",
|
||||
"GET:/openapi.json:fastapi.applications.openapi": "hub.fastapi.applications.openapi.get",
|
||||
"GET:/openapi.json:hub_core.runtime.compat.openapi_json": "hub.hub_core.runtime.compat.openapi_json.get",
|
||||
"GET:/openapi.yaml:hub_core.runtime.compat.openapi_yaml": "hub.hub_core.runtime.compat.openapi_yaml.get",
|
||||
"GET:/outcome-signals:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/policy-scopes:hub_core.runtime.compat.policy_scopes": "hub.hub_core.runtime.compat.policy_scopes.get",
|
||||
"GET:/ports/messaging/messages:hub_core.runtime.ports.list_messages": "hub.hub_core.runtime.ports.list_messages.get",
|
||||
"GET:/ports/projections/repository-navigation/facets/{facet_kind}/{facet_value}:hub_core.runtime.repository_navigation_routes.query_facet": "hub.hub_core.runtime.repository_navigation_routes.query_facet.get",
|
||||
"GET:/ports/projections/repository-navigation/repositories:hub_core.runtime.repository_navigation_routes.query_repositories": "hub.hub_core.runtime.repository_navigation_routes.query_repositories.get",
|
||||
"GET:/ports/projections/statehub-inbox:hub_core.runtime.inbox_projection.inbox": "hub.hub_core.runtime.inbox_projection.inbox.get",
|
||||
"GET:/ports/projections/workloads/resolve:hub_core.runtime.workload_projection_routes.resolve_workload": "hub.hub_core.runtime.workload_projection_routes.resolve_workload.get",
|
||||
"GET:/ports/projections/workloads:hub_core.runtime.workload_projection_routes.query_workloads": "hub.hub_core.runtime.workload_projection_routes.query_workloads.get",
|
||||
"GET:/ports/projections/{projection_id}:hub_core.runtime.ports.query_projection": "hub.hub_core.runtime.ports.query_projection.get",
|
||||
"GET:/ports/registry/registrations/{hub_slug}/audit:hub_core.runtime.ports.registration_audit": "hub.hub_core.runtime.ports.registration_audit.get",
|
||||
"GET:/ports/registry/registrations/{hub_slug}:hub_core.runtime.ports.resolve_registration": "hub.hub_core.runtime.ports.resolve_registration.get",
|
||||
"GET:/readyz:hub_core.runtime.app.readyz": "hub.hub_core.runtime.app.readyz.get",
|
||||
"GET:/redoc:fastapi.applications.redoc_html": "hub.fastapi.applications.redoc_html.get",
|
||||
"GET:/requirement-candidates:hub_core.runtime.compat.empty_collection": "hub.hub_core.runtime.compat.empty_collection.get",
|
||||
"GET:/widget-types:hub_core.runtime.compat.widget_types": "hub.hub_core.runtime.compat.widget_types.get",
|
||||
"GET:/widgets:hub_core.runtime.compat.list_widgets": "hub.hub_core.runtime.compat.list_widgets.get",
|
||||
"HEAD:/docs/oauth2-redirect:fastapi.applications.swagger_ui_redirect": "hub.fastapi.applications.swagger_ui_redirect.head",
|
||||
"HEAD:/docs:fastapi.applications.swagger_ui_html": "hub.fastapi.applications.swagger_ui_html.head",
|
||||
"HEAD:/openapi.json:fastapi.applications.openapi": "hub.fastapi.applications.openapi.head",
|
||||
"HEAD:/redoc:fastapi.applications.redoc_html": "hub.fastapi.applications.redoc_html.head",
|
||||
"PATCH:/api/v2/hub-capability-manifests/{manifest_id}:hub_core.runtime.compat.patch_manifest": "hub.hub_core.runtime.compat.patch_manifest.patch",
|
||||
"PATCH:/hub-capability-manifests/{manifest_id}:hub_core.runtime.compat.patch_manifest": "hub.hub_core.runtime.compat.patch_manifest.patch",
|
||||
"POST:/annotations:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/api-consumers/{consumer_id}/api-keys:hub_core.runtime.compat.create_key": "hub.hub_core.runtime.compat.create_key.post",
|
||||
"POST:/api-consumers:hub_core.runtime.compat.create_consumer": "hub.hub_core.runtime.compat.create_consumer.post",
|
||||
"POST:/api/v2/annotations:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/api/v2/api-consumers/{consumer_id}/api-keys:hub_core.runtime.compat.create_key": "hub.hub_core.runtime.compat.create_key.post",
|
||||
"POST:/api/v2/api-consumers:hub_core.runtime.compat.create_consumer": "hub.hub_core.runtime.compat.create_consumer.post",
|
||||
"POST:/api/v2/decision-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/api/v2/deployment-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/api/v2/hub-capability-manifests/{manifest_id}/activate:hub_core.runtime.compat.activate_manifest": "hub.hub_core.runtime.compat.activate_manifest.post",
|
||||
"POST:/api/v2/hub-capability-manifests:hub_core.runtime.compat.create_manifest": "hub.hub_core.runtime.compat.create_manifest.post",
|
||||
"POST:/api/v2/hubs:hub_core.runtime.compat.create_hub": "hub.hub_core.runtime.compat.create_hub.post",
|
||||
"POST:/api/v2/interaction-events:hub_core.runtime.compat.create_interaction": "hub.hub_core.runtime.compat.create_interaction.post",
|
||||
"POST:/api/v2/outcome-signals:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/api/v2/requirement-candidates:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/api/v2/token:hub_core.runtime.compat.token": "hub.hub_core.runtime.compat.token.post",
|
||||
"POST:/api/v2/widgets:hub_core.runtime.compat.create_widget": "hub.hub_core.runtime.compat.create_widget.post",
|
||||
"POST:/decision-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/deployment-records:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/hub-capability-manifests/{manifest_id}/activate:hub_core.runtime.compat.activate_manifest": "hub.hub_core.runtime.compat.activate_manifest.post",
|
||||
"POST:/hub-capability-manifests:hub_core.runtime.compat.create_manifest": "hub.hub_core.runtime.compat.create_manifest.post",
|
||||
"POST:/hubs:hub_core.runtime.compat.create_hub": "hub.hub_core.runtime.compat.create_hub.post",
|
||||
"POST:/interaction-events:hub_core.runtime.compat.create_interaction": "hub.hub_core.runtime.compat.create_interaction.post",
|
||||
"POST:/outcome-signals:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/ports/events/interaction:hub_core.runtime.ports.append_interaction": "hub.hub_core.runtime.ports.append_interaction.post",
|
||||
"POST:/ports/events/progress:hub_core.runtime.ports.append_progress": "hub.hub_core.runtime.ports.append_progress.post",
|
||||
"POST:/ports/messaging/messages:hub_core.runtime.ports.send_message": "hub.hub_core.runtime.ports.send_message.post",
|
||||
"POST:/ports/registry/registrations:hub_core.runtime.ports.register_extension": "hub.hub_core.runtime.ports.register_extension.post",
|
||||
"POST:/requirement-candidates:hub_core.runtime.compat.accept_deferred": "hub.hub_core.runtime.compat.accept_deferred.post",
|
||||
"POST:/token:hub_core.runtime.compat.token": "hub.hub_core.runtime.compat.token.post",
|
||||
"POST:/widgets:hub_core.runtime.compat.create_widget": "hub.hub_core.runtime.compat.create_widget.post"
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
7
tests/fixtures/flex-auth/README.md
vendored
Normal file
7
tests/fixtures/flex-auth/README.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
These public conformance fixtures were copied from flex-auth
|
||||
`examples/secrets-engine/replay/` and `check_request_allow_rotate.json` on
|
||||
2026-09-28. They retain the owner's Go-generated signature and submitted digest.
|
||||
`keys.json` contains a well-known **test-only public key**, not production trust.
|
||||
The old decision is used only to test cryptographic interoperability, never as
|
||||
an active authorization decision. Hub policy lifetime/caller tests use fresh
|
||||
synthetic decisions with ephemeral test keys.
|
||||
23
tests/fixtures/flex-auth/check_request_allow_rotate.json
vendored
Normal file
23
tests/fixtures/flex-auth/check_request_allow_rotate.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "check:secrets-engine-rotate",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {}
|
||||
}
|
||||
118
tests/fixtures/flex-auth/decision_rotate_signed.json
vendored
Normal file
118
tests/fixtures/flex-auth/decision_rotate_signed.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
118
tests/fixtures/flex-auth/decision_rotate_signed_tampered.json
vendored
Normal file
118
tests/fixtures/flex-auth/decision_rotate_signed_tampered.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "deny",
|
||||
"reason": "tampered_after_signing",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
tests/fixtures/flex-auth/keys.json
vendored
Normal file
11
tests/fixtures/flex-auth/keys.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"algorithm": "ed25519",
|
||||
"keys": [
|
||||
{
|
||||
"kid": "testdata-ed25519",
|
||||
"alg": "ed25519",
|
||||
"public_key": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI",
|
||||
"note": "Well-known non-production seed 0x42 repeated. Not a custody path. FLEX-WP-0024-T03 fixtures only."
|
||||
}
|
||||
]
|
||||
}
|
||||
227
tests/test_access_boundary.py
Normal file
227
tests/test_access_boundary.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hub_core.runtime.app import create_app
|
||||
from hub_core.runtime.config import RuntimeSettings
|
||||
from hub_core.runtime.store import InMemoryPortStore
|
||||
from hub_core.security.boundary import (
|
||||
AccessController, Actor, Decision, LiveFacts, iter_routes, route_key,
|
||||
)
|
||||
from hub_core.security.identity import AccessFailure
|
||||
|
||||
|
||||
class Owners:
|
||||
def __init__(self):
|
||||
self.actor = Actor('https://issuer.example', 'immutable-root', 'tenant:platform',
|
||||
'human', 'aal2', int(time.time()), int(time.time()) + 300)
|
||||
self.facts = LiveFacts(self.actor.issuer, self.actor.subject, self.actor.tenant,
|
||||
'tenant:platform', True, True, True, True, time.time(),
|
||||
'owner-receipt', frozenset({'agent:root'}))
|
||||
self.records, self.requests = [], []
|
||||
self.allow = True
|
||||
self.audit_down = False
|
||||
self.policy_down = False
|
||||
|
||||
async def authenticate(self, token):
|
||||
if token != 'verified-root':
|
||||
raise AccessFailure(401, 'invalid_access_token')
|
||||
return self.actor
|
||||
|
||||
async def resolve(self, actor, resource):
|
||||
return self.facts
|
||||
|
||||
async def evaluate(self, request):
|
||||
self.requests.append(request)
|
||||
if self.policy_down:
|
||||
raise ConnectionError('private backend details')
|
||||
return Decision(self.allow, 'decision:1', 'policy:v1')
|
||||
|
||||
async def append(self, record):
|
||||
if self.audit_down:
|
||||
raise ConnectionError('private audit details')
|
||||
self.records.append(record)
|
||||
|
||||
def controller(self):
|
||||
return AccessController(identity=self, facts=self, policy=self, audit=self,
|
||||
root_issuer='https://issuer.example', root_subject='immutable-root')
|
||||
|
||||
|
||||
def runtime(owners=None):
|
||||
return create_app(settings=RuntimeSettings(environment='test', access_mode='enforce'),
|
||||
port_store=InMemoryPortStore(),
|
||||
access_controller=owners.controller() if owners else None)
|
||||
|
||||
|
||||
HEADERS = {'Authorization': 'Bearer verified-root'}
|
||||
CATALOG = json.loads(Path('hub_core/security/routes.json').read_text())['routes']
|
||||
SURFACES = [(key.split(':', 2)[0], key.split(':', 2)[1]) for key in CATALOG]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method,path', SURFACES)
|
||||
def test_every_catalog_surface_denies_anonymous(method, path):
|
||||
with TestClient(runtime()) as client:
|
||||
response = client.request(method, path)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_production_is_closed_without_owner_adapters():
|
||||
app = create_app(settings=RuntimeSettings(environment='production'))
|
||||
with TestClient(app) as client:
|
||||
assert client.get('/healthz').json() == {'status': 'ok'}
|
||||
assert client.get('/readyz').status_code == 401
|
||||
assert client.get('/ports/projections/hub_registry', headers=HEADERS).status_code == 503
|
||||
assert client.get('/healthz/').status_code == 401
|
||||
with pytest.raises(ValueError):
|
||||
RuntimeSettings(environment='production', access_mode='development')
|
||||
|
||||
|
||||
def test_root_access_requires_fresh_facts_and_audit_on_every_request():
|
||||
owners = Owners()
|
||||
with TestClient(runtime(owners)) as client:
|
||||
first = client.get('/ports/projections/hub_registry', headers=HEADERS)
|
||||
assert first.status_code == 200
|
||||
assert first.headers['cache-control'] == 'no-store'
|
||||
assert owners.requests[0].facts.root_entitled
|
||||
owners.facts = replace(owners.facts, root_entitled=False)
|
||||
assert client.get('/ports/projections/hub_registry', headers=HEADERS).status_code == 403
|
||||
assert len(owners.requests) == 1
|
||||
assert owners.records[0]['outcome'] == 'authorized'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change,status', [
|
||||
({'subject': 'ordinary'}, 403), ({'issuer': 'https://other.example'}, 403),
|
||||
({'assurance': 'aal1'}, 403), ({'tenant': 'tenant:other'}, 403),
|
||||
({'expires_at': 1}, 401),
|
||||
])
|
||||
def test_root_cannot_be_claimed_by_name_or_role(change, status):
|
||||
owners = Owners()
|
||||
owners.actor = replace(owners.actor, **change)
|
||||
with TestClient(runtime(owners)) as client:
|
||||
assert client.get('/docs', headers=HEADERS).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change,status', [
|
||||
({'checked_at': 1}, 503), ({'subject': 'different'}, 503),
|
||||
({'account_active': False}, 403), ({'actor_tenant_active': False}, 403),
|
||||
({'target_tenant_active': False}, 403), ({'target_tenant': 'tenant:other'}, 403),
|
||||
])
|
||||
def test_authoritative_account_and_tenant_checks(change, status):
|
||||
owners = Owners()
|
||||
owners.facts = replace(owners.facts, **change)
|
||||
with TestClient(runtime(owners)) as client:
|
||||
assert client.get('/openapi.json', headers=HEADERS).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize('attribute,status', [('allow', 403), ('policy_down', 503), ('audit_down', 503)])
|
||||
def test_denial_and_dependency_failure_never_reach_handler(attribute, status):
|
||||
owners = Owners()
|
||||
setattr(owners, attribute, attribute != 'allow')
|
||||
with TestClient(runtime(owners)) as client:
|
||||
result = client.post('/ports/messaging/messages', headers=HEADERS, json={})
|
||||
assert result.status_code == status
|
||||
assert 'private' not in result.text
|
||||
|
||||
|
||||
def test_new_route_and_wrong_method_remain_denied():
|
||||
owners = Owners()
|
||||
app = runtime(owners)
|
||||
calls = []
|
||||
|
||||
@app.get('/newly-added')
|
||||
def new_route():
|
||||
calls.append(True)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ['/newly-added', '/unknown', '/ports/projections/hub_registry/']:
|
||||
assert client.get(path, headers=HEADERS).status_code == 403
|
||||
assert client.delete('/docs', headers=HEADERS).status_code == 403
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_native_sender_is_bound_and_body_reaches_handler():
|
||||
owners = Owners()
|
||||
body = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a',
|
||||
'from_address': 'agent:root', 'to_addresses': ['agent:reader'], 'body': 'private text'}
|
||||
with TestClient(runtime(owners)) as client:
|
||||
assert client.post('/ports/messaging/messages', headers=HEADERS, json=body).status_code == 202
|
||||
body['from_address'] = 'agent:someone-else'
|
||||
assert client.post('/ports/messaging/messages', headers=HEADERS, json=body).status_code == 403
|
||||
assert 'private text' not in json.dumps(owners.records)
|
||||
|
||||
|
||||
def test_catalog_covers_current_routes_and_does_not_auto_admit():
|
||||
app = runtime()
|
||||
missing = [route_key(r, method) for r in iter_routes(app) if hasattr(r, 'methods')
|
||||
for method in r.methods if r.path != '/healthz' and route_key(r, method) not in CATALOG]
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_concurrent_requests_keep_separate_contexts():
|
||||
owners = Owners()
|
||||
app = runtime(owners)
|
||||
|
||||
async def run():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
|
||||
return await asyncio.gather(*[
|
||||
client.get('/ports/projections/hub_registry', headers=HEADERS,
|
||||
params={'n': n}) for n in range(10)
|
||||
])
|
||||
|
||||
results = asyncio.run(run())
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert len({r.correlation_id for r in owners.requests}) == 10
|
||||
assert len({r.request_digest for r in owners.requests}) == 10
|
||||
|
||||
|
||||
def test_event_provenance_overrides_asserted_producer():
|
||||
from datetime import datetime, timezone
|
||||
owners = Owners()
|
||||
event = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a',
|
||||
'event_type': 'hub.progress.recorded', 'occurred_at': datetime.now(timezone.utc).isoformat(),
|
||||
'subject_refs': {'hub': 'untrusted-business-reference'},
|
||||
'payload': {'_hub_access': {'subject': 'forged'}}}
|
||||
with TestClient(runtime(owners)) as client:
|
||||
assert client.post('/ports/events/progress', headers=HEADERS, json=event).status_code == 202
|
||||
record = client.get('/ports/projections/progress_events', headers=HEADERS).json()
|
||||
item = record['data']['items'][0]
|
||||
assert item['payload']['_hub_access']['subject'] == 'immutable-root'
|
||||
|
||||
|
||||
def test_embedded_router_uses_the_same_boundary():
|
||||
from fastapi import FastAPI
|
||||
from hub_core.security.boundary import AccessBoundary
|
||||
owners = Owners()
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
calls = []
|
||||
@app.get('/embedded')
|
||||
def embedded():
|
||||
calls.append(True)
|
||||
return {'ok': True}
|
||||
route = next(iter(iter_routes(app)))
|
||||
app.add_middleware(AccessBoundary, host=app, controller=owners.controller(),
|
||||
catalog={route_key(route, 'GET'): 'extension.read'})
|
||||
with TestClient(app) as client:
|
||||
assert client.get('/embedded').status_code == 401
|
||||
assert client.get('/embedded', headers=HEADERS).status_code == 200
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor():
|
||||
owners = Owners()
|
||||
with pytest.raises(ValueError):
|
||||
replace(owners.facts, root_entitled='false')
|
||||
with pytest.raises(ValueError):
|
||||
Decision('allow', 'id', 'v1')
|
||||
owners.actor = replace(owners.actor, subject='ordinary')
|
||||
with TestClient(runtime(owners)) as client:
|
||||
assert client.get('/docs', headers=HEADERS).status_code == 403
|
||||
assert owners.records[-1]['subject'] == 'ordinary'
|
||||
102
tests/test_access_identity.py
Normal file
102
tests/test_access_identity.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from hub_core.security.identity import AccessFailure, OIDCVerifier
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def signing_key():
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def setup(signing_key, changes=None, header_changes=None):
|
||||
now = int(time.time())
|
||||
claims = dict(iss='https://issuer.example', sub='immutable-root', aud='hub-core',
|
||||
iat=now, exp=now+300, nbf=now, tenant='tenant:platform',
|
||||
principal_type='human', groups=[], roles=[], scope='openid',
|
||||
assurance=dict(level='aal2', methods=['pwd', 'otp'], mfa=True,
|
||||
source='key-cape', at=now))
|
||||
claims.update(changes or {})
|
||||
headers = {'kid': 'key-1', 'typ': 'at+jwt', **(header_changes or {})}
|
||||
token = jwt.encode(claims, signing_key, algorithm='RS256', headers=headers)
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))
|
||||
jwk.update(kid='key-1', use='sig', alg='RS256')
|
||||
responses = {'discovery': 200, 'keys': [jwk]}
|
||||
|
||||
def handle(request):
|
||||
if request.url.path.endswith('openid-configuration'):
|
||||
return httpx.Response(responses['discovery'], json={
|
||||
'issuer': 'https://issuer.example', 'jwks_uri': 'https://issuer.example/keys',
|
||||
})
|
||||
return httpx.Response(200, json={'keys': responses['keys']})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
|
||||
verifier = OIDCVerifier(issuer='https://issuer.example', audience='hub-core', client=client)
|
||||
return token, verifier, responses, client
|
||||
|
||||
|
||||
def test_accepts_valid_iam_access_token(signing_key):
|
||||
token, verifier, _, client = setup(signing_key)
|
||||
async def run():
|
||||
async with client:
|
||||
actor = await verifier.authenticate(token)
|
||||
assert actor.subject == 'immutable-root'
|
||||
assert actor.tenant == 'tenant:platform'
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('claims,headers', [
|
||||
({'iss': 'https://evil.example'}, {}), ({'aud': 'different'}, {}),
|
||||
({'exp': 1}, {}), ({'nbf': int(time.time())+3600}, {}),
|
||||
({'iat': int(time.time())+3600}, {}), ({'sub': ''}, {}),
|
||||
({'roles': 'platform-root'}, {}), ({'groups': {}}, {}),
|
||||
({'tenant': None}, {}), ({'scope': None}, {}),
|
||||
({'assurance': {'level': 'aal2'}}, {}), ({'principal_type': 'root'}, {}),
|
||||
({'exp': int(time.time())+3600}, {}), ({}, {'typ': 'JWT'}),
|
||||
({}, {'kid': 'unknown'}),
|
||||
({'principal_type': 'agent', 'agent': {'id': 'a', 'mode': 'delegated'}}, {}),
|
||||
])
|
||||
def test_invalid_tokens_are_401(signing_key, claims, headers):
|
||||
token, verifier, _, client = setup(signing_key, claims, headers)
|
||||
async def run():
|
||||
async with client:
|
||||
with pytest.raises(AccessFailure) as result:
|
||||
await verifier.authenticate(token)
|
||||
assert result.value.status == 401
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_key_rotation_removes_old_trust_and_outage_fails_closed(signing_key):
|
||||
token, verifier, responses, client = setup(signing_key)
|
||||
second = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
next_jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(second.public_key()))
|
||||
next_jwk.update(kid='key-2', alg='RS256', use='sig')
|
||||
claims = jwt.decode(token, options={'verify_signature': False})
|
||||
rotated = jwt.encode(claims, second, algorithm='RS256', headers={'kid': 'key-2', 'typ': 'at+jwt'})
|
||||
async def run():
|
||||
async with client:
|
||||
await verifier.authenticate(token)
|
||||
responses['keys'] = [next_jwk]
|
||||
verifier._loaded -= 2
|
||||
await verifier.authenticate(rotated)
|
||||
with pytest.raises(AccessFailure) as result:
|
||||
await verifier.authenticate(token)
|
||||
assert result.value.status == 401
|
||||
responses['discovery'] = 503
|
||||
verifier._loaded = 0
|
||||
with pytest.raises(AccessFailure) as result:
|
||||
await verifier.authenticate(rotated)
|
||||
assert result.value.status == 503
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_untrusted_issuer_configuration_rejected():
|
||||
for issuer in ['http://issuer.example', 'https://localhost', 'https://u:p@example.com']:
|
||||
with pytest.raises(ValueError):
|
||||
OIDCVerifier(issuer=issuer, audience='hub-core', client=None)
|
||||
159
tests/test_access_policy.py
Normal file
159
tests/test_access_policy.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import base64
|
||||
import copy
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from hub_core.security.policy import go_json, parse_json, submitted_digest, verify_decision, verify_signature
|
||||
|
||||
FIXTURES = Path(__file__).parent / 'fixtures/flex-auth'
|
||||
|
||||
|
||||
def test_real_go_signer_fixture_and_tampered_pair():
|
||||
keys = parse_json((FIXTURES / 'keys.json').read_bytes())
|
||||
verify_signature(parse_json((FIXTURES / 'decision_rotate_signed.json').read_bytes()), keys)
|
||||
with pytest.raises(InvalidSignature):
|
||||
verify_signature(parse_json((FIXTURES / 'decision_rotate_signed_tampered.json').read_bytes()), keys)
|
||||
|
||||
|
||||
def test_go_submitted_digest_known_answer():
|
||||
request = parse_json((FIXTURES / 'check_request_allow_rotate.json').read_bytes())
|
||||
envelope = parse_json((FIXTURES / 'decision_rotate_signed.json').read_bytes())
|
||||
assert submitted_digest(request) == envelope['binding']['submitted_request_digest']
|
||||
|
||||
|
||||
def case():
|
||||
now = datetime.now(timezone.utc)
|
||||
request = {'id': 'request:1', 'tenant': 'tenant:platform',
|
||||
'subject': {'id': 'root-sub', 'type': 'human', 'tenant': 'tenant:platform'},
|
||||
'action': 'hub.read', 'resource': {'id': '/docs', 'type': 'hub-route',
|
||||
'system': 'hub-core', 'tenant': 'tenant:platform'},
|
||||
'context': {'http_request_digest': 'request-hash', 'root_entitled': True}}
|
||||
envelope = {'id': 'decision:1', 'contract_version': 'flex-auth.decision-record.v1',
|
||||
'request_id': request['id'], 'effect': 'allow',
|
||||
'resource': copy.deepcopy(request['resource']), 'subject': copy.deepcopy(request['subject']),
|
||||
'binding': {**copy.deepcopy(request), 'submitted_request_digest': submitted_digest(request)},
|
||||
'lifetime': {'kind': 'ttl', 'not_before': now.isoformat(),
|
||||
'expires_at': (now+timedelta(seconds=300)).isoformat()},
|
||||
'provenance': {'policy_version': 'v1', 'policy_package_digest': 'sha256:'+'a'*64,
|
||||
'decision_time': now.isoformat(), 'caller': {
|
||||
'mode': 'enforce', 'principal': 'workload:hub', 'audience': 'flex-auth',
|
||||
'not_after': (now+timedelta(seconds=300)).isoformat()}}}
|
||||
return request, envelope, now
|
||||
|
||||
|
||||
def sign(envelope):
|
||||
key = Ed25519PrivateKey.generate()
|
||||
encode = lambda value: base64.urlsafe_b64encode(value).decode().rstrip('=')
|
||||
envelope.pop('signature', None)
|
||||
signature = key.sign(go_json(envelope))
|
||||
envelope['signature'] = {'mode': 'signed', 'alg': 'ed25519', 'kid': 'test', 'value': encode(signature)}
|
||||
return {'keys': [{'kid': 'test', 'alg': 'ed25519',
|
||||
'public_key': encode(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))}]}
|
||||
|
||||
|
||||
def test_signed_bound_allow():
|
||||
request, envelope, now = case()
|
||||
decision = verify_decision(envelope, request=request, keys=sign(envelope), caller='workload:hub', now=now)
|
||||
assert decision.allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mutation', [
|
||||
lambda d: d.update(effect='redact'), lambda d: d.update(request_id='other'),
|
||||
lambda d: d['binding'].update(submitted_request_digest='sha256:wrong'),
|
||||
lambda d: d['binding'].update(action='destroy'),
|
||||
lambda d: d['binding']['subject'].update(id='other'),
|
||||
lambda d: d['binding']['resource'].update(tenant='tenant:other'),
|
||||
lambda d: d['binding']['resource'].update(id='/secrets'),
|
||||
lambda d: d['binding']['context'].update(root_entitled=False),
|
||||
lambda d: d['lifetime'].update(expires_at='2000-01-01T00:00:00Z'),
|
||||
lambda d: d['lifetime'].update(not_before='2099-01-01T00:00:00Z'),
|
||||
lambda d: d['provenance'].update(decision_time='2000-01-01T00:00:00Z'),
|
||||
lambda d: d['provenance']['caller'].update(mode='warn'),
|
||||
lambda d: d['provenance']['caller'].update(principal='other'),
|
||||
lambda d: d.update(obligations=[{'type': 'approval'}]),
|
||||
])
|
||||
def test_even_authentically_signed_wrong_decisions_are_rejected(mutation):
|
||||
request, envelope, now = case()
|
||||
mutation(envelope)
|
||||
with pytest.raises(ValueError):
|
||||
verify_decision(envelope, request=request, keys=sign(envelope), caller='workload:hub', now=now)
|
||||
|
||||
|
||||
def test_unsigned_untrusted_and_ambiguous_inputs_fail():
|
||||
request, envelope, now = case()
|
||||
keys = sign(envelope)
|
||||
for wrong in ({'keys': []}, {'keys': keys['keys']*2}):
|
||||
with pytest.raises(ValueError):
|
||||
verify_decision(envelope, request=request, keys=wrong, caller='workload:hub', now=now)
|
||||
envelope['signature'] = {'mode': 'unsigned'}
|
||||
with pytest.raises(ValueError):
|
||||
verify_signature(envelope, keys)
|
||||
for raw in ['{"effect":"deny","effect":"allow"}', '{"x":NaN}', '{"x":1.1}']:
|
||||
with pytest.raises(ValueError):
|
||||
parse_json(raw)
|
||||
|
||||
|
||||
def test_policy_client_rotates_workload_credentials_and_retains_no_user_token(tmp_path):
|
||||
import asyncio
|
||||
import time
|
||||
import httpx
|
||||
from hub_core.security.boundary import Actor, Authorization, LiveFacts
|
||||
from hub_core.security.policy import FlexPolicy
|
||||
from hub_core.security.identity import AccessFailure
|
||||
token_file, keys_file = tmp_path/'caller', tmp_path/'keys'
|
||||
token_file.write_text('first-workload-token')
|
||||
seen = []
|
||||
unavailable = False
|
||||
caller = 'system:serviceaccount:hub-core:hub-core'
|
||||
actor = Actor('https://issuer.example', 'root-sub', 'tenant:platform', 'human', 'aal2',
|
||||
int(time.time()), int(time.time())+300)
|
||||
facts = LiveFacts(actor.issuer, actor.subject, actor.tenant, 'tenant:platform',
|
||||
True, True, True, True, time.time(), 'owner:receipt')
|
||||
authorization = Authorization(actor, 'hub.read', '/docs', facts, 'request:1', 'body-hash')
|
||||
|
||||
def handle(request):
|
||||
seen.append(request.headers['authorization'])
|
||||
if unavailable:
|
||||
return httpx.Response(503, text='sensitive backend details')
|
||||
check = json.loads(request.content)
|
||||
_, envelope, _ = case()
|
||||
envelope['subject'], envelope['resource'] = check['subject'], check['resource']
|
||||
envelope['binding'] = {k: v for k, v in check.items() if k != 'id'}
|
||||
envelope['binding']['submitted_request_digest'] = submitted_digest(check)
|
||||
envelope['provenance']['caller']['principal'] = caller
|
||||
# Key publication precedes this call in reality; write the fixture before
|
||||
# evaluation and sign with the corresponding ephemeral test key below.
|
||||
envelope.pop('signature', None)
|
||||
encode = lambda v: base64.urlsafe_b64encode(v).decode().rstrip('=')
|
||||
envelope['signature'] = {'mode': 'signed', 'alg': 'ed25519', 'kid': 'test',
|
||||
'value': encode(signing_key.sign(go_json(envelope)))}
|
||||
assert 'token' not in request.content.decode()
|
||||
return httpx.Response(200, content=go_json(envelope))
|
||||
|
||||
signing_key = Ed25519PrivateKey.generate()
|
||||
keys_file.write_text(json.dumps({'keys': [{'kid': 'test', 'alg': 'ed25519',
|
||||
'public_key': base64.urlsafe_b64encode(signing_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw)).decode().rstrip('=')}]}))
|
||||
|
||||
async def run():
|
||||
nonlocal unavailable
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
|
||||
policy = FlexPolicy(base_url='https://policy.example', client=client, caller=caller,
|
||||
caller_token_file=token_file, trusted_keys_file=keys_file)
|
||||
assert (await policy.evaluate(authorization)).allowed
|
||||
token_file.write_text('second-workload-token')
|
||||
assert (await policy.evaluate(authorization)).allowed
|
||||
unavailable = True
|
||||
with pytest.raises(AccessFailure) as error:
|
||||
await policy.evaluate(authorization)
|
||||
assert error.value.status == 503
|
||||
assert 'sensitive' not in str(error.value)
|
||||
asyncio.run(run())
|
||||
assert seen == ['Bearer first-workload-token', 'Bearer second-workload-token',
|
||||
'Bearer second-workload-token']
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue