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 <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 12:08:40 +00:00
parent 34047739cf
commit 362627981d
8 changed files with 603 additions and 10 deletions

View File

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

View File

@@ -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<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
ollama: 'Ollama',
};
export default function ApiKeySettings() {
const [keys, setKeys] = useState<ApiKeyEntry[]>([]);
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 (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<p className="text-sm text-muted">Lade API-Schlüssel...</p>
</div>
);
}
return (
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-6">
<h3 className="text-sm font-semibold text-foreground">API-Schlüssel</h3>
{/* Existing keys */}
{keys.length > 0 && (
<div className="divide-y divide-card-border">
{keys.map((k) => (
<div key={k.id} className="py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{PROVIDER_LABELS[k.provider] ?? k.provider}
</span>
{k.label && (
<span className="text-xs text-muted">({k.label})</span>
)}
<span
className={`inline-block w-2 h-2 rounded-full ${k.isActive ? 'bg-green-500' : 'bg-gray-400'}`}
title={k.isActive ? 'Aktiv' : 'Inaktiv'}
/>
</div>
<p className="text-xs text-muted font-mono mt-0.5">
****{k.keyHint}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => handleToggle(k.id, k.isActive)}
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
>
{k.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button
type="button"
onClick={() => handleDelete(k.id)}
className="text-xs px-2 py-1 rounded border border-red-300 text-red-500 hover:bg-red-50"
>
Löschen
</button>
</div>
</div>
))}
</div>
)}
{keys.length === 0 && (
<p className="text-sm text-muted">
Keine API-Schlüssel gespeichert. Fügen Sie einen Schlüssel hinzu, um den gewählten AI-Provider zu nutzen.
</p>
)}
{/* Add new key */}
<form onSubmit={handleCreate} className="space-y-3 pt-3 border-t border-card-border">
<p className="text-xs font-medium text-muted uppercase tracking-wide">Neuen Schlüssel hinzufügen</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm text-muted mb-1">Provider</label>
<select
value={newProvider}
onChange={(e) => setNewProvider(e.target.value)}
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground"
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="ollama">Ollama</option>
</select>
</div>
<div>
<label className="block text-sm text-muted mb-1">Label (optional)</label>
<input
type="text"
value={newLabel}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm text-muted mb-1">API-Schlüssel</label>
<input
type="password"
value={newKey}
onChange={(e) => 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"
/>
</div>
{message && (
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
{message.text}
</p>
)}
<button
type="submit"
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Schlüssel speichern'}
</button>
</form>
</div>
);
}

View File

@@ -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<string, string> = {
admin: 'Administrator',
@@ -73,6 +74,8 @@ export default async function EinstellungenPage() {
{isAdmin && <AISettingsForm />}
{isAdmin && <ApiKeySettings />}
{isAdmin && tenantUsers.length > 0 && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>

View File

@@ -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<string, unknown> = { updatedAt: new Date() };
const auditDetails: Record<string, unknown> = {};
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 });
}

View File

@@ -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<AIProvider>(['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 });
}

View File

@@ -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<string | null> {
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 };
}

59
src/lib/crypto.ts Normal file
View File

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

View File

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