From 362627981d391c8dc124c0a036b5d78b0f909bc5 Mon Sep 17 00:00:00 2001 From: "CTO (LegalAI)" Date: Thu, 9 Apr 2026 12:08:40 +0000 Subject: [PATCH] feat: implement per-tenant API key management with AES-256-GCM encryption Add encrypted API key storage for AI providers (Anthropic, OpenAI, Ollama) with admin-only CRUD endpoints, tenant isolation, and audit logging. - DB migration: tenant_api_keys table with RLS policy - AES-256-GCM encryption utility (ENCRYPTION_KEY env var) - CRUD API: GET/POST /api/settings/api-keys, PATCH/DELETE /api/settings/api-keys/[id] - Provider integration: getModelForTenant() checks tenant keys before env fallback - Frontend: API key management section in Einstellungen page - Audit logging on all key CRUD operations (DSGVO) Co-Authored-By: Paperclip --- drizzle/0003_tenant_api_keys.sql | 31 +++ .../einstellungen/api-key-settings.tsx | 218 ++++++++++++++++++ src/app/(dashboard)/einstellungen/page.tsx | 3 + src/app/api/settings/api-keys/[id]/route.ts | 97 ++++++++ src/app/api/settings/api-keys/route.ts | 102 ++++++++ src/lib/ai/providers/index.ts | 64 ++++- src/lib/crypto.ts | 59 +++++ src/lib/db/schema.ts | 39 ++++ 8 files changed, 603 insertions(+), 10 deletions(-) create mode 100644 drizzle/0003_tenant_api_keys.sql create mode 100644 src/app/(dashboard)/einstellungen/api-key-settings.tsx create mode 100644 src/app/api/settings/api-keys/[id]/route.ts create mode 100644 src/app/api/settings/api-keys/route.ts create mode 100644 src/lib/crypto.ts diff --git a/drizzle/0003_tenant_api_keys.sql b/drizzle/0003_tenant_api_keys.sql new file mode 100644 index 0000000..11491f9 --- /dev/null +++ b/drizzle/0003_tenant_api_keys.sql @@ -0,0 +1,31 @@ +-- Migration: Add tenant_api_keys table for per-tenant encrypted API key storage + +CREATE TYPE "api_key_provider" AS ENUM ('anthropic', 'openai', 'ollama'); + +CREATE TABLE "tenant_api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tenant_id" uuid NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE, + "provider" "api_key_provider" NOT NULL, + "encrypted_key" text NOT NULL, + "key_hint" varchar(8) NOT NULL, + "label" varchar(100), + "is_active" boolean NOT NULL DEFAULT true, + "created_by_user_id" uuid REFERENCES "users"("id"), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX "tenant_api_keys_tenant_provider_label_idx" + ON "tenant_api_keys" ("tenant_id", "provider", "label"); + +CREATE INDEX "tenant_api_keys_tenant_idx" + ON "tenant_api_keys" ("tenant_id"); + +CREATE INDEX "tenant_api_keys_provider_idx" + ON "tenant_api_keys" ("tenant_id", "provider", "is_active"); + +-- RLS policy: tenants can only see their own API keys +ALTER TABLE "tenant_api_keys" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "tenant_api_keys_tenant_isolation" ON "tenant_api_keys" + USING ("tenant_id" = current_setting('app.tenant_id', true)::uuid); diff --git a/src/app/(dashboard)/einstellungen/api-key-settings.tsx b/src/app/(dashboard)/einstellungen/api-key-settings.tsx new file mode 100644 index 0000000..83b6862 --- /dev/null +++ b/src/app/(dashboard)/einstellungen/api-key-settings.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; + +interface ApiKeyEntry { + id: string; + provider: string; + keyHint: string; + label: string | null; + isActive: boolean; + createdAt: string; +} + +const PROVIDER_LABELS: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', + ollama: 'Ollama', +}; + +export default function ApiKeySettings() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // New key form + const [newProvider, setNewProvider] = useState('anthropic'); + const [newKey, setNewKey] = useState(''); + const [newLabel, setNewLabel] = useState(''); + const [saving, setSaving] = useState(false); + + const loadKeys = useCallback(async () => { + try { + const res = await fetch('/api/settings/api-keys'); + if (res.ok) { + setKeys(await res.json()); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadKeys(); + }, [loadKeys]); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!newKey || newKey.length < 8) { + setMessage({ type: 'error', text: 'API-Schlüssel muss mindestens 8 Zeichen lang sein.' }); + return; + } + setSaving(true); + setMessage(null); + + try { + const res = await fetch('/api/settings/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: newProvider, + apiKey: newKey, + label: newLabel || undefined, + }), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'API-Schlüssel gespeichert.' }); + setNewKey(''); + setNewLabel(''); + await loadKeys(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' }); + } + } catch { + setMessage({ type: 'error', text: 'Netzwerkfehler.' }); + } finally { + setSaving(false); + } + } + + async function handleToggle(id: string, isActive: boolean) { + await fetch(`/api/settings/api-keys/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !isActive }), + }); + await loadKeys(); + } + + async function handleDelete(id: string) { + if (!confirm('Diesen API-Schlüssel wirklich löschen?')) return; + await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' }); + setMessage({ type: 'success', text: 'Schlüssel gelöscht.' }); + await loadKeys(); + } + + if (loading) { + return ( +
+

Lade API-Schlüssel...

+
+ ); + } + + return ( +
+

API-Schlüssel

+ + {/* Existing keys */} + {keys.length > 0 && ( +
+ {keys.map((k) => ( +
+
+
+ + {PROVIDER_LABELS[k.provider] ?? k.provider} + + {k.label && ( + ({k.label}) + )} + +
+

+ ****{k.keyHint} +

+
+
+ + +
+
+ ))} +
+ )} + + {keys.length === 0 && ( +

+ Keine API-Schlüssel gespeichert. Fügen Sie einen Schlüssel hinzu, um den gewählten AI-Provider zu nutzen. +

+ )} + + {/* Add new key */} +
+

Neuen Schlüssel hinzufügen

+ +
+
+ + +
+
+ + setNewLabel(e.target.value)} + placeholder="z.B. Produktion" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50" + /> +
+
+ +
+ + setNewKey(e.target.value)} + placeholder="sk-..." + autoComplete="off" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50" + /> +
+ + {message && ( +

+ {message.text} +

+ )} + + +
+
+ ); +} diff --git a/src/app/(dashboard)/einstellungen/page.tsx b/src/app/(dashboard)/einstellungen/page.tsx index bc677fe..0e1bb48 100644 --- a/src/app/(dashboard)/einstellungen/page.tsx +++ b/src/app/(dashboard)/einstellungen/page.tsx @@ -5,6 +5,7 @@ import { db } from '@/lib/db'; import { tenants, users } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import AISettingsForm from './ai-settings'; +import ApiKeySettings from './api-key-settings'; const ROLE_LABELS: Record = { admin: 'Administrator', @@ -73,6 +74,8 @@ export default async function EinstellungenPage() { {isAdmin && } + {isAdmin && } + {isAdmin && tenantUsers.length > 0 && (

Benutzer

diff --git a/src/app/api/settings/api-keys/[id]/route.ts b/src/app/api/settings/api-keys/[id]/route.ts new file mode 100644 index 0000000..5c9fdf6 --- /dev/null +++ b/src/app/api/settings/api-keys/[id]/route.ts @@ -0,0 +1,97 @@ +// PATCH /api/settings/api-keys/[id] — Update an API key (label, isActive, or replace key) +// DELETE /api/settings/api-keys/[id] — Delete an API key + +import { db } from '@/lib/db'; +import { tenantApiKeys } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requirePermission } from '@/lib/auth/rbac'; +import { encrypt, keyHint } from '@/lib/crypto'; +import { logAuditEvent } from '@/lib/auth/audit'; + +async function findKeyForTenant(keyId: string, tenantId: string) { + const [key] = await db + .select({ id: tenantApiKeys.id, tenantId: tenantApiKeys.tenantId }) + .from(tenantApiKeys) + .where(and(eq(tenantApiKeys.id, keyId), eq(tenantApiKeys.tenantId, tenantId))) + .limit(1); + return key ?? null; +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requirePermission('settings:manage'); + if ('response' in auth) return auth.response; + const { ctx } = auth; + const { id } = await params; + + const key = await findKeyForTenant(id, ctx.tenantId); + if (!key) { + return Response.json({ error: 'Schlüssel nicht gefunden.' }, { status: 404 }); + } + + const body = await request.json(); + const { label, isActive, apiKey } = body as { + label?: string; + isActive?: boolean; + apiKey?: string; + }; + + const updates: Record = { updatedAt: new Date() }; + const auditDetails: Record = {}; + + if (label !== undefined) { + updates.label = label || null; + auditDetails.label = label || null; + } + if (isActive !== undefined) { + updates.isActive = isActive; + auditDetails.isActive = isActive; + } + if (apiKey && typeof apiKey === 'string' && apiKey.length >= 8) { + updates.encryptedKey = encrypt(apiKey); + updates.keyHint = keyHint(apiKey); + auditDetails.keyRotated = true; + } + + const [updated] = await db + .update(tenantApiKeys) + .set(updates) + .where(eq(tenantApiKeys.id, id)) + .returning({ + id: tenantApiKeys.id, + provider: tenantApiKeys.provider, + keyHint: tenantApiKeys.keyHint, + label: tenantApiKeys.label, + isActive: tenantApiKeys.isActive, + updatedAt: tenantApiKeys.updatedAt, + }); + + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + await logAuditEvent(ctx, 'update', 'tenant_api_key', id, auditDetails, ip); + + return Response.json(updated); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requirePermission('settings:manage'); + if ('response' in auth) return auth.response; + const { ctx } = auth; + const { id } = await params; + + const key = await findKeyForTenant(id, ctx.tenantId); + if (!key) { + return Response.json({ error: 'Schlüssel nicht gefunden.' }, { status: 404 }); + } + + await db.delete(tenantApiKeys).where(eq(tenantApiKeys.id, id)); + + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + await logAuditEvent(ctx, 'delete', 'tenant_api_key', id, {}, ip); + + return Response.json({ ok: true }); +} diff --git a/src/app/api/settings/api-keys/route.ts b/src/app/api/settings/api-keys/route.ts new file mode 100644 index 0000000..54533f2 --- /dev/null +++ b/src/app/api/settings/api-keys/route.ts @@ -0,0 +1,102 @@ +// GET /api/settings/api-keys — List all API keys for the tenant (keyHint only, never full key) +// POST /api/settings/api-keys — Create a new encrypted API key + +import { db } from '@/lib/db'; +import { tenantApiKeys } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requirePermission } from '@/lib/auth/rbac'; +import { encrypt, keyHint } from '@/lib/crypto'; +import { logAuditEvent } from '@/lib/auth/audit'; +import type { AIProvider } from '@/lib/ai/providers'; + +const VALID_PROVIDERS = new Set(['anthropic', 'openai', 'ollama']); + +export async function GET() { + const auth = await requirePermission('settings:manage'); + if ('response' in auth) return auth.response; + + const keys = await db + .select({ + id: tenantApiKeys.id, + provider: tenantApiKeys.provider, + keyHint: tenantApiKeys.keyHint, + label: tenantApiKeys.label, + isActive: tenantApiKeys.isActive, + createdAt: tenantApiKeys.createdAt, + updatedAt: tenantApiKeys.updatedAt, + }) + .from(tenantApiKeys) + .where(eq(tenantApiKeys.tenantId, auth.ctx.tenantId)); + + return Response.json(keys); +} + +export async function POST(request: Request) { + const auth = await requirePermission('settings:manage'); + if ('response' in auth) return auth.response; + const { ctx } = auth; + + const body = await request.json(); + const { provider, apiKey, label } = body as { + provider?: string; + apiKey?: string; + label?: string; + }; + + if (!provider || !VALID_PROVIDERS.has(provider as AIProvider)) { + return Response.json( + { error: `Ungültiger Provider. Erlaubt: ${[...VALID_PROVIDERS].join(', ')}` }, + { status: 400 }, + ); + } + if (!apiKey || typeof apiKey !== 'string' || apiKey.length < 8) { + return Response.json({ error: 'API-Schlüssel ist erforderlich (mindestens 8 Zeichen).' }, { status: 400 }); + } + + // Check for duplicate provider+label + const existing = await db + .select({ id: tenantApiKeys.id }) + .from(tenantApiKeys) + .where( + and( + eq(tenantApiKeys.tenantId, ctx.tenantId), + eq(tenantApiKeys.provider, provider as 'anthropic' | 'openai' | 'ollama'), + label ? eq(tenantApiKeys.label, label) : undefined, + ), + ) + .limit(1); + + if (existing.length > 0) { + return Response.json( + { error: 'Ein Schlüssel mit diesem Provider und Label existiert bereits.' }, + { status: 409 }, + ); + } + + const encryptedKey = encrypt(apiKey); + const hint = keyHint(apiKey); + + const [created] = await db + .insert(tenantApiKeys) + .values({ + tenantId: ctx.tenantId, + provider: provider as 'anthropic' | 'openai' | 'ollama', + encryptedKey, + keyHint: hint, + label: label || null, + createdByUserId: ctx.userId, + }) + .returning({ + id: tenantApiKeys.id, + provider: tenantApiKeys.provider, + keyHint: tenantApiKeys.keyHint, + label: tenantApiKeys.label, + isActive: tenantApiKeys.isActive, + createdAt: tenantApiKeys.createdAt, + }); + + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + await logAuditEvent(ctx, 'create', 'tenant_api_key', created.id, { provider, label: label || null }, ip); + + return Response.json(created, { status: 201 }); +} diff --git a/src/lib/ai/providers/index.ts b/src/lib/ai/providers/index.ts index 72b3524..85e49f9 100644 --- a/src/lib/ai/providers/index.ts +++ b/src/lib/ai/providers/index.ts @@ -1,12 +1,14 @@ // AI Provider abstraction via Vercel AI SDK v6 // Supports: Anthropic, OpenAI, Ollama — selected via AI_PROVIDER env var or tenant settings +// Per-tenant API keys are loaded from tenant_api_keys (AES-256-GCM encrypted) -import { anthropic } from '@ai-sdk/anthropic'; +import { createAnthropic, anthropic } from '@ai-sdk/anthropic'; import { createOpenAI, openai } from '@ai-sdk/openai'; import type { LanguageModel } from 'ai'; import { db } from '@/lib/db'; -import { tenants } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { tenants, tenantApiKeys } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { decrypt } from '@/lib/crypto'; export type AIProvider = 'anthropic' | 'openai' | 'ollama'; @@ -23,17 +25,31 @@ export function getProvider(): AIProvider { return 'anthropic'; } -function buildModel(provider: AIProvider, modelId: string, ollamaUrl?: string): LanguageModel { +function buildModel( + provider: AIProvider, + modelId: string, + options?: { apiKey?: string; ollamaUrl?: string }, +): LanguageModel { switch (provider) { - case 'anthropic': + case 'anthropic': { + if (options?.apiKey) { + const custom = createAnthropic({ apiKey: options.apiKey }); + return custom(modelId); + } return anthropic(modelId); - case 'openai': + } + case 'openai': { + if (options?.apiKey) { + const custom = createOpenAI({ apiKey: options.apiKey }); + return custom(modelId); + } return openai(modelId); + } case 'ollama': { - const baseURL = ollamaUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434'; + const baseURL = options?.ollamaUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434'; const ollama = createOpenAI({ baseURL: `${baseURL}/v1`, - apiKey: 'ollama', + apiKey: options?.apiKey ?? 'ollama', }); return ollama(modelId); } @@ -46,7 +62,33 @@ export function getModel(provider?: AIProvider, modelId?: string): LanguageModel return buildModel(p, id); } -/** Tenant-aware model getter: reads AI settings from tenant DB, falls back to env vars. */ +/** + * Load the active API key for a tenant+provider from the DB. + * Returns the decrypted key or null if none exists. + */ +async function getTenantApiKey(tenantId: string, provider: AIProvider): Promise { + const [row] = await db + .select({ encryptedKey: tenantApiKeys.encryptedKey }) + .from(tenantApiKeys) + .where( + and( + eq(tenantApiKeys.tenantId, tenantId), + eq(tenantApiKeys.provider, provider), + eq(tenantApiKeys.isActive, true), + ), + ) + .limit(1); + + if (!row) return null; + + try { + return decrypt(row.encryptedKey); + } catch { + return null; + } +} + +/** Tenant-aware model getter: checks tenant_api_keys first, then falls back to env vars. */ export async function getModelForTenant(tenantId: string): Promise<{ model: LanguageModel; provider: AIProvider; modelId: string }> { const [tenant] = await db .select({ settings: tenants.settings }) @@ -65,5 +107,7 @@ export async function getModelForTenant(tenantId: string): Promise<{ model: Lang } const ollamaUrl = s?.ollamaUrl; - return { model: buildModel(provider, modelId, ollamaUrl), provider, modelId }; + const apiKey = await getTenantApiKey(tenantId, provider); + + return { model: buildModel(provider, modelId, { apiKey: apiKey ?? undefined, ollamaUrl }), provider, modelId }; } diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..2d11d1c --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,59 @@ +// AES-256-GCM encryption for tenant API keys +// ENCRYPTION_KEY env var must be a 32-byte hex string (64 hex chars) + +import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // GCM recommended IV size +const AUTH_TAG_LENGTH = 16; + +function getEncryptionKey(): Buffer { + const hex = process.env.ENCRYPTION_KEY; + if (!hex || hex.length !== 64) { + throw new Error('ENCRYPTION_KEY env var must be a 64-character hex string (32 bytes)'); + } + return Buffer.from(hex, 'hex'); +} + +/** + * Encrypt a plaintext string with AES-256-GCM. + * Returns a base64 string containing: IV (12 bytes) + authTag (16 bytes) + ciphertext. + */ +export function encrypt(plaintext: string): string { + const key = getEncryptionKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + // Pack: iv + authTag + ciphertext + const packed = Buffer.concat([iv, authTag, encrypted]); + return packed.toString('base64'); +} + +/** + * Decrypt a base64 string produced by encrypt(). + * Returns the original plaintext. + */ +export function decrypt(packed64: string): string { + const key = getEncryptionKey(); + const packed = Buffer.from(packed64, 'base64'); + + const iv = packed.subarray(0, IV_LENGTH); + const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString('utf8'); +} + +/** + * Extract a display hint from an API key (last 4 chars). + */ +export function keyHint(apiKey: string): string { + return apiKey.slice(-4); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 4179405..b67b0e0 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -961,6 +961,45 @@ export const documents = pgTable( ], ); +// ============================================================ +// API Key Management (per-tenant encrypted keys) +// ============================================================ + +/** Supported AI provider for API key storage */ +export const apiKeyProviderEnum = pgEnum("api_key_provider", [ + "anthropic", + "openai", + "ollama", +]); + +/** + * Tenant API Keys — encrypted per-tenant API keys for AI providers. + * Keys are stored with AES-256-GCM encryption; only keyHint is exposed via API. + */ +export const tenantApiKeys = pgTable( + "tenant_api_keys", + { + id: uuid("id").primaryKey().defaultRandom(), + tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), + provider: apiKeyProviderEnum("provider").notNull(), + /** AES-256-GCM encrypted key (base64: iv + authTag + ciphertext) */ + encryptedKey: text("encrypted_key").notNull(), + /** Last 4 characters of the original key for display (e.g. "sk-...ab12") */ + keyHint: varchar("key_hint", { length: 8 }).notNull(), + /** Optional label, e.g. "Produktion" or "Test" */ + label: varchar("label", { length: 100 }), + isActive: boolean("is_active").notNull().default(true), + createdByUserId: uuid("created_by_user_id").references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (t) => [ + uniqueIndex("tenant_api_keys_tenant_provider_label_idx").on(t.tenantId, t.provider, t.label), + index("tenant_api_keys_tenant_idx").on(t.tenantId), + index("tenant_api_keys_provider_idx").on(t.tenantId, t.provider, t.isActive), + ], +); + // ============================================================ // DSGVO / Audit // ============================================================