Files
foxhunt/services/api/src/config/authz.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

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);
}
}