diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e9f038..768eb3f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1738,3 +1738,72 @@ export async function fetchPodcastCollections( } return res.json(); } + +// ============================================================================= +// API-nøkler (oppgave 060) +// ============================================================================= + +export interface ApiKeyInfo { + id: string; + provider: string; + label: string | null; + key_hint: string | null; + is_active: boolean; + created_at: string; + updated_at: string; + last_used: string | null; + usage_count: number; +} + +export interface ApiKeysResponse { + keys: ApiKeyInfo[]; +} + +export interface TestKeyResponse { + success: boolean; + message: string; +} + +/** Hent alle API-nøkler. */ +export async function fetchApiKeys(accessToken: string): Promise { + const res = await fetch(`${BASE_URL}/admin/api-keys`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`api-keys failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Opprett ny API-nøkkel. */ +export function createApiKey( + accessToken: string, + data: { provider: string; label?: string; api_key: string } +): Promise<{ id: string; key_hint: string }> { + return post(accessToken, '/admin/api-keys/create', data); +} + +/** Test API-nøkkel tilkobling. */ +export function testApiKey( + accessToken: string, + data: { provider: string; api_key: string } +): Promise { + return post(accessToken, '/admin/api-keys/test', data); +} + +/** Deaktiver/aktiver API-nøkkel. */ +export function deactivateApiKey( + accessToken: string, + id: string +): Promise<{ is_active: boolean }> { + return post(accessToken, '/admin/api-keys/deactivate', { id }); +} + +/** Slett API-nøkkel permanent. */ +export function deleteApiKey( + accessToken: string, + id: string +): Promise<{ deleted: boolean }> { + return post(accessToken, '/admin/api-keys/delete', { id }); +} diff --git a/frontend/src/routes/admin/keys/+page.svelte b/frontend/src/routes/admin/keys/+page.svelte new file mode 100644 index 0000000..8d4c00e --- /dev/null +++ b/frontend/src/routes/admin/keys/+page.svelte @@ -0,0 +1,338 @@ + + +
+
+
+
+ Hjem + Admin +

API-nokler

