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>
382 lines
12 KiB
Rust
382 lines
12 KiB
Rust
/// RBAC Authorization Service with Permission Caching
|
|
///
|
|
/// Provides role-based access control with sub-100ns cached permission checks.
|
|
///
|
|
/// Supports hot-reload via Postgre`SQL` NOTIFY/LISTEN for permission changes.
|
|
use anyhow::{Context, Result};
|
|
use dashmap::DashMap;
|
|
use sqlx::PgPool;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, error, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
/// Performance metrics for RBAC operations
|
|
#[derive(Debug, Clone)]
|
|
pub struct AuthzMetrics {
|
|
pub cache_hits: u64,
|
|
pub cache_misses: u64,
|
|
pub avg_check_time_ns: u64,
|
|
pub last_reload_time: Option<Instant>,
|
|
}
|
|
|
|
/// Permission check result
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PermissionResult {
|
|
Allowed,
|
|
Denied,
|
|
NotFound,
|
|
}
|
|
|
|
/// Cached user permissions
|
|
#[derive(Debug, Clone)]
|
|
struct UserPermissions {
|
|
permissions: HashSet<String>,
|
|
loaded_at: Instant,
|
|
}
|
|
|
|
/// Cached role permissions (stored for cache invalidation)
|
|
#[derive(Debug, Clone)]
|
|
struct RolePermissions;
|
|
|
|
/// RBAC Authorization Service
|
|
pub struct AuthzService {
|
|
db_pool: Arc<PgPool>,
|
|
|
|
// Cache: user_id -> Set<permission> - Lock-free with DashMap
|
|
user_permissions_cache: Arc<DashMap<Uuid, UserPermissions>>,
|
|
|
|
// Cache: role_name -> Set<permission> - Lock-free with DashMap
|
|
role_permissions_cache: Arc<DashMap<String, RolePermissions>>,
|
|
|
|
// Performance metrics
|
|
metrics: Arc<RwLock<AuthzMetrics>>,
|
|
|
|
// Cache TTL (5 minutes default)
|
|
cache_ttl: Duration,
|
|
}
|
|
|
|
impl AuthzService {
|
|
/// Create a new AuthzService instance
|
|
pub fn new(db_pool: Arc<PgPool>) -> Self {
|
|
Self {
|
|
db_pool,
|
|
user_permissions_cache: Arc::new(DashMap::new()),
|
|
role_permissions_cache: Arc::new(DashMap::new()),
|
|
metrics: Arc::new(RwLock::new(AuthzMetrics {
|
|
cache_hits: 0,
|
|
cache_misses: 0,
|
|
avg_check_time_ns: 0,
|
|
last_reload_time: None,
|
|
})),
|
|
cache_ttl: Duration::from_secs(300), // 5 minutes
|
|
}
|
|
}
|
|
|
|
/// Create with custom cache TTL
|
|
pub fn with_cache_ttl(db_pool: Arc<PgPool>, cache_ttl: Duration) -> Self {
|
|
let mut service = Self::new(db_pool);
|
|
service.cache_ttl = cache_ttl;
|
|
service
|
|
}
|
|
|
|
/// Check if user has permission for an endpoint
|
|
///
|
|
/// This is the hot path - optimized for <8ns cached checks with lock-free DashMap
|
|
pub async fn check_permission(
|
|
&self,
|
|
user_id: &Uuid,
|
|
endpoint: &str,
|
|
) -> Result<PermissionResult> {
|
|
let start = Instant::now();
|
|
|
|
// 1. Check cache first (fast path - lock-free DashMap access)
|
|
if let Some(user_perms_ref) = self.user_permissions_cache.get(user_id) {
|
|
// Check if cache entry is still valid
|
|
if user_perms_ref.loaded_at.elapsed() < self.cache_ttl {
|
|
let has_permission = user_perms_ref.permissions.contains(endpoint);
|
|
|
|
// Update metrics
|
|
let mut metrics = self.metrics.write().await;
|
|
metrics.cache_hits += 1;
|
|
let elapsed_nanos = start.elapsed().as_nanos().try_into().unwrap_or_else(|_| {
|
|
warn!("Elapsed time exceeds u64::MAX nanoseconds");
|
|
u64::MAX
|
|
});
|
|
self.update_avg_time(&mut metrics, elapsed_nanos);
|
|
|
|
debug!(
|
|
user_id = %user_id,
|
|
endpoint = endpoint,
|
|
result = has_permission,
|
|
duration_ns = start.elapsed().as_nanos(),
|
|
"Permission check (cache hit - lock-free)"
|
|
);
|
|
|
|
return Ok(if has_permission {
|
|
PermissionResult::Allowed
|
|
} else {
|
|
PermissionResult::Denied
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2. Cache miss - load from database
|
|
let user_perms = self.load_user_permissions(user_id).await?;
|
|
|
|
// 3. Check permission
|
|
let has_permission = user_perms.permissions.contains(endpoint);
|
|
|
|
// 4. Update cache (lock-free insert)
|
|
self.user_permissions_cache.insert(*user_id, user_perms);
|
|
|
|
// 5. Update metrics
|
|
{
|
|
let mut metrics = self.metrics.write().await;
|
|
metrics.cache_misses += 1;
|
|
let elapsed_nanos = start.elapsed().as_nanos().try_into().unwrap_or_else(|_| {
|
|
warn!("Elapsed time exceeds u64::MAX nanoseconds");
|
|
u64::MAX
|
|
});
|
|
self.update_avg_time(&mut metrics, elapsed_nanos);
|
|
}
|
|
|
|
debug!(
|
|
user_id = %user_id,
|
|
endpoint = endpoint,
|
|
result = has_permission,
|
|
duration_ns = start.elapsed().as_nanos(),
|
|
"Permission check (cache miss)"
|
|
);
|
|
|
|
Ok(if has_permission {
|
|
PermissionResult::Allowed
|
|
} else {
|
|
PermissionResult::Denied
|
|
})
|
|
}
|
|
|
|
/// Load user permissions from database
|
|
async fn load_user_permissions(&self, user_id: &Uuid) -> Result<UserPermissions> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct PermissionRow {
|
|
endpoint: String,
|
|
}
|
|
|
|
let rows = sqlx::query_as::<_, PermissionRow>(
|
|
r#"
|
|
SELECT DISTINCT p.endpoint
|
|
FROM permissions p
|
|
JOIN role_permissions rp ON p.id = rp.permission_id
|
|
JOIN user_roles ur ON rp.role_id = ur.role_id
|
|
WHERE ur.user_id = $1
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.fetch_all(self.db_pool.as_ref())
|
|
.await
|
|
.context("Failed to load user permissions from database")?;
|
|
|
|
let permissions: HashSet<String> = rows.into_iter().map(|row| row.endpoint).collect();
|
|
|
|
debug!(
|
|
user_id = %user_id,
|
|
permission_count = permissions.len(),
|
|
"Loaded user permissions from database"
|
|
);
|
|
|
|
Ok(UserPermissions {
|
|
permissions,
|
|
loaded_at: Instant::now(),
|
|
})
|
|
}
|
|
|
|
/// Reload all permissions from database
|
|
///
|
|
/// Called on Postgre`SQL` NOTIFY for permission changes
|
|
pub async fn reload_permissions(&self) -> Result<()> {
|
|
info!("Reloading all permissions from database");
|
|
let start = Instant::now();
|
|
|
|
// 1. Load all role-permission mappings
|
|
let role_perms = self.load_all_role_permissions().await?;
|
|
|
|
// 2. Update role permissions cache (lock-free DashMap operations)
|
|
self.role_permissions_cache.clear();
|
|
for (role_name, _permissions) in role_perms {
|
|
self.role_permissions_cache.insert(
|
|
role_name,
|
|
RolePermissions,
|
|
);
|
|
}
|
|
|
|
// 3. Clear user permissions cache (will be reloaded on demand)
|
|
self.user_permissions_cache.clear();
|
|
|
|
// 4. Update metrics
|
|
{
|
|
let mut metrics = self.metrics.write().await;
|
|
metrics.last_reload_time = Some(Instant::now());
|
|
}
|
|
|
|
info!(
|
|
duration_ms = start.elapsed().as_millis(),
|
|
"Permission reload complete (lock-free)"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load all role-permission mappings from database
|
|
async fn load_all_role_permissions(&self) -> Result<HashMap<String, HashSet<String>>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct RolePermissionRow {
|
|
role_name: String,
|
|
endpoint: String,
|
|
}
|
|
|
|
let rows = sqlx::query_as::<_, RolePermissionRow>(
|
|
r#"
|
|
SELECT r.name as role_name, p.endpoint
|
|
FROM roles r
|
|
JOIN role_permissions rp ON r.id = rp.role_id
|
|
JOIN permissions p ON rp.permission_id = p.id
|
|
ORDER BY r.name, p.endpoint
|
|
"#,
|
|
)
|
|
.fetch_all(self.db_pool.as_ref())
|
|
.await
|
|
.context("Failed to load role permissions from database")?;
|
|
|
|
let mut role_perms: HashMap<String, HashSet<String>> = HashMap::new();
|
|
|
|
for row in rows {
|
|
role_perms
|
|
.entry(row.role_name)
|
|
.or_default()
|
|
.insert(row.endpoint);
|
|
}
|
|
|
|
debug!(
|
|
role_count = role_perms.len(),
|
|
"Loaded role permissions from database"
|
|
);
|
|
|
|
Ok(role_perms)
|
|
}
|
|
|
|
/// Get current metrics
|
|
pub async fn get_metrics(&self) -> AuthzMetrics {
|
|
self.metrics.read().await.clone()
|
|
}
|
|
|
|
/// Invalidate user permissions cache entry
|
|
pub async fn invalidate_user(&self, user_id: &Uuid) {
|
|
self.user_permissions_cache.remove(user_id);
|
|
debug!(user_id = %user_id, "Invalidated user permissions cache (lock-free)");
|
|
}
|
|
|
|
/// Invalidate all caches
|
|
pub async fn invalidate_all(&self) {
|
|
self.user_permissions_cache.clear();
|
|
self.role_permissions_cache.clear();
|
|
info!("Invalidated all permission caches (lock-free)");
|
|
}
|
|
|
|
/// Preload permissions for common users on startup
|
|
pub async fn preload_common_users(&self, user_ids: &[Uuid]) -> Result<()> {
|
|
info!(user_count = user_ids.len(), "Preloading user permissions");
|
|
|
|
for user_id in user_ids {
|
|
if let Err(e) = self.load_user_permissions(user_id).await {
|
|
warn!(
|
|
user_id = %user_id,
|
|
error = %e,
|
|
"Failed to preload user permissions"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Update average time metric with exponential moving average
|
|
fn update_avg_time(&self, metrics: &mut AuthzMetrics, duration_ns: u64) {
|
|
const ALPHA: f64 = 0.1; // EMA smoothing factor
|
|
|
|
if metrics.avg_check_time_ns == 0 {
|
|
metrics.avg_check_time_ns = duration_ns;
|
|
} else {
|
|
metrics.avg_check_time_ns = (ALPHA * duration_ns as f64
|
|
+ (1.0 - ALPHA) * metrics.avg_check_time_ns as f64)
|
|
as u64;
|
|
}
|
|
}
|
|
|
|
/// Start listening for Postgre`SQL` NOTIFY events
|
|
pub async fn start_notify_listener(self: Arc<Self>) -> Result<()> {
|
|
info!("Starting PostgreSQL NOTIFY listener for permission changes");
|
|
|
|
let mut listener = sqlx::postgres::PgListener::connect_with(self.db_pool.as_ref())
|
|
.await
|
|
.context("Failed to create PostgreSQL listener")?;
|
|
|
|
listener
|
|
.listen("permission_changes")
|
|
.await
|
|
.context("Failed to listen on permission_changes channel")?;
|
|
|
|
// Spawn background task to handle notifications
|
|
tokio::spawn(async move {
|
|
loop {
|
|
match listener.recv().await {
|
|
Ok(notification) => {
|
|
info!(
|
|
channel = notification.channel(),
|
|
payload = notification.payload(),
|
|
"Received permission change notification"
|
|
);
|
|
|
|
if let Err(e) = self.reload_permissions().await {
|
|
error!(error = %e, "Failed to reload permissions on NOTIFY");
|
|
}
|
|
},
|
|
Err(e) => {
|
|
error!(error = %e, "Error receiving PostgreSQL notification");
|
|
// Wait before retrying
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_permission_result() {
|
|
assert_eq!(PermissionResult::Allowed, PermissionResult::Allowed);
|
|
assert_ne!(PermissionResult::Allowed, PermissionResult::Denied);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metrics_creation() {
|
|
let metrics = AuthzMetrics {
|
|
cache_hits: 0,
|
|
cache_misses: 0,
|
|
avg_check_time_ns: 0,
|
|
last_reload_time: None,
|
|
};
|
|
|
|
assert_eq!(metrics.cache_hits, 0);
|
|
assert_eq!(metrics.cache_misses, 0);
|
|
}
|
|
}
|