feat(manager-core,picloud): bearer pic_ keys land in Principal

* auth_middleware: split into resolve_principal → verify_session OR
  verify_api_key (selected by the pic_ prefix). Both paths converge on
  Principal as the request extension; require_admin keeps working as
  a #[deprecated] alias for require_authenticated. AuthState gains an
  api_keys repo; the cookie path is unchanged.
* api-key path takes the first 8 chars after pic_ as the indexed
  lookup key, Argon2-verifies each candidate, soft-rejects deactivated
  users, and updates last_used_at inline.
* auth_api: /auth/me now consumes Extension<Principal> and re-fetches
  the user row so username updates surface immediately.
* picloud: AuthDeps + AuthState wired with PostgresApiKeyRepository;
  the layer call switches to require_authenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-26 21:55:38 +02:00
parent 44db8d107a
commit 5f7ddd23ab
4 changed files with 245 additions and 53 deletions

View File

@@ -22,8 +22,10 @@ use picloud_shared::AdminUserId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use picloud_shared::Principal;
use crate::auth::{generate_session_token, hash_token, verify_password}; 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 { pub fn auth_router(state: AuthState) -> Router {
// /login + /logout are unguarded (login is how you get in; logout // /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. // who you are, so the middleware must run first.
let guarded = Router::new() let guarded = Router::new()
.route("/auth/me", get(me)) .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() Router::new()
.route("/auth/login", post(login)) .route("/auth/login", post(login))
@@ -158,11 +160,22 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
(StatusCode::NO_CONTENT, headers).into_response() (StatusCode::NO_CONTENT, headers).into_response()
} }
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> { async fn me(State(state): State<AuthState>, Extension(principal): Extension<Principal>) -> Response {
Json(AdminUserDto { // /me consumes the resolved Principal directly; we re-fetch the
id: admin.id, // user row only to surface a fresh username (it can change via
username: admin.username, // 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()
}
}
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -1,12 +1,17 @@
//! `require_admin` axum middleware: gates a router on a valid admin //! Authentication middleware — resolves the caller's `Principal` from
//! session. Accepts the token from either the `picloud_session` cookie //! either a session cookie / Bearer session-token OR an API key
//! or an `Authorization: Bearer …` header — same token system serves //! (`Authorization: Bearer pic_…`). Both paths converge on the same
//! the dashboard and CLI/CI clients. //! request extension so downstream handlers see one shape.
//! //!
//! On success, injects `AuthedAdmin` as a request extension so handlers //! Capability checks live in `crate::authz` and are called per-handler
//! can `Extension<AuthedAdmin>` to know who's calling. On failure, //! (after the relevant resource is loaded, so the capability binds to
//! returns 401 with a generic JSON body (no enumeration about whether //! the actual resource's `app_id`). This middleware is gate-only: it
//! the token was wrong vs. the user was deactivated). //! 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::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use chrono::Utc; use chrono::Utc;
use picloud_shared::AdminUserId; use picloud_shared::{AdminUserId, Principal};
use serde_json::json; use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository; use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::AdminUserRepository; 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"; pub const SESSION_COOKIE: &str = "picloud_session";
/// Shared state for auth: the two repos plus the configured sliding /// Prefix on the wire that selects the API-key path. The body that
/// session TTL. Cheap to clone (`Arc` everywhere). /// 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)] #[derive(Clone)]
pub struct AuthState { pub struct AuthState {
pub users: Arc<dyn AdminUserRepository>, pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>, pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration, pub ttl: Duration,
} }
/// Request-extension type that authenticated handlers extract via /// Legacy request-extension alias retained so the (only remaining)
/// `Extension<AuthedAdmin>`. Available only inside guarded routers. /// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
/// keeps compiling during the migration. New handlers should pull
/// `Extension<Principal>` directly.
#[deprecated(note = "use Extension<Principal> directly")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthedAdmin { pub struct AuthedAdmin {
pub id: AdminUserId, pub id: AdminUserId,
pub username: String, pub username: String,
} }
/// Middleware function. Wire with /// Middleware entry point. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`. /// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
pub async fn require_admin( /// 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<AuthState>, State(state): State<AuthState>,
mut req: Request<Body>, mut req: Request<Body>,
next: Next, next: Next,
@@ -53,48 +74,166 @@ pub async fn require_admin(
let Some(token) = extract_token(&req) else { let Some(token) = extract_token(&req) else {
return unauthorized(); 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<AuthState>,
req: Request<Body>,
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<Option<Principal>, 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<Option<Principal>, InternalError> {
let token_hash = hash_token(token);
let lookup = match state.sessions.lookup(&token_hash).await { let lookup = match state.sessions.lookup(&token_hash).await {
Ok(Some(lookup)) => lookup, Ok(Some(l)) => l,
Ok(None) => return unauthorized(), Ok(None) => return Ok(None),
Err(err) => { Err(err) => {
tracing::error!(?err, "admin_sessions lookup failed"); 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 { let user = match state.users.get(lookup.user_id).await {
Ok(Some(u)) if u.is_active => u, Ok(Some(u)) if u.is_active => u,
Ok(_) => return unauthorized(), Ok(_) => return Ok(None),
Err(err) => { Err(err) => {
tracing::error!(?err, "admin_users lookup failed"); 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 // Sliding-window bump — inline so a DB blip surfaces as 500 rather
// surfaces as a request error rather than silent stale sessions. // than silent stale sessions. Same shape as Phase 3a.
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default(); 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 { if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
tracing::error!(?err, "admin_sessions touch failed"); tracing::error!(?err, "admin_sessions touch failed");
return internal_error(); return Err(InternalError);
} }
req.extensions_mut().insert(AuthedAdmin { Ok(Some(Principal {
id: user.id, user_id: user.id,
username: user.username, instance_role: user.instance_role,
}); scopes: None,
next.run(req).await 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<Option<Principal>, 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<ApiKeyVerification> = 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<String> {
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) /// Pull the bearer token out of an `Authorization` header (preferred)
/// or the `picloud_session` cookie (fallback for browser clients). /// 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<Body>) -> Option<String> { fn extract_token(req: &Request<Body>) -> Option<String> {
if let Some(value) = req.headers().get(header::AUTHORIZATION) { if let Some(value) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = value.to_str() { if let Ok(s) = value.to_str() {
@@ -121,6 +260,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
None 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 { fn unauthorized() -> Response {
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
@@ -141,6 +285,7 @@ fn internal_error() -> Response {
mod tests { mod tests {
use super::*; use super::*;
use axum::http::Request; use axum::http::Request;
use picloud_shared::InstanceRole;
fn req_with_header(name: &str, value: &str) -> Request<Body> { fn req_with_header(name: &str, value: &str) -> Request<Body> {
Request::builder() Request::builder()
@@ -155,6 +300,12 @@ mod tests {
assert_eq!(extract_token(&r).as_deref(), Some("abc123")); 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] #[test]
fn ignores_bearer_with_no_token() { fn ignores_bearer_with_no_token() {
let r = req_with_header("authorization", "Bearer "); let r = req_with_header("authorization", "Bearer ");
@@ -182,4 +333,20 @@ mod tests {
let r = Request::builder().body(Body::empty()).unwrap(); let r = Request::builder().body(Body::empty()).unwrap();
assert_eq!(extract_token(&r), None); 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());
}
} }

View File

@@ -52,7 +52,11 @@ pub use auth_api::auth_router;
pub use auth_bootstrap::{ pub use auth_bootstrap::{
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError, 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 authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;
pub use repo::{ pub use repo::{

View File

@@ -11,12 +11,12 @@ 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, apps_api, apps_router, auth_router, compile_routes, migrations, admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, require_authenticated, route_admin_router, AdminSessionRepository, AdminState,
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState, AdminUserRepository, AdminsState, ApiKeyRepository, AppDomainRepository, AppRepository,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository, AppsState, AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppRepository,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
RouteRepository, SandboxCeiling, PostgresScriptRepository, RepoResolver, 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::{
@@ -37,6 +37,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
pub struct AuthDeps { pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>, pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>, pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration, pub ttl: Duration,
} }
@@ -46,7 +47,8 @@ impl AuthDeps {
pub fn from_pool(pool: PgPool) -> Self { pub fn from_pool(pool: PgPool) -> Self {
Self { Self {
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())), 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(), ttl: read_session_ttl(),
} }
} }
@@ -146,6 +148,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let auth_state = AuthState { let auth_state = AuthState {
users: auth.users.clone(), users: auth.users.clone(),
sessions: auth.sessions.clone(), sessions: auth.sessions.clone(),
keys: auth.keys,
ttl: auth.ttl, ttl: auth.ttl,
}; };
let admins_state = AdminsState { let admins_state = AdminsState {
@@ -156,13 +159,18 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
// /admin/auth/login + /logout are unguarded by design (login is how // /admin/auth/login + /logout are unguarded by design (login is how
// you get in). /admin/auth/me applies the middleware internally so // you get in). /admin/auth/me applies the middleware internally so
// the same Router::with_state machinery composes cleanly. Everything // 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() let guarded_admin = Router::new()
.merge(admin_router(admin)) .merge(admin_router(admin))
.merge(route_admin_router(route_admin)) .merge(route_admin_router(route_admin))
.merge(admins_router(admins_state)) .merge(admins_router(admins_state))
.merge(apps_router(apps_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 // Silence "unused import" lint on `apps_api` — we re-export via the
// facade above; the bare module path is retained so it's discoverable. // facade above; the bare module path is retained so it's discoverable.