+
+ +
+
+ +
+ {#if !accessToken} +

Logg inn for tilgang.

+ {:else} + {#if error} +
+ {error} +
+ {/if} + +
+

+ API-nokler + ({keys.length}) +

+ +
+ + {#if showCreateForm} +
+

Ny API-nokkel

+
+
+ + +
+
+ + +
+
+ + +
+ + {#if testResult} +
+ {testResult.message} +
+ {/if} + +
+ + +
+
+
+ {/if} + + {#if keys.length === 0 && !loading} +
+ Ingen API-nokler registrert enna. +
+ {:else} +
+ {#each keys as key (key.id)} +
+
+
+
+

+ {providerLabel(key.provider)} +

+ {#if key.label} + + {key.label} + + {/if} + + {key.is_active ? 'Aktiv' : 'Inaktiv'} + +
+ +
+ {key.key_hint || '****'} + Brukt {key.usage_count}x + Sist brukt: {timeAgo(key.last_used)} + Lagt til {new Date(key.created_at).toLocaleDateString('nb-NO')} +
+
+ +
+ + +
+
+
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/maskinrommet/Cargo.toml b/maskinrommet/Cargo.toml index 0b74707..204f093 100644 --- a/maskinrommet/Cargo.toml +++ b/maskinrommet/Cargo.toml @@ -21,4 +21,5 @@ hex = "0.4" tokio-util = { version = "0.7", features = ["io"] } tera = "1" rand = "0.8" +aes-gcm = "0.10" libc = "0.2.183" diff --git a/maskinrommet/src/api_keys_admin.rs b/maskinrommet/src/api_keys_admin.rs new file mode 100644 index 0000000..0941168 --- /dev/null +++ b/maskinrommet/src/api_keys_admin.rs @@ -0,0 +1,326 @@ +// 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, + })) +} diff --git a/maskinrommet/src/crypto.rs b/maskinrommet/src/crypto.rs new file mode 100644 index 0000000..b774944 --- /dev/null +++ b/maskinrommet/src/crypto.rs @@ -0,0 +1,94 @@ +// AES-256-GCM kryptering for API-nøkler +// +// Master key leses fra SYNOPS_MASTER_KEY env-variabel (32 bytes, hex-kodet). +// Hver kryptert verdi inneholder nonce (12 bytes) + ciphertext. + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use rand::RngCore; + +/// Hent master key fra env. Returnerer 32 bytes. +pub fn master_key() -> Result<[u8; 32], String> { + let hex_key = std::env::var("SYNOPS_MASTER_KEY") + .map_err(|_| "SYNOPS_MASTER_KEY er ikke satt".to_string())?; + + let bytes = hex::decode(hex_key.trim()) + .map_err(|e| format!("SYNOPS_MASTER_KEY er ikke gyldig hex: {e}"))?; + + if bytes.len() != 32 { + return Err(format!( + "SYNOPS_MASTER_KEY må være 32 bytes (64 hex-tegn), fikk {}", + bytes.len() + )); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +/// Krypter plaintext med AES-256-GCM. Returnerer nonce (12 bytes) || ciphertext. +pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result, String> { + let cipher = Aes256Gcm::new(key.into()); + + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| format!("Krypteringsfeil: {e}"))?; + + let mut result = Vec::with_capacity(12 + ciphertext.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext); + Ok(result) +} + +/// Dekrypter data (nonce || ciphertext) med AES-256-GCM. +pub fn decrypt(data: &[u8], key: &[u8; 32]) -> Result { + if data.len() < 13 { + return Err("Kryptert data er for kort".to_string()); + } + + let cipher = Aes256Gcm::new(key.into()); + let nonce = Nonce::from_slice(&data[..12]); + let ciphertext = &data[12..]; + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("Dekrypteringsfeil: {e}"))?; + + String::from_utf8(plaintext).map_err(|e| format!("Ugyldig UTF-8: {e}")) +} + +/// Lag key_hint: siste 4 tegn av nøkkelen. +pub fn key_hint(key: &str) -> String { + if key.len() >= 4 { + format!("...{}", &key[key.len() - 4..]) + } else { + "****".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_roundtrip() { + let key = [0x42u8; 32]; + let plaintext = "sk-or-v1-test-key-12345"; + let encrypted = encrypt(plaintext, &key).unwrap(); + let decrypted = decrypt(&encrypted, &key).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_key_hint() { + assert_eq!(key_hint("sk-or-v1-abc123c08b"), "...c08b"); + assert_eq!(key_hint("ab"), "****"); + } +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index dc709cd..7bb1781 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,5 +1,7 @@ pub mod agent; pub mod ai_admin; +mod api_keys_admin; +pub mod crypto; pub mod ai_budget; pub mod ai_edges; pub mod ai_process; @@ -315,6 +317,12 @@ async fn main() { .route("/intentions/set_mute", post(mixer::set_mute)) .route("/intentions/toggle_effect", post(mixer::toggle_effect)) .route("/intentions/set_mixer_role", post(mixer::set_mixer_role)) + // API-nøkler (oppgave 060) + .route("/admin/api-keys", get(api_keys_admin::list_keys)) + .route("/admin/api-keys/create", post(api_keys_admin::create_key)) + .route("/admin/api-keys/test", post(api_keys_admin::test_key)) + .route("/admin/api-keys/deactivate", post(api_keys_admin::deactivate_key)) + .route("/admin/api-keys/delete", post(api_keys_admin::delete_key)) // Webhook-admin (oppgave 29.5) .route("/admin/webhooks", get(webhook_admin::list_webhooks)) .route("/admin/webhooks/events", get(webhook_admin::webhook_events)) diff --git a/migrations/032_api_keys.sql b/migrations/032_api_keys.sql new file mode 100644 index 0000000..ee92c09 --- /dev/null +++ b/migrations/032_api_keys.sql @@ -0,0 +1,20 @@ +-- API-nøkler: kryptert lagring av tredjepartsnøkler +-- Ref: docs/infra/nøkkelhåndtering.md + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL, + label TEXT, + key_encrypted BYTEA NOT NULL, + key_hint TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + created_by UUID REFERENCES nodes(id), + last_used TIMESTAMPTZ, + usage_count BIGINT DEFAULT 0 +); + +CREATE INDEX idx_api_keys_provider ON api_keys(provider, is_active); + +GRANT SELECT ON api_keys TO synops_reader; diff --git a/scripts/maskinrommet-env.sh b/scripts/maskinrommet-env.sh index 97f7c37..2d430f5 100755 --- a/scripts/maskinrommet-env.sh +++ b/scripts/maskinrommet-env.sh @@ -31,5 +31,6 @@ ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE) ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL) PROJECT_DIR=/home/vegard/synops SYNOPS_CLIP_SCRIPTS=/home/vegard/synops/tools/synops-clip/scripts +SYNOPS_MASTER_KEY=$(read_env SYNOPS_MASTER_KEY) RUST_LOG=maskinrommet=debug,tower_http=debug EOF