//! Handlers for API key management (P4.2 Authentication). use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use tracing::{info, instrument}; use crate::{ dto::{ CreateApiKeyRequest, CreateApiKeyResponse, ErrorResponse, ListApiKeysResponse, RevokeApiKeyResponse, RotateApiKeyResponse, UpdateApiKeyRequest, UpdateApiKeyResponse, }, error::{ApiError, Result}, state::AppState, }; use stemedb_storage::{ApiKeyRecord, ApiKeyRole, ApiKeyStore, DEFAULT_API_KEY_RATE_LIMIT}; /// Generate a new API key with the given environment prefix. /// /// Format: `steme_{env}_{random_hex_48}` where random_hex_48 is 24 bytes of entropy. fn generate_api_key(environment: &str) -> std::result::Result { let prefix = if environment == "test" { "test" } else { "live" }; let mut random_bytes = [0u8; 24]; getrandom::getrandom(&mut random_bytes) .map_err(|e| ApiError::Internal(format!("RNG failed: {}", e)))?; Ok(format!("steme_{}_{}", prefix, hex::encode(random_bytes))) } /// Hash an API key using BLAKE3. fn hash_api_key(raw_key: &str) -> [u8; 32] { *blake3::hash(raw_key.as_bytes()).as_bytes() } /// Create a new API key. /// /// # Request /// /// ```json /// { /// "environment": "live", // or "test" /// "role": "admin", // "read_only", "write_agent", or "admin" /// "label": "production_api", /// "rate_limit": 10000, // optional, requests per hour /// "expires_at": 1735689600 // optional, Unix timestamp /// } /// ``` /// /// # Response /// /// Returns the raw API key. **Store this securely - it cannot be retrieved later.** #[utoipa::path( post, path = "/v1/admin/api-keys", request_body = CreateApiKeyRequest, responses( (status = 201, description = "API key created successfully", body = CreateApiKeyResponse), (status = 400, description = "Invalid request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 403, description = "Forbidden", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state, req), fields(label = %req.label, role = %req.role, environment = %req.environment))] pub async fn create_api_key( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/api-keys").increment(1); // Validate environment if req.environment != "live" && req.environment != "test" { return Err(ApiError::InvalidRequest("environment must be 'live' or 'test'".to_string())); } // Parse role let role: ApiKeyRole = req.role.parse().map_err(|e: stemedb_storage::ParseApiKeyRoleError| { ApiError::InvalidRequest(e.to_string()) })?; // Validate label if req.label.is_empty() { return Err(ApiError::InvalidRequest("label cannot be empty".to_string())); } if req.label.len() > 100 { return Err(ApiError::InvalidRequest("label cannot exceed 100 characters".to_string())); } // Generate the key let raw_key = generate_api_key(&req.environment)?; let key_hash = hash_api_key(&raw_key); let key_prefix: String = raw_key.chars().take(12).collect(); // Get current timestamp let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); // Create record let mut record = ApiKeyRecord::new(key_hash, key_prefix.clone(), role, req.label.clone(), now); record.rate_limit = req.rate_limit; record.expires_at = req.expires_at; // Store in database state.api_key_store.put_key(&key_hash, &record).await?; info!( label = %req.label, role = %role, key_hash = %hex::encode(&key_hash[..8]), "Created API key" ); let rate_limit = record.rate_limit.unwrap_or(DEFAULT_API_KEY_RATE_LIMIT); // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/admin/api-keys", "status" => "201" ) .record(start.elapsed().as_secs_f64()); Ok(( StatusCode::CREATED, Json(CreateApiKeyResponse { key: raw_key, key_prefix, key_hash: hex::encode(key_hash), role: role.as_str().to_string(), label: req.label, rate_limit, expires_at: req.expires_at, }), )) } /// List all API keys. /// /// Returns metadata about all keys but not the raw keys themselves. #[utoipa::path( get, path = "/v1/admin/api-keys", responses( (status = 200, description = "List of API keys", body = ListApiKeysResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 403, description = "Forbidden", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn list_api_keys(State(state): State) -> Result> { let records = state.api_key_store.list_keys().await?; let total = records.len(); // Filter out rate limit entries (they have ":RATE:" in their key path) // and convert to DTOs let keys = records.into_iter().map(|r| r.into()).collect(); Ok(Json(ListApiKeysResponse { keys, total })) } /// Revoke (delete) an API key. /// /// Once revoked, the key can no longer be used for authentication. #[utoipa::path( delete, path = "/v1/admin/api-keys/{key_hash}", params( ("key_hash" = String, Path, description = "Hex-encoded BLAKE3 hash of the key to revoke") ), responses( (status = 200, description = "API key revoked", body = RevokeApiKeyResponse), (status = 400, description = "Invalid key hash", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 403, description = "Forbidden", body = ErrorResponse), (status = 404, description = "Key not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state), fields(key_hash = %key_hash_hex))] pub async fn revoke_api_key( State(state): State, Path(key_hash_hex): Path, ) -> Result> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "DELETE", "path" => "/v1/admin/api-keys/{id}").increment(1); // Parse key hash let key_hash_bytes = hex::decode(&key_hash_hex) .map_err(|e| ApiError::InvalidHex(format!("Invalid key hash: {}", e)))?; if key_hash_bytes.len() != 32 { return Err(ApiError::InvalidHashLength { expected: 32, actual: key_hash_bytes.len() }); } let mut key_hash = [0u8; 32]; key_hash.copy_from_slice(&key_hash_bytes); // Check if key exists let record = state.api_key_store.get_key_by_hash(&key_hash).await?; if record.is_none() { return Err(ApiError::NotFound(format!("API key not found: {}", key_hash_hex))); } // Delete the key state.api_key_store.delete_key(&key_hash).await?; info!(key_hash = %key_hash_hex, "Revoked API key"); // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "DELETE", "path" => "/v1/admin/api-keys/{id}", "status" => "200" ) .record(start.elapsed().as_secs_f64()); Ok(Json(RevokeApiKeyResponse { revoked: true, key_hash: key_hash_hex })) } /// Rotate an API key (revoke old, create new with same settings). /// /// Creates a new key with the same role, label, and rate limit as the old key, /// then revokes the old key. #[utoipa::path( post, path = "/v1/admin/api-keys/{key_hash}/rotate", params( ("key_hash" = String, Path, description = "Hex-encoded BLAKE3 hash of the key to rotate") ), responses( (status = 200, description = "API key rotated", body = RotateApiKeyResponse), (status = 400, description = "Invalid key hash", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 403, description = "Forbidden", body = ErrorResponse), (status = 404, description = "Key not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state), fields(key_hash = %key_hash_hex))] pub async fn rotate_api_key( State(state): State, Path(key_hash_hex): Path, ) -> Result> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/api-keys/{id}/rotate").increment(1); // Parse key hash let key_hash_bytes = hex::decode(&key_hash_hex) .map_err(|e| ApiError::InvalidHex(format!("Invalid key hash: {}", e)))?; if key_hash_bytes.len() != 32 { return Err(ApiError::InvalidHashLength { expected: 32, actual: key_hash_bytes.len() }); } let mut old_key_hash = [0u8; 32]; old_key_hash.copy_from_slice(&key_hash_bytes); // Get existing key let old_record = state .api_key_store .get_key_by_hash(&old_key_hash) .await? .ok_or_else(|| ApiError::NotFound(format!("API key not found: {}", key_hash_hex)))?; // Determine environment from prefix let environment = if old_record.key_prefix.contains("test") { "test" } else { "live" }; // Generate new key let new_raw_key = generate_api_key(environment)?; let new_key_hash = hash_api_key(&new_raw_key); let new_key_prefix: String = new_raw_key.chars().take(12).collect(); // Get current timestamp let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); // Create new record with same settings let mut new_record = ApiKeyRecord::new( new_key_hash, new_key_prefix.clone(), old_record.role, old_record.label.clone(), now, ); new_record.rate_limit = old_record.rate_limit; // Don't copy expires_at - new key gets fresh settings // Store new key state.api_key_store.put_key(&new_key_hash, &new_record).await?; // Delete old key state.api_key_store.delete_key(&old_key_hash).await?; info!( old_key_hash = %key_hash_hex, new_key_hash = %hex::encode(&new_key_hash[..8]), label = %old_record.label, "Rotated API key" ); // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/admin/api-keys/{id}/rotate", "status" => "200" ) .record(start.elapsed().as_secs_f64()); Ok(Json(RotateApiKeyResponse { new_key: new_raw_key, new_key_prefix, new_key_hash: hex::encode(new_key_hash), old_key_hash: key_hash_hex, role: old_record.role.as_str().to_string(), label: old_record.label, })) } /// Update an API key's enabled status. /// /// Allows disabling a key without deleting it. #[utoipa::path( patch, path = "/v1/admin/api-keys/{key_hash}", params( ("key_hash" = String, Path, description = "Hex-encoded BLAKE3 hash of the key to update") ), request_body = UpdateApiKeyRequest, responses( (status = 200, description = "API key updated", body = UpdateApiKeyResponse), (status = 400, description = "Invalid key hash", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 403, description = "Forbidden", body = ErrorResponse), (status = 404, description = "Key not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state, req), fields(key_hash = %key_hash_hex, enabled = %req.enabled))] pub async fn update_api_key( State(state): State, Path(key_hash_hex): Path, Json(req): Json, ) -> Result> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "PATCH", "path" => "/v1/admin/api-keys/{id}").increment(1); // Parse key hash let key_hash_bytes = hex::decode(&key_hash_hex) .map_err(|e| ApiError::InvalidHex(format!("Invalid key hash: {}", e)))?; if key_hash_bytes.len() != 32 { return Err(ApiError::InvalidHashLength { expected: 32, actual: key_hash_bytes.len() }); } let mut key_hash = [0u8; 32]; key_hash.copy_from_slice(&key_hash_bytes); // Check if key exists let record = state.api_key_store.get_key_by_hash(&key_hash).await?; if record.is_none() { return Err(ApiError::NotFound(format!("API key not found: {}", key_hash_hex))); } // Update enabled state state.api_key_store.set_enabled(&key_hash, req.enabled).await?; let action = if req.enabled { "enabled" } else { "disabled" }; info!(key_hash = %key_hash_hex, "{} API key", action); // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "PATCH", "path" => "/v1/admin/api-keys/{id}", "status" => "200" ) .record(start.elapsed().as_secs_f64()); Ok(Json(UpdateApiKeyResponse { updated: true, key_hash: key_hash_hex, enabled: req.enabled })) } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_api_key_format() { let key = generate_api_key("live").expect("key generation"); assert!(key.starts_with("steme_live_")); assert_eq!(key.len(), 11 + 48); // "steme_live_" + 48 hex chars let test_key = generate_api_key("test").expect("key generation"); assert!(test_key.starts_with("steme_test_")); } #[test] fn test_generate_api_key_uniqueness() { let key1 = generate_api_key("live").expect("key1"); let key2 = generate_api_key("live").expect("key2"); assert_ne!(key1, key2); } #[test] fn test_hash_api_key_deterministic() { let key = "steme_test_abcdef123456"; let hash1 = hash_api_key(key); let hash2 = hash_api_key(key); assert_eq!(hash1, hash2); } #[test] fn test_hash_api_key_different_keys() { let hash1 = hash_api_key("steme_test_abc"); let hash2 = hash_api_key("steme_test_def"); assert_ne!(hash1, hash2); } }