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
|
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
|
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||||
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||||
/// means the token was structurally valid but didn't match any active
|
/// means the token was structurally valid but didn't match any active
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ pub use auth_bootstrap::{
|
|||||||
};
|
};
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
pub use auth_middleware::{
|
pub use auth_middleware::{
|
||||||
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
|
attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
|
||||||
API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||||
};
|
};
|
||||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use axum::{
|
|||||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::post,
|
routing::post,
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
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
|
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
||||||
/// always-available ID-based bypass.
|
/// 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
|
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||||
where
|
where
|
||||||
E: ExecutorClient + 'static,
|
E: ExecutorClient + 'static,
|
||||||
@@ -68,6 +73,10 @@ where
|
|||||||
/// Build a router that handles ALL paths via the user-defined routing
|
/// Build a router that handles ALL paths via the user-defined routing
|
||||||
/// table. Intended to be merged into the picloud app router as a
|
/// table. Intended to be merged into the picloud app router as a
|
||||||
/// fallback (after the system routes are mounted).
|
/// 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
|
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||||
where
|
where
|
||||||
E: ExecutorClient + 'static,
|
E: ExecutorClient + 'static,
|
||||||
@@ -85,6 +94,7 @@ where
|
|||||||
async fn execute_by_id<E, R>(
|
async fn execute_by_id<E, R>(
|
||||||
State(state): State<DataPlaneState<E, R>>,
|
State(state): State<DataPlaneState<E, R>>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
|
Extension(principal): Extension<Option<Principal>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<Response, ApiError>
|
) -> Result<Response, ApiError>
|
||||||
@@ -98,10 +108,7 @@ where
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ApiError::NotFound(id))?;
|
.ok_or(ApiError::NotFound(id))?;
|
||||||
|
|
||||||
// Principal stays `None` until the data-plane `attach_principal_if_present`
|
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||||
// 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)?;
|
|
||||||
req.sandbox_overrides = script.sandbox;
|
req.sandbox_overrides = script.sandbox;
|
||||||
let request_id = req.request_id;
|
let request_id = req.request_id;
|
||||||
let request_path = req.path.clone();
|
let request_path = req.path.clone();
|
||||||
@@ -137,6 +144,7 @@ where
|
|||||||
|
|
||||||
async fn user_route_handler<E, R>(
|
async fn user_route_handler<E, R>(
|
||||||
State(state): State<DataPlaneState<E, R>>,
|
State(state): State<DataPlaneState<E, R>>,
|
||||||
|
Extension(principal): Extension<Option<Principal>>,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Result<Response, ApiError>
|
) -> Result<Response, ApiError>
|
||||||
where
|
where
|
||||||
@@ -200,7 +208,7 @@ where
|
|||||||
&headers,
|
&headers,
|
||||||
&body_bytes,
|
&body_bytes,
|
||||||
app_id,
|
app_id,
|
||||||
None,
|
principal,
|
||||||
)?;
|
)?;
|
||||||
req.path = path;
|
req.path = path;
|
||||||
req.params = matched.params;
|
req.params = matched.params;
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use axum::{routing::get, Json, Router};
|
|||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||||
auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
|
attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
|
||||||
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
|
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||||
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
|
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||||
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||||
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
|
||||||
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
|
||||||
RouteRepository, SandboxCeiling,
|
RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
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.
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
let _ = apps_api::AppsState::clone;
|
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()
|
let api_v1 = Router::new()
|
||||||
.nest("/admin", auth_router(auth_state))
|
.nest("/admin", auth_router(auth_state))
|
||||||
.nest("/admin", guarded_admin)
|
.nest("/admin", guarded_admin)
|
||||||
.merge(data_plane_router(data_plane.clone()));
|
.merge(data_plane_routed);
|
||||||
|
|
||||||
Ok(Router::new()
|
Ok(Router::new()
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route("/version", get(version))
|
.route("/version", get(version))
|
||||||
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
||||||
.merge(user_routes_router(data_plane))
|
.merge(user_routes)
|
||||||
.layer(TraceLayer::new_for_http()))
|
.layer(TraceLayer::new_for_http()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user