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:
@@ -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<AuthState>, req: Request<Body>) -> Response
|
||||
(StatusCode::NO_CONTENT, headers).into_response()
|
||||
}
|
||||
|
||||
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||
Json(AdminUserDto {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
})
|
||||
async fn me(State(state): State<AuthState>, Extension(principal): Extension<Principal>) -> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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<AuthedAdmin>` 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<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
/// Request-extension type that authenticated handlers extract via
|
||||
/// `Extension<AuthedAdmin>`. 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<Principal>` directly.
|
||||
#[deprecated(note = "use Extension<Principal> 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<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
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<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 {
|
||||
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<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)
|
||||
/// 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> {
|
||||
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<Body>) -> Option<String> {
|
||||
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<Body> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user