Files
foxhunt/services/api/src/auth/interceptor.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

1072 lines
33 KiB
Rust

//! High-Performance gRPC Authentication Interceptor with 6-Layer Security
//!
//! This module implements a comprehensive authentication system optimized for HFT requirements:
//! - Total overhead: <10μs per request
//! - JWT validation: <1μs (cached keys)
//! - Revocation check: <500ns (Redis in-memory)
//! - Authorization: <100ns (cached permissions)
//! - Rate limiting: <50ns (in-memory counters)
//!
//! ## 6-Layer Authentication Architecture
//!
//! ```text
//! Layer 1: mTLS Client Certificate (handled by tonic-tls)
//! Layer 2: JWT Extraction from Authorization header
//! Layer 3: JWT Revocation Check (Redis - <500ns)
//! Layer 4: JWT Signature & Expiration Validation (<1μs)
//! Layer 5: RBAC Permission Check (<100ns)
//! Layer 6: Rate Limiting (<50ns)
//! Layer 7: User Context Injection (metadata enrichment)
//! Layer 8: Async Audit Logging (non-blocking)
//! ```
use anyhow::{Context, Result};
use dashmap::DashMap;
use governor::{state::keyed::DefaultKeyedStateStore, Quota, RateLimiter as GovernorRateLimiter};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use redis::{aio::ConnectionManager, AsyncCommands};
use serde::{Deserialize, Serialize};
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tonic::{Request, Status};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
/// JWT Token ID (`JTI`) for unique token identification and revocation
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Jti(pub String);
impl Jti {
/// Create new random JTI
pub fn new() -> Self {
Self(Uuid::new_v4().to_string())
}
/// Parse `JTI` from string
pub fn from_string(s: String) -> Self {
Self(s)
}
/// Get Redis key for this JTI
fn redis_key(&self) -> String {
format!("jwt:blacklist:{}", self.0)
}
/// Get `JTI` as string reference
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for Jti {
fn default() -> Self {
Self::new()
}
}
/// Enhanced JWT claims with revocation support
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JwtClaims {
/// JWT ID for revocation tracking (MANDATORY)
pub jti: String,
/// Subject (user ID)
pub sub: String,
/// Issued at timestamp
pub iat: u64,
/// Expiration timestamp
pub exp: u64,
/// Not before timestamp (optional)
pub nbf: Option<u64>,
/// Issuer
pub iss: String,
/// Audience
pub aud: String,
/// User roles (for RBAC)
pub roles: Vec<String>,
/// Granular permissions
pub permissions: Vec<String>,
/// Token type: "access" or "refresh"
#[serde(default = "default_token_type")]
pub token_type: String,
/// Session ID for tracking related tokens
#[serde(default)]
pub session_id: Option<String>,
}
fn default_token_type() -> String {
"access".to_string()
}
/// User context injected into gRPC metadata after successful authentication
#[derive(Debug, Clone)]
pub struct UserContext {
pub user_id: String,
pub roles: Vec<String>,
pub permissions: Vec<String>,
pub session_id: String,
pub authenticated_at: Instant,
}
/// Cached revocation result with timestamp
#[derive(Debug, Clone)]
struct CachedRevocationResult {
is_revoked: bool,
cached_at: Instant,
}
/// Local in-memory cache for revocation checks
///
/// PERFORMANCE: Reduces Redis network latency (500μs → <10ns for cache hits)
pub struct LocalRevocationCache {
cache: Arc<DashMap<String, CachedRevocationResult>>,
ttl: Duration,
hits: Arc<std::sync::atomic::AtomicU64>,
misses: Arc<std::sync::atomic::AtomicU64>,
}
impl LocalRevocationCache {
/// Create new local revocation cache
///
/// # Arguments
/// * `ttl` - Time-to-live for cached entries (recommended: 60s)
pub fn new(ttl: Duration) -> Self {
Self {
cache: Arc::new(DashMap::new()),
ttl,
hits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
misses: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
/// Check if token is revoked (with local cache)
///
/// # Performance
/// - Cache hit: <10ns (DashMap lookup)
///
/// - Cache miss: ~500μs (Redis network latency)
/// - Expected hit rate: >95%
pub async fn check_revoked(
&self,
token_id: &str,
redis: &mut ConnectionManager,
) -> Result<bool> {
// Check cache first
if let Some(entry) = self.cache.get(token_id) {
if entry.cached_at.elapsed() < self.ttl {
// Cache hit - increment counter
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return Ok(entry.is_revoked);
} else {
// Entry expired - remove it
drop(entry); // Release read lock
self.cache.remove(token_id);
}
}
// Cache miss - check Redis
self.misses
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let key = format!("jwt:blacklist:{}", token_id);
let is_revoked: bool = redis
.exists(&key)
.await
.context("Failed to check token revocation status in Redis")?;
// Update cache
self.cache.insert(
token_id.to_string(),
CachedRevocationResult {
is_revoked,
cached_at: Instant::now(),
},
);
Ok(is_revoked)
}
/// Invalidate cache entry for a specific token
///
/// Called when a token is revoked to immediately reflect the change
pub fn invalidate(&self, token_id: &str) {
self.cache.remove(token_id);
}
/// Clear all cached entries
pub fn clear(&self) {
self.cache.clear();
}
/// Get cache statistics
pub fn stats(&self) -> CacheStats {
let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
let misses = self.misses.load(std::sync::atomic::Ordering::Relaxed);
let total = hits.saturating_add(misses);
let hit_rate = if total > 0 {
let rate = (hits as f64 / total as f64) * 100.0;
// f64 multiplication is safe, check for valid result
if rate.is_finite() {
rate
} else {
0.0
}
} else {
0.0
};
CacheStats {
hits,
misses,
total,
hit_rate,
entries: self.cache.len(),
}
}
/// Reset cache statistics
pub fn reset_stats(&self) {
self.hits.store(0, std::sync::atomic::Ordering::Relaxed);
self.misses.store(0, std::sync::atomic::Ordering::Relaxed);
}
}
/// Cache statistics for monitoring
#[derive(Debug, Clone)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub total: u64,
pub hit_rate: f64,
pub entries: usize,
}
/// JWT Revocation Service (Redis-backed with local cache)
#[derive(Clone)]
pub struct RevocationService {
redis: ConnectionManager,
cache: Arc<LocalRevocationCache>,
}
impl RevocationService {
/// Create new revocation service with local cache
///
/// # Arguments
/// * `redis_url` - Redis connection URL
///
/// * `cache_ttl` - TTL for local cache entries (default: 60s)
pub async fn new(redis_url: &str) -> Result<Self> {
Self::new_with_cache_ttl(redis_url, Duration::from_secs(60)).await
}
/// Create new revocation service with custom cache TTL
pub async fn new_with_cache_ttl(redis_url: &str, cache_ttl: Duration) -> Result<Self> {
let client = redis::Client::open(redis_url)
.context("Failed to create Redis client for revocation service")?;
let redis = ConnectionManager::new(client)
.await
.context("Failed to connect to Redis for revocation service")?;
Ok(Self {
redis,
cache: Arc::new(LocalRevocationCache::new(cache_ttl)),
})
}
/// Check if token is revoked (TARGET: <10ns for cache hits, <500μs for cache misses)
///
/// PERFORMANCE: Uses local DashMap cache to avoid Redis network latency
pub async fn is_revoked(&self, jti: &Jti) -> Result<bool> {
let mut conn = self.redis.clone();
self.cache.check_revoked(jti.as_str(), &mut conn).await
}
/// Add token to blacklist (for revocation)
pub async fn revoke_token(&self, jti: &Jti, ttl_seconds: u64) -> Result<()> {
if ttl_seconds == 0 {
return Ok(()); // Already expired
}
let key = jti.redis_key();
let mut conn = self.redis.clone();
// Set with TTL = remaining token lifetime
// Redis will auto-clean when token would have expired
let _: () = conn
.set_ex(&key, "revoked", ttl_seconds)
.await
.context("Failed to add token to blacklist")?;
// Invalidate cache entry to immediately reflect revocation
self.cache.invalidate(jti.as_str());
Ok(())
}
/// Get cache statistics for monitoring
pub fn cache_stats(&self) -> CacheStats {
self.cache.stats()
}
/// Clear local cache (useful for testing or troubleshooting)
pub fn clear_cache(&self) {
self.cache.clear();
}
/// Reset cache statistics
pub fn reset_cache_stats(&self) {
self.cache.reset_stats();
}
}
/// High-performance JWT validator with key caching
pub struct JwtService {
/// Cached decoding key (avoids repeated parsing)
decoding_key: Arc<DecodingKey>,
/// Validation rules
validation: Validation,
}
impl JwtService {
/// Create new JWT service with cached key
///
/// PERFORMANCE: Key is parsed once and cached in Arc for <1μs validation
pub fn new(secret: String, issuer: String, audience: String) -> Self {
let decoding_key = Arc::new(DecodingKey::from_secret(secret.as_bytes()));
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[&issuer]);
validation.set_audience(&[&audience]);
validation.validate_exp = true;
validation.validate_nbf = true;
validation.leeway = 0; // Strict for HFT security
Self {
decoding_key,
validation,
}
}
/// Validate JWT and extract claims (TARGET: <1μs with cached key)
///
/// PERFORMANCE: Cached DecodingKey eliminates key parsing overhead
pub fn validate_token(&self, token: &str) -> Result<JwtClaims> {
// Basic validation before expensive decode
if token.is_empty() || token.len() > 8192 {
return Err(anyhow::anyhow!("Invalid token format"));
}
let token_data =
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
error!("JWT decode failed in interceptor: {:?}", e);
let issuer_str = self
.validation
.iss
.as_ref()
.map(|v| v.iter().cloned().collect::<Vec<_>>().join(", "))
.unwrap_or_default();
let audience_str = self
.validation
.aud
.as_ref()
.map(|v| v.iter().cloned().collect::<Vec<_>>().join(", "))
.unwrap_or_default();
error!(
"Expected issuer: {}, audience: {}",
issuer_str, audience_str
);
anyhow::anyhow!("JWT validation failed: {}", e)
})?;
// Additional security checks
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("System time error")?
.as_secs();
if token_data.claims.exp <= now {
return Err(anyhow::anyhow!("Token expired"));
}
if token_data.claims.jti.is_empty() {
return Err(anyhow::anyhow!("Missing JTI (required for revocation)"));
}
if token_data.claims.sub.is_empty() {
return Err(anyhow::anyhow!("Missing subject claim"));
}
// Check if token was issued in the future (clock skew attack)
if token_data.claims.iat > now + 60 {
return Err(anyhow::anyhow!("Token issued in the future"));
}
// Check token age (max 1 hour from issuance)
let token_age = now
.checked_sub(token_data.claims.iat)
.ok_or_else(|| anyhow::anyhow!("Invalid token timestamp (iat in future)"))?;
if token_age > 3600 {
return Err(anyhow::anyhow!("Token too old (max age: 1 hour)"));
}
Ok(token_data.claims)
}
}
/// High-performance authorization service with permission caching
pub struct AuthzService {
/// Cached user permissions (TARGET: <100ns lookups)
///
/// PERFORMANCE: DashMap provides concurrent access without RwLock overhead
permission_cache: Arc<DashMap<String, Vec<String>>>,
}
impl Default for AuthzService {
fn default() -> Self {
Self::new()
}
}
impl AuthzService {
pub fn new() -> Self {
Self {
permission_cache: Arc::new(DashMap::new()),
}
}
/// Check if user has required permission (TARGET: <100ns)
///
/// PERFORMANCE: In-memory DashMap lookup, no database queries
pub fn has_permission(&self, user_id: &str, permission: &str) -> bool {
if let Some(permissions) = self.permission_cache.get(user_id) {
permissions.contains(&permission.to_string())
} else {
false
}
}
/// Cache user permissions for fast lookups
///
/// Called during user context injection (Layer 7)
pub fn cache_permissions(&self, user_id: String, permissions: Vec<String>) {
self.permission_cache.insert(user_id, permissions);
}
/// Clear cached permissions (for revocation or permission updates)
pub fn clear_cache(&self, user_id: &str) {
self.permission_cache.remove(user_id);
}
}
/// High-performance rate limiter with in-memory counters
#[derive(Clone)]
pub struct RateLimiter {
/// Per-user rate limiters (TARGET: <50ns)
///
/// PERFORMANCE: Governor provides O(1) atomic counter checks
limiters: Arc<
DashMap<
String,
Arc<
GovernorRateLimiter<
String,
DefaultKeyedStateStore<String>,
governor::clock::DefaultClock,
>,
>,
>,
>,
/// Default quota (requests per second)
default_quota: Quota,
}
impl RateLimiter {
pub fn new(requests_per_second: u32) -> Result<Self, String> {
let default_quota =
Quota::per_second(NonZeroU32::new(requests_per_second).ok_or_else(|| {
format!("Invalid rate limit: {} (must be > 0)", requests_per_second)
})?);
Ok(Self {
limiters: Arc::new(DashMap::new()),
default_quota,
})
}
/// Check if request is allowed (TARGET: <50ns)
///
/// PERFORMANCE: Atomic counter increment, no locks
pub fn check_rate_limit(&self, user_id: &str) -> bool {
let limiter = self
.limiters
.entry(user_id.to_string())
.or_insert_with(|| Arc::new(GovernorRateLimiter::dashmap(self.default_quota)));
limiter.check_key(&user_id.to_string()).is_ok()
}
}
/// Async audit logger (non-blocking)
pub struct AuditLogger {
/// Audit log buffer (async writes)
enabled: bool,
}
impl AuditLogger {
pub fn new(enabled: bool) -> Self {
Self { enabled }
}
/// Log authentication success (non-blocking)
///
/// PERFORMANCE: Spawns background task, does not block request path
pub fn log_auth_success(&self, user_id: &str, client_ip: Option<&str>) {
if !self.enabled {
return;
}
let user_id = user_id.to_string();
let client_ip = client_ip.map(|s| s.to_string());
tokio::spawn(async move {
info!(
user_id = %user_id,
client_ip = ?client_ip,
"Authentication successful"
);
});
}
/// Log authentication failure (non-blocking)
pub fn log_auth_failure(&self, reason: &str, client_ip: Option<&str>) {
if !self.enabled {
return;
}
let reason = reason.to_string();
let client_ip = client_ip.map(|s| s.to_string());
tokio::spawn(async move {
warn!(
reason = %reason,
client_ip = ?client_ip,
"Authentication failed"
);
});
}
}
/// Main authentication interceptor with 6-layer security
#[derive(Clone)]
pub struct AuthInterceptor {
jwt_service: Arc<JwtService>,
revocation_service: Arc<RevocationService>,
authz_service: Arc<AuthzService>,
rate_limiter: Arc<RateLimiter>,
audit_logger: Arc<AuditLogger>,
}
impl AuthInterceptor {
/// Create new authentication interceptor
pub fn new(
jwt_service: JwtService,
revocation_service: RevocationService,
authz_service: AuthzService,
rate_limiter: RateLimiter,
audit_logger: AuditLogger,
) -> Self {
Self {
jwt_service: Arc::new(jwt_service),
revocation_service: Arc::new(revocation_service),
authz_service: Arc::new(authz_service),
rate_limiter: Arc::new(rate_limiter),
audit_logger: Arc::new(audit_logger),
}
}
/// Authenticate request with 6-layer security (TARGET: <10μs total)
///
/// ## Performance Breakdown (Target vs Actual)
/// - Layer 1 (mTLS): Handled by tonic-tls (0μs in-band)
///
/// - Layer 2 (Extract JWT): <100ns (header lookup)
/// - Layer 3 (Revocation): <500ns (Redis in-memory)
///
/// - Layer 4 (JWT Validation): <1μs (cached key)
/// - Layer 5 (Authorization): <100ns (cached permissions)
///
/// - Layer 6 (Rate Limit): <50ns (atomic counter)
/// - Layer 7 (Context Inject): <100ns (metadata write)
///
/// - Layer 8 (Audit Log): 0ns (async, non-blocking)
/// **Total**: ~2μs (well under 10μs target)
pub async fn authenticate<T>(&self, mut request: Request<T>) -> Result<Request<T>, Status> {
let start = Instant::now();
// Extract client IP for audit logging
let client_ip = self.extract_client_ip(&request);
// Layer 1: mTLS client certificate
// NOTE: Handled by tonic-tls at transport layer, certificate is already validated
// We can extract it from request extensions if needed for additional checks
// Layer 2: Extract JWT from authorization header (~100ns)
let bearer_token = self.extract_bearer_token(&request).ok_or_else(|| {
self.audit_logger
.log_auth_failure("missing_token", client_ip.as_deref());
Status::unauthenticated("Authorization header required")
})?;
// Layer 4: Validate JWT signature and expiration (<1μs with cached key)
// NOTE: Moved before revocation check for fail-fast on invalid tokens
let claims = self
.jwt_service
.validate_token(&bearer_token)
.map_err(|e| {
self.audit_logger
.log_auth_failure(&format!("invalid_jwt: {}", e), client_ip.as_deref());
Status::unauthenticated("Invalid or expired token")
})?;
// Layer 3: Check JWT revocation (<500ns with Redis)
let jti = Jti::from_string(claims.jti.clone());
let is_revoked = self
.revocation_service
.is_revoked(&jti)
.await
.map_err(|e| {
error!("Revocation check failed: {}", e);
Status::unauthenticated("Authentication service unavailable")
})?;
if is_revoked {
self.audit_logger
.log_auth_failure("token_revoked", client_ip.as_deref());
return Err(Status::unauthenticated("Token has been revoked"));
}
// Layer 5: RBAC permission check (<100ns with cached permissions)
// Cache permissions for this user if not already cached
self.authz_service
.cache_permissions(claims.sub.clone(), claims.permissions.clone());
// Basic permission check example (service-specific checks happen in handlers)
if !self.authz_service.has_permission(&claims.sub, "api.access") {
self.audit_logger
.log_auth_failure("insufficient_permissions", client_ip.as_deref());
return Err(Status::permission_denied("Insufficient permissions"));
}
// Layer 6: Rate limiting check (<50ns with atomic counters)
if !self.rate_limiter.check_rate_limit(&claims.sub) {
self.audit_logger
.log_auth_failure("rate_limit_exceeded", client_ip.as_deref());
return Err(Status::resource_exhausted("Rate limit exceeded"));
}
// Layer 7: Inject user context into request metadata
let user_context = UserContext {
user_id: claims.sub.clone(),
roles: claims.roles.clone(),
permissions: claims.permissions.clone(),
session_id: claims
.session_id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string()),
authenticated_at: start,
};
// Add context to request extensions for downstream handlers
request.extensions_mut().insert(user_context);
// CRITICAL: Add x-user-id to metadata for proxy forwarding
// The trading/backtesting/ML proxies expect this header to identify the user
request.metadata_mut().insert(
"x-user-id",
claims
.sub
.parse()
.map_err(|_| Status::internal("Invalid user_id encoding"))?,
);
// Layer 8: Async audit logging (non-blocking, 0ns overhead)
self.audit_logger
.log_auth_success(&claims.sub, client_ip.as_deref());
let elapsed = start.elapsed();
debug!(
"Authentication successful for user {} in {:?}",
claims.sub, elapsed
);
// Warn if we exceed 10μs target
if elapsed > Duration::from_micros(10) {
warn!(
"Authentication latency exceeded target: {:?} > 10μs",
elapsed
);
}
Ok(request)
}
/// Extract bearer token from Authorization header
fn extract_bearer_token<T>(&self, request: &Request<T>) -> Option<String> {
request
.metadata()
.get("authorization")
.and_then(|auth| auth.to_str().ok())
.and_then(|auth| {
if auth.starts_with("Bearer ") {
Some(auth[7..].to_string())
} else {
None
}
})
}
/// Extract client IP from request metadata
fn extract_client_ip<T>(&self, request: &Request<T>) -> Option<String> {
request
.metadata()
.get("x-forwarded-for")
.and_then(|ip| ip.to_str().ok())
.map(|ip| ip.to_string())
.or_else(|| {
request
.metadata()
.get("x-real-ip")
.and_then(|ip| ip.to_str().ok())
.map(|ip| ip.to_string())
})
}
}
/// Implement Tonic's Interceptor trait for gRPC integration
impl tonic::service::Interceptor for AuthInterceptor {
fn call(&mut self, request: Request<()>) -> Result<Request<()>, Status> {
// Tonic's Interceptor is synchronous, but we need async authentication
// Use block_in_place to run async code in a blocking context
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.authenticate(request))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_jti_generation() {
let jti1 = Jti::new();
let jti2 = Jti::new();
assert_ne!(jti1, jti2);
assert!(!jti1.as_str().is_empty());
}
#[test]
fn test_jwt_claims_defaults() {
let claims = JwtClaims {
jti: "test-jti".to_string(),
sub: "user123".to_string(),
iat: 1234567890,
exp: 1234571490,
nbf: Some(1234567890),
iss: "foxhunt".to_string(),
aud: "api-gateway".to_string(),
roles: vec!["trader".to_string()],
permissions: vec!["api.access".to_string()],
token_type: "access".to_string(),
session_id: Some("session-123".to_string()),
};
assert_eq!(claims.token_type, "access");
assert!(claims.session_id.is_some());
}
#[tokio::test]
async fn test_jwt_service_validation() {
use jsonwebtoken::{encode, EncodingKey, Header};
let secret = "test-secret-must-be-at-least-64-characters-long-for-security-validation-ok"
.to_string();
let jwt_service = JwtService::new(
secret.clone(),
"test-issuer".to_string(),
"test-audience".to_string(),
);
let claims = JwtClaims {
jti: Jti::new().0,
sub: "user123".to_string(),
iat: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
exp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600,
nbf: Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
),
iss: "test-issuer".to_string(),
aud: "test-audience".to_string(),
roles: vec!["trader".to_string()],
permissions: vec!["api.access".to_string()],
token_type: "access".to_string(),
session_id: Some(Uuid::new_v4().to_string()),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap();
let validated = jwt_service.validate_token(&token).unwrap();
assert_eq!(validated.sub, "user123");
}
#[test]
fn test_authz_service_permissions() {
let authz = AuthzService::new();
authz.cache_permissions(
"user123".to_string(),
vec!["api.access".to_string(), "trading.submit".to_string()],
);
assert!(authz.has_permission("user123", "api.access"));
assert!(authz.has_permission("user123", "trading.submit"));
assert!(!authz.has_permission("user123", "admin.access"));
authz.clear_cache("user123");
assert!(!authz.has_permission("user123", "api.access"));
}
#[test]
fn test_rate_limiter() {
let limiter = RateLimiter::new(10).expect("Valid rate limit"); // 10 requests per second
// Should allow first request
assert!(limiter.check_rate_limit("user123"));
}
#[tokio::test]
async fn test_revocation_cache_hit() {
// Create a mock Redis connection manager for testing
// In real tests, you would use a real Redis instance or mock
let cache = LocalRevocationCache::new(Duration::from_secs(60));
// Test cache statistics initialization
let stats = cache.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.total, 0);
assert_eq!(stats.hit_rate, 0.0);
}
#[test]
fn test_cache_ttl_expiration() {
let cache = LocalRevocationCache::new(Duration::from_millis(10));
// Insert an entry
cache.cache.insert(
"test_token".to_string(),
CachedRevocationResult {
is_revoked: false,
cached_at: Instant::now(),
},
);
// Should be present immediately
assert!(cache.cache.contains_key("test_token"));
// Wait for TTL to expire
std::thread::sleep(Duration::from_millis(15));
// Entry should still exist in DashMap but will be removed on next access
// The check_revoked method handles TTL expiration
assert_eq!(cache.cache.len(), 1);
}
#[test]
fn test_cache_invalidation() {
let cache = LocalRevocationCache::new(Duration::from_secs(60));
// Insert an entry
cache.cache.insert(
"test_token".to_string(),
CachedRevocationResult {
is_revoked: false,
cached_at: Instant::now(),
},
);
assert!(cache.cache.contains_key("test_token"));
// Invalidate the entry
cache.invalidate("test_token");
assert!(!cache.cache.contains_key("test_token"));
}
#[test]
fn test_cache_clear() {
let cache = LocalRevocationCache::new(Duration::from_secs(60));
// Insert multiple entries
for i in 0..10 {
cache.cache.insert(
format!("token_{}", i),
CachedRevocationResult {
is_revoked: false,
cached_at: Instant::now(),
},
);
}
assert_eq!(cache.cache.len(), 10);
// Clear all entries
cache.clear();
assert_eq!(cache.cache.len(), 0);
}
#[test]
fn test_cache_stats_tracking() {
let cache = LocalRevocationCache::new(Duration::from_secs(60));
// Simulate cache hits
cache
.hits
.fetch_add(95, std::sync::atomic::Ordering::Relaxed);
cache
.misses
.fetch_add(5, std::sync::atomic::Ordering::Relaxed);
let stats = cache.stats();
assert_eq!(stats.hits, 95);
assert_eq!(stats.misses, 5);
assert_eq!(stats.total, 100);
assert_eq!(stats.hit_rate, 95.0);
// Reset stats
cache.reset_stats();
let stats = cache.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
}
#[test]
fn test_cache_concurrent_access() {
use std::sync::Arc;
use std::thread;
let cache = Arc::new(LocalRevocationCache::new(Duration::from_secs(60)));
// Insert initial entry
cache.cache.insert(
"shared_token".to_string(),
CachedRevocationResult {
is_revoked: false,
cached_at: Instant::now(),
},
);
let mut handles = vec![];
// Spawn 10 threads that all try to access the same cache entry
for _ in 0..10 {
let cache_clone = cache.clone();
let handle = thread::spawn(move || {
for _ in 0..100 {
let entry = cache_clone.cache.get("shared_token");
assert!(entry.is_some());
}
});
handles.push(handle);
}
// Wait for all threads to complete
for handle in handles {
handle.join().expect("INVARIANT: Thread should complete successfully");
}
// Entry should still exist
assert!(cache.cache.contains_key("shared_token"));
}
#[test]
fn test_cache_stats_struct() {
let stats = CacheStats {
hits: 950,
misses: 50,
total: 1000,
hit_rate: 95.0,
entries: 100,
};
assert_eq!(stats.hits, 950);
assert_eq!(stats.misses, 50);
assert_eq!(stats.total, 1000);
assert_eq!(stats.hit_rate, 95.0);
assert_eq!(stats.entries, 100);
}
#[test]
fn test_cached_revocation_result() {
let result = CachedRevocationResult {
is_revoked: true,
cached_at: Instant::now(),
};
assert!(result.is_revoked);
assert!(result.cached_at.elapsed() < Duration::from_secs(1));
}
#[test]
fn test_cache_memory_efficiency() {
let cache = LocalRevocationCache::new(Duration::from_secs(60));
// Insert 1000 entries
for i in 0u32..1000 {
cache.cache.insert(
format!("token_{}", i),
CachedRevocationResult {
is_revoked: i.checked_rem(100).expect("Modulo overflow") == 0, // 1% revoked
cached_at: Instant::now(),
},
);
}
assert_eq!(cache.cache.len(), 1000);
// Verify mix of revoked and valid tokens
let mut revoked_count: u32 = 0;
for i in 0u32..1000 {
if let Some(entry) = cache.cache.get(&format!("token_{}", i)) {
if entry.is_revoked {
revoked_count = revoked_count
.checked_add(1)
.expect("Revoked count overflow");
}
}
}
assert_eq!(revoked_count, 10); // 1% of 1000 = 10
}
}