// Admin-API for API-nøkkelhåndtering (oppgave 060) // // CRUD for krypterte API-nøkler i PG. Nøkler krypteres med AES-256-GCM // via SYNOPS_MASTER_KEY. Kun hint (siste 4 tegn) returneres til frontend. // // Ref: docs/infra/nøkkelhåndtering.md use axum::{extract::State, http::StatusCode, Json}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::AdminUser; use crate::crypto; use crate::AppState; // ============================================================================= // Datatyper // ============================================================================= #[derive(Serialize)] pub struct ErrorResponse { pub error: String, } fn bad_request(msg: &str) -> (StatusCode, Json) { (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() })) } fn internal_error(msg: &str) -> (StatusCode, Json) { (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() })) } /// En API-nøkkel slik den vises i admin-UI (uten selve nøkkelen). #[derive(Serialize, sqlx::FromRow)] pub struct ApiKeyInfo { pub id: Uuid, pub provider: String, pub label: Option, pub key_hint: Option, pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, pub last_used: Option>, pub usage_count: i64, } // Kjente providers med test-URL og modell for tilkoblingstest struct ProviderTestConfig { base_url: &'static str, model_list_path: &'static str, auth_header: &'static str, } fn provider_test_config(provider: &str) -> Option { match provider { "openrouter" => Some(ProviderTestConfig { base_url: "https://openrouter.ai/api/v1", model_list_path: "/models", auth_header: "Bearer", }), "anthropic" => Some(ProviderTestConfig { base_url: "https://api.anthropic.com/v1", model_list_path: "/models", auth_header: "x-api-key", }), "openai" => Some(ProviderTestConfig { base_url: "https://api.openai.com/v1", model_list_path: "/models", auth_header: "Bearer", }), "gemini" => Some(ProviderTestConfig { base_url: "https://generativelanguage.googleapis.com/v1beta", model_list_path: "/models", auth_header: "x-goog-api-key", }), _ => None, } } // ============================================================================= // GET /admin/api-keys — liste alle nøkler // ============================================================================= #[derive(Serialize)] pub struct ApiKeysResponse { pub keys: Vec, } pub async fn list_keys( State(state): State, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { let keys = sqlx::query_as::<_, ApiKeyInfo>( r#"SELECT id, provider, label, key_hint, is_active, created_at, updated_at, last_used, usage_count FROM api_keys ORDER BY provider, created_at DESC"#, ) .fetch_all(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved henting av API-nøkler: {e}")))?; Ok(Json(ApiKeysResponse { keys })) } // ============================================================================= // POST /admin/api-keys/create — opprett ny nøkkel // ============================================================================= #[derive(Deserialize)] pub struct CreateKeyRequest { pub provider: String, pub label: Option, pub api_key: String, } #[derive(Serialize)] pub struct CreateKeyResponse { pub id: Uuid, pub key_hint: String, } pub async fn create_key( State(state): State, admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let provider = req.provider.trim().to_lowercase(); let api_key = req.api_key.trim(); if provider.is_empty() { return Err(bad_request("Provider er påkrevd")); } if api_key.is_empty() { return Err(bad_request("API-nøkkel er påkrevd")); } let master = crypto::master_key() .map_err(|e| internal_error(&e))?; let encrypted = crypto::encrypt(api_key, &master) .map_err(|e| internal_error(&e))?; let hint = crypto::key_hint(api_key); let id = Uuid::now_v7(); sqlx::query( r#"INSERT INTO api_keys (id, provider, label, key_encrypted, key_hint, created_by) VALUES ($1, $2, $3, $4, $5, $6)"#, ) .bind(id) .bind(&provider) .bind(&req.label) .bind(&encrypted) .bind(&hint) .bind(admin.node_id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved lagring av nøkkel: {e}")))?; tracing::info!( provider = %provider, key_hint = %hint, "API-nøkkel opprettet" ); Ok(Json(CreateKeyResponse { id, key_hint: hint })) } // ============================================================================= // POST /admin/api-keys/test — test nøkkel-tilkobling // ============================================================================= #[derive(Deserialize)] pub struct TestKeyRequest { pub provider: String, pub api_key: String, } #[derive(Serialize)] pub struct TestKeyResponse { pub success: bool, pub message: String, } pub async fn test_key( _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let provider = req.provider.trim().to_lowercase(); let api_key = req.api_key.trim(); let config = match provider_test_config(&provider) { Some(c) => c, None => { return Ok(Json(TestKeyResponse { success: true, message: format!("Ingen test tilgjengelig for provider '{provider}' — nøkkel lagres uten validering"), })); } }; let url = format!("{}{}", config.base_url, config.model_list_path); let client = reqwest::Client::new(); let mut request = client.get(&url); if config.auth_header == "Bearer" { request = request.bearer_auth(api_key); } else if config.auth_header == "x-api-key" { request = request.header("x-api-key", api_key); } else if config.auth_header == "x-goog-api-key" { request = request.header("x-goog-api-key", api_key); } // Anthropic krever versjon-header if provider == "anthropic" { request = request.header("anthropic-version", "2023-06-01"); } let response = request .timeout(std::time::Duration::from_secs(10)) .send() .await .map_err(|e| { Ok::<_, (StatusCode, Json)>(Json(TestKeyResponse { success: false, message: format!("Tilkoblingsfeil: {e}"), })) }); match response { Ok(res) => { if res.status().is_success() { Ok(Json(TestKeyResponse { success: true, message: format!("Tilkobling OK ({} {})", provider, res.status()), })) } else { let status = res.status(); let body = res.text().await.unwrap_or_default(); let msg = if body.len() > 200 { &body[..200] } else { &body }; Ok(Json(TestKeyResponse { success: false, message: format!("Feil {status}: {msg}"), })) } } Err(Ok(test_response)) => Ok(test_response), Err(Err(e)) => Err(e), } } // ============================================================================= // POST /admin/api-keys/deactivate — deaktiver nøkkel // ============================================================================= #[derive(Deserialize)] pub struct DeactivateKeyRequest { pub id: Uuid, } #[derive(Serialize)] pub struct DeactivateKeyResponse { pub is_active: bool, } pub async fn deactivate_key( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let result = sqlx::query( "UPDATE api_keys SET is_active = NOT is_active, updated_at = now() WHERE id = $1", ) .bind(req.id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved oppdatering: {e}")))?; if result.rows_affected() == 0 { return Err(bad_request("Nøkkel finnes ikke")); } let is_active: bool = sqlx::query_scalar("SELECT is_active FROM api_keys WHERE id = $1") .bind(req.id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; tracing::info!(key_id = %req.id, is_active, "API-nøkkel status endret"); Ok(Json(DeactivateKeyResponse { is_active })) } // ============================================================================= // POST /admin/api-keys/delete — slett nøkkel permanent // ============================================================================= #[derive(Deserialize)] pub struct DeleteKeyRequest { pub id: Uuid, } #[derive(Serialize)] pub struct DeleteKeyResponse { pub deleted: bool, } pub async fn delete_key( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let result = sqlx::query("DELETE FROM api_keys WHERE id = $1") .bind(req.id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved sletting: {e}")))?; tracing::info!(key_id = %req.id, "API-nøkkel slettet"); Ok(Json(DeleteKeyResponse { deleted: result.rows_affected() > 0, })) }