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:
MechaCat02
2026-06-07 20:23:17 +02:00
parent c80ee194f2
commit 617c216429
2 changed files with 74 additions and 5 deletions

View File

@@ -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(