/** * db/apiKeys.js — API key management. */ import { createHash } from "crypto"; 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/noLog"; import { resolveModelAlias } from "@omniroute/open-sse/services/modelDeprecation.ts"; import { getSyncedAvailableModelsByConnection, getCustomModels, getModelIsHidden, } from "./models"; // ──────────────── Performance Optimizations ──────────────── // Schema check memoization - only run once let _schemaChecked = false; type JsonRecord = Record; interface CacheEntry { timestamp: number; value: TValue; } export interface RateLimitRule { limit: number; window: number; } export interface AccessSchedule { enabled: boolean; from: string; until: string; days: number[]; tz: string; } interface ApiKeyMetadata { id: string; name: string; machineId: string | null; allowedModels: string[]; allowedCombos: string[]; allowedConnections: string[]; allowedQuotas: string[]; noLog: boolean; autoResolve: boolean; isActive: boolean; accessSchedule: AccessSchedule | null; maxRequestsPerDay: number | null; maxRequestsPerMinute: number | null; throttleDelayMs: number | null; rateLimits: RateLimitRule[] | null; // T08: Per-key max concurrent sticky sessions (0 = unlimited) maxSessions: number; // Phase 3 lifecycle/policy fields revokedAt: string | null; expiresAt: string | null; ipAllowlist: string[]; scopes: string[]; isBanned: boolean; keyHash: string | null; allowedEndpoints: string[]; streamDefaultMode: "legacy" | "json"; disableNonPublicModels: boolean; } interface ApiKeyRow extends JsonRecord { id?: unknown; name?: unknown; key?: unknown; machine_id?: unknown; machineId?: unknown; allowed_models?: unknown; allowedModels?: unknown; allowed_combos?: unknown; allowedCombos?: unknown; allowed_connections?: unknown; allowedConnections?: unknown; allowed_quotas?: unknown; allowedQuotas?: unknown; no_log?: unknown; noLog?: unknown; auto_resolve?: unknown; autoResolve?: unknown; is_active?: unknown; isActive?: unknown; access_schedule?: unknown; accessSchedule?: unknown; rate_limits?: unknown; rateLimits?: unknown; stream_default_mode?: unknown; streamDefaultMode?: unknown; } interface StatementLike { all: (...params: unknown[]) => TRow[]; get: (...params: unknown[]) => TRow | undefined; run: (...params: unknown[]) => { changes?: number }; } interface ApiKeysDbLike { prepare: (sql: string) => StatementLike; exec: (sql: string) => void; } interface ApiKeysStatements { getAllKeys: StatementLike; getKeyById: StatementLike; validateKey: StatementLike; getKeyMetadata: StatementLike; insertKey: StatementLike; deleteKey: StatementLike; } interface ApiKeyView extends JsonRecord { id?: string; allowedModels: string[]; allowedCombos: string[]; allowedConnections: string[]; allowedQuotas: string[]; noLog: boolean; autoResolve: boolean; isActive: boolean; accessSchedule: AccessSchedule | null; throttleDelayMs?: number | null; rateLimits: RateLimitRule[] | null; scopes: string[]; isBanned?: boolean; expiresAt?: string | null; allowedEndpoints: string[]; streamDefaultMode: "legacy" | "json"; disableNonPublicModels?: boolean; } // LRU cache for API key validation (valid keys only) const _keyValidationCache = new Map(); const _keyMetadataCache = new Map>(); const _lastUsedUpdateCache = new Map(); const CACHE_TTL = 60 * 1000; // 1 minute TTL const LAST_USED_UPDATE_TTL = 5 * 60 * 1000; const MAX_CACHE_SIZE = 1000; // Wildcard scope matching is now handled by `matchesWildcardPattern` // (deterministic, no RegExp from dynamic strings). const API_KEY_COLUMN_FALLBACKS = [ { name: "allowed_models", definition: "allowed_models TEXT" }, { name: "allowed_combos", definition: "allowed_combos TEXT" }, { name: "no_log", definition: "no_log INTEGER NOT NULL DEFAULT 0" }, { name: "allowed_connections", definition: "allowed_connections TEXT" }, { name: "auto_resolve", definition: "auto_resolve INTEGER NOT NULL DEFAULT 0" }, { name: "is_active", definition: "is_active INTEGER NOT NULL DEFAULT 1" }, { name: "access_schedule", definition: "access_schedule TEXT" }, { name: "max_requests_per_day", definition: "max_requests_per_day INTEGER" }, { name: "max_requests_per_minute", definition: "max_requests_per_minute INTEGER" }, { name: "throttle_delay_ms", definition: "throttle_delay_ms INTEGER" }, { name: "max_sessions", definition: "max_sessions INTEGER NOT NULL DEFAULT 0" }, { name: "revoked_at", definition: "revoked_at TEXT" }, { name: "expires_at", definition: "expires_at TEXT" }, { name: "last_used_at", definition: "last_used_at TEXT" }, { name: "key_prefix", definition: "key_prefix TEXT" }, { name: "ip_allowlist", definition: "ip_allowlist TEXT" }, { name: "scopes", definition: "scopes TEXT" }, { name: "rate_limits", definition: "rate_limits TEXT" }, { name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" }, { name: "key_hash", definition: "key_hash TEXT" }, { name: "allowed_endpoints", definition: "allowed_endpoints TEXT" }, { name: "allowed_quotas", definition: "allowed_quotas TEXT NOT NULL DEFAULT '[]'" }, { name: "stream_default_mode", definition: "stream_default_mode TEXT NOT NULL DEFAULT 'legacy'" }, { name: "disable_non_public_models", definition: "disable_non_public_models INTEGER NOT NULL DEFAULT 0" }, ] as const; // Cache for model permission checks const _modelPermissionCache = new Map(); // Prepared statements cache let _stmtGetAllKeys: ApiKeysStatements["getAllKeys"] | null = null; let _stmtGetKeyById: ApiKeysStatements["getKeyById"] | null = null; let _stmtValidateKey: ApiKeysStatements["validateKey"] | null = null; let _stmtGetKeyMetadata: ApiKeysStatements["getKeyMetadata"] | null = null; let _stmtInsertKey: ApiKeysStatements["insertKey"] | null = null; let _stmtDeleteKey: ApiKeysStatements["deleteKey"] | null = null; /** * Clear all caches (called on key create/update/delete) */ function invalidateCaches() { _keyValidationCache.clear(); _keyMetadataCache.clear(); _modelPermissionCache.clear(); _lastUsedUpdateCache.clear(); } function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" ? (value as JsonRecord) : {}; } function isConfiguredEnvApiKey(key: string): boolean { const envKey = process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY; return Boolean(envKey && key === envKey); } function isRedisAuthCacheEnabled(): boolean { return ( process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE !== "1" && process.env.NODE_ENV !== "test" && process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" ); } async function deleteRedisAuthCacheEntry(keyHash: unknown): Promise { if (!isRedisAuthCacheEnabled() || typeof keyHash !== "string" || keyHash.trim() === "") return; try { const { getRedisClient, isRedisConfigured } = await import("@/shared/utils/rateLimiter"); if (!isRedisConfigured()) return; const redis = getRedisClient(); await redis.del(`auth:api_key:${keyHash}`); } catch { // Redis is an optimization for auth caching; SQLite remains authoritative. } } async function deleteRedisAuthCacheEntries(...keyHashes: unknown[]): Promise { await Promise.all(keyHashes.map((keyHash) => deleteRedisAuthCacheEntry(keyHash))); } async function deleteRedisAuthCacheForKeyId(db: ApiKeysDbLike, id: string): Promise { if (!isRedisAuthCacheEnabled()) return; const row = db .prepare<{ key_hash: string | null }>("SELECT key_hash FROM api_keys WHERE id = ?") .get(id); await deleteRedisAuthCacheEntry(row?.key_hash); } function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void { if (typeof id !== "string" || id.trim() === "") return; const lastUpdate = _lastUsedUpdateCache.get(id); if (lastUpdate && now - lastUpdate < LAST_USED_UPDATE_TTL) return; db.prepare("UPDATE api_keys SET last_used_at = @lastUsedAt WHERE id = @id").run({ id, lastUsedAt: new Date(now).toISOString(), }); _lastUsedUpdateCache.set(id, now); } /** * LRU eviction for cache */ function evictIfNeeded(cache: Map) { if (cache.size > MAX_CACHE_SIZE) { // Remove oldest 20% of entries const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2); let i = 0; for (const key of cache.keys()) { if (i++ >= entriesToRemove) break; cache.delete(key); } } } /** * Match an API-key wildcard scope pattern against a model id without * compiling a RegExp from string concatenation (avoid ReDoS exposure on * operator-supplied patterns and silence the Semgrep `js/regex-injection` * advisory for `new RegExp()`). * * Supported pattern syntax (only what real scopes use): * - literal segments * - `*` matches any run of characters, but does NOT cross `/` * * Walks the pattern token-by-token: each `*` consumes the longest possible * run within the current path segment, then the next literal anchor must * appear before the segment boundary. Worst-case complexity is O(n*m) * where n = pattern length, m = candidate length — there is no nested * backtracking that could explode adversarially. */ function matchesWildcardPattern(pattern: string, candidate: string): boolean { const pSegs = pattern.split("/"); const cSegs = candidate.split("/"); if (pSegs.length !== cSegs.length) return false; for (let i = 0; i < pSegs.length; i++) { if (!segmentMatchesWildcard(pSegs[i], cSegs[i])) return false; } return true; } function segmentMatchesWildcard(pattern: string, segment: string): boolean { if (pattern === segment) return true; if (!pattern.includes("*")) return false; const parts = pattern.split("*"); // Anchor first literal to the start. let cursor = 0; const first = parts[0]; if (first) { if (!segment.startsWith(first)) return false; cursor = first.length; } // Anchor last literal to the end. const last = parts[parts.length - 1]; const endLimit = segment.length - last.length; if (last) { if (!segment.endsWith(last)) return false; } // Each middle literal must appear in order between cursor and endLimit. for (let i = 1; i < parts.length - 1; i++) { const piece = parts[i]; if (!piece) continue; const idx = segment.indexOf(piece, cursor); if (idx === -1 || idx + piece.length > endLimit) return false; cursor = idx + piece.length; } return cursor <= endLimit; } function ensureApiKeyColumn( db: ApiKeysDbLike, columnNames: Set, column: (typeof API_KEY_COLUMN_FALLBACKS)[number] ): void { if (columnNames.has(column.name)) return; db.exec(`ALTER TABLE api_keys ADD COLUMN ${column.definition}`); console.log(`[DB] Added api_keys.${column.name} column`); } // Ensure api_keys extension columns exist (memoized) function ensureApiKeysColumns(db: ApiKeysDbLike) { if (_schemaChecked) return; try { const columns = db.prepare("PRAGMA table_info(api_keys)").all(); const columnNames = new Set(columns.map((column) => String(column.name ?? ""))); for (const column of API_KEY_COLUMN_FALLBACKS) { ensureApiKeyColumn(db, columnNames, column); } _schemaChecked = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify api_keys schema:", message); } } /** * Initialize prepared statements (lazy initialization) * Re-creates statements if the underlying DB connection changed (HMR, backup restore). */ let _stmtDb: ApiKeysDbLike | null = null; function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { ensureApiKeysColumns(db); if ( !_stmtGetAllKeys || !_stmtGetKeyById || !_stmtValidateKey || !_stmtGetKeyMetadata || !_stmtInsertKey || !_stmtDeleteKey || _stmtDb !== db ) { _stmtDb = db; _stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at"); _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); _stmtValidateKey = db.prepare( "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); } if ( !_stmtGetAllKeys || !_stmtGetKeyById || !_stmtValidateKey || !_stmtGetKeyMetadata || !_stmtInsertKey || !_stmtDeleteKey ) { throw new Error("Failed to initialize API key prepared statements"); } return { getAllKeys: _stmtGetAllKeys, getKeyById: _stmtGetKeyById, validateKey: _stmtValidateKey, getKeyMetadata: _stmtGetKeyMetadata, insertKey: _stmtInsertKey, deleteKey: _stmtDeleteKey, }; } export async function getApiKeys() { const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); const rows = stmt.getAllKeys.all(); return rows.map((row) => { const camelRow = toRecord(rowToCamel(row)) as ApiKeyView; camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels); camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos); camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections); camelRow.allowedQuotas = parseAllowedQuotas((camelRow as JsonRecord).allowedQuotas); camelRow.noLog = parseNoLog(camelRow.noLog); camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve); camelRow.isActive = parseIsActive(camelRow.isActive); camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule); camelRow.rateLimits = parseRateLimits(camelRow.rateLimits); camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels((camelRow as JsonRecord).disableNonPublicModels); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } return camelRow; }); } export async function getApiKeyById(id: string) { const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); const row = stmt.getKeyById.get(id); if (!row) return null; const camelRow = toRecord(rowToCamel(row)) as ApiKeyView; camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels); camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos); camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections); camelRow.allowedQuotas = parseAllowedQuotas((camelRow as JsonRecord).allowedQuotas); camelRow.noLog = parseNoLog(camelRow.noLog); camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve); camelRow.isActive = parseIsActive(camelRow.isActive); camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule); camelRow.rateLimits = parseRateLimits(camelRow.rateLimits); camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels((camelRow as JsonRecord).disableNonPublicModels); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } return camelRow; } /** * Helper function to safely parse allowed_models JSON */ function parseAllowedModels(value: unknown): string[] { if (!value || typeof value !== "string" || value.trim() === "") { return []; } try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []; } catch { return []; } } function parseAllowedCombos(value: unknown): string[] { return parseStringList(value); } function parseNoLog(value: unknown): boolean { return value === true || value === 1 || value === "1"; } function parseAutoResolve(value: unknown): boolean { return value === true || value === 1 || value === "1"; } function parseDisableNonPublicModels(value: unknown): boolean { return value === true || value === 1 || value === "1"; } function parseIsActive(value: unknown): boolean { // DEFAULT 1 — active unless explicitly set to 0 if (value === 0 || value === "0" || value === false) return false; return true; } function parseAccessSchedule(value: unknown): AccessSchedule | null { if (!value || typeof value !== "string" || value.trim() === "") return null; try { const parsed: unknown = JSON.parse(value); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; const obj = parsed as Record; if ( typeof obj["enabled"] !== "boolean" || typeof obj["from"] !== "string" || typeof obj["until"] !== "string" || !Array.isArray(obj["days"]) || typeof obj["tz"] !== "string" ) { return null; } const days = (obj["days"] as unknown[]).filter( (d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6 ); return { enabled: obj["enabled"], from: obj["from"], until: obj["until"], days, tz: obj["tz"], }; } catch { return null; } } function parseRateLimits(value: unknown): RateLimitRule[] | null { if (!value || typeof value !== "string" || value.trim() === "") return null; try { const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return null; return parsed.filter( (rule: RateLimitRule) => typeof rule === "object" && rule !== null && typeof rule.limit === "number" && typeof rule.window === "number" ) as RateLimitRule[]; } catch { return null; } } /** * Helper function to safely parse allowed_connections JSON */ function parseAllowedConnections(value: unknown): string[] { if (!value || typeof value !== "string" || value.trim() === "") { return []; } try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []; } catch { return []; } } /** * Helper function to safely parse allowed_quotas JSON */ function parseAllowedQuotas(value: unknown): string[] { if (!value || typeof value !== "string" || value.trim() === "") { return []; } try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []; } catch { return []; } } function parseStringList(value: unknown): string[] { if (!value || typeof value !== "string" || value.trim() === "") return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []; } catch { return []; } } function parseNullableTimestamp(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed === "" ? null : trimmed; } function parseIsBanned(value: unknown): boolean { return value === 1 || value === "1" || value === true; } function parseStreamDefaultMode(value: unknown): "legacy" | "json" { return value === "json" ? "json" : "legacy"; } async function hashKey(key: string): Promise { if (!key || typeof key !== "string") return ""; // CodeQL: This is intentionally SHA-256, NOT password hashing. API keys are // high-entropy random tokens (not user-chosen passwords) and need fast O(1) // comparison for per-request validation. bcrypt/scrypt would add ~100ms per // request, which is unacceptable for an API proxy. // lgtm[js/insufficient-password-hash] return createHash("sha256").update(key).digest("hex"); // nosemgrep: insufficient-password-hash } export async function createApiKey(name: string, machineId: string, scopes: string[] = []) { if (!machineId) { throw new Error("machineId is required"); } const db = getDbInstance() as ApiKeysDbLike; const now = new Date().toISOString(); const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey"); const result = generateApiKeyWithMachine(machineId); const apiKey = { id: uuidv4(), name: name, key: result.key, machineId: machineId, allowedModels: [], // Empty array means all models allowed allowedCombos: [], // Empty array means no explicit combo restriction allowedConnections: [], // Empty array means all connections allowed noLog: false, createdAt: now, scopes, }; const stmt = getPreparedStatements(db); stmt.insertKey.run( apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, "[]", 0, apiKey.createdAt, apiKey.key.slice(0, 12), await hashKey(apiKey.key), JSON.stringify(scopes) ); setNoLog(apiKey.id, false); backupDbFile("pre-write"); return apiKey; } export async function regenerateApiKey(id: string) { const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); const row = stmt.getKeyById.get(id) as ApiKeyRow | undefined; if (!row) return null; const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey"); const machineId = (row.machine_id || row.machineId || "0000000000000000") as string; const { key: newKey } = generateApiKeyWithMachine(machineId); const newHash = await hashKey(newKey); const newPrefix = newKey.slice(0, 12); // Update in DB const updateStmt = db.prepare( "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?" ); updateStmt.run(newKey, newHash, newPrefix, id); // Invalidate all caches clearApiKeyCaches(); await deleteRedisAuthCacheEntries(row.key_hash, newHash); const { logAuditEvent } = await import("@/lib/compliance"); logAuditEvent({ action: "apiKey.regenerate", target: id, details: { name: String(row.name || "") }, }); return { id, key: newKey }; } export async function updateApiKeyPermissions( id: string, update: | string[] | { name?: string; allowedModels?: string[]; allowedCombos?: string[]; allowedConnections?: string[]; allowedQuotas?: string[]; noLog?: boolean; autoResolve?: boolean; isActive?: boolean; accessSchedule?: AccessSchedule | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: number | null; throttleDelayMs?: number | null; rateLimits?: RateLimitRule[] | null; isBanned?: boolean; expiresAt?: string | null; // T08: max concurrent sessions for this key (0 = unlimited) maxSessions?: number | null; scopes?: string[] | null; allowedEndpoints?: string[] | null; streamDefaultMode?: "legacy" | "json" | null; disableNonPublicModels?: boolean; } ) { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); const normalized = Array.isArray(update) || update === undefined ? { allowedModels: update || [] } : { name: update.name, allowedModels: update.allowedModels, allowedCombos: update.allowedCombos, allowedConnections: update.allowedConnections, allowedQuotas: (update as { allowedQuotas?: string[] }).allowedQuotas, noLog: update.noLog, autoResolve: update.autoResolve, isActive: update.isActive, accessSchedule: update.accessSchedule, maxRequestsPerDay: update.maxRequestsPerDay, maxRequestsPerMinute: update.maxRequestsPerMinute, throttleDelayMs: update.throttleDelayMs, rateLimits: update.rateLimits, isBanned: update.isBanned, expiresAt: update.expiresAt, maxSessions: (update as { maxSessions?: number | null }).maxSessions, scopes: (update as { scopes?: string[] | null }).scopes, allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints, streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null }) .streamDefaultMode, disableNonPublicModels: (update as { disableNonPublicModels?: boolean }) .disableNonPublicModels, }; if ( normalized.name === undefined && normalized.allowedModels === undefined && normalized.allowedCombos === undefined && normalized.allowedConnections === undefined && (normalized as Record).allowedQuotas === undefined && normalized.noLog === undefined && normalized.autoResolve === undefined && normalized.isActive === undefined && normalized.accessSchedule === undefined && normalized.maxRequestsPerDay === undefined && normalized.maxRequestsPerMinute === undefined && normalized.throttleDelayMs === undefined && normalized.rateLimits === undefined && normalized.isBanned === undefined && normalized.expiresAt === undefined && (normalized as Record).maxSessions === undefined && (normalized as Record).scopes === undefined && (normalized as Record).allowedEndpoints === undefined && (normalized as Record).streamDefaultMode === undefined && normalized.disableNonPublicModels === undefined ) { return false; } const updates: string[] = []; const params: { id: string; name?: string; allowedModels?: string; allowedCombos?: string; allowedConnections?: string; allowedQuotas?: string; noLog?: number; autoResolve?: number; isActive?: number; accessSchedule?: string | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: number | null; throttleDelayMs?: number | null; rateLimits?: string | null; isBanned?: number; maxSessions?: number; expiresAt?: string | null; scopes?: string; streamDefaultMode?: "legacy" | "json"; disableNonPublicModels?: number; } = { id }; if (normalized.name !== undefined) { updates.push("name = @name"); params.name = normalized.name; } if (normalized.allowedModels !== undefined) { // Empty array means all models are allowed updates.push("allowed_models = @allowedModels"); params.allowedModels = JSON.stringify(normalized.allowedModels || []); } if (normalized.allowedCombos !== undefined) { // Empty array means no explicit combo restriction; legacy allowed_models rules still apply. updates.push("allowed_combos = @allowedCombos"); params.allowedCombos = JSON.stringify(normalized.allowedCombos || []); } if (normalized.allowedConnections !== undefined) { // Empty array means all connections are allowed updates.push("allowed_connections = @allowedConnections"); params.allowedConnections = JSON.stringify(normalized.allowedConnections || []); } const allowedQuotasUpdate = (normalized as Record).allowedQuotas; if (allowedQuotasUpdate !== undefined) { // Empty array means no quota-pool restriction; non-empty restricts to listed pools updates.push("allowed_quotas = @allowedQuotas"); const nextQuotas: string[] = Array.isArray(allowedQuotasUpdate) ? (allowedQuotasUpdate as unknown[]).filter((s): s is string => typeof s === "string") : []; params.allowedQuotas = JSON.stringify(nextQuotas); } if (normalized.noLog !== undefined) { updates.push("no_log = @noLog"); params.noLog = normalized.noLog ? 1 : 0; } if (normalized.autoResolve !== undefined) { updates.push("auto_resolve = @autoResolve"); params.autoResolve = normalized.autoResolve ? 1 : 0; } if (normalized.isActive !== undefined) { updates.push("is_active = @isActive"); params.isActive = normalized.isActive ? 1 : 0; } if (normalized.accessSchedule !== undefined) { updates.push("access_schedule = @accessSchedule"); params.accessSchedule = normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null; } if (normalized.maxRequestsPerDay !== undefined) { updates.push("max_requests_per_day = @maxRequestsPerDay"); params.maxRequestsPerDay = normalized.maxRequestsPerDay; } if (normalized.maxRequestsPerMinute !== undefined) { updates.push("max_requests_per_minute = @maxRequestsPerMinute"); params.maxRequestsPerMinute = normalized.maxRequestsPerMinute; } if (normalized.throttleDelayMs !== undefined) { updates.push("throttle_delay_ms = @throttleDelayMs"); params.throttleDelayMs = normalized.throttleDelayMs; } if (normalized.rateLimits !== undefined) { updates.push("rate_limits = @rateLimits"); params.rateLimits = normalized.rateLimits !== null ? JSON.stringify(normalized.rateLimits) : null; } if (normalized.isBanned !== undefined) { updates.push("is_banned = @isBanned"); params.isBanned = normalized.isBanned ? 1 : 0; } if (normalized.expiresAt !== undefined) { updates.push("expires_at = @expiresAt"); params.expiresAt = normalized.expiresAt; } if (normalized.disableNonPublicModels !== undefined) { updates.push("disable_non_public_models = @disableNonPublicModels"); params.disableNonPublicModels = normalized.disableNonPublicModels ? 1 : 0; } const maxSessionsUpdate = (normalized as Record).maxSessions; if (maxSessionsUpdate !== undefined) { updates.push("max_sessions = @maxSessions"); params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0; } const allowedEndpointsUpdate = (normalized as Record).allowedEndpoints; if (allowedEndpointsUpdate !== undefined) { updates.push("allowed_endpoints = @allowedEndpoints"); const nextEndpoints: string[] = Array.isArray(allowedEndpointsUpdate) ? (allowedEndpointsUpdate as unknown[]).filter((s): s is string => typeof s === "string") : []; (params as Record).allowedEndpoints = JSON.stringify(nextEndpoints); } const streamDefaultModeUpdate = (normalized as Record).streamDefaultMode; if (streamDefaultModeUpdate !== undefined) { updates.push("stream_default_mode = @streamDefaultMode"); params.streamDefaultMode = parseStreamDefaultMode(streamDefaultModeUpdate); } const scopesUpdate = (normalized as Record).scopes; const nextScopes: string[] = Array.isArray(scopesUpdate) ? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string") : []; // Capture previous scopes BEFORE the UPDATE so we can compare for the audit // event below. We only fetch when the caller is actually changing scopes — // a privileged change ("manage" grants management API surface access) that // must always leave an audit trail per OWASP A09 / SOC2 CC7.2. // // The previous-scopes SELECT and the row UPDATE are wrapped in a single // transaction so a concurrent writer cannot slip in between and make the // audit log lie about what changed. SQLite is single-writer in practice, // but the transaction also gives us atomicity if the underlying driver // ever swaps to a backend that allows multiple writers (sqljsAdapter / // nodeSqliteAdapter fall-back per v3.8.1 db driver cascade). let previousScopes: string[] = []; let changedRows = 0; if (scopesUpdate !== undefined) { updates.push("scopes = @scopes"); params.scopes = JSON.stringify(nextScopes); // SELECT-then-UPDATE wrapped in an explicit transaction so a concurrent // writer can't slip between the read and the write and make the audit // log lie about what changed. `exec("BEGIN"/"COMMIT")` works across all // driver backends (better-sqlite3 / node:sqlite / sql.js) wired by the // v3.8.1 db driver cascade — none of them expose `db.transaction()` via // ApiKeysDbLike, which is intentionally minimal. db.exec("BEGIN IMMEDIATE"); try { const prevRow = db .prepare<{ scopes: string | null }>("SELECT scopes FROM api_keys WHERE id = ?") .get(id); previousScopes = parseStringList(prevRow?.scopes ?? null); const upd = db .prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`) .run(params); changedRows = upd.changes ?? 0; db.exec("COMMIT"); } catch (err) { // Guard the ROLLBACK: if it throws (e.g. transaction already ended // due to an implicit commit, or backend in a bad state), the original // error from the try block is the actionable one — don't shadow it. try { db.exec("ROLLBACK"); } catch { // swallow: original error is more important } throw err; } } else { const upd = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params); changedRows = upd.changes ?? 0; } if (changedRows === 0) return false; const { logAuditEvent } = await import("@/lib/compliance"); if (normalized.isBanned !== undefined) { logAuditEvent({ action: normalized.isBanned ? "apiKey.ban" : "apiKey.unban", target: id, }); } if (normalized.isActive !== undefined) { logAuditEvent({ action: normalized.isActive ? "apiKey.activate" : "apiKey.deactivate", target: id, }); } if (scopesUpdate !== undefined) { // Compare prev vs next scope sets and emit a dedicated audit event when // the privileged "manage" scope is granted or revoked. Other scope // mutations also emit a generic "apiKey.scopes.update" so the audit log // captures the full change history (action + details). const hadManage = previousScopes.includes("manage"); const hasManage = nextScopes.includes("manage"); if (!hadManage && hasManage) { logAuditEvent({ action: "apiKey.scopes.grant", target: id, details: { scopes: nextScopes, previous: previousScopes }, }); } else if (hadManage && !hasManage) { logAuditEvent({ action: "apiKey.scopes.revoke", target: id, details: { scopes: nextScopes, previous: previousScopes }, }); } else if ( previousScopes.length !== nextScopes.length || previousScopes.some((s) => !nextScopes.includes(s)) || nextScopes.some((s) => !previousScopes.includes(s)) ) { logAuditEvent({ action: "apiKey.scopes.update", target: id, details: { scopes: nextScopes, previous: previousScopes }, }); } } if (normalized.noLog !== undefined) { setNoLog(id, normalized.noLog); } // Invalidate caches since permissions changed invalidateCaches(); await deleteRedisAuthCacheForKeyId(db, id); backupDbFile("pre-write"); return true; } export async function deleteApiKey(id: string) { const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); const row = stmt.getKeyById.get(id) as ApiKeyRow | undefined; const result = stmt.deleteKey.run(id); if (result.changes === 0) return false; db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(id); db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(id); setNoLog(id, false); // Invalidate caches since a key was removed invalidateCaches(); await deleteRedisAuthCacheEntry(row?.key_hash); backupDbFile("pre-write"); return true; } /** * Revoke an API key by id. Logical, not destructive: the row stays so it can * be audited, but validateApiKey() rejects it immediately after caches expire * (or sooner because invalidateCaches() runs here). */ export async function revokeApiKey(id: string): Promise { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); const result = db .prepare( "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id" ) .run({ id, ts: new Date().toISOString() }); if ((result.changes ?? 0) === 0) return false; invalidateCaches(); await deleteRedisAuthCacheForKeyId(db, id); backupDbFile("pre-write"); return true; } /** * Set or clear the expiry of an API key. Pass null to remove the expiry. */ export async function setApiKeyExpiry(id: string, expiresAt: string | null): Promise { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); const result = db .prepare("UPDATE api_keys SET expires_at = @expiresAt WHERE id = @id") .run({ id, expiresAt }); if ((result.changes ?? 0) === 0) return false; invalidateCaches(); await deleteRedisAuthCacheForKeyId(db, id); backupDbFile("pre-write"); return true; } /** * Validate API key with lifecycle gates and caching. * * A key is valid only when ALL of the following are true: * - the row exists, * - is_active = 1, * - revoked_at IS NULL, * - expires_at IS NULL OR expires_at > now. * * Cache TTL is short (CACHE_TTL) and the metadata cache is also invalidated * by revokeApiKey/updateApiKeyPermissions/deleteApiKey, so a revoke takes * effect within at most CACHE_TTL even without an explicit clear in the * caller. */ export async function validateApiKey(key: string | null | undefined) { if (!key || typeof key !== "string") return false; if (isConfiguredEnvApiKey(key)) return true; const now = Date.now(); const hashedKey = await hashKey(key); const cacheKey = hashedKey; const cached = _keyValidationCache.get(cacheKey); if (cached && now - cached.timestamp < CACHE_TTL) { return cached.valid; } if (isRedisAuthCacheEnabled()) { // Try Redis cache for multi-instance consistency try { const { getRedisClient, isRedisConfigured } = await import("@/shared/utils/rateLimiter"); if (isRedisConfigured()) { const redis = getRedisClient(); const redisKey = `auth:api_key:${hashedKey}`; const redisData = await redis.get(redisKey); if (redisData) { const data = JSON.parse(redisData); const isBanned = !!data.isBanned; const isActive = !!data.isActive; const revokedAt = data.revokedAt; const expiresAt = data.expiresAt; if (isBanned || !isActive) return false; if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false; if (typeof expiresAt === "string" && expiresAt.trim() !== "") { const expiresMs = Date.parse(expiresAt); if (Number.isFinite(expiresMs) && expiresMs <= now) return false; } return true; } } } catch { // Redis lookup failures fall through to SQLite. } } const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); const row = stmt.validateKey.get(key, hashedKey) as JsonRecord | undefined; if (!row) return false; const isBanned = parseIsBanned(row.is_banned ?? row.isBanned); if (isBanned) return false; const isActive = parseIsActive(row.is_active ?? row.isActive); if (!isActive) return false; const revokedAt = row.revoked_at ?? row.revokedAt; if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false; const expiresAt = row.expires_at ?? row.expiresAt; if (typeof expiresAt === "string" && expiresAt.trim() !== "") { const expiresMs = Date.parse(expiresAt); if (Number.isFinite(expiresMs) && expiresMs <= now) return false; } evictIfNeeded(_keyValidationCache); _keyValidationCache.set(cacheKey, { valid: true, timestamp: now }); if (isRedisAuthCacheEnabled()) { // Update Redis cache for fast validation try { const { getRedisClient, isRedisConfigured } = await import("@/shared/utils/rateLimiter"); if (isRedisConfigured()) { const redis = getRedisClient(); const redisKey = `auth:api_key:${hashedKey}`; await redis.set( redisKey, JSON.stringify({ id: row.id, isBanned: parseIsBanned(row.is_banned), isActive: parseIsActive(row.is_active), expiresAt: row.expires_at, revokedAt: row.revoked_at, }), "EX", 3600 // 1 hour cache ); } } catch { // Redis cache update failures do not block successful SQLite validation. } } markApiKeyUsed(db, row.id, now); return true; } /** * Get API key metadata with caching for performance */ export async function getApiKeyMetadata( key: string | null | undefined ): Promise { if (!key || typeof key !== "string") return null; const now = Date.now(); // persistent env-var key support (persistent passthrough keys) (#1350) if (isConfiguredEnvApiKey(key)) { // ─── Env-key management-scope bypass ────────────────────────────────── // The deployment-time env key (`OMNIROUTE_API_KEY` / `ROUTER_API_KEY`) // is granted the "manage" scope unconditionally. This is intentional: // // 1. The env key never exists in the SQLite `api_keys` table, so the // DB-backed scopes column does not apply. We synthesize the // metadata record here. // 2. The operator who set the env var is presumed to be the deployment // owner; rotating (or unsetting) the env var is the only way to // rotate this privilege. There is no UI to change it. // 3. Management API access via the env key still passes through // `requireManagementAuth` → `hasManageScope`, so policy decisions // remain centralised in `src/server/authz/*`. // 4. Requests authenticated by the env key are tagged with // `id: "env-key"` for downstream audit-log emitters, making it // possible to distinguish env-key activity from user-created keys // that happen to also hold "manage". // // DO NOT remove "manage" from this list — that would break the // deployment-time bootstrap path that operators rely on for headless // / CI / first-boot scenarios. If you need to disable env-key access, // unset the env var instead. return { id: "env-key", name: "Environment Key", machineId: "server-env", allowedModels: [], allowedCombos: [], allowedConnections: [], allowedQuotas: [], noLog: false, autoResolve: true, isActive: true, accessSchedule: null, rateLimits: null, maxRequestsPerDay: null, maxRequestsPerMinute: null, throttleDelayMs: null, maxSessions: 0, revokedAt: null, expiresAt: null, ipAllowlist: [], isBanned: false, keyHash: null, scopes: ["manage"], allowedEndpoints: [], streamDefaultMode: "legacy", disableNonPublicModels: false, }; } // Check cache first const hashedKey = await hashKey(key); const cached = _keyMetadataCache.get(hashedKey); if (cached && now - cached.timestamp < CACHE_TTL) { return cached.value; } const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); const row = stmt.getKeyMetadata.get(key, hashedKey); if (!row) return null; const record = toRecord(row) as ApiKeyRow; const metadataId = typeof record.id === "string" ? record.id : ""; const metadataName = typeof record.name === "string" ? record.name : ""; const machineIdRaw = record.machine_id ?? record.machineId; const metadataMachineId = typeof machineIdRaw === "string" ? machineIdRaw : null; const rawMaxRPD = record.max_requests_per_day ?? record.maxRequestsPerDay; const rawMaxRPM = record.max_requests_per_minute ?? record.maxRequestsPerMinute; const rawThrottleDelayMs = record.throttle_delay_ms ?? (record as JsonRecord).throttleDelayMs; const rawMaxSessions = record.max_sessions ?? record.maxSessions; const metadata: ApiKeyMetadata = { id: metadataId, name: metadataName, machineId: metadataMachineId, allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels), allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos), allowedConnections: parseAllowedConnections( record.allowed_connections ?? record.allowedConnections ), allowedQuotas: parseAllowedQuotas( (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas ), noLog: parseNoLog(record.no_log ?? record.noLog), autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve), isActive: parseIsActive(record.is_active ?? record.isActive), accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule), rateLimits: parseRateLimits(record.rate_limits ?? (record as JsonRecord).rateLimits), maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null, maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null, throttleDelayMs: typeof rawThrottleDelayMs === "number" && rawThrottleDelayMs > 0 ? rawThrottleDelayMs : null, // T08: max concurrent sessions; 0 = unlimited (default & backward-compatible) maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0, revokedAt: parseNullableTimestamp(record.revoked_at ?? (record as JsonRecord).revokedAt), expiresAt: parseNullableTimestamp(record.expires_at ?? (record as JsonRecord).expiresAt), ipAllowlist: parseStringList(record.ip_allowlist ?? (record as JsonRecord).ipAllowlist), scopes: parseStringList((record as JsonRecord).scopes), isBanned: parseIsBanned(record.is_banned ?? (record as JsonRecord).isBanned), keyHash: (record.key_hash ?? (record as JsonRecord).keyHash) as string | null, allowedEndpoints: parseStringList( (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints ), streamDefaultMode: parseStreamDefaultMode( (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode ), disableNonPublicModels: parseDisableNonPublicModels( (record as JsonRecord).disable_non_public_models ?? (record as JsonRecord).disableNonPublicModels ), }; if (!metadata.id) { return null; } setNoLog(metadata.id, metadata.noLog === true); // Cache the result evictIfNeeded(_keyMetadataCache); _keyMetadataCache.set(hashedKey, { value: metadata, timestamp: now }); return metadata; } /** * Check if a model is allowed for a given API key * @param {string} key - The API key * @param {string} modelId - The model ID to check * @returns {boolean} - true if allowed, false if not */ export async function isModelAllowedForKey( key: string | null | undefined, modelId: string | null | undefined ) { // If no key provided, allow (request may be using different auth method like JWT) // If no modelId provided, deny (invalid request) if (!key) return true; if (!modelId) return false; // Create cache key const cacheKey = `${key}:${modelId}`; const now = Date.now(); // Check permission cache const cached = _modelPermissionCache.get(cacheKey); if (cached && now - cached.timestamp < CACHE_TTL) { return cached.allowed; } const metadata = await getApiKeyMetadata(key); // SECURITY: Key not found in database = deny access (invalid/non-existent key) if (!metadata) return false; const { allowedModels, disableNonPublicModels } = metadata; // Check disableNonPublicModels flag if (disableNonPublicModels) { const resolvedModelId = resolveModelAlias(modelId); const effectiveModelId = resolvedModelId || modelId; const providerId = effectiveModelId.split("/")[0]; const shortModelId = effectiveModelId.split("/").slice(1).join("/"); const syncedModelsByConnection = await getSyncedAvailableModelsByConnection(providerId); const customModels = await getCustomModels(providerId); // Combine synced and custom models const allDiscoveredModels = Object.values(syncedModelsByConnection).flat().concat(customModels); const discovered = allDiscoveredModels.some((m) => m.id === shortModelId); if (!discovered) return false; const isPublic = !getModelIsHidden(providerId, shortModelId); if (!isPublic) return false; } // Empty array means all models allowed if (!allowedModels || allowedModels.length === 0) { return true; } let allowed = false; // Check if model matches each allowed pattern // Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models) for (const pattern of allowedModels) { if (pattern === modelId) { allowed = true; break; } if (pattern.endsWith("/*")) { const prefix = pattern.slice(0, -2); // Remove "/*" if (modelId.startsWith(prefix + "/") || modelId.startsWith(prefix)) { allowed = true; break; } } // Support wildcard patterns via deterministic matcher (no RegExp // compilation from operator input — avoids ReDoS exposure). if (pattern.includes("*")) { if (matchesWildcardPattern(pattern, modelId)) { allowed = true; break; } } } // 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 }); return allowed; } /** * Clear prepared statements cache (called on database reset/restore) * Prepared statements are bound to a specific database connection, * so they must be cleared when the connection is reset. */ function clearPreparedStatementCache() { _stmtGetAllKeys = null; _stmtGetKeyById = null; _stmtValidateKey = null; _stmtGetKeyMetadata = null; _stmtInsertKey = null; _stmtDeleteKey = null; _schemaChecked = false; // Also reset schema check for new connection } /** * Clear all caches (exported for testing/debugging) */ export function clearApiKeyCaches() { invalidateCaches(); _lastUsedUpdateCache.clear(); _modelPermissionCache.clear(); } /** * Reset all cached state for database connection reset/restore. * Called by backup.ts when the database is restored. */ export function resetApiKeyState() { clearPreparedStatementCache(); clearApiKeyCaches(); } registerDbStateResetter(resetApiKeyState);