diff --git a/open-sse/services/keyGroupAuth.ts b/open-sse/services/keyGroupAuth.ts new file mode 100644 index 000000000000..9e58578feed1 --- /dev/null +++ b/open-sse/services/keyGroupAuth.ts @@ -0,0 +1,84 @@ +/** + * Key Group Authorization Service + * + * Enforces model-level access control based on API key group membership. + * Used in the request pipeline to check if an API key's groups allow + * access to the requested model. + * + * If the API key is not in any group, all models are allowed (no restriction). + * If the API key IS in a group, model access is determined by permissions: + * - Deny rules override allow rules + * - Patterns support wildcards: "gpt-*", "claude-*", "*" for all + * - Provider-specific rules: "openai/gpt-4" + */ + +import { checkKeyModelAccess, getKeyGroupsForApiKey } from "@/lib/localDb"; + +export interface KeyGroupAuthResult { + /** Whether the request is authorized */ + authorized: boolean; + /** Human-readable reason if denied */ + reason?: string; + /** Groups that apply to this key */ + groups: Array<{ id: string; name: string }>; +} + +/** + * Check if an API key has access to a specific model. + * This is the main entry point for the request pipeline. + * + * @param apiKeyId - The API key ID from the auth pipeline + * @param model - The model string (e.g., "gpt-4", "claude-opus-4-7") + * @param provider - Optional provider (e.g., "openai", "anthropic") + * @returns KeyGroupAuthResult + */ +export function authorizeKeyModelAccess( + apiKeyId: string | undefined, + model: string, + provider?: string, +): KeyGroupAuthResult { + if (!apiKeyId) { + // No API key = no restriction (public endpoint) + return { authorized: true, groups: [] }; + } + + const groups = getKeyGroupsForApiKey(apiKeyId); + if (groups.length === 0) { + // Key not in any group = no restriction + return { authorized: true, groups: [] }; + } + + const accessCheck = checkKeyModelAccess(apiKeyId, model, provider); + + if (accessCheck.allowed) { + return { + authorized: true, + groups: groups.map((g) => ({ id: g.id, name: g.name })), + }; + } + + const denyReason = accessCheck.deniedBy + ? `Model "${model}" is denied by group permission (pattern: ${accessCheck.deniedBy.modelPattern})` + : `Model "${model}" is not in the allowed models for your API key group(s). ` + + `Configure group permissions or contact your administrator.`; + + return { + authorized: false, + reason: denyReason, + groups: groups.map((g) => ({ id: g.id, name: g.name })), + }; +} + +/** + * Get a summary of group memberships for an API key (for dashboard display). + */ +export function getKeyGroupSummary(apiKeyId: string): { + groups: Array<{ id: string; name: string }>; + restricted: boolean; +} { + const groups = getKeyGroupsForApiKey(apiKeyId); + return { + groups: groups.map((g) => ({ id: g.id, name: g.name })), + restricted: groups.length > 0, + }; +} diff --git a/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx b/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx index 12639d7db7ea..ac60c092f366 100644 --- a/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx +++ b/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx @@ -41,14 +41,21 @@ interface Combo { // ── Status helpers ─────────────────────────────────────────────────────────── function StatusTag({ status }: { status: TargetSimulation["status"] }) { - const map: Record = { + const map: Record< + TargetSimulation["status"], + { label: string; variant: "success" | "error" | "warning" | "info" } + > = { available: { label: "Available", variant: "success" }, no_quota: { label: "No Quota", variant: "error" }, degraded: { label: "Degraded", variant: "warning" }, error: { label: "Error", variant: "error" }, unknown: { label: "Unknown", variant: "info" }, }; - return {map[status].label}; + return ( + + {map[status].label} + + ); } // ── Component ──────────────────────────────────────────────────────────────── @@ -65,7 +72,7 @@ export default function ComboPlaygroundClient() { fetch("/api/combos") .then((r) => r.json()) .then((data) => { - const list: Combo[] = Array.isArray(data) ? data : data?.combos ?? data?.data ?? []; + const list: Combo[] = Array.isArray(data) ? data : (data?.combos ?? data?.data ?? []); setCombos(list); if (list.length > 0) setSelectedComboId(list[0].id); }) @@ -194,8 +201,18 @@ export default function ComboPlaygroundClient() { {i > 0 && (
- - + + {result.strategy === "priority" && ( fallback @@ -213,10 +230,10 @@ export default function ComboPlaygroundClient() { t.status === "available" ? "border-green-500/30 bg-green-500/5" : t.status === "error" - ? "border-red-500/30 bg-red-500/5" - : t.status === "unknown" - ? "border-yellow-500/30 bg-yellow-500/5" - : "border-border bg-surface/50" + ? "border-red-500/30 bg-red-500/5" + : t.status === "unknown" + ? "border-yellow-500/30 bg-yellow-500/5" + : "border-border bg-surface/50" }`} >
@@ -234,9 +251,7 @@ export default function ComboPlaygroundClient() { ${t.estimatedCost.toFixed(6)} - - {t.estimatedLatencyMs}ms - + {t.estimatedLatencyMs}ms {t.contextWindow && ( {(t.contextWindow / 1000).toFixed(0)}K ctx @@ -301,7 +316,7 @@ export default function ComboPlaygroundClient() {

- No combos configured yet.{' '} + No combos configured yet.{" "} Create one first diff --git a/src/app/api/keys/groups/[id]/keys/route.ts b/src/app/api/keys/groups/[id]/keys/route.ts new file mode 100644 index 000000000000..858650923e66 --- /dev/null +++ b/src/app/api/keys/groups/[id]/keys/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { addKeyToGroup, removeKeyFromGroup, getGroupMembers, getKeyGroup } from "@/lib/localDb"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * GET /api/keys/groups/[id]/keys — List API keys in a group + */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const group = getKeyGroup(id); + if (!group) return NextResponse.json({ error: "Group not found" }, { status: 404 }); + const members = getGroupMembers(id); + return NextResponse.json({ members }); + } catch (error) { + return NextResponse.json({ error: "Failed to list members" }, { status: 500 }); + } +} + +/** + * POST /api/keys/groups/[id]/keys — Add an API key to the group + * Body: { keyId } + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const group = getKeyGroup(id); + if (!group) return NextResponse.json({ error: "Group not found" }, { status: 404 }); + + const body = await request.json(); + if (!body.keyId) { + return NextResponse.json({ error: "keyId is required" }, { status: 400 }); + } + const added = addKeyToGroup(body.keyId, id); + if (!added) { + return NextResponse.json({ error: "Failed to add key" }, { status: 500 }); + } + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + return NextResponse.json({ error: "Failed to add key to group" }, { status: 500 }); + } +} + +/** + * DELETE /api/keys/groups/[id]/keys?keyId=xxx — Remove an API key from the group + */ +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const url = new URL(request.url); + const keyId = url.searchParams.get("keyId"); + if (!keyId) { + return NextResponse.json({ error: "keyId query param required" }, { status: 400 }); + } + const removed = removeKeyFromGroup(keyId, id); + if (!removed) { + return NextResponse.json({ error: "Key not found in group" }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: "Failed to remove key from group" }, { status: 500 }); + } +} diff --git a/src/app/api/keys/groups/[id]/permissions/route.ts b/src/app/api/keys/groups/[id]/permissions/route.ts new file mode 100644 index 000000000000..c234a03e885d --- /dev/null +++ b/src/app/api/keys/groups/[id]/permissions/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { addGroupPermission, removeGroupPermission, getGroupPermissions, getKeyGroup } from "@/lib/localDb"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * GET /api/keys/groups/[id]/permissions — List permissions for a group + */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const group = getKeyGroup(id); + if (!group) return NextResponse.json({ error: "Group not found" }, { status: 404 }); + + const permissions = getGroupPermissions(id); + return NextResponse.json({ permissions }); + } catch (error) { + return NextResponse.json({ error: "Failed to list permissions" }, { status: 500 }); + } +} + +/** + * POST /api/keys/groups/[id]/permissions — Add a permission rule + * Body: { modelPattern, accessType, provider? } + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const group = getKeyGroup(id); + if (!group) return NextResponse.json({ error: "Group not found" }, { status: 404 }); + + const body = await request.json(); + if (!body.modelPattern || !body.accessType) { + return NextResponse.json({ error: "modelPattern and accessType are required" }, { status: 400 }); + } + if (body.accessType !== "allow" && body.accessType !== "deny") { + return NextResponse.json({ error: "accessType must be 'allow' or 'deny'" }, { status: 400 }); + } + + const permission = addGroupPermission(id, body.modelPattern, body.accessType, body.provider); + return NextResponse.json({ permission }, { status: 201 }); + } catch (error) { + return NextResponse.json({ error: "Failed to add permission" }, { status: 500 }); + } +} + +/** + * DELETE /api/keys/groups/[id]/permissions?permissionId=xxx — Remove a permission + */ +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const url = new URL(request.url); + const permissionId = url.searchParams.get("permissionId"); + if (!permissionId) { + return NextResponse.json({ error: "permissionId query param required" }, { status: 400 }); + } + const removed = removeGroupPermission(permissionId); + if (!removed) { + return NextResponse.json({ error: "Permission not found" }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: "Failed to remove permission" }, { status: 500 }); + } +} diff --git a/src/app/api/keys/groups/[id]/route.ts b/src/app/api/keys/groups/[id]/route.ts new file mode 100644 index 000000000000..8ea46c914d48 --- /dev/null +++ b/src/app/api/keys/groups/[id]/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { + getKeyGroupWithPermissions, + updateKeyGroup, + deleteKeyGroup, + getGroupMembers, + addGroupPermission, + removeGroupPermission, + addKeyToGroup, + removeKeyFromGroup, +} from "@/lib/localDb"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * GET /api/keys/groups/[id] — Get group details with permissions and members + */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const group = getKeyGroupWithPermissions(id); + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + const members = getGroupMembers(id); + return NextResponse.json({ group, members }); + } catch (error) { + return NextResponse.json({ error: "Failed to get group" }, { status: 500 }); + } +} + +/** + * PUT /api/keys/groups/[id] — Update a group + */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + const updates: { name?: string; description?: string; isActive?: boolean } = {}; + if (body.name !== undefined) updates.name = body.name; + if (body.description !== undefined) updates.description = body.description; + if (body.isActive !== undefined) updates.isActive = body.isActive; + + const group = updateKeyGroup(id, updates); + if (!group) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + return NextResponse.json({ group }); + } catch (error) { + return NextResponse.json({ error: "Failed to update group" }, { status: 500 }); + } +} + +/** + * DELETE /api/keys/groups/[id] — Delete a group + */ +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const deleted = deleteKeyGroup(id); + if (!deleted) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: "Failed to delete group" }, { status: 500 }); + } +} diff --git a/src/app/api/keys/groups/route.ts b/src/app/api/keys/groups/route.ts new file mode 100644 index 000000000000..4a03760f4f88 --- /dev/null +++ b/src/app/api/keys/groups/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { + getAllKeyGroups, + createKeyGroup, + getKeyGroup, +} from "@/lib/localDb"; + +/** + * GET /api/keys/groups — List all key groups + */ +export async function GET() { + try { + const groups = getAllKeyGroups(); + return NextResponse.json({ groups }); + } catch (error) { + return NextResponse.json({ error: "Failed to list groups" }, { status: 500 }); + } +} + +/** + * POST /api/keys/groups — Create a key group + * Body: { name, description? } + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + if (!body.name || typeof body.name !== "string" || !body.name.trim()) { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + const group = createKeyGroup(body.name.trim(), body.description || ""); + return NextResponse.json({ group }, { status: 201 }); + } catch (error) { + return NextResponse.json({ error: "Failed to create group" }, { status: 500 }); + } +} diff --git a/src/app/api/playground/simulate-route/route.ts b/src/app/api/playground/simulate-route/route.ts index 04c2f64e7fdd..3e52b317333b 100644 --- a/src/app/api/playground/simulate-route/route.ts +++ b/src/app/api/playground/simulate-route/route.ts @@ -7,9 +7,7 @@ import { NextResponse } from "next/server"; import { getAllCombos } from "@/lib/db/combos"; -import { - getProviderConnections, -} from "@/lib/db/providers"; +import { getProviderConnections } from "@/lib/db/providers"; interface SimulateRequest { /** Combo ID to simulate */ @@ -52,18 +50,29 @@ interface SimulateResponse { function estimateCost(model: string, promptTokens: number): number { // Rough cost estimates per model family const modelLower = model.toLowerCase(); - const rate = modelLower.includes("sonnet") ? 3 : - modelLower.includes("haiku") ? 0.25 : - modelLower.includes("opus") ? 15 : - modelLower.includes("gpt-4") ? 10 : - modelLower.includes("gpt-3.5") ? 0.5 : - modelLower.includes("gemini") ? 0.15 : - modelLower.includes("claude") ? 3 : - modelLower.includes("deepseek") ? 0.5 : - modelLower.includes("mistral") ? 0.6 : - modelLower.includes("llama") ? 0.3 : - modelLower.includes("qwen") ? 0.4 : - 1; + const rate = modelLower.includes("sonnet") + ? 3 + : modelLower.includes("haiku") + ? 0.25 + : modelLower.includes("opus") + ? 15 + : modelLower.includes("gpt-4") + ? 10 + : modelLower.includes("gpt-3.5") + ? 0.5 + : modelLower.includes("gemini") + ? 0.15 + : modelLower.includes("claude") + ? 3 + : modelLower.includes("deepseek") + ? 0.5 + : modelLower.includes("mistral") + ? 0.6 + : modelLower.includes("llama") + ? 0.3 + : modelLower.includes("qwen") + ? 0.4 + : 1; return (rate * promptTokens) / 1_000_000; } @@ -97,7 +106,11 @@ export async function POST(request: Request) { const body: SimulateRequest = await request.json(); const warnings: string[] = []; const errors: string[] = []; - let comboInfo: { name: string; strategy: string; targets: Array<{ provider: string; model: string; weight?: number }> } | null = null; + let comboInfo: { + name: string; + strategy: string; + targets: Array<{ provider: string; model: string; weight?: number }>; + } | null = null; // Resolve combo if (body.comboId) { @@ -127,7 +140,7 @@ export async function POST(request: Request) { for (let i = 0; i < comboInfo.targets.length; i++) { const t = comboInfo.targets[i]; const conn = connections.find( - (c: any) => c.id === t.provider || c.name === t.provider || c.displayName === t.provider, + (c: any) => c.id === t.provider || c.name === t.provider || c.displayName === t.provider ); const cost = estimateCost(t.model, promptTokens); const latency = estimateLatency(t.model); @@ -176,9 +189,6 @@ export async function POST(request: Request) { return NextResponse.json(response, { status: 200 }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - return NextResponse.json( - { error: `Simulation error: ${message}` }, - { status: 500 }, - ); + return NextResponse.json({ error: `Simulation error: ${message}` }, { status: 500 }); } } diff --git a/src/lib/db/apiKeyGroups.ts b/src/lib/db/apiKeyGroups.ts new file mode 100644 index 000000000000..e5f5fe7c6b05 --- /dev/null +++ b/src/lib/db/apiKeyGroups.ts @@ -0,0 +1,279 @@ +/** + * API Key Groups DB — CRUD operations for team/enterprise key grouping + * + * Tables: key_groups, group_model_permissions, key_group_members + * Migration: 065_api_key_groups.sql + * + * Enables team-level API key management with model-level access control. + */ + +import { getDbInstance } from "@/lib/db/core"; +import { randomUUID } from "crypto"; + +// ── Types ──────────────────────────────────────────────────────────────── + +export interface KeyGroup { + id: string; + name: string; + description: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface GroupModelPermission { + id: string; + groupId: string; + modelPattern: string; + provider: string | null; + accessType: "allow" | "deny"; + createdAt: string; +} + +export interface KeyGroupMember { + keyId: string; + groupId: string; + createdAt: string; +} + +export interface KeyGroupWithPermissions extends KeyGroup { + permissions: GroupModelPermission[]; + memberCount: number; +} + +// ── Key Groups CRUD ────────────────────────────────────────────────────── + +export function getAllKeyGroups(): KeyGroup[] { + const db = getDbInstance() as any; + const rows = db.prepare("SELECT * FROM key_groups ORDER BY name ASC").all() as any[]; + return rows.map(rowToGroup); +} + +export function getKeyGroup(id: string): KeyGroup | undefined { + const db = getDbInstance() as any; + const row = db.prepare("SELECT * FROM key_groups WHERE id = ?").get(id) as any; + return row ? rowToGroup(row) : undefined; +} + +export function getKeyGroupWithPermissions(id: string): KeyGroupWithPermissions | undefined { + const group = getKeyGroup(id); + if (!group) return undefined; + + const permissions = getGroupPermissions(id); + const memberCount = getGroupMemberCount(id); + + return { ...group, permissions, memberCount }; +} + +export function createKeyGroup(name: string, description = ""): KeyGroup { + const db = getDbInstance() as any; + const id = randomUUID(); + const now = new Date().toISOString(); + + db.prepare( + "INSERT INTO key_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ).run(id, name, description, now, now); + + return getKeyGroup(id)!; +} + +export function updateKeyGroup(id: string, updates: { name?: string; description?: string; isActive?: boolean }): KeyGroup | undefined { + const existing = getKeyGroup(id); + if (!existing) return undefined; + + const db = getDbInstance() as any; + const sets: string[] = []; + const params: Record = { id }; + + if (updates.name !== undefined) { sets.push("name = @name"); params.name = updates.name; } + if (updates.description !== undefined) { sets.push("description = @description"); params.description = updates.description; } + if (updates.isActive !== undefined) { sets.push("is_active = @isActive"); params.isActive = updates.isActive ? 1 : 0; } + + if (sets.length === 0) return existing; + sets.push("updated_at = datetime('now')"); + + db.prepare(`UPDATE key_groups SET ${sets.join(", ")} WHERE id = @id`).run(params); + return getKeyGroup(id); +} + +export function deleteKeyGroup(id: string): boolean { + const db = getDbInstance() as any; + // CASCADE deletes permissions and members + const result = db.prepare("DELETE FROM key_groups WHERE id = ?").run(id); + return result.changes > 0; +} + +// ── Group Permissions ──────────────────────────────────────────────────── + +export function getGroupPermissions(groupId: string): GroupModelPermission[] { + const db = getDbInstance() as any; + const rows = db.prepare( + "SELECT * FROM group_model_permissions WHERE group_id = ? ORDER BY access_type ASC, model_pattern ASC", + ).all(groupId) as any[]; + return rows.map(rowToPermission); +} + +export function addGroupPermission( + groupId: string, + modelPattern: string, + accessType: "allow" | "deny", + provider?: string, +): GroupModelPermission { + const db = getDbInstance() as any; + const id = randomUUID(); + const now = new Date().toISOString(); + + db.prepare( + "INSERT INTO group_model_permissions (id, group_id, model_pattern, provider, access_type, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run(id, groupId, modelPattern, provider || null, accessType, now); + + return getGroupPermissions(groupId).find((p) => p.id === id)!; +} + +export function removeGroupPermission(permissionId: string): boolean { + const db = getDbInstance() as any; + const result = db.prepare("DELETE FROM group_model_permissions WHERE id = ?").run(permissionId); + return result.changes > 0; +} + +export function clearGroupPermissions(groupId: string): void { + const db = getDbInstance() as any; + db.prepare("DELETE FROM group_model_permissions WHERE group_id = ?").run(groupId); +} + +// ── Key Group Members ──────────────────────────────────────────────────── + +export function getGroupMembers(groupId: string): KeyGroupMember[] { + const db = getDbInstance() as any; + const rows = db.prepare( + "SELECT * FROM key_group_members WHERE group_id = ? ORDER BY created_at ASC", + ).all(groupId) as any[]; + return rows.map(rowToMember); +} + +export function getKeyGroupsForApiKey(keyId: string): KeyGroup[] { + const db = getDbInstance() as any; + const rows = db.prepare(` + SELECT g.* FROM key_groups g + INNER JOIN key_group_members m ON g.id = m.group_id + WHERE m.key_id = ? AND g.is_active = 1 + ORDER BY g.name ASC + `).all(keyId) as any[]; + return rows.map(rowToGroup); +} + +export function addKeyToGroup(keyId: string, groupId: string): boolean { + const db = getDbInstance() as any; + try { + db.prepare("INSERT OR IGNORE INTO key_group_members (key_id, group_id) VALUES (?, ?)").run(keyId, groupId); + return true; + } catch { + return false; + } +} + +export function removeKeyFromGroup(keyId: string, groupId: string): boolean { + const db = getDbInstance() as any; + const result = db.prepare("DELETE FROM key_group_members WHERE key_id = ? AND group_id = ?").run(keyId, groupId); + return result.changes > 0; +} + +function getGroupMemberCount(groupId: string): number { + const db = getDbInstance() as any; + const row = db.prepare("SELECT COUNT(*) as count FROM key_group_members WHERE group_id = ?").get(groupId) as any; + return row?.count || 0; +} + +// ── Authorization Helper ──────────────────────────────────────────────── + +export interface ModelAccessCheck { + allowed: boolean; + matchedRules: GroupModelPermission[]; + deniedBy: GroupModelPermission | null; +} + +/** + * Check if an API key has access to a specific model. + * Deny rules override allow rules. If no rules match, access is allowed by default. + */ +export function checkKeyModelAccess(keyId: string, model: string, provider?: string): ModelAccessCheck { + const groups = getKeyGroupsForApiKey(keyId); + if (groups.length === 0) { + // No groups = no restrictions + return { allowed: true, matchedRules: [], deniedBy: null }; + } + + const db = getDbInstance() as any; + const groupIds = groups.map((g) => g.id); + const placeholders = groupIds.map(() => "?").join(","); + + const rules = db.prepare(` + SELECT * FROM group_model_permissions + WHERE group_id IN (${placeholders}) + ORDER BY access_type ASC + `).all(...groupIds) as any[]; + + const permissions = rules.map(rowToPermission); + + // Check deny rules first (they take precedence) + const denyRules = permissions.filter( + (p) => p.accessType === "deny" && matchesModelPattern(p.modelPattern, model) && (!p.provider || p.provider === provider), + ); + + if (denyRules.length > 0) { + return { allowed: false, matchedRules: permissions, deniedBy: denyRules[0] }; + } + + // Check allow rules + const allowRules = permissions.filter( + (p) => p.accessType === "allow" && matchesModelPattern(p.modelPattern, model) && (!p.provider || p.provider === provider), + ); + + if (allowRules.length > 0) { + return { allowed: true, matchedRules: permissions, deniedBy: null }; + } + + // No matching rules = restricted by group membership but no explicit allow + return { allowed: false, matchedRules: permissions, deniedBy: null }; +} + +function matchesModelPattern(pattern: string, model: string): boolean { + if (pattern === "*") return true; + if (pattern.includes("*")) { + const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); + return regex.test(model); + } + return pattern === model; +} + +// ── Row Mappers ────────────────────────────────────────────────────────── + +function rowToGroup(row: any): KeyGroup { + return { + id: row.id, + name: row.name, + description: row.description || "", + isActive: row.is_active === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToPermission(row: any): GroupModelPermission { + return { + id: row.id, + groupId: row.group_id, + modelPattern: row.model_pattern, + provider: row.provider || null, + accessType: row.access_type, + createdAt: row.created_at, + }; +} + +function rowToMember(row: any): KeyGroupMember { + return { + keyId: row.key_id, + groupId: row.group_id, + createdAt: row.created_at, + }; +} diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index aca2127cb7d5..9f44fab27114 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel } from "./core"; import { backupDbFile } from "./backup"; import { registerDbStateResetter } from "./stateReset"; +import { getKeyGroupsForApiKey, checkKeyModelAccess } from "./apiKeyGroups"; import { setNoLog } from "../compliance"; // ──────────────── Performance Optimizations ──────────────── @@ -1235,6 +1236,13 @@ export async function isModelAllowedForKey( } } + // If key belongs to groups, also check group-level permissions + if (metadata.id) { + const groupAccess = checkKeyModelAccess(metadata.id, modelId || ""); + if (!groupAccess.allowed) { + allowed = false; + } + } // Cache the result evictIfNeeded(_modelPermissionCache); _modelPermissionCache.set(cacheKey, { allowed, timestamp: now }); diff --git a/src/lib/db/migrations/066_api_key_groups.sql b/src/lib/db/migrations/066_api_key_groups.sql new file mode 100644 index 000000000000..c015b9c8daa9 --- /dev/null +++ b/src/lib/db/migrations/066_api_key_groups.sql @@ -0,0 +1,50 @@ +-- Migration 065: API Key Groups & Model Permissions +-- Enables team/enterprise key grouping with model-level access control. + +BEGIN TRANSACTION; + +-- Key groups (logical grouping of API keys for team management) +CREATE TABLE IF NOT EXISTS key_groups ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Group-to-model permissions: which models each group can access +CREATE TABLE IF NOT EXISTS group_model_permissions ( + id TEXT PRIMARY KEY NOT NULL, + group_id TEXT NOT NULL, + -- Model identifier (supports wildcards: "gpt-*", "claude-*", "*" for all) + model_pattern TEXT NOT NULL, + -- Provider constraint (NULL = any provider, otherwise specific provider ID) + provider TEXT, + -- Allow or deny + access_type TEXT NOT NULL DEFAULT 'allow' CHECK(access_type IN ('allow', 'deny')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (group_id) REFERENCES key_groups(id) ON DELETE CASCADE +); + +-- Group-to-key membership +CREATE TABLE IF NOT EXISTS key_group_members ( + key_id TEXT NOT NULL, + group_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (key_id, group_id), + FOREIGN KEY (group_id) REFERENCES key_groups(id) ON DELETE CASCADE, + FOREIGN KEY (key_id) REFERENCES api_keys(id) ON DELETE CASCADE +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_group_permissions_group + ON group_model_permissions(group_id); + +CREATE INDEX IF NOT EXISTS idx_group_members_key + ON key_group_members(key_id); + +CREATE INDEX IF NOT EXISTS idx_group_members_group + ON key_group_members(group_id); + +COMMIT; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 8d3c2a406c4a..23e3bd025482 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -433,3 +433,55 @@ export { } from "./db/contextHandoffs"; export type { HandoffPayload } from "./db/contextHandoffs"; +export { + getAllMiddlewareHooks, + getEnabledMiddlewareHooks, + getComboMiddlewareHooks, + getMiddlewareHook, + createMiddlewareHook, + updateMiddlewareHook, + deleteMiddlewareHook, + recordHookExecution, + insertHookLog, + getHookLogs, + cleanupHookLogs, +} from "./db/middleware"; + +export { + getAllKeyGroups, + getKeyGroup, + getKeyGroupWithPermissions, + createKeyGroup, + updateKeyGroup, + deleteKeyGroup, + getGroupPermissions, + addGroupPermission, + removeGroupPermission, + clearGroupPermissions, + getGroupMembers, + getKeyGroupsForApiKey, + addKeyToGroup, + removeKeyFromGroup, + checkKeyModelAccess, +} from "./db/apiKeyGroups"; + +export { + createRelayToken, + getRelayTokens, + getRelayToken, + getRelayTokenByHash, + updateRelayToken, + deleteRelayToken, + toggleRelayToken, + checkRateLimit, + recordRelayUsage, + getRelayUsage, + getRelayLogs, +} from "./db/relayProxies"; +export type { + RelayToken, + RelayTokenRow, + RelayLogRow, + CreateRelayTokenInput, + RelayTokenWithSecret, +} from "./db/relayProxies";