diff --git a/.env.example b/.env.example index aedff9f46b..89a97f7eea 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,12 @@ STORAGE_ENCRYPTION_KEY_VERSION=v1 # Default: false (backups enabled) | Set true to skip backup on every restart. DISABLE_SQLITE_AUTO_BACKUP=false +# ── Redis (Rate Limiting) ── +# Redis connection URL for the rate limiter backend. +# Used by: src/shared/utils/rateLimiter.ts +# Default: redis://localhost:6379 (or redis://redis:6379 in Docker) +REDIS_URL=redis://localhost:6379 + # ═══════════════════════════════════════════════════════════════════════════════ # 3. NETWORK & PORTS diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cb86dc5706..7f58e614b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -12,8 +12,25 @@ # ────────────────────────────────────────────────────────────────────── services: + # ── Redis (Rate Limiter Backend) ────────────────────────────────── + redis: + image: redis:8.6.2 + container_name: omniroute-redis-prod + restart: unless-stopped + volumes: + - redis-prod-data:/data + command: redis-server --save 60 1 --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + omniroute-prod: container_name: omniroute-prod + depends_on: + redis: + condition: service_healthy build: context: . target: runner-cli @@ -44,3 +61,5 @@ services: volumes: omniroute-prod-data: name: omniroute-prod-data + redis-prod-data: + name: redis-prod-data diff --git a/docker-compose.yml b/docker-compose.yml index 7e24dbce84..8c53d290c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ x-common: &common - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} volumes: - ./data:/app/data healthcheck: @@ -38,6 +39,22 @@ x-common: &common start_period: 15s services: + # ── Redis (Rate Limiter Backend) ────────────────────────────────── + redis: + image: redis:8.6.2 + container_name: omniroute-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + command: redis-server --save 60 1 --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + # ── Profile: base (minimal, no CLI tools) ────────────────────────── omniroute-base: <<: *common @@ -138,3 +155,5 @@ services: volumes: cliproxyapi-data: name: cliproxyapi-data + redis-data: + name: omniroute-redis-data diff --git a/package-lock.json b/package-lock.json index 473f51bf14..bfc42134f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "fuse.js": "^7.3.0", "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^9.0.0", + "ioredis": "^5.10.1", "isomorphic-dompurify": "^3.12.0", "jose": "^6.2.3", "js-yaml": "^4.1.1", @@ -2084,6 +2085,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -6329,6 +6336,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7346,6 +7362,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9576,6 +9601,30 @@ "integrity": "sha512-Ocd1vPuD68rW6BJDuAOtnnc1GPeVepY5kZXML1psGVFQ+1Q8CfkftT3Tnam+Mxx97Pz08jIEDCotl/GV+Naccg==", "license": "MIT" }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", @@ -10995,6 +11044,18 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -13392,6 +13453,27 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -14321,6 +14403,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", diff --git a/package.json b/package.json index 173282c924..7be8a32f37 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "fuse.js": "^7.3.0", "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^9.0.0", + "ioredis": "^5.10.1", "isomorphic-dompurify": "^3.12.0", "jose": "^6.2.3", "js-yaml": "^4.1.1", diff --git a/scripts/scratch/check_usage.js b/scripts/scratch/check_usage.js new file mode 100644 index 0000000000..15e6cab603 --- /dev/null +++ b/scripts/scratch/check_usage.js @@ -0,0 +1,34 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import os from 'os'; + +const SQLITE_FILE = path.join(process.cwd(), 'data', 'storage.sqlite'); + +console.log('Checking database at:', SQLITE_FILE); + +const db = new Database(SQLITE_FILE, { readonly: true }); + +try { + const rows = db.prepare(` + SELECT api_key_id, api_key_name, COUNT(*) as count, MAX(timestamp) as last_used + FROM usage_history + GROUP BY api_key_id, api_key_name + ORDER BY count DESC + `).all(); + + console.log('Top Usage Entries:'); + console.table(rows); + + const keys = db.prepare(` + SELECT id, name, key_prefix, machine_id + FROM api_keys + `).all(); + + console.log('All API Keys:'); + console.table(keys); + +} catch (err) { + console.error('Error:', err.message); +} finally { + db.close(); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 2c686833ba..747ebcc6e7 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -69,8 +69,11 @@ interface ApiKey { noLog?: boolean; autoResolve?: boolean; isActive?: boolean; + isBanned?: boolean; + expiresAt?: string | null; maxSessions?: number; accessSchedule?: AccessSchedule | null; + rateLimits?: Array<{ limit: number; window: number }> | null; scopes?: string[]; createdAt: string; } @@ -177,28 +180,24 @@ export default function ApiManagerPageClient() { fetch("/api/usage/analytics?range=all"), fetch("/api/usage/call-logs?limit=1000"), ]); - const analytics = analyticsRes.ok ? await analyticsRes.json() : null; const byApiKey: any[] = analytics?.byApiKey || []; const logs = logsRes.ok ? await logsRes.json() : []; - const stats: Record = {}; - for (const key of apiKeys) { - const analyticsMatch = byApiKey.find( - (entry: any) => - entry.apiKeyId === key.id || (!entry.apiKeyId && entry.apiKeyName === key.name) - ); + // Match analytics entry by unique API Key ID (isolates usage to this specific key instance) + const matches = byApiKey.filter((entry: any) => entry.apiKeyId === key.id); + const totalRequests = matches.reduce((sum: number, entry: any) => sum + (Number(entry.requests) || 0), 0); - // The call-logs endpoint returns entries sorted by timestamp DESC, - // so the first match is the most recent one. + // Match call logs by unique ID as well for the lastUsed timestamp const lastUsed = + (logs || []).find((log: any) => log.apiKeyId === key.id)?.timestamp || null; (logs || []).find( (log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name) )?.timestamp || null; stats[key.id] = { - totalRequests: analyticsMatch?.requests ?? 0, + totalRequests, lastUsed, }; } @@ -273,7 +272,6 @@ export default function ApiManagerPageClient() { }; const handleDeleteKey = async (id: string) => { - // Validate ID format to prevent injection if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) { setPageError(t("invalidKeyId")); return; @@ -300,6 +298,30 @@ export default function ApiManagerPageClient() { } }; + const handleRegenerateKey = async (id: string) => { + if (!id) return; + if (!confirm(t("regenerateConfirm"))) return; + + setIsSubmitting(true); + clearPageError(); + + try { + const res = await fetch(`/api/keys/${encodeURIComponent(id)}/regenerate`, { method: "POST" }); + const data = await res.json(); + if (res.ok) { + setCreatedKey(data.key); + await fetchData(); + } else { + setPageError(data.error || t("failedRegenerateKey")); + } + } catch (error) { + console.error("Error regenerating key:", error); + setPageError(t("failedRegenerateKeyRetry")); + } finally { + setIsSubmitting(false); + } + }; + const handleOpenPermissions = (key: ApiKey) => { if (!key || !key.id) return; setEditingKey(key); @@ -332,8 +354,11 @@ export default function ApiManagerPageClient() { allowedConnections: string[], autoResolve: boolean, isActive: boolean, + isBanned: boolean, + expiresAt: string | null, maxSessions: number, accessSchedule: AccessSchedule | null, + rateLimits: Array<{ limit: number; window: number }> | null scopes: string[] ) => { if (!editingKey || !editingKey.id) return; @@ -378,8 +403,11 @@ export default function ApiManagerPageClient() { noLog, autoResolve, isActive, + isBanned, + expiresAt, maxSessions: normalizedMaxSessions, accessSchedule, + rateLimits, scopes, }), }); @@ -707,6 +735,18 @@ export default function ApiManagerPageClient() { {t("scheduleActive")} )} + {key.isBanned && ( + + gavel + BANNED + + )} + {key.expiresAt && new Date(key.expiresAt).getTime() < Date.now() && ( + + event_busy + EXPIRED + + )}
@@ -726,6 +766,13 @@ export default function ApiManagerPageClient() { {new Date(key.createdAt).toLocaleDateString()}
+
+ {/* Custom Rate Limits */} +
+
+
+

Custom Rate Limits

+

+ Override global default limits. Leave empty to use defaults. +

+
+ +
+ {rateLimits.length > 0 && ( +
+ {rateLimits.map((rl, index) => ( +
+ { + const val = parseInt(e.target.value) || 0; + setRateLimits(prev => { + const next = [...prev]; + next[index].limit = val; + return next; + }); + }} + placeholder="Requests" + /> + req / + { + const val = parseInt(e.target.value) || 0; + setRateLimits(prev => { + const next = [...prev]; + next[index].window = val; + return next; + }); + }} + placeholder="Seconds" + /> + sec + +
+ ))} +
+ )} +
+ {/* Access Schedule */}
@@ -1389,6 +1516,12 @@ const PermissionsModal = memo(function PermissionsModal({
+ {/* Ban Toggle (SECURITY) */} +
+
+

Banned Status

+

+ Immediately revoke all access. Used for suspected abuse or compromised keys. {/* Management API Access Toggle */}

@@ -1401,6 +1534,36 @@ const PermissionsModal = memo(function PermissionsModal({ +
+ + {/* Expiration Date */} +
+
+

Expiration Date

+

Key will automatically stop working after this date.

+
+ { + const val = e.target.value; + setExpiresAt(val ? new Date(val).toISOString() : ""); + }} + className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main" + /> +
+ aria-checked={manageEnabled} onClick={() => setManageEnabled((prev) => !prev)} className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${ diff --git a/src/app/api/keys/[id]/regenerate/route.ts b/src/app/api/keys/[id]/regenerate/route.ts new file mode 100644 index 0000000000..6a0fa81e75 --- /dev/null +++ b/src/app/api/keys/[id]/regenerate/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { regenerateApiKey } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; + +/** + * POST /api/keys/[id]/regenerate + * + * Regenerates the API key value for a given ID. + * The old key is immediately invalidated. + */ +export async function POST(request, { params }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + if (!id) { + return NextResponse.json({ error: "Missing key ID" }, { status: 400 }); + } + + const result = await regenerateApiKey(id); + if (!result) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + return NextResponse.json({ + message: "API key regenerated successfully", + key: result.key, + id: result.id, + }); + } catch (error) { + log.error("keys", "Error regenerating key", error); + return NextResponse.json({ error: "Failed to regenerate key" }, { status: 500 }); + } +} diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index ec13591ca8..a36e9994b0 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -70,8 +70,11 @@ export async function PATCH(request, { params }) { noLog, autoResolve, isActive, + isBanned, + expiresAt, maxSessions, accessSchedule, + rateLimits, scopes, } = validation.data; @@ -82,8 +85,11 @@ export async function PATCH(request, { params }) { if (noLog !== undefined) payload.noLog = noLog; if (autoResolve !== undefined) payload.autoResolve = autoResolve; if (isActive !== undefined) payload.isActive = isActive; + if (isBanned !== undefined) payload.isBanned = isBanned; + if (expiresAt !== undefined) payload.expiresAt = expiresAt; if (maxSessions !== undefined) payload.maxSessions = maxSessions; if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule; + if (rateLimits !== undefined) payload.rateLimits = rateLimits; if (scopes !== undefined) payload.scopes = scopes; const updated = await updateApiKeyPermissions(id, payload); @@ -102,8 +108,11 @@ export async function PATCH(request, { params }) { ...(noLog !== undefined && { noLog }), ...(autoResolve !== undefined && { autoResolve }), ...(isActive !== undefined && { isActive }), + ...(isBanned !== undefined && { isBanned }), + ...(expiresAt !== undefined && { expiresAt }), ...(maxSessions !== undefined && { maxSessions }), ...(accessSchedule !== undefined && { accessSchedule }), + ...(rateLimits !== undefined && { rateLimits }), ...(scopes !== undefined && { scopes }), }); } catch (error) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0707029ea6..1d29ba0b34 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1033,6 +1033,10 @@ "lastUsedOn": "Last: {date}", "editPermissions": "Edit permissions", "deleteKey": "Delete key", + "regenerateKey": "Regenerate key", + "regenerateConfirm": "Are you sure you want to regenerate this API key? The old key will be immediately invalidated.", + "failedRegenerateKey": "Failed to regenerate API key", + "failedRegenerateKeyRetry": "Failed to regenerate API key. Please try again.", "model": "{count} model", "models": "{count} models", "permissionsTitle": "Permissions: {name}", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 3071460e71..f168e40513 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -931,6 +931,10 @@ "lastUsedOn": "Terakhir: {date}", "editPermissions": "Edit izin", "deleteKey": "Hapus kunci", + "regenerateKey": "Hasilkan ulang kunci", + "regenerateConfirm": "Apakah Anda yakin ingin menghasilkan ulang kunci API ini? Kunci lama akan segera tidak berlaku.", + "failedRegenerateKey": "Gagal menghasilkan ulang kunci API", + "failedRegenerateKeyRetry": "Gagal menghasilkan ulang kunci API. Silakan coba lagi.", "model": "{count} modelnya", "models": "{count} model", "permissionsTitle": "Izin: {name}", diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 32b1cad1fa..01b8784e60 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -2,6 +2,7 @@ * 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"; @@ -20,6 +21,11 @@ interface CacheEntry { value: TValue; } +export interface RateLimitRule { + limit: number; + window: number; +} + export interface AccessSchedule { enabled: boolean; from: string; @@ -40,6 +46,7 @@ interface ApiKeyMetadata { accessSchedule: AccessSchedule | null; maxRequestsPerDay: number | null; maxRequestsPerMinute: number | null; + rateLimits: RateLimitRule[] | null; // T08: Per-key max concurrent sticky sessions (0 = unlimited) maxSessions: number; // Phase 3 lifecycle/policy fields @@ -47,6 +54,8 @@ interface ApiKeyMetadata { expiresAt: string | null; ipAllowlist: string[]; scopes: string[]; + isBanned: boolean; + keyHash: string | null; } interface ApiKeyRow extends JsonRecord { @@ -67,6 +76,8 @@ interface ApiKeyRow extends JsonRecord { isActive?: unknown; access_schedule?: unknown; accessSchedule?: unknown; + rate_limits?: unknown; + rateLimits?: unknown; } interface StatementLike { @@ -97,6 +108,7 @@ interface ApiKeyView extends JsonRecord { autoResolve: boolean; isActive: boolean; accessSchedule: AccessSchedule | null; + rateLimits: RateLimitRule[] | null; } // LRU cache for API key validation (valid keys only) @@ -126,6 +138,9 @@ const API_KEY_COLUMN_FALLBACKS = [ { 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" }, ] as const; // Cache for model permission checks @@ -248,12 +263,13 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { _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 FROM api_keys WHERE key = ?" + "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_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes FROM api_keys WHERE key = ?" + "SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); @@ -292,6 +308,8 @@ export async function getApiKeys() { 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); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -311,6 +329,8 @@ export async function getApiKeyById(id: string) { 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); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -378,6 +398,23 @@ function parseAccessSchedule(value: unknown): AccessSchedule | 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: any) => + 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 */ @@ -413,6 +450,16 @@ function parseNullableTimestamp(value: unknown): string | null { return trimmed === "" ? null : trimmed; } +function parseIsBanned(value: unknown): boolean { + return value === 1 || value === "1" || value === true; +} + +async function hashKey(key: string): Promise { + if (!key || typeof key !== "string") return ""; + return createHash("sha256").update(key).digest("hex"); +} + +export async function createApiKey(name: string, machineId: string) { export async function createApiKey(name: string, machineId: string, scopes: string[] = []) { if (!machineId) { throw new Error("machineId is required"); @@ -446,6 +493,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri 0, apiKey.createdAt, apiKey.key.slice(0, 12), + await hashKey(apiKey.key) JSON.stringify(scopes) ); setNoLog(apiKey.id, false); @@ -454,6 +502,47 @@ export async function createApiKey(name: string, machineId: string, scopes: stri 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(); + + // Redis invalidation + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + const redis = getRedisClient(); + if (typeof row.key_hash === "string") await redis.del(`auth:api_key:${row.key_hash}`); + await redis.del(`auth:api_key:${newHash}`); + } catch (err) { + // Fail silent + } + + 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: @@ -468,6 +557,9 @@ export async function updateApiKeyPermissions( accessSchedule?: AccessSchedule | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: 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; @@ -489,6 +581,11 @@ export async function updateApiKeyPermissions( accessSchedule: update.accessSchedule, maxRequestsPerDay: update.maxRequestsPerDay, maxRequestsPerMinute: update.maxRequestsPerMinute, + rateLimits: update.rateLimits, + isBanned: update.isBanned, + expiresAt: update.expiresAt, + maxSessions: (update as { maxSessions?: number | null; expiresAt?: string | null }) + .maxSessions, maxSessions: (update as { maxSessions?: number | null }).maxSessions, scopes: (update as { scopes?: string[] | null }).scopes, }; @@ -503,6 +600,10 @@ export async function updateApiKeyPermissions( normalized.accessSchedule === undefined && normalized.maxRequestsPerDay === undefined && normalized.maxRequestsPerMinute === undefined && + normalized.rateLimits === undefined && + normalized.isBanned === undefined && + normalized.expiresAt === undefined && + (normalized as Record).maxSessions === undefined (normalized as Record).maxSessions === undefined && (normalized as Record).scopes === undefined ) { @@ -521,7 +622,10 @@ export async function updateApiKeyPermissions( accessSchedule?: string | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: number | null; + rateLimits?: string | null; + isBanned?: number; maxSessions?: number; + expiresAt?: string | null; scopes?: string; } = { id }; @@ -573,6 +677,22 @@ export async function updateApiKeyPermissions( params.maxRequestsPerMinute = normalized.maxRequestsPerMinute; } + 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; + } + const maxSessionsUpdate = (normalized as Record).maxSessions; if (maxSessionsUpdate !== undefined) { updates.push("max_sessions = @maxSessions"); @@ -589,6 +709,22 @@ export async function updateApiKeyPermissions( if (result.changes === 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 (normalized.noLog !== undefined) { setNoLog(id, normalized.noLog); } @@ -596,6 +732,18 @@ export async function updateApiKeyPermissions( // Invalidate caches since permissions changed invalidateCaches(); + // Also invalidate Redis if key_hash is available + try { + const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as { key_hash: string | null } | undefined; + if (row?.key_hash) { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + const redis = getRedisClient(); + await redis.del(`auth:api_key:${row.key_hash}`); + } + } catch (err) { + // Fail silent + } + backupDbFile("pre-write"); return true; } @@ -678,18 +826,48 @@ export async function validateApiKey(key: string | null | undefined) { if (isConfiguredEnvApiKey(key)) return true; const now = Date.now(); + const hashedKey = await hashKey(key); + const cacheKey = hashedKey; - const cached = _keyValidationCache.get(key); + const cached = _keyValidationCache.get(cacheKey); if (cached && now - cached.timestamp < CACHE_TTL) { return cached.valid; } + // Try Redis cache for multi-instance consistency + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + 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 (err) { + // Fail silent for Redis lookup + } + const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); - const row = stmt.validateKey.get(key) as JsonRecord | undefined; + 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; @@ -703,7 +881,29 @@ export async function validateApiKey(key: string | null | undefined) { } evictIfNeeded(_keyValidationCache); - _keyValidationCache.set(key, { valid: true, timestamp: now }); + _keyValidationCache.set(cacheKey, { valid: true, timestamp: now }); + + // Update Redis cache for fast validation + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + 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 (err) { + // Fail silent for Redis cache update + } + markApiKeyUsed(db, row.id, now); return true; @@ -731,25 +931,30 @@ export async function getApiKeyMetadata( autoResolve: true, isActive: true, accessSchedule: null, + rateLimits: null, maxRequestsPerDay: null, maxRequestsPerMinute: null, maxSessions: 0, revokedAt: null, expiresAt: null, ipAllowlist: [], + scopes: [], + isBanned: false, + keyHash: null, scopes: ["manage"], }; } // Check cache first - const cached = _keyMetadataCache.get(key); + 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); + const row = stmt.getKeyMetadata.get(key, hashedKey); if (!row) return null; @@ -776,6 +981,7 @@ export async function getApiKeyMetadata( 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, // T08: max concurrent sessions; 0 = unlimited (default & backward-compatible) @@ -784,6 +990,8 @@ export async function getApiKeyMetadata( 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, }; if (!metadata.id) { @@ -794,7 +1002,7 @@ export async function getApiKeyMetadata( // Cache the result evictIfNeeded(_keyMetadataCache); - _keyMetadataCache.set(key, { value: metadata, timestamp: now }); + _keyMetadataCache.set(hashedKey, { value: metadata, timestamp: now }); return metadata; } diff --git a/src/lib/db/registeredKeys.ts b/src/lib/db/registeredKeys.ts index 3e42cb6353..e56fd8851b 100644 --- a/src/lib/db/registeredKeys.ts +++ b/src/lib/db/registeredKeys.ts @@ -88,6 +88,7 @@ function nowHour(): string { } function hashKey(raw: string): string { + if (!raw || typeof raw !== "string") return ""; return createHash("sha256").update(raw).digest("hex"); } diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index a0f67e4022..efe96f3199 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -92,6 +92,7 @@ export { validateApiKey, getApiKeyMetadata, updateApiKeyPermissions, + regenerateApiKey, isModelAllowedForKey, clearApiKeyCaches, resetApiKeyState, diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 211a074ee9..5909d6b37f 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -14,6 +14,13 @@ import { checkBudget } from "@/domain/costRules"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; +import { checkRateLimit, RateLimitRule } from "./rateLimiter"; + +const DEFAULT_RATE_LIMITS: RateLimitRule[] = [ + { limit: 1000, window: 86400 }, // 1000 per day + { limit: 5000, window: 604800 }, // 5000 per week + { limit: 20000, window: 2592000 } // 20000 per month +]; interface AccessSchedule { enabled: boolean; @@ -34,10 +41,13 @@ export interface ApiKeyMetadata { budget?: number; usedBudget?: number; isActive?: boolean; + isBanned?: boolean; + expiresAt?: string | null; accessSchedule?: AccessSchedule | null; maxRequestsPerDay?: number | null; maxRequestsPerMinute?: number | null; maxSessions?: number | null; + rateLimits?: RateLimitRule[] | null; } /** @@ -106,64 +116,7 @@ function isWithinSchedule(schedule: AccessSchedule): boolean { return localMinutes >= fromMinutes && localMinutes < untilMinutes; } -// ── In-memory request counter for per-key rate limits (#452) ── - -/** Sliding-window request timestamps per API key */ -const _requestTimestamps = new Map(); -const REQUEST_COUNTER_MAX_KEYS = 5000; -const REQUEST_DAY_MS = 24 * 60 * 60 * 1000; -const REQUEST_MINUTE_MS = 60 * 1000; - -/** Record a request and check per-key limits. Returns null if OK, or an error message. */ -function checkRequestCountLimits( - apiKeyId: string, - maxPerDay: number | null | undefined, - maxPerMinute: number | null | undefined -): string | null { - if (!maxPerDay && !maxPerMinute) return null; - - const now = Date.now(); - - // Get or create timestamp array for this key - let timestamps = _requestTimestamps.get(apiKeyId); - if (!timestamps) { - timestamps = []; - _requestTimestamps.set(apiKeyId, timestamps); - // Prevent unbounded growth - if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) { - const firstKey = _requestTimestamps.keys().next().value; - if (firstKey) _requestTimestamps.delete(firstKey); - } - } - - // Prune timestamps older than 24h - const dayAgo = now - REQUEST_DAY_MS; - while (timestamps.length > 0 && timestamps[0] < dayAgo) { - timestamps.shift(); - } - - // Check per-minute limit (before recording this request) - if (maxPerMinute && maxPerMinute > 0) { - const minuteAgo = now - REQUEST_MINUTE_MS; - const recentCount = timestamps.filter((t) => t >= minuteAgo).length; - if (recentCount >= maxPerMinute) { - return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`; - } - } - - // Check per-day limit - if (maxPerDay && maxPerDay > 0) { - if (timestamps.length >= maxPerDay) { - return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil( - (timestamps[0] + REQUEST_DAY_MS - now) / 60000 - )} minutes.`; - } - } - - // All checks passed — record this request - timestamps.push(now); - return null; -} +// Legacy in-memory request counter has been replaced by Redis-backed multi-window rate limiter export interface ApiKeyPolicyResult { /** API key string (null if no key provided) */ @@ -222,7 +175,7 @@ export async function enforceApiKeyPolicy( return { apiKey, apiKeyInfo: null, rejection: null }; } - // ── Check 1: is_active — hard block regardless of schedule ── + // ── Check 1: is_active / is_banned ── if (apiKeyInfo.isActive === false) { return { apiKey, @@ -230,6 +183,25 @@ export async function enforceApiKeyPolicy( rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"), }; } + if (apiKeyInfo.isBanned === true) { + return { + apiKey, + apiKeyInfo, + rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is banned due to policy violations"), + }; + } + + // ── Check 1.5: expires_at ── + if (apiKeyInfo.expiresAt) { + const expiry = new Date(apiKeyInfo.expiresAt).getTime(); + if (Date.now() > expiry) { + return { + apiKey, + apiKeyInfo, + rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key has expired"), + }; + } + } // ── Check 2: access_schedule — time-based access window ── if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) { @@ -286,18 +258,31 @@ export async function enforceApiKeyPolicy( } } - // ── Check 5: Request-count limits (#452) ── - if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) { - const limitError = checkRequestCountLimits( - apiKeyInfo.id, - apiKeyInfo.maxRequestsPerDay, - apiKeyInfo.maxRequestsPerMinute - ); - if (limitError) { + // ── Check 5: Generic Multi-Window Rate Limits ── + if (apiKeyInfo.id) { + const rulesToApply = (apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0) + ? [...apiKeyInfo.rateLimits] + : [...DEFAULT_RATE_LIMITS]; + + // Combine with legacy limits if they exist and custom rate limits aren't set + if (!apiKeyInfo.rateLimits || apiKeyInfo.rateLimits.length === 0) { + if (apiKeyInfo.maxRequestsPerDay) { + rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerDay, window: 86400 }); + } + if (apiKeyInfo.maxRequestsPerMinute) { + rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerMinute, window: 60 }); + } + } + + const rateLimitResult = await checkRateLimit(apiKeyInfo.id, rulesToApply); + if (!rateLimitResult.allowed) { + const failedWindowStr = rateLimitResult.failedWindow + ? ` (${rateLimitResult.failedWindow}s window)` + : ""; return { apiKey, apiKeyInfo, - rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError), + rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, `Request limit exceeded${failedWindowStr}. Please try again later.`), }; } } diff --git a/src/shared/utils/rateLimiter.ts b/src/shared/utils/rateLimiter.ts new file mode 100644 index 0000000000..14fc5a89fe --- /dev/null +++ b/src/shared/utils/rateLimiter.ts @@ -0,0 +1,142 @@ +import Redis from "ioredis"; + +// Reuse existing REDIS_URL if set, or local redis via default docker-compose +// Use REDIS_URL from env (Docker/Production) or fallback to local redis +const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; +if (process.env.NODE_ENV === 'production' && !process.env.REDIS_URL) { + console.warn('[REDIS] REDIS_URL is not set in production. Falling back to default.'); +} + + +let redisClient: Redis | null = null; + +export function getRedisClient() { + if (!redisClient) { + redisClient = new Redis(REDIS_URL, { + maxRetriesPerRequest: 3, + enableReadyCheck: false, + retryStrategy(times) { + return Math.min(times * 50, 2000); // Exponential backoff + } + }); + redisClient.on('error', (err) => console.error('[REDIS] Error:', err.message)); + } + return redisClient; +} + +export interface RateLimitRule { + limit: number; + window: number; // in seconds +} + +export interface RateLimitResult { + allowed: boolean; + failedWindow?: number; +} + +/** + * Atomic Lua script for multi-rule rate limiting using fixed window. + * Returns {1, 0} if allowed, or {0, failedWindow} if rejected. + */ +const RATE_LIMIT_SCRIPT = ` +local key_prefix = KEYS[1] +local current_time = tonumber(ARGV[1]) + +local rules = {} +for i = 2, #ARGV, 2 do + table.insert(rules, { + limit = tonumber(ARGV[i]), + window = tonumber(ARGV[i+1]) + }) +end + +-- First pass: check if any limit is exceeded +for i, rule in ipairs(rules) do + local current_window = math.floor(current_time / rule.window) + local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window + + local count = tonumber(redis.call("GET", window_key) or "0") + if count >= rule.limit then + return { 0, rule.window } -- Reject, return which window failed + end +end + +-- Second pass: increment all rules +for i, rule in ipairs(rules) do + local current_window = math.floor(current_time / rule.window) + local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window + + local count = redis.call("INCR", window_key) + if count == 1 then + -- TTL is twice the window size to ensure it covers the current window safely + redis.call("EXPIRE", window_key, rule.window * 2) + end +end + +return { 1, 0 } -- Accepted +`; + +const TEST_MEMORY_STORE = new Map(); +let explicitTestMode = false; + +export function setRateLimiterTestMode(enabled: boolean) { + explicitTestMode = enabled; + if (enabled) TEST_MEMORY_STORE.clear(); +} + +/** + * Checks multi-window rate limits for an API key atomically via Redis. + */ +export async function checkRateLimit( + keyId: string, + rules: RateLimitRule[] +): Promise { + if (!rules || rules.length === 0) return { allowed: true }; + + // ── In-memory mock for unit tests ── + const isTestMode = explicitTestMode || process.env.NODE_ENV === "test" || process.env.DISABLE_SQLITE_AUTO_BACKUP === "true"; + + if (isTestMode) { + const now = Math.floor(Date.now() / 1000); + for (const rule of rules) { + const currentWindow = Math.floor(now / rule.window); + const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`; + const count = TEST_MEMORY_STORE.get(windowKey) || 0; + if (count >= rule.limit) { + return { allowed: false, failedWindow: rule.window }; + } + } + for (const rule of rules) { + const currentWindow = Math.floor(now / rule.window); + const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`; + TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1); + } + return { allowed: true }; + } + + const redis = getRedisClient(); + const args: (string | number)[] = [Math.floor(Date.now() / 1000)]; + + for (const rule of rules) { + args.push(rule.limit, rule.window); + } + + try { + const result = await redis.eval( + RATE_LIMIT_SCRIPT, + 1, + `rl:api_key:${keyId}`, + ...args + ) as [number, number]; + + if (result[0] === 0) { + return { allowed: false, failedWindow: result[1] }; + } + + return { allowed: true }; + } catch (error) { + // Fail-open strategy if Redis goes down to prevent complete API outage + console.error("[RATE_LIMITER] Redis eval failed, bypassing rate limit:", error); + return { allowed: true }; + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 56d321d131..2c4d3ed2cf 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1465,8 +1465,11 @@ export const updateKeyPermissionsSchema = z noLog: z.boolean().optional(), autoResolve: z.boolean().optional(), isActive: z.boolean().optional(), + isBanned: z.boolean().optional(), + expiresAt: z.string().datetime().nullable().optional(), maxSessions: z.number().int().min(0).max(10000).optional(), accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(), + rateLimits: z.union([z.array(z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })).max(50), z.null()]).optional(), scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(), }) .superRefine((value, ctx) => { @@ -1477,8 +1480,11 @@ export const updateKeyPermissionsSchema = z value.noLog === undefined && value.autoResolve === undefined && value.isActive === undefined && + value.isBanned === undefined && + value.expiresAt === undefined && value.maxSessions === undefined && value.accessSchedule === undefined && + value.rateLimits === undefined value.scopes === undefined ) { ctx.addIssue({ diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts index 9225abd417..a812e3713b 100644 --- a/tests/unit/api-key-policy.test.ts +++ b/tests/unit/api-key-policy.test.ts @@ -28,6 +28,9 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-607-api-key-sec const coreDb = await import("../../src/lib/db/core.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const costRules = await import("../../src/domain/costRules.ts"); +const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts"); + +rateLimiter.setRateLimiterTestMode(true); async function resetStorage() { apiKeysDb.resetApiKeyState(); @@ -477,5 +480,5 @@ test("enforceApiKeyPolicy enforces request-per-minute limits and returns success "openai/gpt-4.1" ); assert.equal(second.rejection.status, 429); - assert.match(await readErrorMessage(second.rejection), /Per-minute request limit exceeded/); + assert.match(await readErrorMessage(second.rejection), /Request limit exceeded/); }); diff --git a/tests/unit/api-key-regeneration.test.ts b/tests/unit/api-key-regeneration.test.ts new file mode 100644 index 0000000000..d7624811ca --- /dev/null +++ b/tests/unit/api-key-regeneration.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-apikey-regen-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-regen"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +function reset() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + reset(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("regenerateApiKey creates a new key and invalidates the old one", async () => { + const machineId = "test-machine-regen"; + const created = await apiKeysDb.createApiKey("Regen Test", machineId); + const oldKey = created.key; + const oldId = created.id; + + assert.ok(oldKey); + assert.equal(await apiKeysDb.validateApiKey(oldKey), true); + + // Regenerate + const result = await apiKeysDb.regenerateApiKey(oldId); + assert.ok(result?.key); + const regenerated = result!.key; + + assert.notEqual(regenerated, oldKey); + + // New key should be valid + assert.equal(await apiKeysDb.validateApiKey(regenerated), true); + + // Old key should be invalid + assert.equal(await apiKeysDb.validateApiKey(oldKey), false); + + // Name and machineId should persist + const md = await apiKeysDb.getApiKeyMetadata(regenerated); + assert.equal(md?.name, "Regen Test"); + assert.ok(regenerated.startsWith(`sk-${machineId}-`)); +}); + +test("regenerateApiKey returns null for non-existent ID", async () => { + const result = await apiKeysDb.regenerateApiKey("00000000-0000-0000-0000-000000000000"); + assert.equal(result, null); +}); diff --git a/tests/unit/security-hashkey.test.ts b/tests/unit/security-hashkey.test.ts new file mode 100644 index 0000000000..020f29ee0d --- /dev/null +++ b/tests/unit/security-hashkey.test.ts @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const registeredKeysDb = await import("../../src/lib/db/registeredKeys.ts"); + +test("hashKey in apiKeys handles null/undefined safely", async () => { + // @ts-ignore - testing runtime safety + const resultNull = await apiKeysDb.validateApiKey(null); + assert.equal(resultNull, false); + + // @ts-ignore - testing runtime safety + const resultUndefined = await apiKeysDb.validateApiKey(undefined); + assert.equal(resultUndefined, false); +}); + +test("hashKey in registeredKeys handles null/undefined safely", () => { + // @ts-ignore - testing runtime safety + const resultNull = registeredKeysDb.validateRegisteredKey(null); + assert.equal(resultNull, null); + + // @ts-ignore - testing runtime safety + const resultUndefined = registeredKeysDb.validateRegisteredKey(undefined); + assert.equal(resultUndefined, null); +});