- 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
326 lines
9.8 KiB
Rust
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,
|
|
}))
|
|
}
|