feat(picloud): opportunistic principal middleware on the data plane
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<Option<Principal>>: 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<Option<Principal>> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,35 @@ pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Ne
|
||||
require_authenticated(state, req, next).await
|
||||
}
|
||||
|
||||
/// Opportunistic data-plane variant: always inserts an
|
||||
/// `Extension<Option<Principal>>` 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<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let principal: Option<Principal> = 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<E, R> Clone for DataPlaneState<E, R> {
|
||||
|
||||
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
||||
/// always-available ID-based bypass.
|
||||
///
|
||||
/// Handlers expect an `Extension<Option<Principal>>` 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<E, R>(state: DataPlaneState<E, R>) -> 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<Option<Principal>>`.
|
||||
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
@@ -85,6 +94,7 @@ where
|
||||
async fn execute_by_id<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Path(id): Path<ScriptId>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response, ApiError>
|
||||
@@ -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<Principal>`.
|
||||
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<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
request: Request,
|
||||
) -> Result<Response, ApiError>
|
||||
where
|
||||
@@ -200,7 +208,7 @@ where
|
||||
&headers,
|
||||
&body_bytes,
|
||||
app_id,
|
||||
None,
|
||||
principal,
|
||||
)?;
|
||||
req.path = path;
|
||||
req.params = matched.params;
|
||||
|
||||
@@ -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<Router> {
|
||||
// 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<Option<Principal>>`: 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()))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user