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:
parent
b8841f7b1a
commit
d53304a0f3
8 changed files with 857 additions and 0 deletions
|
|
@ -1738,3 +1738,72 @@ export async function fetchPodcastCollections(
|
||||||
}
|
}
|
||||||
return res.json();
|
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 });
|
||||||
|
}
|
||||||
|
|
|
||||||
338
frontend/src/routes/admin/keys/+page.svelte
Normal file
338
frontend/src/routes/admin/keys/+page.svelte
Normal 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 'nå';
|
||||||
|
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>
|
||||||
|
|
@ -21,4 +21,5 @@ hex = "0.4"
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tera = "1"
|
tera = "1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
aes-gcm = "0.10"
|
||||||
libc = "0.2.183"
|
libc = "0.2.183"
|
||||||
|
|
|
||||||
326
maskinrommet/src/api_keys_admin.rs
Normal file
326
maskinrommet/src/api_keys_admin.rs
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
94
maskinrommet/src/crypto.rs
Normal file
94
maskinrommet/src/crypto.rs
Normal 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"), "****");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod ai_admin;
|
pub mod ai_admin;
|
||||||
|
mod api_keys_admin;
|
||||||
|
pub mod crypto;
|
||||||
pub mod ai_budget;
|
pub mod ai_budget;
|
||||||
pub mod ai_edges;
|
pub mod ai_edges;
|
||||||
pub mod ai_process;
|
pub mod ai_process;
|
||||||
|
|
@ -315,6 +317,12 @@ async fn main() {
|
||||||
.route("/intentions/set_mute", post(mixer::set_mute))
|
.route("/intentions/set_mute", post(mixer::set_mute))
|
||||||
.route("/intentions/toggle_effect", post(mixer::toggle_effect))
|
.route("/intentions/toggle_effect", post(mixer::toggle_effect))
|
||||||
.route("/intentions/set_mixer_role", post(mixer::set_mixer_role))
|
.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)
|
// Webhook-admin (oppgave 29.5)
|
||||||
.route("/admin/webhooks", get(webhook_admin::list_webhooks))
|
.route("/admin/webhooks", get(webhook_admin::list_webhooks))
|
||||||
.route("/admin/webhooks/events", get(webhook_admin::webhook_events))
|
.route("/admin/webhooks/events", get(webhook_admin::webhook_events))
|
||||||
|
|
|
||||||
20
migrations/032_api_keys.sql
Normal file
20
migrations/032_api_keys.sql
Normal 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;
|
||||||
|
|
@ -31,5 +31,6 @@ ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE)
|
||||||
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)
|
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)
|
||||||
PROJECT_DIR=/home/vegard/synops
|
PROJECT_DIR=/home/vegard/synops
|
||||||
SYNOPS_CLIP_SCRIPTS=/home/vegard/synops/tools/synops-clip/scripts
|
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
|
RUST_LOG=maskinrommet=debug,tower_http=debug
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue