Repository navigation
fix: enforce API key model restrictions across all /v1/* endpoints #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv | |
| import { parseTranscriptionModel } from "@omniroute/open-sse/config/audioRegistry.ts"; | ||
| import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; | ||
| import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; | ||
| import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; | ||
|
|
||
| /** | ||
| * Handle CORS preflight | ||
|
|
@@ -43,6 +44,10 @@ export async function POST(request) { | |
| return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); | ||
| } | ||
|
|
||
| // Enforce API key policies (model restrictions + budget limits) | ||
| const policy = await enforceApiKeyPolicy(request, model as string); | ||
| if (policy.rejection) return policy.rejection; | ||
|
Comment on lines
+47
to
+49
|
||
|
|
||
| const { provider } = parseTranscriptionModel(model); | ||
| if (!provider) { | ||
| return errorResponse( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ 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 { toJsonErrorPayload } from "@/shared/utils/upstreamError"; | ||
| import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; | ||
|
|
||
| /** | ||
| * Handle CORS preflight | ||
|
|
@@ -78,6 +79,10 @@ export async function POST(request) { | |
| return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing input"); | ||
| } | ||
|
|
||
| // Enforce API key policies (model restrictions + budget limits) | ||
| const policy = await enforceApiKeyPolicy(request, body.model); | ||
| if (policy.rejection) return policy.rejection; | ||
|
|
||
|
Comment on lines
+82
to
+85
|
||
| // Parse model to get provider | ||
| const { provider } = parseEmbeddingModel(body.model); | ||
| if (!provider) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ 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 { toJsonErrorPayload } from "@/shared/utils/upstreamError"; | ||
| import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; | ||
|
|
||
| /** | ||
| * Handle CORS preflight | ||
|
|
@@ -75,6 +76,10 @@ export async function POST(request) { | |
| return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string"); | ||
| } | ||
|
|
||
| // Enforce API key policies (model restrictions + budget limits) | ||
| const policy = await enforceApiKeyPolicy(request, body.model); | ||
| if (policy.rejection) return policy.rejection; | ||
|
|
||
|
Comment on lines
+79
to
+82
|
||
| // Parse model to get provider | ||
| const { provider } = parseImageModel(body.model); | ||
| if (!provider) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv | |
| import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts"; | ||
| import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; | ||
| import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; | ||
| import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; | ||
|
|
||
| /** | ||
| * Handle CORS preflight | ||
|
|
@@ -38,6 +39,11 @@ export async function POST(request) { | |
| } | ||
|
|
||
| const model = body.model || "omni-moderation-latest"; | ||
|
|
||
| // Enforce API key policies (model restrictions + budget limits) | ||
| const policy = await enforceApiKeyPolicy(request, model); | ||
| if (policy.rejection) return policy.rejection; | ||
|
Comment on lines
41
to
+45
|
||
|
|
||
| const { provider } = parseModerationModel(model); | ||
|
|
||
| // Default to openai if no provider prefix | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv | |
| import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts"; | ||
| import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; | ||
| import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; | ||
| import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; | ||
|
|
||
| /** | ||
| * Handle CORS preflight | ||
|
|
@@ -44,6 +45,10 @@ export async function POST(request) { | |
| return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); | ||
| } | ||
|
|
||
| // Enforce API key policies (model restrictions + budget limits) | ||
| const policy = await enforceApiKeyPolicy(request, body.model); | ||
| if (policy.rejection) return policy.rejection; | ||
|
Comment on lines
+48
to
+50
|
||
|
|
||
| const { provider } = parseRerankModel(body.model); | ||
| if (!provider) { | ||
| return errorResponse( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,104 @@ | ||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * API Key Policy Enforcement — Shared middleware for all /v1/* endpoints. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * Enforces API key policies: model restrictions and budget limits. | ||||||||||||||||||||||||||||||
| * Should be called after API key authentication in every endpoint that | ||||||||||||||||||||||||||||||
| * accepts a model parameter. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * @module shared/utils/apiKeyPolicy | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import { extractApiKey } from "@/sse/services/auth"; | ||||||||||||||||||||||||||||||
| import { getApiKeyMetadata, isModelAllowedForKey } from "@/lib/localDb"; | ||||||||||||||||||||||||||||||
| import { checkBudget } from "@/domain/costRules"; | ||||||||||||||||||||||||||||||
| import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; | ||||||||||||||||||||||||||||||
| import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export interface ApiKeyPolicyResult { | ||||||||||||||||||||||||||||||
| /** API key string (null if no key provided) */ | ||||||||||||||||||||||||||||||
| apiKey: string | null; | ||||||||||||||||||||||||||||||
| /** Metadata from DB (null if no key or key not found) */ | ||||||||||||||||||||||||||||||
| apiKeyInfo: any | null; | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||||||||||||
| /** If set, the request should be rejected with this Response */ | ||||||||||||||||||||||||||||||
| rejection: Response | null; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Enforce API key policies for a request. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * Checks: | ||||||||||||||||||||||||||||||
| * 1. Model restriction — if the key has `allowedModels`, verify the requested model is permitted | ||||||||||||||||||||||||||||||
| * 2. Budget limit — if the key has a budget configured, verify it hasn't been exceeded | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * @param request - The incoming HTTP request | ||||||||||||||||||||||||||||||
| * @param modelStr - The model ID from the request body | ||||||||||||||||||||||||||||||
| * @returns ApiKeyPolicyResult with apiKey, metadata, and optional rejection response | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * @example | ||||||||||||||||||||||||||||||
| * ```ts | ||||||||||||||||||||||||||||||
| * const policy = await enforceApiKeyPolicy(request, body.model); | ||||||||||||||||||||||||||||||
| * if (policy.rejection) return policy.rejection; | ||||||||||||||||||||||||||||||
| * // proceed with request, optionally use policy.apiKeyInfo | ||||||||||||||||||||||||||||||
| * ``` | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| export async function enforceApiKeyPolicy( | ||||||||||||||||||||||||||||||
| request: Request, | ||||||||||||||||||||||||||||||
| modelStr: string | null | ||||||||||||||||||||||||||||||
| ): Promise<ApiKeyPolicyResult> { | ||||||||||||||||||||||||||||||
| const apiKey = extractApiKey(request); | ||||||||||||||||||||||||||||||
|
Comment on lines
+44
to
+48
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // No API key = local mode, skip policy checks | ||||||||||||||||||||||||||||||
| if (!apiKey) { | ||||||||||||||||||||||||||||||
| return { apiKey: null, apiKeyInfo: null, rejection: null }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Fetch key metadata (includes allowedModels) | ||||||||||||||||||||||||||||||
| let apiKeyInfo: any = null; | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| apiKeyInfo = await getApiKeyMetadata(apiKey); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // If metadata fetch fails, don't block — degrade gracefully | ||||||||||||||||||||||||||||||
| return { apiKey, apiKeyInfo: null, rejection: null }; | ||||||||||||||||||||||||||||||
|
Comment on lines
+59
to
+61
|
||||||||||||||||||||||||||||||
| } catch { | |
| // If metadata fetch fails, don't block — degrade gracefully | |
| return { apiKey, apiKeyInfo: null, rejection: null }; | |
| } catch (err) { | |
| // If metadata fetch fails, fail closed to avoid bypassing policy checks | |
| console.error("Failed to fetch API key metadata", err); | |
| return { | |
| apiKey, | |
| apiKeyInfo: null, | |
| rejection: errorResponse( | |
| HTTP_STATUS.SERVICE_UNAVAILABLE, | |
| "Unable to validate API key metadata. Please try again later." | |
| ), | |
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The try...catch block for getApiKeyMetadata currently suppresses errors. While degrading gracefully is the correct behavior for the user, logging the error is crucial for maintainability. It would help you debug issues with the database or the metadata retrieval logic. You'll need to import the logger: import * as log from "@/sse/utils/logger";
| try { | |
| apiKeyInfo = await getApiKeyMetadata(apiKey); | |
| } catch { | |
| // If metadata fetch fails, don't block — degrade gracefully | |
| return { apiKey, apiKeyInfo: null, rejection: null }; | |
| } | |
| try { | |
| apiKeyInfo = await getApiKeyMetadata(apiKey); | |
| } catch (error) { | |
| // If metadata fetch fails, don't block — degrade gracefully but log it | |
| log.warn("API_POLICY", "Failed to fetch API key metadata. Policies will not be applied.", { error }); | |
| return { apiKey, apiKeyInfo: null, rejection: null }; | |
| } |
Copilot
AI
Feb 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
enforceApiKeyPolicy already fetched apiKeyInfo (including allowedModels), but isModelAllowedForKey() will fetch metadata again internally. Even with caching, this is duplicated work for restricted keys. Consider refactoring the model-allowed check to reuse the already-fetched allowedModels/metadata.
| const allowed = await isModelAllowedForKey(apiKey, modelStr); | |
| const allowed = | |
| Array.isArray(apiKeyInfo.allowedModels) && | |
| apiKeyInfo.allowedModels.includes(modelStr); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the metadata fetch, this catch block suppresses errors from the budget check. To help with debugging and monitoring, it's best to log these errors, even if the request is allowed to proceed. This requires importing the logger if you haven't already: import * as log from "@/sse/utils/logger";
} catch (error) {
// Budget check is best-effort — don't block on errors, but log them.
log.warn("API_POLICY", "Budget check failed. Request will be allowed.", { error });
}| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,7 @@ import { | |
| } from "@omniroute/open-sse/utils/proxyFetch.ts"; | ||
| import * as log from "../utils/logger"; | ||
| import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh"; | ||
| import { getSettings, getCombos, getApiKeyMetadata } from "@/lib/localDb"; | ||
| import { getSettings, getCombos } from "@/lib/localDb"; | ||
| import { resolveProxyForConnection } from "@/lib/localDb"; | ||
| import { logProxyEvent } from "../../lib/proxyLogger"; | ||
| import { logTranslationEvent } from "../../lib/translatorEvents"; | ||
|
|
@@ -34,8 +34,9 @@ import { getCircuitBreaker, CircuitBreakerOpenError } from "../../shared/utils/c | |
| import { isModelAvailable, setModelUnavailable } from "../../domain/modelAvailability"; | ||
| import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry"; | ||
| import { generateRequestId } from "../../shared/utils/requestId"; | ||
| import { checkBudget, recordCost } from "../../domain/costRules"; | ||
| import { recordCost } from "../../domain/costRules"; | ||
| import { logAuditEvent } from "../../lib/compliance/index"; | ||
| import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy"; | ||
|
|
||
| /** | ||
| * Handle chat completion request | ||
|
|
@@ -97,15 +98,8 @@ export async function handleChat(request: any, clientRawRequest: any = null) { | |
| // Log API key (masked) | ||
| const authHeader = request.headers.get("Authorization"); | ||
| const apiKey = extractApiKey(request); | ||
| let apiKeyInfo = null; | ||
| if (authHeader && apiKey) { | ||
| const masked = log.maskKey(apiKey); | ||
| log.debug("AUTH", `API Key: ${masked}`); | ||
| try { | ||
| apiKeyInfo = await getApiKeyMetadata(apiKey); | ||
| } catch { | ||
| apiKeyInfo = null; | ||
| } | ||
| log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); | ||
| } else { | ||
| log.debug("AUTH", "No API key provided (local mode)"); | ||
| } | ||
|
|
@@ -129,19 +123,14 @@ export async function handleChat(request: any, clientRawRequest: any = null) { | |
| return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); | ||
| } | ||
|
|
||
| // Pipeline: Budget check (if API key has budget limits) | ||
| // Pipeline: API key policy enforcement (model restrictions + budget limits) | ||
| telemetry.startPhase("policy"); | ||
| if (apiKeyInfo?.id) { | ||
| try { | ||
| const budgetOk = checkBudget(apiKeyInfo.id); | ||
| if (!budgetOk.allowed) { | ||
| log.warn("BUDGET", `API key ${apiKeyInfo.id} exceeded budget: ${budgetOk.reason}`); | ||
| return errorResponse(429, budgetOk.reason || "Budget limit exceeded"); | ||
| } | ||
| } catch { | ||
| // Budget check is best-effort — don't block on errors | ||
| } | ||
| const policy = await enforceApiKeyPolicy(request, modelStr); | ||
| if (policy.rejection) { | ||
| log.warn("POLICY", `API key policy rejected: ${modelStr} (key=${policy.apiKeyInfo?.id || "unknown"})`); | ||
| return policy.rejection; | ||
| } | ||
| const apiKeyInfo = policy.apiKeyInfo; | ||
|
Comment on lines
+126
to
+133
|
||
| telemetry.endPhase(); | ||
|
|
||
| // Check if model is a combo (has multiple models with fallback) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Policy enforcement runs before parsing/normalizing the speech model. parseSpeechModel supports bare model IDs; if allowedModels is stored as provider/model (as in /v1/models), restricted keys may be incorrectly denied when clients omit the provider prefix. Consider normalizing (parseSpeechModel -> provider/model) before calling enforceApiKeyPolicy.