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:
31
drizzle/0003_tenant_api_keys.sql
Normal file
31
drizzle/0003_tenant_api_keys.sql
Normal 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);
|
||||
218
src/app/(dashboard)/einstellungen/api-key-settings.tsx
Normal file
218
src/app/(dashboard)/einstellungen/api-key-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
97
src/app/api/settings/api-keys/[id]/route.ts
Normal file
97
src/app/api/settings/api-keys/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
102
src/app/api/settings/api-keys/route.ts
Normal file
102
src/app/api/settings/api-keys/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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
59
src/lib/crypto.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user