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
This commit is contained in:
vegard 2026-03-19 18:57:01 +00:00
parent b8841f7b1a
commit d53304a0f3
8 changed files with 857 additions and 0 deletions

View file

@ -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<ApiKeysResponse> {
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<TestKeyResponse> {
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 });
}

View file

@ -0,0 +1,338 @@
<script lang="ts">
/**
* Admin — API-nøkler (oppgave 060)
*
* Administrer krypterte API-nøkler for tredjepartstjenester.
* Nøkler lagres kryptert i PG, kun hint vises etter lagring.
*/
import { page } from '$app/stores';
import {
fetchApiKeys,
createApiKey,
testApiKey,
deactivateApiKey,
deleteApiKey,
type ApiKeyInfo,
type TestKeyResponse
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let keys = $state<ApiKeyInfo[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let actionLoading = $state<string | null>(null);
// Opprett-skjema
let showCreateForm = $state(false);
let newProvider = $state('openrouter');
let newLabel = $state('');
let newApiKey = $state('');
let testResult = $state<TestKeyResponse | null>(null);
let testLoading = $state(false);
const providers = [
{ value: 'openrouter', label: 'OpenRouter' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Google Gemini' },
{ value: 'xai', label: 'xAI' },
{ value: 'elevenlabs', label: 'ElevenLabs' },
{ value: 'other', label: 'Annen' }
];
function providerLabel(provider: string): string {
return providers.find((p) => p.value === provider)?.label ?? provider;
}
$effect(() => {
if (!accessToken) return;
loadKeys();
const interval = setInterval(loadKeys, 15000);
return () => clearInterval(interval);
});
async function loadKeys() {
if (!accessToken) return;
try {
const res = await fetchApiKeys(accessToken);
keys = res.keys;
error = null;
} catch (e) {
error = String(e);
}
}
function toggleCreateForm() {
showCreateForm = !showCreateForm;
testResult = null;
newApiKey = '';
newLabel = '';
newProvider = 'openrouter';
}
async function handleTest() {
if (!accessToken || !newApiKey.trim()) return;
testLoading = true;
testResult = null;
try {
testResult = await testApiKey(accessToken, {
provider: newProvider,
api_key: newApiKey.trim()
});
} catch (e) {
testResult = { success: false, message: String(e) };
} finally {
testLoading = false;
}
}
async function handleCreate() {
if (!accessToken || !newApiKey.trim()) return;
actionLoading = 'create';
error = null;
try {
await createApiKey(accessToken, {
provider: newProvider,
label: newLabel.trim() || undefined,
api_key: newApiKey.trim()
});
newApiKey = '';
newLabel = '';
testResult = null;
showCreateForm = false;
await loadKeys();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
async function handleToggleActive(id: string) {
if (!accessToken) return;
actionLoading = `toggle-${id}`;
error = null;
try {
await deactivateApiKey(accessToken, id);
await loadKeys();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
async function handleDelete(id: string) {
if (!accessToken) return;
if (!confirm('Slett nøkkelen permanent? Dette kan ikke angres.')) return;
actionLoading = `del-${id}`;
error = null;
try {
await deleteApiKey(accessToken, id);
await loadKeys();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
function timeAgo(iso: string | null): string {
if (!iso) return 'Aldri';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '';
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}t`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
</script>
<div class="min-h-screen bg-gray-50">
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-500 hover:text-gray-700">Hjem</a>
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700">Admin</a>
<h1 class="text-lg font-semibold text-gray-900">API-nokler</h1>
</div>
<nav class="flex gap-2 text-sm">
<a href="/admin" class="rounded-lg bg-gray-100 px-3 py-1.5 text-gray-700 hover:bg-gray-200">
Vedlikehold
</a>
<a href="/admin/ai" class="rounded-lg bg-gray-100 px-3 py-1.5 text-gray-700 hover:bg-gray-200">
AI
</a>
<a href="/admin/webhooks" class="rounded-lg bg-gray-100 px-3 py-1.5 text-gray-700 hover:bg-gray-200">
Webhooks
</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-3xl px-4 py-6">
{#if !accessToken}
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
{:else}
{#if error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
{/if}
<div class="mb-4 flex items-center justify-between">
<h2 class="text-base font-semibold text-gray-800">
API-nokler
<span class="ml-1 text-sm font-normal text-gray-400">({keys.length})</span>
</h2>
<button
onclick={toggleCreateForm}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showCreateForm ? 'Avbryt' : 'Legg til nokkel'}
</button>
</div>
{#if showCreateForm}
<section class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="mb-3 text-sm font-semibold text-gray-800">Ny API-nokkel</h3>
<div class="space-y-3">
<div>
<label for="ak-provider" class="mb-1 block text-xs font-medium text-gray-600">
Provider
</label>
<select
id="ak-provider"
bind:value={newProvider}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
>
{#each providers as p (p.value)}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
<div>
<label for="ak-label" class="mb-1 block text-xs font-medium text-gray-600">
Label (valgfritt)
</label>
<input
id="ak-label"
type="text"
placeholder="F.eks. Prod, Vegards konto"
bind:value={newLabel}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<div>
<label for="ak-key" class="mb-1 block text-xs font-medium text-gray-600">
API-nokkel
</label>
<input
id="ak-key"
type="password"
placeholder="sk-..."
bind:value={newApiKey}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm font-mono"
/>
</div>
{#if testResult}
<div
class="rounded-lg border p-2 text-sm {testResult.success
? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700'}"
>
{testResult.message}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={handleTest}
disabled={!newApiKey.trim() || testLoading}
class="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 disabled:opacity-50"
>
{testLoading ? 'Tester...' : 'Test tilkobling'}
</button>
<button
onclick={handleCreate}
disabled={!newApiKey.trim() || actionLoading === 'create'}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{actionLoading === 'create' ? 'Lagrer...' : 'Lagre'}
</button>
</div>
</div>
</section>
{/if}
{#if keys.length === 0 && !loading}
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center text-sm text-gray-400">
Ingen API-nokler registrert enna.
</div>
{:else}
<div class="space-y-3">
{#each keys as key (key.id)}
<div class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="flex items-start justify-between p-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-800">
{providerLabel(key.provider)}
</h3>
{#if key.label}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{key.label}
</span>
{/if}
<span
class="rounded-full px-2 py-0.5 text-xs {key.is_active
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'}"
>
{key.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div class="mt-1.5 flex items-center gap-4 text-xs text-gray-400">
<span class="font-mono">{key.key_hint || '****'}</span>
<span>Brukt {key.usage_count}x</span>
<span>Sist brukt: {timeAgo(key.last_used)}</span>
<span>Lagt til {new Date(key.created_at).toLocaleDateString('nb-NO')}</span>
</div>
</div>
<div class="ml-3 flex shrink-0 gap-2">
<button
onclick={() => handleToggleActive(key.id)}
disabled={actionLoading === `toggle-${key.id}`}
class="rounded px-2 py-1 text-xs {key.is_active
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50"
>
{#if actionLoading === `toggle-${key.id}`}
...
{:else}
{key.is_active ? 'Deaktiver' : 'Aktiver'}
{/if}
</button>
<button
onclick={() => handleDelete(key.id)}
disabled={actionLoading === `del-${key.id}`}
class="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
>
{actionLoading === `del-${key.id}` ? '...' : 'Slett'}
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</main>
</div>

View file

@ -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"

View file

@ -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<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,
}))
}

View file

@ -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<Vec<u8>, 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<String, String> {
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"), "****");
}
}

View file

@ -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))

View file

@ -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;

View file

@ -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