From 902dd78027731698b98fe6be37039b40ca744cb9 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 30 May 2026 18:53:27 +0200 Subject: [PATCH] feat(picloud): opportunistic principal middleware on the data plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-plane (POST /execute/{id} + user-route fallback) is unauthenticated by default — public scripts get hit by anonymous HTTP traffic. But some calls are authed (dashboard test-runs, API-key invocations) and v1.1.x services will want to see the caller via `cx.principal` for audit / authz once those features land. - New manager-core::attach_principal_if_present middleware. Always inserts Extension>: Some on resolved bearer/cookie, None on absent or malformed token. Fail-open on DB blip so a transient infra failure can't 500 anonymous traffic. - Wired in picloud build_app, scoped to the data-plane and user-routes routers only. The admin path keeps using require_authenticated; no double-resolve on the same token. - orchestrator-core handlers (execute_by_id, user_route_handler) now extract Extension> and pass it to build_exec_request. Replaces the temporary `None` placeholders from the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/auth_middleware.rs | 29 ++++++++++++++++++ crates/manager-core/src/lib.rs | 4 +-- crates/orchestrator-core/src/api.rs | 20 +++++++++---- crates/picloud/src/lib.rs | 35 +++++++++++++++------- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/crates/manager-core/src/auth_middleware.rs b/crates/manager-core/src/auth_middleware.rs index 754b5ce..2136534 100644 --- a/crates/manager-core/src/auth_middleware.rs +++ b/crates/manager-core/src/auth_middleware.rs @@ -100,6 +100,35 @@ pub async fn require_admin(state: State, req: Request, next: Ne require_authenticated(state, req, next).await } +/// Opportunistic data-plane variant: always inserts an +/// `Extension>` and forwards the request. Used on +/// `/execute/{id}` and the user-route fallback, where most invocations +/// are anonymous public HTTP and the few authed ones (dashboard +/// test-runs, API keys) should still let scripts see the caller via +/// `cx.principal` once services consume it. +/// +/// Failure modes — all degrade to `None` rather than rejecting: +/// * No bearer / cookie → `None`. +/// * Malformed or unknown token → `None`. +/// * DB blip while resolving → `None` (fail-open; the data plane +/// should not 500 on transient infra failures for an *optional* +/// identity check). +/// +/// Admin-side routes that REQUIRE an identity keep using +/// `require_authenticated`. +pub async fn attach_principal_if_present( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let principal: Option = match extract_token(&req) { + Some(token) => resolve_principal(&state, &token).await.unwrap_or(None), + None => None, + }; + req.extensions_mut().insert(principal); + next.run(req).await +} + /// Decide whether the token is an API key (pic_ prefix) or a session /// token, then resolve the corresponding `Principal`. `Ok(None)` /// means the token was structurally valid but didn't match any active diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index b2c249f..b126f9e 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -59,8 +59,8 @@ pub use auth_bootstrap::{ }; #[allow(deprecated)] pub use auth_middleware::{ - require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX, - API_KEY_PREFIX_LEN, SESSION_COOKIE, + attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin, + API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE, }; pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; pub use log_sink::PostgresExecutionLogSink; diff --git a/crates/orchestrator-core/src/api.rs b/crates/orchestrator-core/src/api.rs index 82008e2..779b93e 100644 --- a/crates/orchestrator-core/src/api.rs +++ b/crates/orchestrator-core/src/api.rs @@ -12,7 +12,7 @@ use axum::{ http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::post, - Json, Router, + Extension, Json, Router, }; use chrono::Utc; use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType}; @@ -55,6 +55,11 @@ impl Clone for DataPlaneState { /// Build the data-plane router. Handles `POST /execute/:id` — the /// always-available ID-based bypass. +/// +/// Handlers expect an `Extension>` to be attached by +/// upstream middleware (`manager-core::attach_principal_if_present`); +/// requests without that extension panic at extraction time. The +/// picloud binary wires this in `build_app`. pub fn data_plane_router(state: DataPlaneState) -> Router where E: ExecutorClient + 'static, @@ -68,6 +73,10 @@ where /// Build a router that handles ALL paths via the user-defined routing /// table. Intended to be merged into the picloud app router as a /// fallback (after the system routes are mounted). +/// +/// Same middleware expectation as `data_plane_router` — wrap with +/// `attach_principal_if_present` so handlers can extract +/// `Extension>`. pub fn user_routes_router(state: DataPlaneState) -> Router where E: ExecutorClient + 'static, @@ -85,6 +94,7 @@ where async fn execute_by_id( State(state): State>, Path(id): Path, + Extension(principal): Extension>, headers: HeaderMap, body: Bytes, ) -> Result @@ -98,10 +108,7 @@ where .await? .ok_or(ApiError::NotFound(id))?; - // Principal stays `None` until the data-plane `attach_principal_if_present` - // middleware lands in the picloud-wiring commit. Both shapes are - // valid against `ExecRequest.principal: Option`. - let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, None)?; + let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?; req.sandbox_overrides = script.sandbox; let request_id = req.request_id; let request_path = req.path.clone(); @@ -137,6 +144,7 @@ where async fn user_route_handler( State(state): State>, + Extension(principal): Extension>, request: Request, ) -> Result where @@ -200,7 +208,7 @@ where &headers, &body_bytes, app_id, - None, + principal, )?; req.path = path; req.params = matched.params; diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 1203f8d..297301f 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -11,14 +11,14 @@ use axum::{routing::get, Json, Router}; use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router, - auth_router, compile_routes, migrations, require_authenticated, route_admin_router, - AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, - ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, - AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, - PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, - PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, - RouteRepository, SandboxCeiling, + attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated, + route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, + ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, + AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, + PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, + PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository, + PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver, + RouteAdminState, RouteRepository, SandboxCeiling, }; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::{ @@ -206,16 +206,31 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { // facade above; the bare module path is retained so it's discoverable. let _ = apps_api::AppsState::clone; + // Opportunistic principal extraction on every data-plane request. + // Always inserts `Extension>`: Some for authed + // ingress (bearer / cookie), None otherwise. Handlers depend on + // this layer being applied — scoped to the data-plane routers so + // the admin path (which uses `require_authenticated`) doesn't + // double-resolve the same token. + let data_plane_routed = data_plane_router(data_plane.clone()).layer(from_fn_with_state( + auth_state.clone(), + attach_principal_if_present, + )); + let user_routes = user_routes_router(data_plane).layer(from_fn_with_state( + auth_state.clone(), + attach_principal_if_present, + )); + let api_v1 = Router::new() .nest("/admin", auth_router(auth_state)) .nest("/admin", guarded_admin) - .merge(data_plane_router(data_plane.clone())); + .merge(data_plane_routed); Ok(Router::new() .route("/healthz", get(healthz)) .route("/version", get(version)) .nest(&format!("/api/v{API_VERSION}"), api_v1) - .merge(user_routes_router(data_plane)) + .merge(user_routes) .layer(TraceLayer::new_for_http())) }