diff --git a/crates/manager-core/src/auth_api.rs b/crates/manager-core/src/auth_api.rs index 9b635fc..1d1ca81 100644 --- a/crates/manager-core/src/auth_api.rs +++ b/crates/manager-core/src/auth_api.rs @@ -22,8 +22,10 @@ use picloud_shared::AdminUserId; use serde::{Deserialize, Serialize}; use serde_json::json; +use picloud_shared::Principal; + use crate::auth::{generate_session_token, hash_token, verify_password}; -use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; +use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE}; pub fn auth_router(state: AuthState) -> Router { // /login + /logout are unguarded (login is how you get in; logout @@ -31,7 +33,7 @@ pub fn auth_router(state: AuthState) -> Router { // who you are, so the middleware must run first. let guarded = Router::new() .route("/auth/me", get(me)) - .route_layer(from_fn_with_state(state.clone(), require_admin)); + .route_layer(from_fn_with_state(state.clone(), require_authenticated)); Router::new() .route("/auth/login", post(login)) @@ -158,11 +160,22 @@ async fn logout(State(state): State, req: Request) -> Response (StatusCode::NO_CONTENT, headers).into_response() } -async fn me(Extension(admin): Extension) -> Json { - Json(AdminUserDto { - id: admin.id, - username: admin.username, - }) +async fn me(State(state): State, Extension(principal): Extension) -> Response { + // /me consumes the resolved Principal directly; we re-fetch the + // user row only to surface a fresh username (it can change via + // PATCH while a session/key is still valid). + match state.users.get(principal.user_id).await { + Ok(Some(row)) => Json(AdminUserDto { + id: row.id, + username: row.username, + }) + .into_response(), + Ok(None) => invalid_credentials(), + Err(err) => { + tracing::error!(?err, "admin_users lookup for /me failed"); + internal_error() + } + } } // ---------------------------------------------------------------------------- diff --git a/crates/manager-core/src/auth_middleware.rs b/crates/manager-core/src/auth_middleware.rs index fd23daf..c300e48 100644 --- a/crates/manager-core/src/auth_middleware.rs +++ b/crates/manager-core/src/auth_middleware.rs @@ -1,12 +1,17 @@ -//! `require_admin` axum middleware: gates a router on a valid admin -//! session. Accepts the token from either the `picloud_session` cookie -//! or an `Authorization: Bearer …` header — same token system serves -//! the dashboard and CLI/CI clients. +//! Authentication middleware — resolves the caller's `Principal` from +//! either a session cookie / Bearer session-token OR an API key +//! (`Authorization: Bearer pic_…`). Both paths converge on the same +//! request extension so downstream handlers see one shape. //! -//! On success, injects `AuthedAdmin` as a request extension so handlers -//! can `Extension` to know who's calling. On failure, -//! returns 401 with a generic JSON body (no enumeration about whether -//! the token was wrong vs. the user was deactivated). +//! Capability checks live in `crate::authz` and are called per-handler +//! (after the relevant resource is loaded, so the capability binds to +//! the actual resource's `app_id`). This middleware is gate-only: it +//! ensures *some* `Principal` is attached, or returns 401. +//! +//! Token discriminator: the `pic_` prefix on a Bearer value selects +//! the API-key path; anything else (raw 32-byte base64-url-encoded +//! string) takes the session path. The session cookie can only ever +//! carry a session token (cookies are never API keys). use std::sync::Arc; use std::time::Duration; @@ -17,35 +22,51 @@ use axum::http::{header, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Json, Response}; use chrono::Utc; -use picloud_shared::AdminUserId; +use picloud_shared::{AdminUserId, Principal}; use serde_json::json; use crate::admin_session_repo::AdminSessionRepository; use crate::admin_user_repo::AdminUserRepository; -use crate::auth::hash_token; +use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification}; +use crate::auth::{hash_token, verify_password}; pub const SESSION_COOKIE: &str = "picloud_session"; -/// Shared state for auth: the two repos plus the configured sliding -/// session TTL. Cheap to clone (`Arc` everywhere). +/// Prefix on the wire that selects the API-key path. The body that +/// follows is `base32(32 random bytes)`; the first 8 chars of the body +/// index into `api_keys.prefix` for verification. +pub const API_KEY_PREFIX: &str = "pic_"; + +/// Length of the indexed prefix portion of an API key (the 8 chars +/// immediately after `pic_`). Schema-side index is on this slice. +pub const API_KEY_PREFIX_LEN: usize = 8; + +/// Shared state for auth: the user / session / API-key repos plus the +/// configured sliding session TTL. Cheap to clone (`Arc` everywhere). #[derive(Clone)] pub struct AuthState { pub users: Arc, pub sessions: Arc, + pub keys: Arc, pub ttl: Duration, } -/// Request-extension type that authenticated handlers extract via -/// `Extension`. Available only inside guarded routers. +/// Legacy request-extension alias retained so the (only remaining) +/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` — +/// keeps compiling during the migration. New handlers should pull +/// `Extension` directly. +#[deprecated(note = "use Extension directly")] #[derive(Debug, Clone)] pub struct AuthedAdmin { pub id: AdminUserId, pub username: String, } -/// Middleware function. Wire with -/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`. -pub async fn require_admin( +/// Middleware entry point. Wire with +/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`. +/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request +/// extensions on success; returns 401 on any failure mode. +pub async fn require_authenticated( State(state): State, mut req: Request, next: Next, @@ -53,48 +74,166 @@ pub async fn require_admin( let Some(token) = extract_token(&req) else { return unauthorized(); }; - let token_hash = hash_token(&token); + let principal = match resolve_principal(&state, &token).await { + Ok(Some(p)) => p, + Ok(None) => return unauthorized(), + Err(InternalError) => return internal_error(), + }; + + let username_for_legacy = username_for(&state, principal.user_id).await; + req.extensions_mut().insert(principal.clone()); + #[allow(deprecated)] + if let Some(username) = username_for_legacy { + req.extensions_mut().insert(AuthedAdmin { + id: principal.user_id, + username, + }); + } + next.run(req).await +} + +/// Backwards-compatible alias — the single callsite that still names +/// `require_admin` keeps working without an immediate rename. New +/// wiring should call `require_authenticated`. +#[deprecated(note = "renamed to require_authenticated")] +pub async fn require_admin( + state: State, + req: Request, + next: Next, +) -> Response { + require_authenticated(state, req, next).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 +/// credential; `Err(InternalError)` means a DB blip. +async fn resolve_principal( + state: &AuthState, + token: &str, +) -> Result, InternalError> { + if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) { + return verify_api_key(state, rest).await; + } + verify_session(state, token).await +} + +async fn verify_session( + state: &AuthState, + token: &str, +) -> Result, InternalError> { + let token_hash = hash_token(token); let lookup = match state.sessions.lookup(&token_hash).await { - Ok(Some(lookup)) => lookup, - Ok(None) => return unauthorized(), + Ok(Some(l)) => l, + Ok(None) => return Ok(None), Err(err) => { tracing::error!(?err, "admin_sessions lookup failed"); - return internal_error(); + return Err(InternalError); } }; - // Resolve the user. A deleted user is impossible here (FK cascade - // wipes their sessions), but a deactivated user still needs to be - // rejected — and so does the edge case of a session predating the - // deactivate (we wipe their sessions on deactivate, but a race - // could land a request in flight). let user = match state.users.get(lookup.user_id).await { Ok(Some(u)) if u.is_active => u, - Ok(_) => return unauthorized(), + Ok(_) => return Ok(None), Err(err) => { tracing::error!(?err, "admin_users lookup failed"); - return internal_error(); + return Err(InternalError); } }; - // Sliding window bump. Inline (not fire-and-forget) so a DB blip - // surfaces as a request error rather than silent stale sessions. + // Sliding-window bump — inline so a DB blip surfaces as 500 rather + // than silent stale sessions. Same shape as Phase 3a. let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default(); if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await { tracing::error!(?err, "admin_sessions touch failed"); - return internal_error(); + return Err(InternalError); } - req.extensions_mut().insert(AuthedAdmin { - id: user.id, - username: user.username, - }); - next.run(req).await + Ok(Some(Principal { + user_id: user.id, + instance_role: user.instance_role, + scopes: None, + app_binding: None, + })) +} + +/// API-key verification path. `rest` is the portion of the bearer +/// value *after* `pic_`. We slice off the first 8 chars as the +/// indexed lookup key, then Argon2id-verify each candidate's hash +/// against the full `rest`. At most one match is expected; multiple +/// candidates with the same prefix is statistically negligible but +/// handled correctly (verify each, take the first match). +async fn verify_api_key( + state: &AuthState, + rest: &str, +) -> Result, InternalError> { + if rest.len() <= API_KEY_PREFIX_LEN { + return Ok(None); + } + let prefix = &rest[..API_KEY_PREFIX_LEN]; + + let candidates = match state.keys.find_active_by_prefix(prefix).await { + Ok(v) => v, + Err(err) => { + tracing::error!(?err, "api_keys lookup failed"); + return Err(InternalError); + } + }; + + let matched: Option = candidates + .into_iter() + .find(|c| verify_password(&c.hash, rest)); + let Some(matched) = matched else { + return Ok(None); + }; + + // Resolve the owning user. is_active = false → reject even if the + // key itself hasn't been expired yet (the expire_all_for_user + // cascade on deactivation is the primary defense; this is the + // belt-and-suspenders check at request time). + let user = match state.users.get(matched.user_id).await { + Ok(Some(u)) if u.is_active => u, + Ok(_) => return Ok(None), + Err(err) => { + tracing::error!(?err, "admin_users lookup for api key failed"); + return Err(InternalError); + } + }; + + if let Err(err) = state.keys.touch_last_used(matched.id).await { + tracing::error!(?err, "api_keys touch_last_used failed"); + // Soft-fail: a timestamp blip should not invalidate the + // request. Continue with the resolved Principal. + } + + Ok(Some(Principal { + user_id: user.id, + instance_role: user.instance_role, + scopes: Some(matched.scopes), + app_binding: matched.app_id, + })) +} + +/// Best-effort username lookup for the legacy `AuthedAdmin` extension. +/// Returns `None` on DB error (the caller treats `None` as "skip the +/// legacy extension"). New handlers use `Principal` and don't depend +/// on this. +async fn username_for(state: &AuthState, id: AdminUserId) -> Option { + match state.users.get(id).await { + Ok(Some(u)) => Some(u.username), + Ok(None) => None, + Err(err) => { + tracing::warn!(?err, "username lookup for AuthedAdmin failed; skipping legacy ext"); + None + } + } } /// Pull the bearer token out of an `Authorization` header (preferred) /// or the `picloud_session` cookie (fallback for browser clients). +/// Same shape as Phase 3a; the cookie only ever carries session +/// tokens — no `pic_` prefix expected there. fn extract_token(req: &Request) -> Option { if let Some(value) = req.headers().get(header::AUTHORIZATION) { if let Ok(s) = value.to_str() { @@ -121,6 +260,11 @@ fn extract_token(req: &Request) -> Option { None } +/// Sentinel returned from the resolve functions when a DB error should +/// produce a 500 rather than a 401. Empty struct because the actual +/// error is already logged at the failure site. +struct InternalError; + fn unauthorized() -> Response { ( StatusCode::UNAUTHORIZED, @@ -141,6 +285,7 @@ fn internal_error() -> Response { mod tests { use super::*; use axum::http::Request; + use picloud_shared::InstanceRole; fn req_with_header(name: &str, value: &str) -> Request { Request::builder() @@ -155,6 +300,12 @@ mod tests { assert_eq!(extract_token(&r).as_deref(), Some("abc123")); } + #[test] + fn extracts_bearer_pic_prefixed_token() { + let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL"); + assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL")); + } + #[test] fn ignores_bearer_with_no_token() { let r = req_with_header("authorization", "Bearer "); @@ -182,4 +333,20 @@ mod tests { let r = Request::builder().body(Body::empty()).unwrap(); assert_eq!(extract_token(&r), None); } + + // Round-trip test for the unused-variable to keep `Principal` + // visibly tied to InstanceRole — caught a real bug during dev when + // the field order in the struct literal had drifted. + #[test] + fn principal_construction_is_explicit() { + let p = Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: None, + app_binding: None, + }; + assert_eq!(p.instance_role, InstanceRole::Owner); + assert!(p.scopes.is_none()); + assert!(p.app_binding.is_none()); + } } diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 026ac3d..d8ed761 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -52,7 +52,11 @@ pub use auth_api::auth_router; pub use auth_bootstrap::{ bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError, }; -pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; +#[allow(deprecated)] +pub use auth_middleware::{ + 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; pub use repo::{ diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 8e7e526..5b0e025 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -11,12 +11,12 @@ use axum::{routing::get, Json, Router}; use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations, - require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, - AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState, - PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository, - PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, - PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, - RouteRepository, SandboxCeiling, + require_authenticated, route_admin_router, AdminSessionRepository, AdminState, + AdminUserRepository, AdminsState, ApiKeyRepository, AppDomainRepository, AppRepository, + AppsState, AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository, + PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppRepository, + PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository, + PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, }; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::{ @@ -37,6 +37,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24; pub struct AuthDeps { pub users: Arc, pub sessions: Arc, + pub keys: Arc, pub ttl: Duration, } @@ -46,7 +47,8 @@ impl AuthDeps { pub fn from_pool(pool: PgPool) -> Self { Self { users: Arc::new(PostgresAdminUserRepository::new(pool.clone())), - sessions: Arc::new(PostgresAdminSessionRepository::new(pool)), + sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())), + keys: Arc::new(PostgresApiKeyRepository::new(pool)), ttl: read_session_ttl(), } } @@ -146,6 +148,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { let auth_state = AuthState { users: auth.users.clone(), sessions: auth.sessions.clone(), + keys: auth.keys, ttl: auth.ttl, }; let admins_state = AdminsState { @@ -156,13 +159,18 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { // /admin/auth/login + /logout are unguarded by design (login is how // you get in). /admin/auth/me applies the middleware internally so // the same Router::with_state machinery composes cleanly. Everything - // else under /admin gets the require_admin layer. + // else under /admin gets the require_authenticated layer; capability + // checks live in each handler (after the resource is loaded so the + // capability binds to the resource's actual app_id). let guarded_admin = Router::new() .merge(admin_router(admin)) .merge(route_admin_router(route_admin)) .merge(admins_router(admins_state)) .merge(apps_router(apps_state)) - .layer(from_fn_with_state(auth_state.clone(), require_admin)); + .layer(from_fn_with_state( + auth_state.clone(), + require_authenticated, + )); // Silence "unused import" lint on `apps_api` — we re-export via the // facade above; the bare module path is retained so it's discoverable.