synops/maskinrommet/src/api_keys_admin.rs
vegard d53304a0f3 Implementer API-nøkkelhåndtering med kryptert lagring
- PG-migrasjon: api_keys-tabell med krypterte nøkler (032)
- AES-256-GCM kryptering via SYNOPS_MASTER_KEY (crypto.rs)
- Admin-endepunkter: list/create/test/deactivate/delete
- Test-tilkobling for OpenRouter, Anthropic, OpenAI, Gemini
- Frontend: /admin/keys med nøkkelliste og opprettskjema
- SYNOPS_MASTER_KEY injiseres via maskinrommet-env.sh
2026-03-19 18:57:01 +00:00

326 lines
9.8 KiB
Rust

// 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<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() }))
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(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<String>,
pub key_hint: Option<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_used: Option<DateTime<Utc>>,
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<ProviderTestConfig> {
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<ApiKeyInfo>,
}
pub async fn list_keys(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<ApiKeysResponse>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
pub api_key: String,
}
#[derive(Serialize)]
pub struct CreateKeyResponse {
pub id: Uuid,
pub key_hint: String,
}
pub async fn create_key(
State(state): State<AppState>,
admin: AdminUser,
Json(req): Json<CreateKeyRequest>,
) -> Result<Json<CreateKeyResponse>, (StatusCode, Json<ErrorResponse>)> {
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<TestKeyRequest>,
) -> Result<Json<TestKeyResponse>, (StatusCode, Json<ErrorResponse>)> {
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<ErrorResponse>)>(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<AppState>,
_admin: AdminUser,
Json(req): Json<DeactivateKeyRequest>,
) -> Result<Json<DeactivateKeyResponse>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Json(req): Json<DeleteKeyRequest>,
) -> Result<Json<DeleteKeyResponse>, (StatusCode, Json<ErrorResponse>)> {
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,
}))
}