fix(manager-core): F-P-009 cache resolved Principals (60s TTL) in auth middleware
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<HashMap<String, (Instant, Principal)>>,
|
||||
}
|
||||
|
||||
/// 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<Principal> {
|
||||
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<dyn AdminSessionRepository>,
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
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<PrincipalCache>,
|
||||
}
|
||||
|
||||
/// Legacy request-extension alias retained so the (only remaining)
|
||||
@@ -137,10 +194,21 @@ 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;
|
||||
// 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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user