diff --git a/docs/decisions/issue-1356-control-plane-participant-access-preflight.md b/docs/decisions/issue-1356-control-plane-participant-access-preflight.md index eb7a6d7cb..338a1252a 100644 --- a/docs/decisions/issue-1356-control-plane-participant-access-preflight.md +++ b/docs/decisions/issue-1356-control-plane-participant-access-preflight.md @@ -80,7 +80,11 @@ are never participant-view fallbacks. This table inventories the optional reference P2 HTTP app. `read` is the existing `_ReadIdentity` role check, `mutate` is `_MutatingIdentity`, and -`resolve` is `_ResolutionIdentity`. All authenticated P2 identities require +`resolve` is `_ResolutionIdentity`. Issue #1359 renamed these dependencies to +`_AdministrativeReadIdentity`, `_AdministrativeMutationIdentity`, and +`_OperatorResolutionIdentity`, the `administrative-read`, +`administrative-mutation`, and `operator-resolution` transport authorities, with +unchanged role sets. All authenticated P2 identities require the exact target binding. These checks authenticate the host's P2 caller; they do not authenticate the host's participant-facing user. The host applies its own caller and participant checks before releasing any result. diff --git a/docs/decisions/issue-1359-runtime-api-trust-boundary-preflight.md b/docs/decisions/issue-1359-runtime-api-trust-boundary-preflight.md new file mode 100644 index 000000000..3dc7cb682 --- /dev/null +++ b/docs/decisions/issue-1359-runtime-api-trust-boundary-preflight.md @@ -0,0 +1,189 @@ +# Issue 1359: Runtime API trust-boundary enforcement preflight + +Status: architecture guidance for implementation. This note applies the +accepted [issue #1356 decision](issue-1356-control-plane-participant-access-preflight.md) +to the current repository. It changes no runtime behavior, requirement, +published contract, or profile guarantee. + +## Decision to implement + +The selected exposure model is **host-mediated, administrative-only P2**. +Participant-facing code does not receive a RAE credential, the +`RuntimeControlPlane` object, or store access. The organizational or local host +authenticates its own caller, selects the exact target/run, participant, +episode, audience and operation, calls RAE with a privileged service identity, +and releases only an authorized governed result. + +`ControlPlaneIdentity` remains the authenticated transport principal. An SDL +participant, runtime actor, controller, audience, deployment tenant and host +user are different concepts. `ParticipantAudienceSubjectBinding` and +`ParticipantControlSubjectBinding` constrain a semantic operation after +transport admission; they do not create a role or authenticate an end user. + +This resolves the issue's “participant-view-limited credential” criterion as +follows: + +- a target-bound identity with participant/audience bindings but without a + `BACKEND`, `OPERATOR` or `AUDITOR` route role is not an admitted P2 reader and + must fail both governed-view and administrative reads; +- an identity with one of those read roles is an administrative service + identity even when it also carries an audience binding. Under the accepted + API-404-C3 contract it can read `/snapshot`; it must never be described or + delegated as a participant-limited credential; and +- creating a participant-only role, episode-scoped token or direct + participant-to-RAE transport would be a different exposure model requiring a + new reviewed decision and API-404/ADR-104 amendment. + +No new authorization schema is required. The incumbent seam is +`control_plane_api/_auth.py`: bearer or explicitly trusted verified-proxy +authentication, exact target binding, then one of the existing read, mutating +or operator-resolution dependencies. New operation or inject routes must enter +through that seam and then invoke their existing core semantic authorization; +they must not implement route-local token parsing, role sets or subject tuples. +Names and documentation at that seam should make its administrative meaning +unambiguous. The route role is transport admission, never disclosure, +controller or backend-effect authority by itself. + +Configuration is part of that boundary. Python annotations on +`ControlPlaneIdentity` are not runtime admission: the current class itself does +not fully reject untyped roles, malformed target values, non-tuple subject +collections or overlong actor/scope components. Validate the exact closed +identity and binding shape once when `ControlPlaneSecurityConfig` copies and +freezes its principal maps, against the existing operation-context and audit +bounds. Do not defer malformed trusted configuration until a request, operation +commit or audit write, and do not compensate with route-local checks. + +## Current route and authority matrix + +Every current route that reveals runtime state or can cause an effect is +privileged. Only the value-free probes and framework descriptions are public. + +| Surface | Required P2 authority | Additional core/release boundary | +| --- | --- | --- | +| `GET /health/live`, `GET /health/ready` | Public, value-free probe | Preserve the closed health payload. No target, run, actor, operation, revision, count, path or provider value. | +| `/openapi.json`, `/docs`, `/redoc` | Public framework description on the administration network | Describes capability, not permission. A deployment may disable or network-restrict it; it cannot reveal runtime state. | +| `GET /snapshot`, `GET /apparatus/operational-summary` | Administrative read: `BACKEND`, `OPERATOR` or `AUDITOR`, exact target | Raw snapshots, histories, diagnostics and summaries never become participant views. | +| `GET /operations/{operation_id}` | Administrative read, exact target | `RuntimeControlPlane.get_operation()` additionally requires the original actor and complete immutable authorization scope; unknown and unauthorized IDs remain indistinguishable. | +| `POST /operations/{provisioning,orchestration,evaluation}` | Administrative mutation: `BACKEND` or `OPERATOR`, exact target | Reuse planner authorization, typed plan validation, immutable operation context, actor-scoped claim, idempotency and one mutation authority. | +| `POST /operations/{operation_id}/resolution` | `OPERATOR`, exact target | Reauthorize the linked parent scope; administrative disposition is not participant recovery or effect proof. | +| `POST /workflows/{workflow_address}/cancel`, `POST /workflows/reconcile-timeouts` | Administrative mutation | Reuse workflow/run admission, operation context and idempotent receipt. | +| `POST /participant-executions/{execution_scope_ref}/control`, `GET /participant-executions/{execution_scope_ref}` | Administrative mutation/read | Execution scope is service state, not an API-408 projection or audience entitlement. | +| `POST /participants/{participant_address}/episodes/{initialize,reset,restart,terminate}` | Administrative mutation | Participant and optional episode values are request data. Core lifecycle/run checks own their meaning; they are not credential scope. | +| `POST /participants/{participant_address}/control-occurrences` | Administrative mutation | Core additionally requires the exact participant/controller subject binding and API-409/RUN-310 admission. Audience binding is not control authority. | +| `GET /participants/{participant_address}/status`, `GET /participants/{participant_address}/episodes/{episode_id}/history`, `GET /participants/{participant_address}/context` | Administrative read | With a crossing resolver, require one exact participant/audience binding and commit API-423/RUN-319 crossing evidence before serialization. The episode and source cut are validated by retrieval/crossing contracts. Without a resolver this is a legacy administrative projection and must not be released to a participant. | + +There is no P2 raw audit, event-stream, callback, export or cache route. A new +route in any of those families inherits the authority of the data/effect it +exposes. Per-item authorization is required for streams and callbacks; checking +only subscription creation is insufficient. A future external inject trigger is +an administrative mutation that creates an actor-bound operation. Optional +participant delivery remains a later, separately governed API-423 crossing; an +authored inject, operation receipt or successful effect does not grant that +delivery. + +The P0/P1 SDK boundary remains trusted object possession. HTTP dependencies do +not protect direct Python calls. `get_snapshot()`, `snapshot`, `audit_log()`, +`observation_execution()`, participant control/action methods, directed-view +delivery and direct store reads are privileged host methods, not alternate +participant transports. + +## Canonical incumbents and cross-cutting gates + +| Layer | Canonical incumbent and required use | +| --- | --- | +| Authentication and route admission | `raes_runtime/control_plane_security.py`, `control_plane_api/_auth.py`, `ControlPlaneSecurityConfig.strict_defaults()`. Preserve immutable credential maps, constant-time bearer comparison, hard failure for an invalid presented bearer token, explicit proxy-header trust, valid/distinct non-Authorization header names and exact target equality. Admit exact `ControlPlaneRole` values and exact, bounded identity/subject-binding shapes at configuration time; annotations and later operation/audit failures are not configuration validation. | +| Transport shape and bounded execution | `create_control_plane_app()`, `RequestSizeLimitMiddleware`, `_ControlPlaneCallExecutor`, closed FastAPI/Pydantic request models, `control_plane_store.require_idempotency_key()` and the single-worker gate. Body limits do not bound the request line, path, query or headers. Reuse the owning address/reference validators where they match; otherwise add one bounded transport-shape check at ingress rather than duplicating semantic validation in routes. Server/proxy request-line and header limits remain mandatory. A future inject route also inherits the bounded, duplicate-aware JSON ingress and exact carrier validation required by the [issue #1353 preflight](../explain/sdl/issue-1353-external-inject-triggering-preflight.md); a permissive body model is not an authorization or shape gate. | +| Operation identity, readback and retry | `control_plane_operation_context.py`, `control_plane_admission.py`, `control_plane_store.require_idempotency_key()`, atomic claims and `RuntimeControlPlane.get_operation()`. Persist the actor and complete authorization scope; authorize before returning a retained receipt/status or idempotent replay. Operation IDs, idempotency keys and request commitments are identifiers, not bearer authority. | +| Participant disclosure | `participant_retrieval.py`, `control_plane_api_participant_retrieval.py`, `participant_crossing_egress.py`, `participant_flow_sink.py`, API-408 models in `raes_contracts/contracts/participant_views.py` and API-423 contextual validation in `participant_crossing_validation.py`. Project and validate before serialization; never serialize a full snapshot and filter it afterward. | +| Participant control and inject composition | `participant_control_mediation.py`, `participant_control_orchestration.py`, API-409 carriers and compiled DSL-142/API-423 bindings. Keep effect authority, controller authority, participant disclosure and delivery as separate gates. Do not make participant fields optional on an administrative operation carrier to reuse it as a view. | +| DTOs and schemas | Existing `OperationReceiptModel`, `OperationStatusModel`, `RuntimeSnapshotEnvelopeModel`, participant view models, route request DTOs and generated `contracts/schemas/control-plane/` artifacts. Authorization is internal adapter policy, not a second HTTP DTO or published participant schema. | +| Persistence and concurrency | `ControlPlaneStore`, `control_plane_store_local*`, `RuntimeMutationAuthority`, revision CAS, owner lease, strict codecs and atomic terminal operation/audit commit. No participant store, authorization cache, second event writer or route-owned persistence transaction. | +| Errors and diagnostics | `control_plane_api/_responses.py`, the redacted 422/500 handlers, `portable_diagnostic_payload()`, `backend_result_diagnostics.py` and existing stable 403/404/409 details. Never expose `str(exc)`, rejected values, provider payloads, paths, SQL, headers, tokens or traceback chains. Secondary audit failure cannot replace the selected denial. | +| Audit and logging | `control_plane_audit.py` and terminal operation audit. Use bounded action/reason codes and safe identifiers only. Authentication/rejection/read audits stay distinct from atomic terminal audit. Proxy, ASGI and backend logs obey the same disclosure rule. | +| Configuration, secrets and OS boundary | `ControlPlaneSecurityConfig` is supplied by trusted composition; `require_single_worker_configuration()`, `control_plane_store_paths.py` and the local-store lease retain process/filesystem admission. This issue adds no SDL field, environment parser, token endpoint or CLI credential. If a later deployment artifact represents secret or environment inputs, reuse `raes_contracts/secret_references.py` and `raes/runtime_environment.py`/`runtime_configuration.py` rather than inventing a token env grammar. Keep credentials out of URLs, argv, shell history/tracing, environment dumps, fixtures, versioned files, access logs, crash reports and backups. Retain private store permissions, one worker, reload disabled, TLS termination, request-line/header limits and an administration/service network; disable or redact proxy/ASGI access logging that would capture sensitive selectors. | +| Repository workflow | `.ground-control.yaml`, `.gc/plan-rules.md`, `tools/check_repo_policy.py`, targeted control-plane tests and CI full suites. Published-schema changes, if ever justified, use the existing schema publication manifest/generator; ADR amendments use the ADR index and immutability tooling. | + +### Cached, replayed and alternate outputs + +Authorization applies at every output, not only at the first computation. +Authenticated runtime responses, including errors and idempotent readback, +must carry a shared `Cache-Control: no-store` policy at the P2 application +boundary so a route cannot omit it accidentally. Deployment proxies must not +cache authenticated responses or key them only by URL. Host-side caches remain +outside RAE and must be scoped by caller/session, target/run, participant, +episode, audience, policy and source revision, with reauthorization on every +release and invalidation on handoff, revocation or state-cut change. + +The retained result of an idempotent governed GET remains bound to its original +actor/scope, crossing subject and source cut. Do not relabel it with a current +revision, use `X-RAES-Snapshot-Revision` as authorization, or bypass the +crossing because bytes already exist. Errors, redirects, conditional responses, +future stream items and callbacks are disclosure surfaces too. No response may +fall back to a snapshot, raw operation diagnostic or provider error when a +projection fails. + +## Extensibility seam + +The extension seam has two independent parameters: + +1. the route selects one existing transport authority: public value-free, + administrative read, administrative mutation or operator resolution; and +2. the core operation supplies any additional typed subject policy required by + its semantics: original operation actor/scope, participant/controller, or + participant/audience/exact-cut crossing. + +That split lets a new operation, inject, export or stream route reuse P2 +authentication without editing every incumbent route, and lets a new governed +carrier reuse API-423 without turning audience into a transport role. A future +inject route takes only this transport-authority parameter from #1359; its +authored binding, occurrence, payload, capability, supervision, effect evidence +and optional delivery semantics remain owned by the #1353/ADR-112 boundary. A +future direct-participant exposure mode would be a new first parameter and is +deliberately unavailable; it cannot be smuggled in through a nullable binding, +role alias, route tag or configuration flag. + +## Verification guardrails + +Evidence must exercise the real ASGI/HTTP boundary, not call auth helpers or +core methods alone. The route matrix needs positive administrative cases and +negative unauthenticated, wrong-target, wrong-role and no-role audience-bound +cases for every route family. It must also cover cross-actor operation readback, +cross-participant governed retrieval, ambiguous/missing audience binding, +episode mismatch, legacy no-resolver posture, controller mismatch, exact retry +versus conflicting idempotency reuse, malformed configured principals, bounded +non-body selectors, redacted error paths, no-store headers and route-inventory +drift when a new route is registered. Public probes must remain value-free. +Framework routes must not be counted as authenticated runtime APIs. + +The test identity with a read role plus audience binding should explicitly prove +that it is broad administrative authority under the accepted model. The +no-role, audience-bound identity is the negative “participant-limited” case. +This prevents tests or deployment examples from silently asserting a narrower +credential contract than the runtime enforces. + +## Non-goals and anti-patterns + +This issue does not add organizational IAM, tenancy, host-user sessions, direct +participant authentication, a participant role, an episode/run token, TLS, a +serve command, a credential loader, multi-worker coordination, P3, an event +API, a response cache, or a new published carrier. Backend-owned organizational +authentication and policy stay at the host boundary. + +Avoid: + +- treating audience/controller bindings, URL selectors, operation IDs, + revisions, receipts or idempotency keys as credentials; +- copying role checks into route modules or adding a second principal, + exception, audit, DTO, validation or persistence hierarchy; +- granting `AUDITOR` mutation authority or treating `BACKEND` as participant + disclosure authority; +- filtering snapshots after serialization, returning raw diagnostics on + projection failure, or using 403/404 differences as a host-user oracle; +- accepting a caller-supplied identity, participant, episode, audience or + policy claim without joining it to trusted configured/compiled/runtime + state; +- treating type annotations as runtime configuration validation, or treating a + body-size limit as a bound on credentials, request lines, selectors or access + logs; and +- weakening the resolver, final-sink, revision, single-writer or atomic-audit + boundaries to make a route appear read-only or noncontending. diff --git a/docs/explain/sdl/runtime-architecture.md b/docs/explain/sdl/runtime-architecture.md index aeae0de6f..825003adf 100644 --- a/docs/explain/sdl/runtime-architecture.md +++ b/docs/explain/sdl/runtime-architecture.md @@ -673,6 +673,15 @@ applies its own caller boundary. The accepted [route and deployment boundary](../../decisions/issue-1356-control-plane-participant-access-preflight.md) also covers operation readback, histories, errors, caches and events. +Every served P2 route declares exactly one transport authority: public probe, +administrative read, administrative mutation or operator resolution. +`create_control_plane_app()` refuses a route with none or several, and exposes +the `(method, path)` inventory as `app.state.control_plane_route_authority`. +Participant, audience, controller and operation-actor checks follow in the +core. Every response carries `Cache-Control: no-store`. The +[deployment guide](../../public/guides/control-plane.md) covers the host's +duties. + ## Current Scope The current runtime scope includes: diff --git a/docs/public/guides/control-plane.md b/docs/public/guides/control-plane.md new file mode 100644 index 000000000..04f7f4b55 --- /dev/null +++ b/docs/public/guides/control-plane.md @@ -0,0 +1,145 @@ +# Serve the runtime control plane + +The optional P2 HTTP adapter serves one durable P1 control-plane core. It is an +administration and service interface for a trusted host application. It is not +a participant API. + +[API-404](https://github.com/OpenRAE/rae/blob/main/docs/requirements/API-404/requirement.md) +owns the guarantees. The +[trust-boundary decision](https://github.com/OpenRAE/rae/blob/main/docs/decisions/issue-1356-control-plane-participant-access-preflight.md) +owns the route and release rules. Follow them if this guide seems to differ. + +## Keep participants behind your host + +Participant clients call your application, not RAES. Your host authenticates +its own caller. It selects the run, participant, exact episode, audience, and +operation that caller may use. It calls RAES with a private service identity. +It releases only an authorized, governed result. + +Never give a browser, participant agent, plugin, or mobile client a P2 token or +proxy identity. Never give one the `RuntimeControlPlane` object or its store. +Possession of any of these is administrative access. + +RAES does not authenticate your end users. An organizational host keeps its +users, groups, tenants, sessions, and policy. A local host applies its own +caller boundary. A P2 identity is not an SDL participant, controller, or role. + +## Know what each route admits + +Every served route declares exactly one transport authority. The adapter +refuses to start if a route declares none or more than one. Each authority also +serves only its own HTTP methods: `public-probe` and `administrative-read` serve +only `GET`, and the other two serve only `POST`, `PUT`, `PATCH`, or `DELETE`. + +| Authority | Admitted roles | Routes | +| --- | --- | --- | +| `public-probe` | none | `GET /health/live`, `GET /health/ready` | +| `administrative-read` | backend, operator, auditor | `GET /snapshot`, `/apparatus/operational-summary`, `/operations/{operation_id}`, `/participant-executions/{execution_scope_ref}`, and participant `status`, `history`, and `context` views | +| `administrative-mutation` | backend, operator | Operation submission, workflow cancellation and timeouts, participant episode lifecycle, control occurrences, and participant execution control | +| `operator-resolution` | operator | `POST /operations/{operation_id}/resolution` | + +Every authenticated route also requires an identity bound to this exact +target. FastAPI's `/openapi.json`, `/docs`, and `/redoc` describe the API. They +carry no runtime state. + +The core applies further checks after admission: + +- Operation readback requires the original actor and authorization scope. + Another actor gets the same `404` as an unknown operation. +- A control occurrence requires a matching participant/controller binding. +- With a crossing-policy resolver, a participant view requires exactly one + matching participant/audience binding. RAES commits the API-423 crossing + before it writes the view. + +A read role admits the full snapshot, even when the identity also carries an +audience binding. An identity with bindings but no role is refused on every +route. There is no participant-limited P2 credential. + +`app.state.control_plane_route_authority` maps each `(method, path)` to its +authority. Use it to build a proxy allowlist for your host's outbound calls. + +## Configure service identities + +```python +from raes_runtime import ControlPlaneProfile +from raes_runtime.control_plane_api import create_control_plane_app +from raes_runtime.control_plane_security import ( + ControlPlaneIdentity, + ControlPlaneRole, + ControlPlaneSecurityConfig, + ParticipantAudienceSubjectBinding, +) + +host_service = ControlPlaneIdentity( + identity="host-service", + roles=frozenset({ControlPlaneRole.BACKEND}), + target_name=control_plane.target_name, + participant_audience_subjects=( + ParticipantAudienceSubjectBinding("participant.alice", "audience:alice-console"), + ), +) +security = ControlPlaneSecurityConfig( + bearer_tokens={secret_store.read("raes-host-token"): host_service}, +) +app = create_control_plane_app(control_plane, security=security, profile=ControlPlaneProfile.P2) +``` + +Load each bearer token from your deployment's secret channel. Use a frozenset +for roles and tuples for bindings. The configuration refuses mistyped or +mutable authority fields. It also refuses an identity name or binding set that +exceeds the operation record limits: 256 characters per name or scope entry, +and 64 scope entries. A participant address in a binding cannot contain `:`. +It refuses a token or identity name with leading or trailing whitespace, so +strip a trailing newline from a secret file before you use it. The two trust +flags must be real `bool` values, and the size and queue limits must be `int` +values; parse strings from environment variables or YAML before you pass them. +An unknown bearer token fails with `401`. It never falls back to proxy headers. + +Every transport-admission refusal returns the same body for its status: `401` +is always `unauthorized`, and `403` is always `forbidden`. The specific reason +goes only to the audit log. A malformed request body gets `422` only after the +caller is admitted. + +Enable `trust_proxy_identity_headers` only behind a proxy that strips +client-supplied identity headers and sets verified ones. +`ControlPlaneSecurityConfig.strict_defaults()` trusts no token or header. + +## Release participant views safely + +- Configure a crossing-policy resolver before any participant-facing use. + Without one, `status`, `history`, and `context` return legacy administrative + projections. Do not release them to a participant. +- Check the returned participant, episode, and source state cut against your + selection before release. +- Reauthorize every release, including cached, retried, and replayed results. + A same-key replay returns the retained view to its original actor only. +- Never turn a snapshot, operation record, history, receipt, or error into a + participant view by filtering it. + +## Deploy the adapter + +- Serve P2 on an administration or service network. Participants must not + reach its socket. +- End TLS at your proxy. Authenticate service callers there as well. +- Run one worker with reload disabled. The adapter refuses a multi-worker + environment. +- Keep tokens out of URLs, process arguments, environment dumps, logs, crash + reports, fixtures, backups, and SDL. +- Every response carries `Cache-Control: no-store`. Do not configure proxy + caching for this API. +- Scope any host cache by caller or session, run, participant, episode, + audience, policy, and source revision. Invalidate it when any of them changes. +- Return your own participant-safe errors. Do not forward RAES errors, and do + not let `403` or `404` reveal whether another participant exists. +- Keep the store directory private. Treat store backups as privileged. + +## Add a route to the adapter + +Register the route inside `create_control_plane_app()`, before the adapter +checks its route inventory. Do not change the returned app. Its routes, +middleware, exception handlers, and dependency overrides are sealed at +construction; after any change, startup fails and every request gets a redacted +`500`. Declare one transport authority with the dependencies in +`raes_runtime.control_plane_api._auth`. A public probe is GET-only and must +return no runtime value. Then apply the operation's own subject policy in the +core. A stream, callback, or export must authorize every item it releases. diff --git a/docs/public/index.md b/docs/public/index.md index 90e7e076d..538e0fa68 100644 --- a/docs/public/index.md +++ b/docs/public/index.md @@ -16,7 +16,8 @@ about five minutes and uses the Python package. - **Controlling participant input or output?** Use the [participant-control guide](participant-control.md). - **Integrating RAES?** Choose the [Python API](guides/python.md) or - [command-line interface](guides/cli.md). + [command-line interface](guides/cli.md). To serve the runtime over HTTP, read + [serve the runtime control plane](guides/control-plane.md). - **Building a backend?** Read the [backend and conformance guide](backends.md). - **Evaluating the research?** Start with the [research context](research.md), [current limits](limitations.md), and [citation](citation.md). @@ -35,6 +36,7 @@ sdl/index participant-control guides/python guides/cli +guides/control-plane backends research limitations diff --git a/docs/requirements/API-404/requirement.md b/docs/requirements/API-404/requirement.md index 883074d79..4ae390a9e 100644 --- a/docs/requirements/API-404/requirement.md +++ b/docs/requirements/API-404/requirement.md @@ -6,7 +6,7 @@ type: FUNCTIONAL priority: MUST wave: 1 created_at: 2026-04-03T05:55:58.825305Z -updated_at: 2026-09-23T00:00:00.000000Z +updated_at: 2026-09-26T00:00:00.000000Z --- # API-404 — Secure, Durable, And Idempotent Control-Plane Semantics @@ -67,6 +67,17 @@ selected P1 core. The corresponding runtime guarantee identifiers are `owner-serialized-mutation`, and `revision-carrying-reads`. Only P2 authenticates transport callers; P0 and P1 rely on their trusted embedders. +Every served P2 route shall declare exactly one transport authority: a +value-free GET-only public probe, GET-only administrative read, or a +state-changing-method administrative mutation or operator resolution. P2 +composition shall fail when any other route is registered, and a served app +shall refuse startup and every request when its routes, middleware, exception +handlers or dependency overrides differ from the composed set. Every +P2 response, including errors and idempotent readback, shall carry +`Cache-Control: no-store`. Transport-admission refusals shall not reveal why +admission failed, and no refusal shall reveal whether an operation outside the +caller's authority exists. + P2 identities are deployment-configured bearer tokens or verified proxy identities, not RAES-issued participant credentials. A host may use one as a service identity. Read-role admission to an API-408 participant projection @@ -131,6 +142,18 @@ identifies those implementation gaps and the retained canonical requirements. ## Traceability +- DOCUMENTS → GITHUB_ISSUE `1359` (Enforce the accepted runtime API trust boundary) +- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1359-runtime-api-trust-boundary-preflight.md` (Administrative-only P2 enforcement guardrails and route authority matrix) +- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_security.py` (Route transport-authority roles and HTTP methods, unambiguous subject bindings, and fail-closed principal, credential, trust-flag and limit validation) +- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api/_auth.py` (One declared method-bound transport authority per served route, fail-closed route inventory, sealed app composition, and non-revealing refusals) +- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api/__init__.py` (Route-authority inventory and composition seal enforced at app construction and middleware-stack build, and exposed to the embedder) +- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api_guards.py` (Application-wide `Cache-Control: no-store` boundary and per-request sealed-composition refusal) +- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api/_operation_routes.py` (No-store and seal installation, uncacheable redacted 500 envelope, admission before request-validation errors, and resolution refusal indistinguishable from an unknown operation) +- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api_participant_retrieval.py` (Participant views admitted through the shared administrative-read authority) +- DOCUMENTS → DOCUMENTATION `docs/public/guides/control-plane.md` (P2 deployment guidance and host responsibilities) +- TESTS → TEST `implementations/python/tests/test_issue_1359_runtime_api_trust_boundary.py` (ASGI-boundary route inventory, method binding, composition seal, per-route admission, non-revealing refusals, governed and legacy views, replay scope, no-store and configuration checks) +- TESTS → TEST `implementations/python/tests/test_issue_1179_startup_reconciliation.py` (HTTP resolution refusal indistinguishable from an unknown operation) + - DOCUMENTS → GITHUB_ISSUE `1356` (Control-plane and participant-access trust boundary) - DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1356-control-plane-participant-access-preflight.md` (Accepted exposure model, route authority matrix, deployment and crossing guardrails) diff --git a/implementations/python/packages/raes_runtime/control_plane_api/__init__.py b/implementations/python/packages/raes_runtime/control_plane_api/__init__.py index 8590828ad..6b8fe1a86 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api/__init__.py +++ b/implementations/python/packages/raes_runtime/control_plane_api/__init__.py @@ -3,7 +3,10 @@ This package is a thin facade over cohesive route families: * :mod:`._responses` - shared response-code declarations and the receipt builder. -* :mod:`._auth` - authentication, authorization, and identity dependencies. +* :mod:`._auth` - authentication, per-route transport authority, and identity + dependencies. App construction fails unless every served route declares one + authority; the resulting ``(method, path)`` inventory is exposed as + ``app.state.control_plane_route_authority``. * :mod:`._operation_routes` - request guards and operation submission/read routes. * :mod:`._workflow_routes` - workflow cancellation and timeout reconciliation. * :mod:`._participant_routes` - participant execution, control, and episode routes. @@ -27,8 +30,10 @@ from fastapi import FastAPI from starlette.concurrency import run_in_threadpool +from starlette.types import ASGIApp from ..control_plane import RuntimeControlPlane +from ..control_plane_api_guards import refuse_unsealed_request from ..control_plane_api_participant_retrieval import register_participant_retrieval_routes from ..control_plane_profiles import ( ControlPlaneCapability, @@ -38,7 +43,12 @@ ) from ..control_plane_security import ControlPlaneSecurityConfig from ..control_plane_store_lease import require_single_worker_configuration -from ._auth import _ControlPlaneApiAuth +from ._auth import ( + _ControlPlaneApiAuth, + _require_route_transport_authority, + _require_sealed_app_composition, + _seal_app_composition, +) from ._health_routes import _register_health_routes from ._offload import _ControlPlaneCallExecutor from ._operation_routes import _install_request_guards, _register_operation_routes @@ -76,6 +86,20 @@ def _control_plane_api_version() -> str: return "0.0.0+unknown" +class _ControlPlaneFastAPI(FastAPI): + """FastAPI app that serves only the composition checked at construction.""" + + def build_middleware_stack(self) -> ASGIApp: + # Middleware and exception handlers are fixed when the stack is built, so + # this is the last point at which a post-construction addition can be + # refused; the stack then refuses lifespan startup and every request. + try: + _require_sealed_app_composition(self) + except RuntimeError: + return refuse_unsealed_request + return super().build_middleware_stack() + + def create_control_plane_app( control_plane: RuntimeControlPlane, *, @@ -100,14 +124,15 @@ def create_control_plane_app( executor = _ControlPlaneCallExecutor(max_pending_mutations=security.max_pending_mutations) @asynccontextmanager - async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + async def lifespan(served: FastAPI) -> AsyncIterator[None]: try: + _require_sealed_app_composition(served) yield finally: await executor.close() await run_in_threadpool(control_plane.close) - app = FastAPI( + app = _ControlPlaneFastAPI( title="RAES Runtime Control Plane", version=_control_plane_api_version(), description="Reference HTTP/JSON adapter over the repo-owned runtime control plane.", @@ -124,4 +149,6 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: _register_participant_control_routes(app, control_plane) _register_participant_execution_routes(app, control_plane) register_participant_retrieval_routes(app, control_plane) + app.state.control_plane_route_authority = _require_route_transport_authority(app) + _seal_app_composition(app) return app diff --git a/implementations/python/packages/raes_runtime/control_plane_api/_auth.py b/implementations/python/packages/raes_runtime/control_plane_api/_auth.py index 48486fb58..04589bd6c 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api/_auth.py +++ b/implementations/python/packages/raes_runtime/control_plane_api/_auth.py @@ -1,21 +1,46 @@ -"""Authentication, authorization, and FastAPI identity dependencies for the control plane.""" +"""Authentication, authorization, and FastAPI identity dependencies for the control plane. + +Every served route declares exactly one :class:`ControlPlaneRouteAuthority` +through one of the dependencies below (issue #1359). P2 is administrative-only: +the authenticated authorities admit privileged, target-bound service identities, +never participant-limited credentials. Core operations apply any participant, +audience, controller or operation-actor policy after this admission. +""" from __future__ import annotations import hmac import logging +from collections.abc import Callable, Mapping +from types import MappingProxyType from typing import Annotated -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.routing import APIRoute +from starlette.routing import Route from ..control_plane import RuntimeControlPlane from ..control_plane_security import ( + ROUTE_AUTHORITY_METHODS, + ROUTE_AUTHORITY_ROLES, ControlPlaneIdentity, ControlPlaneRole, + ControlPlaneRouteAuthority, ControlPlaneSecurityConfig, ) _LOGGER = logging.getLogger(__name__) +_UNAUTHORIZED_DETAIL = "unauthorized" +_FORBIDDEN_DETAIL = "forbidden" + + +class _AdmissionRejected(Exception): + """An admission refusal whose specific reason is audited, never returned.""" + + def __init__(self, status_code: int, reason: str) -> None: + super().__init__(reason) + self.status_code = status_code + self.reason = reason class _ControlPlaneApiAuth: @@ -27,40 +52,22 @@ def __init__( self._control_plane = control_plane self._security = security - def mutating_identity(self, request: Request) -> ControlPlaneIdentity: - identity = self._authenticated_identity(request) - return self._authorize( - identity, - roles={ControlPlaneRole.BACKEND, ControlPlaneRole.OPERATOR}, - request=request, - ) + def admit(self, request: Request, authority: ControlPlaneRouteAuthority) -> ControlPlaneIdentity: + """Authenticate the caller and require a role admitted by ``authority``.""" - def read_identity(self, request: Request) -> ControlPlaneIdentity: identity = self._authenticated_identity(request) - return self._authorize( - identity, - roles={ - ControlPlaneRole.BACKEND, - ControlPlaneRole.OPERATOR, - ControlPlaneRole.AUDITOR, - }, - request=request, - ) - - def resolution_identity(self, request: Request) -> ControlPlaneIdentity: - identity = self._authenticated_identity(request) - return self._authorize( - identity, - roles={ControlPlaneRole.OPERATOR}, - request=request, - ) + return self._authorize(identity, roles=ROUTE_AUTHORITY_ROLES[authority], request=request) def _authenticated_identity(self, request: Request) -> ControlPlaneIdentity: + # Every refusal returns one stable body per status: the audited reason + # would otherwise tell an unauthenticated caller how proxy trust is + # configured, or confirm that a token is valid for another target. try: return self._authenticate_request(request) - except HTTPException as exc: - self._record_denial(request, str(exc.detail)) - raise + except _AdmissionRejected as exc: + self._record_denial(request, exc.reason) + detail = _UNAUTHORIZED_DETAIL if exc.status_code == 401 else _FORBIDDEN_DETAIL + raise HTTPException(status_code=exc.status_code, detail=detail) from None def _authenticate_request(self, request: Request) -> ControlPlaneIdentity: authorization = request.headers.get("authorization", "") @@ -72,17 +79,17 @@ def _authenticate_request(self, request: Request) -> ControlPlaneIdentity: # working whenever header identities are trusted, and would leave the # rejected credential out of the audit log entirely. if identity is None: - raise HTTPException(status_code=401, detail="invalid bearer token") + raise _AdmissionRejected(401, "invalid bearer token") return self._require_target_binding(identity) if not self._security.trust_proxy_identity_headers: - raise HTTPException(status_code=401, detail="trusted proxy identity headers are not enabled") + raise _AdmissionRejected(401, "trusted proxy identity headers are not enabled") identity_name = request.headers.get(self._security.identity_header, "") verified = request.headers.get(self._security.verified_header, "").lower() if self._security.require_verified_identity and verified != "true": - raise HTTPException(status_code=401, detail="verified client identity required") + raise _AdmissionRejected(401, "verified client identity required") identity = self._security.trusted_identities.get(identity_name) if identity is None: - raise HTTPException(status_code=401, detail="unknown client identity") + raise _AdmissionRejected(401, "unknown client identity") return self._require_target_binding(identity) def _resolve_bearer_identity(self, token: str) -> ControlPlaneIdentity | None: @@ -102,14 +109,14 @@ def _require_target_binding(self, identity: ControlPlaneIdentity) -> ControlPlan """Require an identity scoped exactly to this control-plane target.""" if identity.target_name != self._control_plane.target_name: - raise HTTPException(status_code=403, detail="identity is not authorized for this target") + raise _AdmissionRejected(403, "identity is not authorized for this target") return identity def _authorize( self, identity: ControlPlaneIdentity, *, - roles: set[ControlPlaneRole], + roles: frozenset[ControlPlaneRole], request: Request, ) -> ControlPlaneIdentity: if not identity.roles.isdisjoint(roles): @@ -120,7 +127,7 @@ def _authorize( allowed=False, reason="forbidden", ) - raise HTTPException(status_code=403, detail="forbidden") + raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL) def _record_denial(self, request: Request, reason: str) -> None: self._record_audit_safely( @@ -140,18 +147,131 @@ def _record_audit_safely(self, **fields: object) -> None: _LOGGER.error("control-plane-auth-audit-failed") -def _mutating_identity_dependency(request: Request) -> ControlPlaneIdentity: - return request.app.state.control_plane_api_auth.mutating_identity(request) +def _public_probe_dependency() -> None: + """Declare a value-free public probe; it admits no identity.""" + + +def _administrative_read_dependency(request: Request) -> ControlPlaneIdentity: + return request.app.state.control_plane_api_auth.admit(request, ControlPlaneRouteAuthority.ADMINISTRATIVE_READ) + + +def _administrative_mutation_dependency(request: Request) -> ControlPlaneIdentity: + return request.app.state.control_plane_api_auth.admit( + request, + ControlPlaneRouteAuthority.ADMINISTRATIVE_MUTATION, + ) + + +def _operator_resolution_dependency(request: Request) -> ControlPlaneIdentity: + return request.app.state.control_plane_api_auth.admit(request, ControlPlaneRouteAuthority.OPERATOR_RESOLUTION) + +_PublicProbe = Depends(_public_probe_dependency) +_AdministrativeReadIdentity = Annotated[ControlPlaneIdentity, Depends(_administrative_read_dependency)] +_AdministrativeMutationIdentity = Annotated[ControlPlaneIdentity, Depends(_administrative_mutation_dependency)] +_OperatorResolutionIdentity = Annotated[ControlPlaneIdentity, Depends(_operator_resolution_dependency)] + +_DEPENDENCY_AUTHORITY: Mapping[Callable[..., object], ControlPlaneRouteAuthority] = MappingProxyType( + { + _public_probe_dependency: ControlPlaneRouteAuthority.PUBLIC_PROBE, + _administrative_read_dependency: ControlPlaneRouteAuthority.ADMINISTRATIVE_READ, + _administrative_mutation_dependency: ControlPlaneRouteAuthority.ADMINISTRATIVE_MUTATION, + _operator_resolution_dependency: ControlPlaneRouteAuthority.OPERATOR_RESOLUTION, + } +) + + +def _declared_route_authority(route: APIRoute) -> ControlPlaneRouteAuthority: + declared = [ + _DEPENDENCY_AUTHORITY[dependency.call] + for dependency in route.dependant.dependencies + if dependency.call in _DEPENDENCY_AUTHORITY + ] + if len(declared) != 1: + raise ValueError(f"route {sorted(route.methods)} {route.path} must declare exactly one transport authority") + authority = declared[0] + if not route.methods or not route.methods.issubset(ROUTE_AUTHORITY_METHODS[authority]): + raise ValueError( + f"{authority.value} transport authority cannot serve {sorted(route.methods or ())} {route.path}" + ) + return authority + + +def _require_route_transport_authority( + app: FastAPI, +) -> Mapping[tuple[str, str], ControlPlaneRouteAuthority]: + """Fail app construction unless every served route declares one transport authority. + + Only FastAPI's own API-description routes are exempt; they carry no runtime + state. Any other route, mount or raw Starlette endpoint must enter through + the dependencies above, and each authority serves only its own HTTP methods, + so a new operation, inject, event or export route cannot silently bypass P2 + admission. The checked route table is then sealed (see + :func:`_require_sealed_route_table`). + """ + + framework_paths = {app.openapi_url, app.docs_url, app.redoc_url, app.swagger_ui_oauth2_redirect_url} - {None} + inventory: dict[tuple[str, str], ControlPlaneRouteAuthority] = {} + for route in app.router.routes: + if isinstance(route, APIRoute): + authority = _declared_route_authority(route) + for method in route.methods: + if (method, route.path) in inventory: + raise ValueError(f"route {method} {route.path} registers a second transport authority") + inventory[(method, route.path)] = authority + elif not (type(route) is Route and route.path in framework_paths): + raise ValueError(f"route {getattr(route, 'path', '?')} must declare exactly one transport authority") + return MappingProxyType(inventory) -def _read_identity_dependency(request: Request) -> ControlPlaneIdentity: - return request.app.state.control_plane_api_auth.read_identity(request) +def _app_composition(app: object) -> tuple[object, ...] | None: + """Identity of everything that decides whether and how a request is admitted.""" -def _resolution_identity_dependency(request: Request) -> ControlPlaneIdentity: - return request.app.state.control_plane_api_auth.resolution_identity(request) + router = getattr(app, "router", None) + routes = getattr(router, "routes", None) + middleware = getattr(app, "user_middleware", None) + handlers = getattr(app, "exception_handlers", None) + overrides = getattr(app, "dependency_overrides", None) + if routes is None or middleware is None or handlers is None or overrides is None: + return None + return ( + tuple(routes), + tuple(middleware), + tuple(handlers.items()), + tuple(overrides.items()), + ) + + +def _seal_app_composition(app: FastAPI) -> None: + """Record the exact routes, middleware, handlers and (no) overrides that were checked.""" + + app.state.control_plane_sealed_composition = _app_composition(app) + + +def _require_sealed_app_composition(app: object) -> None: + """Fail closed when the served app differs from the composition that was checked. + + The route inventory is checked once, at construction. A route, middleware, + exception handler or dependency override attached to the returned app + afterwards never passed that check: it could serve state before admission, + replace an admission dependency, or drop the no-store policy. + """ + + sealed = getattr(getattr(app, "state", None), "control_plane_sealed_composition", None) + current = _app_composition(app) + if ( + sealed is None + or current is None + or len(current) != len(sealed) + or any( + len(now) != len(then) or any(_differs(a, b) for a, b in zip(now, then, strict=True)) + for now, then in zip(current, sealed, strict=True) + ) + ): + raise RuntimeError("control-plane app composition changed after construction") -_MutatingIdentity = Annotated[ControlPlaneIdentity, Depends(_mutating_identity_dependency)] -_ReadIdentity = Annotated[ControlPlaneIdentity, Depends(_read_identity_dependency)] -_ResolutionIdentity = Annotated[ControlPlaneIdentity, Depends(_resolution_identity_dependency)] +def _differs(current: object, sealed: object) -> bool: + if isinstance(current, tuple) and isinstance(sealed, tuple): + return len(current) != len(sealed) or any(a is not b and a != b for a, b in zip(current, sealed, strict=True)) + return current is not sealed diff --git a/implementations/python/packages/raes_runtime/control_plane_api/_health_routes.py b/implementations/python/packages/raes_runtime/control_plane_api/_health_routes.py index 7001063b7..9a223b2e9 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api/_health_routes.py +++ b/implementations/python/packages/raes_runtime/control_plane_api/_health_routes.py @@ -7,14 +7,15 @@ from ..control_plane import RuntimeControlPlane from ..control_plane_health import control_plane_liveness, control_plane_readiness +from ._auth import _PublicProbe def _register_health_routes(app: FastAPI, control_plane: RuntimeControlPlane) -> None: - @app.get("/health/live") + @app.get("/health/live", dependencies=[_PublicProbe]) async def liveness() -> JSONResponse: return JSONResponse(content=control_plane_liveness().to_payload()) - @app.get("/health/ready") + @app.get("/health/ready", dependencies=[_PublicProbe]) async def readiness() -> JSONResponse: health = control_plane_readiness(control_plane) return JSONResponse( diff --git a/implementations/python/packages/raes_runtime/control_plane_api/_operation_routes.py b/implementations/python/packages/raes_runtime/control_plane_api/_operation_routes.py index 1aa675854..1b90df241 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api/_operation_routes.py +++ b/implementations/python/packages/raes_runtime/control_plane_api/_operation_routes.py @@ -16,9 +16,15 @@ ProvisioningPlanModel, RuntimeSnapshotEnvelopeModel, ) +from starlette.concurrency import run_in_threadpool from ..control_plane import RuntimeControlPlane -from ..control_plane_api_guards import RequestSizeLimitMiddleware +from ..control_plane_api_guards import ( + NO_STORE_CACHE_CONTROL, + AppCompositionSealMiddleware, + NoStoreResponseMiddleware, + RequestSizeLimitMiddleware, +) from ..control_plane_api_models import ( _evaluation_plan, _operation_status_model, @@ -27,8 +33,13 @@ _snapshot_model, ) from ..control_plane_recovery import IndeterminateResolutionDisposition -from ..control_plane_security import ControlPlaneSecurityConfig -from ._auth import _MutatingIdentity, _ReadIdentity, _ResolutionIdentity +from ..control_plane_security import ControlPlaneRouteAuthority, ControlPlaneSecurityConfig +from ._auth import ( + _AdministrativeMutationIdentity, + _AdministrativeReadIdentity, + _OperatorResolutionIdentity, + _require_sealed_app_composition, +) from ._offload import _control_plane_calls from ._responses import ( _CONFLICT_RESPONSES, @@ -83,12 +94,16 @@ def _install_request_guards( control_plane: RuntimeControlPlane, security: ControlPlaneSecurityConfig, ) -> None: + # Innermost guard: an unadmitted app composition refuses before any route runs. + app.add_middleware(AppCompositionSealMiddleware, verify_app=_require_sealed_app_composition) app.add_middleware( RequestSizeLimitMiddleware, control_plane=control_plane, max_request_bytes=security.max_request_bytes, max_pending_rejection_audits=security.max_pending_rejection_audits, ) + # Added last so it wraps the size guard's own rejections as well. + app.add_middleware(NoStoreResponseMiddleware) @app.exception_handler(Exception) async def _redacted_errors(request: Request, exc: Exception) -> JSONResponse: @@ -96,17 +111,40 @@ async def _redacted_errors(request: Request, exc: Exception) -> JSONResponse: await _record_admission_denial_best_effort( request, control_plane, action="http-internal-error", reason="internal-error" ) - return JSONResponse(status_code=500, content={"detail": "internal server error"}) + return JSONResponse( + status_code=500, + content={"detail": "internal server error"}, + headers={"cache-control": NO_STORE_CACHE_CONTROL}, + ) @app.exception_handler(RequestValidationError) async def _redacted_request_validation_errors(request: Request, exc: RequestValidationError) -> JSONResponse: del exc + # FastAPI decodes a JSON body before it resolves route dependencies, so a + # malformed body would otherwise be reported to a caller that was never + # admitted. Admission decides first; only an admitted caller sees a 422. + refused = await _refuse_unadmitted_caller(request) + if refused is not None: + return refused await _record_admission_denial_best_effort( request, control_plane, action="http-request-validation-failed", reason="request-validation-failed" ) return JSONResponse(status_code=422, content={"detail": "request validation failed"}) +async def _refuse_unadmitted_caller(request: Request) -> JSONResponse | None: + route = request.scope.get("route") + inventory = getattr(request.app.state, "control_plane_route_authority", {}) + authority = inventory.get((request.method, getattr(route, "path", None))) + if authority is None or authority is ControlPlaneRouteAuthority.PUBLIC_PROBE: + return None + try: + await run_in_threadpool(request.app.state.control_plane_api_auth.admit, request, authority) + except HTTPException as refusal: + return JSONResponse(status_code=refusal.status_code, content={"detail": refusal.detail}) + return None + + def _register_operation_routes( app: FastAPI, control_plane: RuntimeControlPlane, @@ -134,7 +172,7 @@ async def resolve_indeterminate_operation( operation_id: str, request: Request, resolution: _IndeterminateResolutionRequest, - identity: _ResolutionIdentity, + identity: _OperatorResolutionIdentity, ) -> OperationReceiptModel: try: receipt = await _control_plane_calls(request).mutate( @@ -144,10 +182,10 @@ async def resolve_indeterminate_operation( idempotency_key=request.headers.get("idempotency-key", ""), identity=identity, ) - except KeyError as exc: + except (KeyError, PermissionError) as exc: + # The core audits a forbidden resolution; the response matches an unknown + # operation so an operator cannot probe which operation ids exist. raise HTTPException(status_code=404, detail="indeterminate operation not found") from exc - except PermissionError as exc: - raise HTTPException(status_code=403, detail="indeterminate resolution forbidden") from exc except (TypeError, ValueError, RuntimeError) as exc: raise HTTPException(status_code=409, detail="indeterminate resolution conflict") from exc return _receipt_response(receipt) @@ -161,7 +199,7 @@ def _register_provisioning_submission_route( async def submit_provisioning( request: Request, plan: ProvisioningPlanModel, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, ) -> OperationReceiptModel: submitted_plan = _provisioning_plan(plan) calls = _control_plane_calls(request) @@ -198,7 +236,7 @@ def _register_orchestration_submission_route( async def submit_orchestration( request: Request, plan: OrchestrationPlanModel, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, ) -> OperationReceiptModel: submitted_plan = _orchestration_plan(plan) calls = _control_plane_calls(request) @@ -234,7 +272,7 @@ def _register_evaluation_submission_route( async def submit_evaluation( request: Request, plan: EvaluationPlanModel, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, ) -> OperationReceiptModel: submitted_plan = _evaluation_plan(plan) calls = _control_plane_calls(request) @@ -270,7 +308,7 @@ def _register_operation_read_routes( async def get_operation( operation_id: str, request: Request, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, ) -> OperationStatusModel: calls = _control_plane_calls(request) status = await calls.run(control_plane.get_operation, operation_id, identity=identity) @@ -290,7 +328,7 @@ async def get_operation( async def get_snapshot( request: Request, response: Response, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, ) -> RuntimeSnapshotEnvelopeModel: calls = _control_plane_calls(request) await calls.run( @@ -311,7 +349,7 @@ async def get_snapshot( async def get_operational_apparatus_summary( request: Request, response: Response, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, ) -> dict[str, object]: calls = _control_plane_calls(request) await calls.run( diff --git a/implementations/python/packages/raes_runtime/control_plane_api/_participant_routes.py b/implementations/python/packages/raes_runtime/control_plane_api/_participant_routes.py index c3d2134a0..3150ba67b 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api/_participant_routes.py +++ b/implementations/python/packages/raes_runtime/control_plane_api/_participant_routes.py @@ -19,7 +19,7 @@ _ParticipantTerminateBody, ) from ..participant_control_intents import ParticipantControlIntent -from ._auth import _MutatingIdentity, _ReadIdentity +from ._auth import _AdministrativeMutationIdentity, _AdministrativeReadIdentity from ._offload import _control_plane_calls from ._responses import ( _BAD_REQUEST_CONFLICT_RESPONSES, @@ -42,7 +42,7 @@ def _register_participant_execution_routes( async def control_participant_execution( execution_scope_ref: str, request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, body: _ParticipantExecutionControlBody, ) -> OperationReceiptModel: calls = _control_plane_calls(request) @@ -71,7 +71,7 @@ async def get_participant_execution_state( execution_scope_ref: str, request: Request, response: Response, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, ) -> ParticipantExecutionServiceStateModel: calls = _control_plane_calls(request) try: @@ -112,7 +112,7 @@ async def record_participant_control( participant_address: str, request: Request, body: ParticipantControlIntent, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, ) -> OperationReceiptModel: calls = _control_plane_calls(request) try: @@ -152,7 +152,7 @@ def _register_participant_episode_start_routes( async def initialize_participant_episode( participant_address: str, request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, body: _ParticipantInitializeBody | None = None, ) -> OperationReceiptModel: payload = body or _ParticipantInitializeBody() @@ -176,7 +176,7 @@ async def initialize_participant_episode( async def reset_participant_episode( participant_address: str, request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, body: _ParticipantResetBody | None = None, ) -> OperationReceiptModel: payload = body or _ParticipantResetBody() @@ -206,7 +206,7 @@ def _register_participant_episode_end_routes( async def restart_participant_episode( participant_address: str, request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, body: _ParticipantRestartBody | None = None, ) -> OperationReceiptModel: payload = body or _ParticipantRestartBody() @@ -231,7 +231,7 @@ async def restart_participant_episode( async def terminate_participant_episode( participant_address: str, request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, body: _ParticipantTerminateBody | None = None, ) -> OperationReceiptModel: payload = body or _ParticipantTerminateBody() diff --git a/implementations/python/packages/raes_runtime/control_plane_api/_workflow_routes.py b/implementations/python/packages/raes_runtime/control_plane_api/_workflow_routes.py index 55bc4e831..0d4c6ff0f 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api/_workflow_routes.py +++ b/implementations/python/packages/raes_runtime/control_plane_api/_workflow_routes.py @@ -6,7 +6,7 @@ from raes_contracts.contracts import OperationReceiptModel, WorkflowCancellationRequestModel from ..control_plane import RuntimeControlPlane -from ._auth import _MutatingIdentity +from ._auth import _AdministrativeMutationIdentity from ._offload import _control_plane_calls from ._responses import _CONFLICT_RESPONSES, _conflict_detail, _receipt_response @@ -19,7 +19,7 @@ def _register_workflow_routes( async def cancel_workflow( workflow_address: str, request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, cancellation: WorkflowCancellationRequestModel | None = None, ) -> OperationReceiptModel: payload = cancellation or WorkflowCancellationRequestModel() @@ -40,7 +40,7 @@ async def cancel_workflow( @app.post("/workflows/reconcile-timeouts", responses=_CONFLICT_RESPONSES) async def reconcile_timeouts( request: Request, - identity: _MutatingIdentity, + identity: _AdministrativeMutationIdentity, ) -> OperationReceiptModel: calls = _control_plane_calls(request) try: diff --git a/implementations/python/packages/raes_runtime/control_plane_api_guards.py b/implementations/python/packages/raes_runtime/control_plane_api_guards.py index 9d9fb6b8d..419ee0f08 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api_guards.py +++ b/implementations/python/packages/raes_runtime/control_plane_api_guards.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from collections.abc import Sequence +from collections.abc import Callable, Sequence from functools import partial from anyio import CapacityLimiter, to_thread @@ -12,6 +12,7 @@ from .control_plane import RuntimeControlPlane +NO_STORE_CACHE_CONTROL = "no-store" _REQUEST_TOO_LARGE_DETAIL = "request too large" _INVALID_CONTENT_LENGTH_DETAIL = "invalid content-length" _LOGGER = logging.getLogger(__name__) @@ -56,6 +57,83 @@ async def record( return True +class NoStoreResponseMiddleware: + """Mark every control-plane HTTP response uncacheable. + + Snapshots, operation readback, governed views, idempotent replays and error + envelopes are all authorization-bound outputs (issue #1359). A shared proxy + or browser cache keyed by URL could re-release them without admission or a + governed crossing, so the policy is applied once at the application boundary + instead of per route. Starlette's server-error layer sits outside user + middleware, so the redacted 500 handler sets the same header itself. + """ + + def __init__(self, app: ASGIApp) -> None: + self._app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + async def send_uncacheable(message: Message) -> None: + if message["type"] == "http.response.start": + headers = [ + (name, value) for name, value in message.get("headers", ()) if name.lower() != b"cache-control" + ] + headers.append((b"cache-control", NO_STORE_CACHE_CONTROL.encode("ascii"))) + message = {**message, "headers": headers} + await send(message) + + await self._app(scope, receive, send_uncacheable) + + +async def refuse_unsealed_request(scope: Scope, receive: Receive, send: Send) -> None: + """Refuse one request or startup because the app composition is not the admitted one.""" + + _LOGGER.error("control-plane app composition changed after construction") + if scope["type"] == "lifespan": + # As Starlette's router does: report the failed startup, then raise so the + # server stops instead of waiting for a shutdown the app will not serve. + await receive() + await send({"type": "lifespan.startup.failed", "message": "control-plane app composition changed"}) + raise RuntimeError("control-plane app composition changed after construction") + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1011}) + elif scope["type"] == "http": + response = JSONResponse( + status_code=500, + content={"detail": "internal server error"}, + headers={"cache-control": NO_STORE_CACHE_CONTROL}, + ) + await response(scope, receive, send) + + +class AppCompositionSealMiddleware: + """Refuse every request once the served app differs from the admitted composition. + + ``verify_app`` raises when the app's routes, middleware, exception handlers + or dependency overrides are not exactly those checked at construction + (issue #1359). The app also re-checks when it builds its middleware stack; + this per-request check covers what can still change afterwards, such as a + route or dependency override added to a running app. No route runs, not + even one that was admitted. + """ + + def __init__(self, app: ASGIApp, *, verify_app: Callable[[object], None]) -> None: + self._app = app + self._verify_app = verify_app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] in {"http", "websocket"}: + try: + self._verify_app(scope.get("app")) + except RuntimeError: + await refuse_unsealed_request(scope, receive, send) + return + await self._app(scope, receive, send) + + class RequestSizeLimitMiddleware: """Reject oversized HTTP bodies before FastAPI parses or dispatches them. diff --git a/implementations/python/packages/raes_runtime/control_plane_api_participant_retrieval.py b/implementations/python/packages/raes_runtime/control_plane_api_participant_retrieval.py index 91f97bee6..42ddd1b44 100644 --- a/implementations/python/packages/raes_runtime/control_plane_api_participant_retrieval.py +++ b/implementations/python/packages/raes_runtime/control_plane_api_participant_retrieval.py @@ -1,4 +1,11 @@ -"""HTTP routes for API-408 participant retrieval views.""" +"""HTTP routes for API-408 participant retrieval views. + +These are administrative reads (issue #1356/#1359): the host's service identity +is admitted by the shared read authority. With a crossing resolver, a view also +requires one exact participant/audience binding and a committed API-423 +crossing. Without a resolver, the legacy view is an administrative projection +that a host must not release to a participant. +""" from __future__ import annotations @@ -15,6 +22,7 @@ from raes_contracts.runtime_state import OperationKind from .control_plane import RuntimeControlPlane +from .control_plane_api._auth import _AdministrativeReadIdentity from .control_plane_api._offload import _control_plane_calls from .control_plane_api._responses import _set_snapshot_revision_header from .control_plane_security import ControlPlaneIdentity, ParticipantAudienceSubjectBinding @@ -28,13 +36,6 @@ _ViewT = TypeVar("_ViewT") -def _read_identity_dependency(request: Request) -> ControlPlaneIdentity: - return request.app.state.control_plane_api_auth.read_identity(request) - - -_ReadIdentity = Annotated[ControlPlaneIdentity, Depends(_read_identity_dependency)] - - @dataclass(frozen=True) class _GovernedViewResolution(Generic[_ViewT]): action: str @@ -124,7 +125,7 @@ async def get_participant_status_view( participant_address: str, request: Request, response: Response, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, ) -> ParticipantStatusViewModel: return await _resolved_governed_view( control_plane, @@ -153,7 +154,7 @@ async def get_participant_history_view( episode_id: str, request: Request, response: Response, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, ) -> ParticipantHistoryViewModel: return await _resolved_governed_view( control_plane, @@ -182,7 +183,7 @@ async def get_participant_context_view( participant_address: str, request: Request, response: Response, - identity: _ReadIdentity, + identity: _AdministrativeReadIdentity, query: _ContextQuery, ) -> ParticipantContextViewModel: return await _resolved_governed_view( diff --git a/implementations/python/packages/raes_runtime/control_plane_security.py b/implementations/python/packages/raes_runtime/control_plane_security.py index 23d37f1c3..f73213a6c 100644 --- a/implementations/python/packages/raes_runtime/control_plane_security.py +++ b/implementations/python/packages/raes_runtime/control_plane_security.py @@ -8,6 +8,11 @@ from enum import Enum from types import MappingProxyType +from pydantic import TypeAdapter, ValidationError +from raes_contracts.operation_lifecycle import OperationAdmissionContext + +from .control_plane_operation_context import operation_actor_scope + class ControlPlaneRole(str, Enum): """Authorization roles for control-plane callers.""" @@ -17,6 +22,71 @@ class ControlPlaneRole(str, Enum): AUDITOR = "auditor" +class ControlPlaneRouteAuthority(str, Enum): + """Transport authority that one served P2 route declares. + + P2 is a host-mediated, administrative-only surface (issue #1356). Every + authority except the value-free public probe admits a privileged, + target-bound service identity; none is participant, controller or + disclosure authority. Participant/audience and participant/controller + bindings are applied by the core operation after this admission. + """ + + PUBLIC_PROBE = "public-probe" + ADMINISTRATIVE_READ = "administrative-read" + ADMINISTRATIVE_MUTATION = "administrative-mutation" + OPERATOR_RESOLUTION = "operator-resolution" + + +ROUTE_AUTHORITY_ROLES: Mapping[ControlPlaneRouteAuthority, frozenset[ControlPlaneRole]] = MappingProxyType( + { + ControlPlaneRouteAuthority.ADMINISTRATIVE_READ: frozenset( + {ControlPlaneRole.BACKEND, ControlPlaneRole.OPERATOR, ControlPlaneRole.AUDITOR} + ), + ControlPlaneRouteAuthority.ADMINISTRATIVE_MUTATION: frozenset( + {ControlPlaneRole.BACKEND, ControlPlaneRole.OPERATOR} + ), + ControlPlaneRouteAuthority.OPERATOR_RESOLUTION: frozenset({ControlPlaneRole.OPERATOR}), + } +) +"""Roles admitted by each authenticated route authority. + +A read role admits full snapshots and operational reads as well as participant +views (API-404-C3); an audience binding does not narrow it. +""" + +_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) +ROUTE_AUTHORITY_METHODS: Mapping[ControlPlaneRouteAuthority, frozenset[str]] = MappingProxyType( + { + ControlPlaneRouteAuthority.PUBLIC_PROBE: frozenset({"GET"}), + ControlPlaneRouteAuthority.ADMINISTRATIVE_READ: frozenset({"GET"}), + ControlPlaneRouteAuthority.ADMINISTRATIVE_MUTATION: _MUTATING_METHODS, + ControlPlaneRouteAuthority.OPERATOR_RESOLUTION: _MUTATING_METHODS, + } +) +"""HTTP methods each route authority may serve. + +Read authorities admit the auditor role, so they must never front a state change; +mutating authorities must never be reachable by a safe method. +""" + + +def _require_binding_fields(participant_address: object, subject_ref: object, *, kind: str) -> None: + """Require non-empty string binding fields that encode unambiguously as scopes. + + Bindings become ``participant-{kind}:{participant_address}:{subject_ref}`` + authorization scopes, and readback splits them after the participant prefix, + so a participant address must not contain ``:``. + """ + + if not isinstance(participant_address, str) or not isinstance(subject_ref, str): + raise ValueError(f"participant {kind} subject binding fields must be strings") + if not participant_address or not subject_ref: + raise ValueError(f"participant {kind} subject binding fields must be non-empty") + if ":" in participant_address: + raise ValueError(f"participant {kind} subject binding address must not contain ':'") + + @dataclass(frozen=True) class ParticipantControlSubjectBinding: """One authenticated principal-to-participant/controller binding.""" @@ -25,8 +95,7 @@ class ParticipantControlSubjectBinding: controller_ref: str def __post_init__(self) -> None: - if not self.participant_address or not self.controller_ref: - raise ValueError("participant control subject binding fields must be non-empty") + _require_binding_fields(self.participant_address, self.controller_ref, kind="control") @dataclass(frozen=True) @@ -37,8 +106,7 @@ class ParticipantAudienceSubjectBinding: audience_scope_ref: str def __post_init__(self) -> None: - if not self.participant_address or not self.audience_scope_ref: - raise ValueError("participant audience subject binding fields must be non-empty") + _require_binding_fields(self.participant_address, self.audience_scope_ref, kind="audience") @dataclass(frozen=True) @@ -61,17 +129,60 @@ def _require_identity_headers(verified: str, identity: str) -> None: raise ValueError("identity headers must be distinct and must not alias Authorization") +# The operation context owns these bounds; reuse its field constraints directly. +_OPERATION_ACTOR = TypeAdapter(OperationAdmissionContext.model_fields["actor_id"].rebuild_annotation()) +_OPERATION_AUTHORIZATION_SCOPE = TypeAdapter( + OperationAdmissionContext.model_fields["authorization_scope"].rebuild_annotation() +) + + +def _require_principal_shape(principal: ControlPlaneIdentity) -> None: + """Reject configured principals whose authority fields are mistyped or mutable. + + Annotations do not validate: a mutable role set or binding list could grant + authority after the principal maps are frozen, and a mistyped role or + binding would silently change admission. Messages stay value-free. + """ + + roles = principal.roles + if not isinstance(roles, frozenset) or not all(isinstance(role, ControlPlaneRole) for role in roles): + raise ValueError("configured principal roles must be a frozenset of ControlPlaneRole") + target_name = principal.target_name + if target_name is not None and (not isinstance(target_name, str) or not target_name.strip()): + raise ValueError("configured principal target must be a non-empty string") + for bindings, binding_type in ( + (principal.participant_control_subjects, ParticipantControlSubjectBinding), + (principal.participant_audience_subjects, ParticipantAudienceSubjectBinding), + ): + if not isinstance(bindings, tuple) or not all(isinstance(binding, binding_type) for binding in bindings): + raise ValueError(f"configured principal subject bindings must be a tuple of {binding_type.__name__}") + # Reuse the canonical actor/scope derivation so an over-bound principal + # fails here rather than at its first admitted request or audit event. + actor, authorization_scope = operation_actor_scope(principal) + try: + _OPERATION_ACTOR.validate_python(actor) + except ValidationError: + raise ValueError("configured principal identity exceeds the operation-context bound") from None + try: + _OPERATION_AUTHORIZATION_SCOPE.validate_python(authorization_scope) + except ValidationError: + raise ValueError("configured principal authorization scope exceeds the operation-context bound") from None + + def _frozen_principals(principals: Mapping[str, ControlPlaneIdentity]) -> Mapping[str, ControlPlaneIdentity]: copied = dict(principals) for key, principal in copied.items(): - if not isinstance(key, str) or not key.strip(): - raise ValueError("configured credentials and principal names must be non-empty") + # Presented bearer tokens and header values arrive stripped, so a key with + # surrounding whitespace (a secret file's trailing newline) could never match. + if not isinstance(key, str) or not key.strip() or key != key.strip(): + raise ValueError("configured credentials and principal names must be non-empty and unpadded") if ( not isinstance(principal, ControlPlaneIdentity) or not isinstance(principal.identity, str) or not principal.identity.strip() ): raise ValueError("configured principal must have a non-empty identity") + _require_principal_shape(principal) return MappingProxyType(copied) @@ -95,10 +206,17 @@ class ControlPlaneSecurityConfig: def __post_init__(self) -> None: _require_identity_headers(self.verified_header, self.identity_header) - if self.max_pending_mutations <= 0: - raise ValueError("max_pending_mutations must be positive") - if self.max_pending_rejection_audits <= 0: - raise ValueError("max_pending_rejection_audits must be positive") + # A truthy non-bool (for example the string "false" read from env or YAML) + # must not silently enable header trust or disable verification. + for name in ("require_verified_identity", "trust_proxy_identity_headers"): + if type(getattr(self, name)) is not bool: + raise ValueError(f"{name} must be a bool") + for name in ("max_request_bytes", "max_pending_mutations", "max_pending_rejection_audits"): + value = getattr(self, name) + if type(value) is not int: + raise ValueError(f"{name} must be an int") + if value <= 0: + raise ValueError(f"{name} must be positive") # ``frozen=True`` only blocks rebinding the attributes; a caller (or a # later code path) could still mutate the underlying dicts and grant # principals or tokens after construction, defeating ``strict_defaults``. diff --git a/implementations/python/tests/test_issue_1179_startup_reconciliation.py b/implementations/python/tests/test_issue_1179_startup_reconciliation.py index 5c43d9b9c..ed6f9191a 100644 --- a/implementations/python/tests/test_issue_1179_startup_reconciliation.py +++ b/implementations/python/tests/test_issue_1179_startup_reconciliation.py @@ -580,3 +580,29 @@ def test_http_resolution_is_operator_only_and_returns_stable_errors() -> None: ) assert accepted.status_code == 200 assert accepted.json()["context"]["parent_operation_id"] == parent.receipt.operation_id + + +def test_http_resolution_refusal_is_indistinguishable_from_an_unknown_operation() -> None: + scope = "participant-control:participant.alpha:controller.alpha" + parent = _record("hidden-parent-1179", subject_scope=scope) + store = _store_with(parent) + control_plane = RuntimeControlPlane(_target(), store=store) + security = ControlPlaneSecurityConfig(bearer_tokens={"operator-token": _operator()}) + body = {"disposition": "accept-current-snapshot"} + + with TestClient(create_control_plane_app(control_plane, security=security)) as client: + forbidden = client.post( + f"/operations/{parent.receipt.operation_id}/resolution", + json=body, + headers={"authorization": "Bearer operator-token", "idempotency-key": "hidden-child"}, + ) + unknown = client.post( + "/operations/op-unknown/resolution", + json=body, + headers={"authorization": "Bearer operator-token", "idempotency-key": "unknown-child"}, + ) + + assert forbidden.status_code == unknown.status_code == 404 + assert forbidden.json() == unknown.json() + assert store.find_by_idempotency("hidden-child") is None + assert store.read_audit()[-1].reason == "resolution-forbidden" diff --git a/implementations/python/tests/test_issue_1359_runtime_api_trust_boundary.py b/implementations/python/tests/test_issue_1359_runtime_api_trust_boundary.py new file mode 100644 index 000000000..9bb372785 --- /dev/null +++ b/implementations/python/tests/test_issue_1359_runtime_api_trust_boundary.py @@ -0,0 +1,887 @@ +"""Served control-plane trust-boundary enforcement (issue #1359, API-404-C3). + +The accepted exposure model is host-mediated, administrative-only P2 (#1356). +Every route that reveals runtime state or causes an effect admits only a +privileged, target-bound service identity. Participant/audience and +participant/controller bindings narrow a core operation after that admission; +they never make a credential participant-limited. These tests drive the real +ASGI boundary rather than the auth helpers. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest +from fastapi import FastAPI +from participant_crossing_fixtures import ( + AUDIENCE, + PARTICIPANT, + StaticCrossingResolver, + action_plane, + evidence, + policy_capable_target, +) +from raes_backend_stubs.stubs import create_stub_target +from raes_runtime import control_plane_api +from raes_runtime.control_plane import RuntimeControlPlane +from raes_runtime.control_plane_api import create_control_plane_app +from raes_runtime.control_plane_api._auth import ( + _administrative_read_dependency, + _AdministrativeMutationIdentity, + _AdministrativeReadIdentity, + _PublicProbe, +) +from raes_runtime.control_plane_security import ( + ControlPlaneIdentity, + ControlPlaneRole, + ControlPlaneRouteAuthority, + ControlPlaneSecurityConfig, + ParticipantAudienceSubjectBinding, + ParticipantControlSubjectBinding, +) +from starlette.responses import PlainTextResponse +from starlette.routing import Mount +from starlette.testclient import TestClient +from test_run_310_supervisory_lifecycle import ( + _PARTICIPANT as _CONTROLLED_PARTICIPANT, +) +from test_run_310_supervisory_lifecycle import ( + _SPEC_ADDRESS, + _compiled_specification, + _proposal_body, +) + +pytestmark = pytest.mark.control_plane_conformance + +_TARGET = "stub" +_ALICE = "participant.alice" +_NO_STORE = "no-store" +_PUBLIC = ControlPlaneRouteAuthority.PUBLIC_PROBE +_READ = ControlPlaneRouteAuthority.ADMINISTRATIVE_READ +_MUTATE = ControlPlaneRouteAuthority.ADMINISTRATIVE_MUTATION +_RESOLVE = ControlPlaneRouteAuthority.OPERATOR_RESOLUTION + +# The accepted route/authority matrix. A route added without updating this +# table fails the inventory test; a route added without a transport authority +# fails app construction. +_EXPECTED_ROUTE_AUTHORITY = { + ("GET", "/health/live"): _PUBLIC, + ("GET", "/health/ready"): _PUBLIC, + ("POST", "/operations/provisioning"): _MUTATE, + ("POST", "/operations/orchestration"): _MUTATE, + ("POST", "/operations/evaluation"): _MUTATE, + ("POST", "/operations/{operation_id}/resolution"): _RESOLVE, + ("GET", "/operations/{operation_id}"): _READ, + ("GET", "/snapshot"): _READ, + ("GET", "/apparatus/operational-summary"): _READ, + ("POST", "/workflows/{workflow_address}/cancel"): _MUTATE, + ("POST", "/workflows/reconcile-timeouts"): _MUTATE, + ("POST", "/participants/{participant_address}/episodes/initialize"): _MUTATE, + ("POST", "/participants/{participant_address}/episodes/reset"): _MUTATE, + ("POST", "/participants/{participant_address}/episodes/restart"): _MUTATE, + ("POST", "/participants/{participant_address}/episodes/terminate"): _MUTATE, + ("POST", "/participants/{participant_address}/control-occurrences"): _MUTATE, + ("POST", "/participant-executions/{execution_scope_ref}/control"): _MUTATE, + ("GET", "/participant-executions/{execution_scope_ref}"): _READ, + ("GET", "/participants/{participant_address}/status"): _READ, + ("GET", "/participants/{participant_address}/episodes/{episode_id}/history"): _READ, + ("GET", "/participants/{participant_address}/context"): _READ, +} + +# One concrete request per authenticated route: (method, template, path, json). +_ROUTE_REQUESTS: tuple[tuple[str, str, str, object], ...] = ( + ( + "POST", + "/operations/provisioning", + "/operations/provisioning", + {"operations": [], "diagnostics": [], "realization_authority": []}, + ), + ( + "POST", + "/operations/orchestration", + "/operations/orchestration", + {"operations": [], "startup_order": [], "diagnostics": []}, + ), + ("POST", "/operations/evaluation", "/operations/evaluation", {"operations": [], "diagnostics": []}), + ("POST", "/operations/{operation_id}/resolution", "/operations/op-unknown/resolution", {}), + ("GET", "/operations/{operation_id}", "/operations/op-unknown", None), + ("GET", "/snapshot", "/snapshot", None), + ("GET", "/apparatus/operational-summary", "/apparatus/operational-summary", None), + ("POST", "/workflows/{workflow_address}/cancel", "/workflows/workflow.unknown/cancel", {}), + ("POST", "/workflows/reconcile-timeouts", "/workflows/reconcile-timeouts", None), + ( + "POST", + "/participants/{participant_address}/episodes/initialize", + f"/participants/{_ALICE}/episodes/initialize", + {}, + ), + ("POST", "/participants/{participant_address}/episodes/reset", f"/participants/{_ALICE}/episodes/reset", {}), + ( + "POST", + "/participants/{participant_address}/episodes/restart", + f"/participants/{_ALICE}/episodes/restart", + {}, + ), + ( + "POST", + "/participants/{participant_address}/episodes/terminate", + f"/participants/{_ALICE}/episodes/terminate", + {}, + ), + ( + "POST", + "/participants/{participant_address}/control-occurrences", + f"/participants/{_ALICE}/control-occurrences", + {}, + ), + ( + "POST", + "/participant-executions/{execution_scope_ref}/control", + "/participant-executions/execution.unknown/control", + {}, + ), + ("GET", "/participant-executions/{execution_scope_ref}", "/participant-executions/execution.unknown", None), + ("GET", "/participants/{participant_address}/status", f"/participants/{_ALICE}/status", None), + ( + "GET", + "/participants/{participant_address}/episodes/{episode_id}/history", + f"/participants/{_ALICE}/episodes/episode-1/history", + None, + ), + ( + "GET", + "/participants/{participant_address}/context", + f"/participants/{_ALICE}/context?view_ref=view.alice", + None, + ), +) + +_ALICE_AUDIENCE = ParticipantAudienceSubjectBinding(participant_address=_ALICE, audience_scope_ref=AUDIENCE) +_ALICE_CONTROL = ParticipantControlSubjectBinding(participant_address=_ALICE, controller_ref="controller.alice") + + +def _identity(name: str, *roles: ControlPlaneRole, target_name: str = _TARGET, **bindings: Any) -> ControlPlaneIdentity: + return ControlPlaneIdentity(identity=name, roles=frozenset(roles), target_name=target_name, **bindings) + + +_TOKENS = { + "backend-token": _identity("backend", ControlPlaneRole.BACKEND), + "operator-token": _identity("operator", ControlPlaneRole.OPERATOR), + "auditor-token": _identity("auditor", ControlPlaneRole.AUDITOR), + # A read role plus an audience binding is still broad administrative authority. + "audience-reader-token": _identity( + "audience-reader", + ControlPlaneRole.AUDITOR, + participant_audience_subjects=(_ALICE_AUDIENCE,), + ), + # The would-be "participant-limited" credential: bindings, no route role. + "participant-limited-token": _identity( + "participant-limited", + participant_audience_subjects=(_ALICE_AUDIENCE,), + participant_control_subjects=(_ALICE_CONTROL,), + ), + "other-target-token": _identity( + "other-target", + ControlPlaneRole.BACKEND, + ControlPlaneRole.OPERATOR, + ControlPlaneRole.AUDITOR, + target_name="another-target", + ), +} +_ADMITTED_TOKEN = {_READ: "audience-reader-token", _MUTATE: "backend-token", _RESOLVE: "operator-token"} +_DENIED_ROLE_TOKENS = { + _READ: (), + _MUTATE: ("auditor-token",), + _RESOLVE: ("backend-token", "auditor-token"), +} + + +def _bearer(token: str) -> dict[str, str]: + return {"authorization": f"Bearer {token}"} + + +def _stub_app(**security_overrides: Any) -> tuple[FastAPI, RuntimeControlPlane]: + control_plane = RuntimeControlPlane(create_stub_target()) + security = ControlPlaneSecurityConfig(bearer_tokens=_TOKENS, **security_overrides) + return create_control_plane_app(control_plane, security=security), control_plane + + +def _send(client: TestClient, method: str, path: str, body: object, headers: dict[str, str]) -> Any: + if body is None: + return client.request(method, path, headers=headers) + return client.request(method, path, json=body, headers=headers) + + +def _route_requests(*authorities: ControlPlaneRouteAuthority) -> list[Any]: + return [ + pytest.param(method, template, path, body, id=f"{method} {template}") + for method, template, path, body in _ROUTE_REQUESTS + if _EXPECTED_ROUTE_AUTHORITY[(method, template)] in authorities + ] + + +# --- route inventory ------------------------------------------------------- + + +def test_every_served_route_declares_the_accepted_transport_authority() -> None: + app, _control_plane = _stub_app() + + assert dict(app.state.control_plane_route_authority) == _EXPECTED_ROUTE_AUTHORITY + with pytest.raises(TypeError): + app.state.control_plane_route_authority[("GET", "/rogue")] = _PUBLIC + + +def test_route_request_table_exercises_every_authenticated_route() -> None: + covered = {(method, template) for method, template, _path, _body in _ROUTE_REQUESTS} + authenticated = {key for key, authority in _EXPECTED_ROUTE_AUTHORITY.items() if authority is not _PUBLIC} + + assert covered == authenticated + + +def _with_extra_route(register: Callable[[FastAPI], None]) -> Callable[..., None]: + incumbent = control_plane_api._register_workflow_routes + + def registrar(app: FastAPI, control_plane: RuntimeControlPlane) -> None: + incumbent(app, control_plane) + register(app) + + return registrar + + +def _unauthenticated_event_route(app: FastAPI) -> None: + @app.get("/events") + async def events() -> dict[str, str]: + return {"state": "leaked"} + + +def _double_authority_route(app: FastAPI) -> None: + @app.post("/operations/inject") + async def inject(reader: _AdministrativeReadIdentity, writer: _AdministrativeMutationIdentity) -> None: + del reader, writer + + +def _public_mutation_route(app: FastAPI) -> None: + @app.post("/inject", dependencies=[_PublicProbe]) + async def inject() -> None: + return None + + +def _shadowing_route(app: FastAPI) -> None: + @app.get("/snapshot", dependencies=[_PublicProbe]) + async def public_snapshot() -> dict[str, str]: + return {"state": "leaked"} + + +def _read_authority_mutation_route(app: FastAPI) -> None: + # An auditor holds the read authority; it must never reach a state change. + @app.post("/operations/inject") + async def inject(reader: _AdministrativeReadIdentity) -> None: + del reader + + +def _mutation_authority_safe_method_route(app: FastAPI) -> None: + @app.get("/operations/inject") + async def inject(writer: _AdministrativeMutationIdentity) -> None: + del writer + + +def _mounted_route(app: FastAPI) -> None: + app.router.routes.append(Mount("/export", routes=[])) + + +def _starlette_route(app: FastAPI) -> None: + app.add_route("/raw", lambda _request: PlainTextResponse("raw")) + + +@pytest.mark.parametrize( + "register", + [ + pytest.param(_unauthenticated_event_route, id="no-authority"), + pytest.param(_double_authority_route, id="two-authorities"), + pytest.param(_public_mutation_route, id="public-mutation"), + pytest.param(_read_authority_mutation_route, id="read-authority-mutation"), + pytest.param(_mutation_authority_safe_method_route, id="mutation-authority-get"), + pytest.param(_shadowing_route, id="duplicate-route"), + pytest.param(_mounted_route, id="mount"), + pytest.param(_starlette_route, id="undeclared-framework-route"), + ], +) +def test_app_construction_fails_closed_for_undeclared_route_authority( + monkeypatch: pytest.MonkeyPatch, + register: Callable[[FastAPI], None], +) -> None: + monkeypatch.setattr(control_plane_api, "_register_workflow_routes", _with_extra_route(register)) + control_plane = RuntimeControlPlane(create_stub_target()) + + with pytest.raises(ValueError, match="transport authority"): + create_control_plane_app(control_plane) + + +def _late_route(app: FastAPI) -> None: + @app.get("/late") + async def late() -> dict[str, str]: + return {"state": "leaked"} + + +def _late_middleware(app: FastAPI) -> None: + @app.middleware("http") + async def late(request: Any, call_next: Any) -> Any: + if request.url.path == "/late": + return PlainTextResponse("leaked") + return await call_next(request) + + +def _late_exception_handler(app: FastAPI) -> None: + app.add_exception_handler(403, lambda _request, _exc: PlainTextResponse("leaked", status_code=200)) + + +def _late_dependency_override(app: FastAPI) -> None: + app.dependency_overrides[_administrative_read_dependency] = lambda: _TOKENS["auditor-token"] + + +_LATE_COMPOSITION = [ + pytest.param(_late_route, id="route"), + pytest.param(_late_middleware, id="middleware"), + pytest.param(_late_exception_handler, id="exception-handler"), + pytest.param(_late_dependency_override, id="dependency-override"), +] + + +@pytest.mark.parametrize("tamper", _LATE_COMPOSITION) +def test_app_composition_changed_after_construction_is_never_served(tamper: Callable[[FastAPI], None]) -> None: + app, _control_plane = _stub_app() + tamper(app) + client = TestClient(app, raise_server_exceptions=False) + + responses = [ + client.get("/late"), + client.get("/snapshot"), + client.get("/snapshot", headers=_bearer("auditor-token")), + ] + + for response in responses: + assert response.status_code == 500 + assert response.json() == {"detail": "internal server error"} + assert response.headers["cache-control"] == _NO_STORE + assert ("GET", "/late") not in app.state.control_plane_route_authority + + +@pytest.mark.parametrize("tamper", _LATE_COMPOSITION) +def test_app_composition_changed_after_construction_fails_startup(tamper: Callable[[FastAPI], None]) -> None: + app, _control_plane = _stub_app() + tamper(app) + client = TestClient(app) + + with pytest.raises(RuntimeError), client: + pass + + +@pytest.mark.parametrize("tamper", [_late_route, _late_dependency_override], ids=["route", "dependency-override"]) +def test_running_app_refuses_requests_after_its_composition_changes(tamper: Callable[[FastAPI], None]) -> None: + app, _control_plane = _stub_app() + + with TestClient(app, raise_server_exceptions=False) as client: + assert client.get("/snapshot", headers=_bearer("auditor-token")).status_code == 200 + tamper(app) + late = client.get("/late") + unauthenticated = client.get("/snapshot") + + for response in (late, unauthenticated): + assert response.status_code == 500 + assert response.headers["cache-control"] == _NO_STORE + + +# --- transport admission on every authenticated route ----------------------- + + +@pytest.mark.parametrize(("method", "template", "path", "body"), _route_requests(_READ, _MUTATE, _RESOLVE)) +def test_authenticated_routes_reject_unauthenticated_callers( + method: str, template: str, path: str, body: object +) -> None: + app, _control_plane = _stub_app() + + with TestClient(app) as client: + response = _send(client, method, path, body, {}) + + assert response.status_code == 401 + assert response.headers["cache-control"] == _NO_STORE + + +@pytest.mark.parametrize( + "headers", + [ + pytest.param(_bearer("revoked-token"), id="invalid-bearer"), + pytest.param({"x-raes-client-identity": "backend-token", "x-raes-client-verified": "true"}, id="proxy-headers"), + pytest.param({}, id="no-credential"), + ], +) +def test_unauthenticated_refusals_do_not_reveal_the_reason(headers: dict[str, str]) -> None: + app, control_plane = _stub_app() + + with TestClient(app) as client: + response = client.get("/snapshot", headers=headers) + audited_reason = control_plane.audit_log()[-1].reason + + assert response.status_code == 401 + assert response.json() == {"detail": "unauthorized"} + assert audited_reason != "unauthorized" + + +@pytest.mark.parametrize(("method", "template", "path", "body"), _route_requests(_MUTATE, _RESOLVE)) +def test_malformed_bodies_are_refused_by_admission_before_validation( + method: str, template: str, path: str, body: object +) -> None: + del template, body + app, _control_plane = _stub_app() + malformed = {"content-type": "application/json"} + + with TestClient(app) as client: + anonymous = client.request(method, path, content=b"{not-json", headers=malformed) + other_target = client.request( + method, path, content=b"{not-json", headers={**malformed, **_bearer("other-target-token")} + ) + + assert anonymous.status_code == 401 + assert anonymous.json() == {"detail": "unauthorized"} + assert other_target.status_code == 403 + assert other_target.json() == {"detail": "forbidden"} + assert anonymous.headers["cache-control"] == other_target.headers["cache-control"] == _NO_STORE + + +@pytest.mark.parametrize(("method", "template", "path", "body"), _route_requests(_READ, _MUTATE, _RESOLVE)) +def test_authenticated_routes_reject_identities_bound_to_another_target( + method: str, template: str, path: str, body: object +) -> None: + app, _control_plane = _stub_app() + + with TestClient(app) as client: + response = _send(client, method, path, body, _bearer("other-target-token")) + + assert response.status_code == 403 + assert response.json() == {"detail": "forbidden"} + + +@pytest.mark.parametrize(("method", "template", "path", "body"), _route_requests(_READ, _MUTATE, _RESOLVE)) +def test_participant_bound_identity_without_route_role_is_refused_everywhere( + method: str, template: str, path: str, body: object +) -> None: + app, control_plane = _stub_app() + + with TestClient(app) as client: + response = _send(client, method, path, body, _bearer("participant-limited-token")) + episodes = control_plane.snapshot.participant_episode_results + + assert response.status_code == 403 + assert response.json() == {"detail": "forbidden"} + assert response.headers["cache-control"] == _NO_STORE + assert not episodes + + +@pytest.mark.parametrize(("method", "template", "path", "body"), _route_requests(_MUTATE, _RESOLVE)) +def test_routes_refuse_roles_outside_their_transport_authority( + method: str, template: str, path: str, body: object +) -> None: + app, _control_plane = _stub_app() + authority = _EXPECTED_ROUTE_AUTHORITY[(method, template)] + + with TestClient(app) as client: + responses = [_send(client, method, path, body, _bearer(token)) for token in _DENIED_ROLE_TOKENS[authority]] + + assert responses + assert all(response.status_code == 403 for response in responses) + assert all(response.json() == {"detail": "forbidden"} for response in responses) + + +@pytest.mark.parametrize(("method", "template", "path", "body"), _route_requests(_READ, _MUTATE, _RESOLVE)) +def test_administrative_identity_passes_transport_admission_on_every_route( + method: str, template: str, path: str, body: object +) -> None: + app, _control_plane = _stub_app() + token = _ADMITTED_TOKEN[_EXPECTED_ROUTE_AUTHORITY[(method, template)]] + + with TestClient(app) as client: + response = _send(client, method, path, body, _bearer(token)) + + # The core, not transport admission, decides the remaining outcome. + assert response.status_code not in {401, 403} + assert response.headers["cache-control"] == _NO_STORE + + +def test_audience_bound_read_identity_is_broad_administrative_authority() -> None: + """A read role plus an audience binding can read the full snapshot (API-404-C3). + + Deployments must therefore never hand such an identity to a participant. + """ + + app, _control_plane = _stub_app() + + with TestClient(app) as client: + snapshot = client.get("/snapshot", headers=_bearer("audience-reader-token")) + summary = client.get("/apparatus/operational-summary", headers=_bearer("audience-reader-token")) + + assert snapshot.status_code == 200 + assert summary.status_code == 200 + assert snapshot.headers["cache-control"] == summary.headers["cache-control"] == _NO_STORE + + +# --- route-bypass attempts ------------------------------------------------- + + +def test_route_variants_and_untrusted_identity_headers_do_not_bypass_admission() -> None: + app, _control_plane = _stub_app() + spoofed = {"x-raes-client-verified": "true", "x-raes-client-identity": "backend"} + + with TestClient(app) as client: + redirected = client.get("/snapshot/", follow_redirects=False) + followed = client.get("/snapshot/") + head = client.head("/snapshot") + header_identity = client.get("/snapshot", headers=spoofed) + bad_token_with_headers = client.get("/snapshot", headers={**spoofed, **_bearer("unknown-token")}) + + assert redirected.status_code == 307 + assert followed.status_code == 401 + assert head.status_code == 405 + assert header_identity.status_code == 401 + assert bad_token_with_headers.status_code == 401 + + +def test_public_probes_and_api_description_expose_no_runtime_state() -> None: + app, control_plane = _stub_app() + target_name = control_plane.target_name + + with TestClient(app) as client: + live = client.get("/health/live") + ready = client.get("/health/ready") + description = client.get("/openapi.json") + + assert live.status_code == 200 + assert set(live.json()) == set(ready.json()) == {"status", "reasons"} + assert live.headers["cache-control"] == ready.headers["cache-control"] == _NO_STORE + for response in (live, ready, description): + assert target_name not in response.text + assert "run:" not in response.text + + +# --- operation readback, idempotency and errors ------------------------------ + + +def test_operation_readback_and_replay_are_actor_scoped_and_uncacheable() -> None: + app, _control_plane = _stub_app() + initialize = f"/participants/{_ALICE}/episodes/initialize" + headers = {**_bearer("backend-token"), "idempotency-key": "retry-1"} + + with TestClient(app) as client: + first = client.post(initialize, json={"episode_id": "episode-a"}, headers=headers) + replay = client.post(initialize, json={"episode_id": "episode-a"}, headers=headers) + conflicting = client.post(initialize, json={"episode_id": "episode-b"}, headers=headers) + operation_id = first.json()["operation_id"] + owner = client.get(f"/operations/{operation_id}", headers=_bearer("backend-token")) + other_actor = client.get(f"/operations/{operation_id}", headers=_bearer("audience-reader-token")) + unknown = client.get("/operations/op-unknown", headers=_bearer("audience-reader-token")) + + assert first.status_code == replay.status_code == owner.status_code == 200 + assert replay.json()["operation_id"] == operation_id + assert conflicting.status_code == 409 + assert other_actor.status_code == unknown.status_code == 404 + assert other_actor.json() == unknown.json() + for response in (first, replay, conflicting, owner, other_actor): + assert response.headers["cache-control"] == _NO_STORE + + +def test_rejected_and_failed_responses_are_uncacheable(monkeypatch: pytest.MonkeyPatch) -> None: + app, control_plane = _stub_app(max_request_bytes=64) + monkeypatch.setattr(control_plane, "get_snapshot", lambda: (_ for _ in ()).throw(RuntimeError("secret"))) + + with TestClient(app, raise_server_exceptions=False) as client: + oversized = client.post( + "/operations/provisioning", + json={"operations": [], "padding": "x" * 128}, + headers=_bearer("backend-token"), + ) + invalid = client.post( + "/operations/provisioning", + json={"operations": "not-a-list"}, + headers=_bearer("backend-token"), + ) + failed = client.get("/snapshot", headers=_bearer("auditor-token")) + + assert (oversized.status_code, invalid.status_code, failed.status_code) == (413, 422, 500) + for response in (oversized, invalid, failed): + assert response.headers["cache-control"] == _NO_STORE + assert "secret" not in failed.text + + +# --- participant views ------------------------------------------------------ + + +class _ViewEvidenceResolver(StaticCrossingResolver): + def resolve_participant_view_evidence(self, **_kwargs: object): + return evidence() + + +def _governed_identity(name: str, *bindings: ParticipantAudienceSubjectBinding) -> ControlPlaneIdentity: + return _identity(name, ControlPlaneRole.OPERATOR, participant_audience_subjects=bindings) + + +def _governed_app() -> tuple[FastAPI, RuntimeControlPlane]: + target = policy_capable_target("participant_egress_projection", "participant_transformation") + control_plane = action_plane(_ViewEvidenceResolver(), target=target) + bound = ParticipantAudienceSubjectBinding(participant_address=PARTICIPANT, audience_scope_ref=AUDIENCE) + other = ParticipantAudienceSubjectBinding( + participant_address="participant.behavior.other", audience_scope_ref=AUDIENCE + ) + security = ControlPlaneSecurityConfig( + bearer_tokens={ + "bound-token": _governed_identity("bound", bound), + "other-participant-token": _governed_identity("other-participant", other), + "unbound-token": _governed_identity("unbound"), + "ambiguous-token": _governed_identity( + "ambiguous", + bound, + ParticipantAudienceSubjectBinding(participant_address=PARTICIPANT, audience_scope_ref="audience:other"), + ), + "participant-limited-token": _identity( + "participant-limited", + participant_audience_subjects=(bound,), + ), + } + ) + return create_control_plane_app(control_plane, security=security), control_plane + + +def _crossings(control_plane: RuntimeControlPlane) -> int: + return len(control_plane.snapshot.participant_crossing_history.get(PARTICIPANT, ())) + + +def test_governed_view_is_bound_to_one_participant_without_an_existence_oracle() -> None: + app, _control_plane = _governed_app() + + with TestClient(app) as client: + bound = client.get(f"/participants/{PARTICIPANT}/status", headers=_bearer("bound-token")) + other = client.get(f"/participants/{PARTICIPANT}/status", headers=_bearer("other-participant-token")) + unknown = client.get("/participants/participant.behavior.unknown/status", headers=_bearer("bound-token")) + unbound = client.get(f"/participants/{PARTICIPANT}/status", headers=_bearer("unbound-token")) + limited = client.get(f"/participants/{PARTICIPANT}/status", headers=_bearer("participant-limited-token")) + ambiguous = client.get(f"/participants/{PARTICIPANT}/status", headers=_bearer("ambiguous-token")) + + assert bound.status_code == 200 + assert bound.json()["participant_address"] == PARTICIPANT + denied = (other, unknown, unbound, limited) + assert all(response.status_code == 403 for response in denied) + assert {response.text for response in denied} == {'{"detail":"forbidden"}'} + assert ambiguous.status_code == 409 + for response in (bound, *denied, ambiguous): + assert response.headers["cache-control"] == _NO_STORE + + +def test_governed_view_rejects_an_episode_outside_the_bound_participant_state() -> None: + app, _control_plane = _governed_app() + + with TestClient(app) as client: + current = client.get( + f"/participants/{PARTICIPANT}/episodes/episode-1/history", + headers=_bearer("bound-token"), + ) + history = client.get( + f"/participants/{PARTICIPANT}/episodes/episode-missing/history", + headers=_bearer("bound-token"), + ) + context = client.get( + f"/participants/{PARTICIPANT}/context", + params={"view_ref": "view.red", "episode_id": "episode-missing"}, + headers=_bearer("bound-token"), + ) + + assert current.status_code == 200 + assert current.json()["episode_id"] == "episode-1" + assert history.status_code == context.status_code == 404 + + +def test_governed_view_replay_returns_the_retained_view_only_within_its_scope() -> None: + app, control_plane = _governed_app() + path = f"/participants/{PARTICIPANT}/status" + + with TestClient(app) as client: + first = client.get(path, headers={**_bearer("bound-token"), "idempotency-key": "view-1"}) + after_first = _crossings(control_plane) + replay = client.get(path, headers={**_bearer("bound-token"), "idempotency-key": "view-1"}) + after_replay = _crossings(control_plane) + cross_scope = client.get(path, headers={**_bearer("other-participant-token"), "idempotency-key": "view-1"}) + after_cross_scope = _crossings(control_plane) + + assert first.status_code == replay.status_code == 200 + assert replay.json() == first.json() + assert after_replay == after_first + assert cross_scope.status_code == 403 + assert after_cross_scope == after_first + assert replay.headers["cache-control"] == _NO_STORE + + +def test_legacy_view_without_a_resolver_is_an_administrative_read_only() -> None: + app, control_plane = _stub_app() + + with TestClient(app) as client: + client.post(f"/participants/{_ALICE}/episodes/initialize", json={}, headers=_bearer("backend-token")) + administrative = client.get(f"/participants/{_ALICE}/status", headers=_bearer("auditor-token")) + limited = client.get(f"/participants/{_ALICE}/status", headers=_bearer("participant-limited-token")) + crossings = control_plane.snapshot.participant_crossing_history + + assert administrative.status_code == 200 + assert administrative.json()["participant_address"] == _ALICE + assert not crossings + assert limited.status_code == 403 + + +def test_audience_binding_is_not_participant_control_authority() -> None: + control_plane = RuntimeControlPlane( + create_stub_target(), + behavior_specifications={_SPEC_ADDRESS: _compiled_specification()}, + ) + audience_only = _identity( + "audience-only", + ControlPlaneRole.OPERATOR, + participant_audience_subjects=( + ParticipantAudienceSubjectBinding(participant_address=_CONTROLLED_PARTICIPANT, audience_scope_ref=AUDIENCE), + ), + ) + app = create_control_plane_app( + control_plane, + security=ControlPlaneSecurityConfig(bearer_tokens={"audience-only-token": audience_only}), + ) + + with TestClient(app) as client: + response = client.post( + f"/participants/{_CONTROLLED_PARTICIPANT}/control-occurrences", + json=_proposal_body(), + headers={**_bearer("audience-only-token"), "idempotency-key": "control-1"}, + ) + history = control_plane.snapshot.participant_control_history + + assert response.status_code == 403 + assert response.json() == {"detail": "forbidden"} + assert not history + + +# --- configured principal shape -------------------------------------------- + + +def _audience_bindings(count: int, *, ref_length: int = 16) -> tuple[ParticipantAudienceSubjectBinding, ...]: + return tuple( + ParticipantAudienceSubjectBinding( + participant_address=f"participant.p{index}", audience_scope_ref="a" * ref_length + ) + for index in range(count) + ) + + +_MALFORMED_PRINCIPALS: dict[str, Callable[[], ControlPlaneIdentity]] = { + "mutable-roles": lambda: ControlPlaneIdentity( + identity="mutable-roles", + roles={ControlPlaneRole.BACKEND}, # type: ignore[arg-type] + target_name=_TARGET, + ), + "string-role": lambda: ControlPlaneIdentity( + identity="string-role", + roles=frozenset({"backend"}), # type: ignore[arg-type] + target_name=_TARGET, + ), + "empty-target": lambda: _identity("empty-target", ControlPlaneRole.BACKEND, target_name=""), + "non-string-target": lambda: _identity("typed-target", target_name=7), # type: ignore[arg-type] + "mutable-audience-bindings": lambda: _identity( + "mutable-audience", + participant_audience_subjects=[_ALICE_AUDIENCE], + ), + "mistyped-control-bindings": lambda: _identity( + "mistyped-control", + participant_control_subjects=(_ALICE_AUDIENCE,), + ), + # Downstream operation-context bounds: actor and each scope entry are at most + # 256 characters and a scope holds at most 64 entries. + "over-bound-identity": lambda: _identity("a" * 257, ControlPlaneRole.AUDITOR), + "over-bound-scope-entry": lambda: _identity( + "long-audience", + ControlPlaneRole.AUDITOR, + participant_audience_subjects=_audience_bindings(1, ref_length=256), + ), + "too-many-scope-entries": lambda: _identity( + "many-audiences", + ControlPlaneRole.AUDITOR, + participant_audience_subjects=_audience_bindings(64), + ), +} + + +@pytest.mark.parametrize("principal", list(_MALFORMED_PRINCIPALS), ids=list(_MALFORMED_PRINCIPALS)) +@pytest.mark.parametrize("mapping", ["bearer_tokens", "trusted_identities"]) +def test_security_config_rejects_malformed_principal_shapes(principal: str, mapping: str) -> None: + secret = "credential-value-1359" + principals = {mapping: {secret: _MALFORMED_PRINCIPALS[principal]()}} + + with pytest.raises(ValueError, match="configured principal") as caught: + ControlPlaneSecurityConfig(**principals) + + assert secret not in str(caught.value) + + +@pytest.mark.parametrize("field", ["require_verified_identity", "trust_proxy_identity_headers"]) +@pytest.mark.parametrize("value", ["false", "true", 0, 1, None]) +def test_security_config_rejects_non_bool_trust_flags(field: str, value: object) -> None: + with pytest.raises(ValueError, match=f"{field} must be a bool"): + ControlPlaneSecurityConfig(**{field: value}) + + +@pytest.mark.parametrize("field", ["max_request_bytes", "max_pending_mutations", "max_pending_rejection_audits"]) +@pytest.mark.parametrize("value", ["64", 1.5, True]) +def test_security_config_rejects_non_int_limits(field: str, value: object) -> None: + with pytest.raises(ValueError, match=f"{field} must be an int"): + ControlPlaneSecurityConfig(**{field: value}) + + +@pytest.mark.parametrize("mapping", ["bearer_tokens", "trusted_identities"]) +@pytest.mark.parametrize("key", ["secret\n", " secret", "secret\t"]) +def test_security_config_rejects_padded_credentials(mapping: str, key: str) -> None: + principals = {mapping: {key: _identity("padded", ControlPlaneRole.AUDITOR)}} + + with pytest.raises(ValueError, match="unpadded") as caught: + ControlPlaneSecurityConfig(**principals) + + assert "secret" not in str(caught.value) + + +def test_principal_at_the_operation_context_bounds_is_admitted() -> None: + principal = _identity( + "a" * 256, + ControlPlaneRole.AUDITOR, + participant_audience_subjects=_audience_bindings(63), + ) + app = create_control_plane_app( + RuntimeControlPlane(create_stub_target()), + security=ControlPlaneSecurityConfig(bearer_tokens={"bounded-token": principal}), + ) + + with TestClient(app) as client: + response = client.get("/snapshot", headers=_bearer("bounded-token")) + + assert response.status_code == 200 + + +@pytest.mark.parametrize( + ("participant_address", "subject_ref", "message"), + [ + (["participant.alice"], "audience:alice", "must be strings"), + ("participant.alice", 7, "must be strings"), + ("", "audience:alice", "must be non-empty"), + ("participant:alice", "audience:alice", "must not contain ':'"), + ], +) +@pytest.mark.parametrize("binding_type", [ParticipantAudienceSubjectBinding, ParticipantControlSubjectBinding]) +def test_subject_bindings_reject_fields_that_cannot_encode_an_unambiguous_scope( + binding_type: type, + participant_address: object, + subject_ref: object, + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + binding_type(participant_address, subject_ref) diff --git a/implementations/python/tests/test_runtime_control_plane_api.py b/implementations/python/tests/test_runtime_control_plane_api.py index 11eec0e06..4c983b714 100644 --- a/implementations/python/tests/test_runtime_control_plane_api.py +++ b/implementations/python/tests/test_runtime_control_plane_api.py @@ -55,6 +55,7 @@ from raes_runtime.control_plane_security import ( ControlPlaneIdentity, ControlPlaneRole, + ControlPlaneRouteAuthority, ControlPlaneSecurityConfig, ) from raes_runtime.control_plane_store import ( @@ -1694,7 +1695,7 @@ def test_control_plane_api_rejects_invalid_bearer_token_instead_of_trusting_head audits = control_plane.audit_log() assert response.status_code == 401 - assert response.json() == {"detail": "invalid bearer token"} + assert response.json() == {"detail": "unauthorized"} assert audits[-1].reason == "invalid bearer token" assert audits[-1].allowed is False @@ -1721,10 +1722,10 @@ def test_control_plane_auth_rejects_non_ascii_bearer_token_as_unauthorized(): ) with pytest.raises(HTTPException) as excinfo: - auth.read_identity(request) + auth.admit(request, ControlPlaneRouteAuthority.ADMINISTRATIVE_READ) assert excinfo.value.status_code == 401 - assert excinfo.value.detail == "invalid bearer token" + assert excinfo.value.detail == "unauthorized" def test_control_plane_api_rejects_bearer_token_bound_to_another_target(): @@ -1754,7 +1755,7 @@ def test_control_plane_api_rejects_bearer_token_bound_to_another_target(): ) assert response.status_code == 403 - assert response.json() == {"detail": "identity is not authorized for this target"} + assert response.json() == {"detail": "forbidden"} @pytest.mark.parametrize("authentication_path", ["bearer", "verified-proxy"]) @@ -1783,7 +1784,7 @@ def test_control_plane_api_rejects_identity_without_target_binding(authenticatio response = client.get("/snapshot", headers=headers) assert response.status_code == 403 - assert response.json() == {"detail": "identity is not authorized for this target"} + assert response.json() == {"detail": "forbidden"} def test_control_plane_security_config_mappings_cannot_be_mutated_after_construction():