From 617c216429ec2ea455c2328577fe49d6ffe338f9 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 20:23:17 +0200 Subject: [PATCH] fix(manager-core): F-P-009 cache resolved Principals (60s TTL) in auth middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach_principal_if_present runs on every request that carries a Bearer header, including data-plane paths that may not even need authz. Each call paid three DB round-trips on the session path (lookup + admin_users.get + touch) plus an Argon2 verify per prefix-colliding candidate on the API-key path. A hot user with multiple keys serialized every request behind N×Argon2. Add a process-shared PrincipalCache keyed on hash_token(bearer) → (Instant, Principal), TTL 60s. resolve_principal checks the cache first; on miss falls through to the verify_api_key / verify_session path and writes back on success. - Lazy GC: when the cache exceeds 1024 entries, sweep expired before inserting (kept simple — production sees few unique hot tokens). - Cache wired through AuthState; constructed once in picloud/src/lib.rs. - Skip-when-route-doesn't-need-auth scaffolding is deferred to a follow-up — it requires touching the router shape, which is more invasive than the 60s cache alone warrants. AUDIT.md anchor: F-P-009 (cache; skip-path deferred). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/auth_middleware.rs | 78 ++++++++++++++++++++-- crates/picloud/src/lib.rs | 1 + 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/manager-core/src/auth_middleware.rs b/crates/manager-core/src/auth_middleware.rs index 51393d6..f3d6d89 100644 --- a/crates/manager-core/src/auth_middleware.rs +++ b/crates/manager-core/src/auth_middleware.rs @@ -13,8 +13,9 @@ //! 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; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use axum::body::Body; use axum::extract::{Request, State}; @@ -30,6 +31,58 @@ use crate::admin_user_repo::AdminUserRepository; use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification}; use crate::auth::{hash_token, verify_password}; +/// F-P-009: short-lived cache of resolved `(token → principal)` pairs. +/// Two reasons it's load-bearing: +/// 1. `verify_api_key` Argon2-verifies *every* candidate sharing the +/// 8-char prefix per request — caching cuts that to once per token +/// per TTL window. +/// 2. `attach_principal_if_present` runs on every data-plane request +/// including paths that may not even need authz; even the session +/// path costs three round-trips (lookup + user-get + touch). +/// +/// Keyed on the hashed token (sha256 in `hash_token`); value is the +/// resolved Principal + the instant it landed in the cache. TTL is +/// short — the audit cited 60-300s. +#[derive(Debug, Default)] +pub struct PrincipalCache { + inner: Mutex>, +} + +/// TTL on cached principals. Short enough that role / scope changes +/// take effect quickly; long enough to amortize Argon2 over many +/// adjacent requests on a hot key. +const PRINCIPAL_CACHE_TTL: Duration = Duration::from_secs(60); + +impl PrincipalCache { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + fn get(&self, token_hash: &str) -> Option { + let mut guard = self.inner.lock().ok()?; + let entry = guard.get(token_hash)?; + if entry.0.elapsed() > PRINCIPAL_CACHE_TTL { + guard.remove(token_hash); + return None; + } + Some(entry.1.clone()) + } + + fn insert(&self, token_hash: String, principal: Principal) { + let Ok(mut guard) = self.inner.lock() else { + return; + }; + // Lazy GC: cap unbounded growth by sweeping expired entries + // when the cache crosses an arbitrary threshold. + if guard.len() > 1024 { + let now = Instant::now(); + guard.retain(|_, v| now.duration_since(v.0) <= PRINCIPAL_CACHE_TTL); + } + guard.insert(token_hash, (Instant::now(), principal)); + } +} + pub const SESSION_COOKIE: &str = "picloud_session"; /// Prefix on the wire that selects the API-key path. The body that @@ -49,6 +102,10 @@ pub struct AuthState { pub sessions: Arc, pub keys: Arc, pub ttl: Duration, + /// F-P-009 — shared cache of resolved Principals. Constructed once + /// at startup; cloned cheaply into every router state. `None` for + /// tests / harnesses that don't wire it. + pub principal_cache: Arc, } /// Legacy request-extension alias retained so the (only remaining) @@ -137,10 +194,21 @@ 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; + // F-P-009: cache hit short-circuits Argon2 + 3 DB round-trips. + // Keyed on the hashed token so we never store raw bearer values. + let token_hash = hash_token(token); + if let Some(p) = state.principal_cache.get(&token_hash) { + return Ok(Some(p)); } - verify_session(state, token).await + let resolved = if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) { + verify_api_key(state, rest).await? + } else { + verify_session(state, token).await? + }; + if let Some(p) = &resolved { + state.principal_cache.insert(token_hash, p.clone()); + } + Ok(resolved) } async fn verify_session( diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 162742d..65de655 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -489,6 +489,7 @@ pub async fn build_app( sessions: auth.sessions.clone(), keys: auth.keys.clone(), ttl: auth.ttl, + principal_cache: Arc::new(picloud_manager_core::auth_middleware::PrincipalCache::new()), }; let admins_state = AdminsState { users: auth.users.clone(),