Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/maintenance/17959-model-access-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **refactor(auth):** Extract model authorization decisions and matching into the domain layer while preserving API-key, publication, group, and cache behavior ([#17959](https://github.com/cheaper-inference/OmniRoute/pull/17959)) — thanks @MumuTW
112 changes: 112 additions & 0 deletions src/domain/modelAccessPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { hasClaudeCodeWildcardPermission, modelPatternMatches } from "./modelPermissions";
Comment thread
MumuTW marked this conversation as resolved.
Resolved

export interface ModelAccessPolicy {
id?: string;
modelAccessMode: "all" | "restricted";
allowedModels?: string[];
blockedModels?: string[];
disableNonPublicModels?: boolean;
}

export interface ModelAccessTarget {
modelId: string;
resolvedModelId?: string | null;
}

/** Read ports are lazy: a deny must not trigger later catalog/group lookups. */
export interface ModelAccessLookups {
getCandidates(modelId: string): Promise<string[]>;
resolveAlias(modelId: string): string;
getPublishedTarget(modelId: string): Promise<{ providerId: string; modelId: string } | null>;
findPublishedModel(
providerOrAlias: string,
modelId: string
): Promise<{ providerId: string; publishedModelId: string } | null>;
isPublishedModelHidden(providerId: string, alias: string, modelId: string): boolean;
checkGroupAccess(keyId: string, modelId: string, provider?: string): { allowed: boolean };
isDeniedUnderCanonicalProvider(
keyId: string,
provider: string | undefined,
modelId: string
): Promise<boolean>;
}

export type ModelAccessDecision =
| { allowed: false; reason: "blocked" | "unpublished" | "hidden" }
| { allowed: boolean; reason: "permissions" };

/**
* Decide model access independently of key storage and permission caching.
* Eligibility denials precede the final per-key/group permission decision.
*/
export async function evaluateModelAccess(
policy: ModelAccessPolicy,
{ modelId, resolvedModelId }: ModelAccessTarget,
lookups: ModelAccessLookups
): Promise<ModelAccessDecision> {
const { allowedModels, blockedModels, disableNonPublicModels, modelAccessMode } = policy;
// Allow rules retain the requested alias; deny rules also see the resolved target.
const candidates = Array.from(
new Set([
...(await lookups.getCandidates(modelId)),
...(resolvedModelId ? await lookups.getCandidates(resolvedModelId) : []),
])
);

if (blockedModels?.some((pattern) => modelPatternMatches(pattern, candidates))) {
return { allowed: false, reason: "blocked" };
}

if (disableNonPublicModels) {
const effectiveModelId = resolvedModelId || lookups.resolveAlias(modelId) || modelId;
const publicationCandidates = resolvedModelId
? await lookups.getCandidates(resolvedModelId)
: candidates;

if (!hasClaudeCodeWildcardPermission(allowedModels, publicationCandidates)) {
const lookupTarget = await lookups.getPublishedTarget(effectiveModelId);
const providerOrAlias = lookupTarget?.providerId || effectiveModelId.split("/")[0];
const shortModelId = lookupTarget?.modelId || effectiveModelId.split("/").slice(1).join("/");
if (!providerOrAlias || !shortModelId) return { allowed: false, reason: "unpublished" };

const published = await lookups.findPublishedModel(providerOrAlias, shortModelId);
if (!published) return { allowed: false, reason: "unpublished" };
const { providerId, publishedModelId } = published;

// Effort variants dispatch to a base model whose deny rules still apply.
if (publishedModelId !== shortModelId && blockedModels?.length) {
const baseCandidates = await lookups.getCandidates(`${providerId}/${publishedModelId}`);
if (blockedModels.some((pattern) => modelPatternMatches(pattern, baseCandidates))) {
return { allowed: false, reason: "blocked" };
}
}

if (lookups.isPublishedModelHidden(providerId, providerOrAlias, publishedModelId)) {
return { allowed: false, reason: "hidden" };
}
}
}

// Restricted + [] is deny-all. Groups can narrow, never expand, this grant.
let allowed =
!allowedModels || allowedModels.length === 0
? modelAccessMode !== "restricted"
: allowedModels.some((pattern) => modelPatternMatches(pattern, candidates));

if (policy.id) {
for (const groupModelId of new Set([modelId, ...(resolvedModelId ? [resolvedModelId] : [])])) {
const hasProvider = groupModelId.includes("/");
const provider = hasProvider ? groupModelId.split("/")[0] : undefined;
const target = hasProvider ? groupModelId.split("/").slice(1).join("/") : groupModelId;
const targetOk = lookups.checkGroupAccess(policy.id, target, provider).allowed;
const fullOk = lookups.checkGroupAccess(policy.id, groupModelId, provider).allowed;
if (!targetOk || !fullOk) allowed = false;

if (allowed && (await lookups.isDeniedUnderCanonicalProvider(policy.id, provider, target))) {
allowed = false;
}
}
}

return { allowed, reason: "permissions" };
}
144 changes: 144 additions & 0 deletions src/domain/modelPermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Pure model-permission matching. Runtime settings and storage are resolved by callers.

import { getProviderByAlias, resolveProviderId } from "@/shared/constants/providers";

export const CLAUDE_CODE_PROVIDER_PREFIXES = new Set(["cc", "claude"]);

export const CLAUDE_CODE_SHORT_ALIASES = new Set(["sonnet", "opus", "haiku", "fable"]);

export function stripExtendedContextSuffix(modelId: string): string {
return modelId.endsWith("[1m]") ? modelId.slice(0, -4) : modelId;
}

export function isPotentialUnprefixedClaudeCodeModel(modelId: string): boolean {
const clean = stripExtendedContextSuffix(modelId.trim());
return /^claude-/i.test(clean) || CLAUDE_CODE_SHORT_ALIASES.has(clean.toLowerCase());
}

export function addModelCandidate(candidates: Set<string>, modelId: string): void {
const clean = modelId.trim();
if (!clean) return;
candidates.add(clean);
candidates.add(stripExtendedContextSuffix(clean));
}

/**
* Expand provider-scoped model ids with canonical provider id + public alias forms
* (e.g. codex/gpt-5.6-terra ↔ cx/gpt-5.6-terra) so API-key allow/block patterns match
* dashboard restrictions regardless of which prefix the client sends.
*/
export function addProviderAliasScopedCandidates(
candidates: Set<string>,
providerOrAlias: string,
providerScopedModel: string,
resolveProviderId: (aliasOrId: string) => string,
getProviderAlias: (providerId: string) => string
): void {
if (!providerScopedModel) return;
const canonicalId = resolveProviderId(providerOrAlias);
const alias = getProviderAlias(canonicalId);
if (canonicalId !== providerOrAlias) {
addModelCandidate(candidates, `${canonicalId}/${providerScopedModel}`);
}
if (alias !== providerOrAlias && alias !== canonicalId) {
addModelCandidate(candidates, `${alias}/${providerScopedModel}`);
}
}

export function modelPatternMatches(pattern: string, candidates: string[]): boolean {
for (const candidate of candidates) {
if (pattern === candidate) return true;
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -2);
if (!candidate.startsWith(prefix + "/")) continue;
// `provider/*` covers that provider's own models, including ids whose
// model segment itself contains a slash (`cline/deepseek/deepseek-v4-flash`).
// It must not also cover a different provider nested under the same prefix
// (`cline/openrouter/deepseek/deepseek-v4-flash`): that model is served by
// openrouter, which this entry never named.
const rest = candidate.slice(prefix.length + 1);
const nested = rest.indexOf("/");
if (nested > 0 && rest.slice(nested + 1).includes("/")) {
const nextSegment = rest.slice(0, nested);
const nestedProvider = getProviderByAlias(nextSegment);
// `<gateway>/<other-provider>/<model>` is another provider's model
// reached through this gateway (`cline/openrouter/deepseek/deepseek-v4-flash`).
// `<gateway>/<family>/<model>` is still this gateway's own model even
// when the family name is also a provider (`cline/deepseek/deepseek-v4-flash`).
const namedProvider = resolveProviderId(prefix.split("/")[0]);
if (nestedProvider && nestedProvider.id !== namedProvider) continue;
}
return true;
}
if (pattern.includes("*") && matchesWildcardPattern(pattern, candidate)) {
return true;
}
}
return false;
}

export function hasClaudeCodeWildcardPermission(
allowedModels: string[] | undefined,
candidates: string[]
): boolean {
if (!allowedModels || allowedModels.length === 0) return false;
return allowedModels.some(
(pattern) =>
(pattern === "cc/*" || pattern === "claude/*") &&
candidates.some((candidate) => modelPatternMatches(pattern, [candidate]))
);
}

/**
* 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(<dynamic>)`).
*
* 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.
*/
export 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;
}

export 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;
}
100 changes: 17 additions & 83 deletions src/lib/db/apiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { setNoLog } from "../compliance/noLog";
import { resolveModelAlias } from "@omniroute/open-sse/services/modelDeprecation.ts";
import { stripNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts";
import { getProviderAlias, resolveProviderId } from "@/shared/constants/providers";
import { evaluateModelAccess } from "@/domain/modelAccessPolicy";
import { isSelfLoopBearer, selfLoopKeyOverrides } from "./apiKeys/selfLoopKey";
import {
backfillApiKeySecretsAtRest,
Expand All @@ -44,7 +45,6 @@ import {
addModelCandidate,
addProviderAliasScopedCandidates,
modelPatternMatches,
hasClaudeCodeWildcardPermission,
matchesWildcardPattern,
} from "./apiKeys/modelPermissions";
import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess";
Expand Down Expand Up @@ -1618,93 +1618,27 @@ export async function isModelAllowedForKey(
// SECURITY: Key not found in database = deny access (invalid/non-existent key)
if (!metadata) return false;

const { modelAccessMode, allowedModels, blockedModels, disableNonPublicModels } = metadata;
// Preserve requested aliases for allow-list matching while applying resolved
// target candidates to deny rules and group access checks.
const modelPermissionCandidates = Array.from(
new Set([
...(await getModelPermissionCandidates(modelId)),
...(resolvedModelId ? await getModelPermissionCandidates(resolvedModelId) : []),
])
);

// Deny-list patterns win over any allow-list entry. This lets operators keep
// broad dynamic scopes like cc/* while excluding expensive families.
if (blockedModels?.some((pattern) => modelPatternMatches(pattern, modelPermissionCandidates))) {
return false;
}

// Check disableNonPublicModels flag
if (disableNonPublicModels) {
const effectiveModelId = resolvedModelId || resolveModelAlias(modelId) || modelId;
const publicationCandidates = resolvedModelId
? await getModelPermissionCandidates(resolvedModelId)
: modelPermissionCandidates;

if (!hasClaudeCodeWildcardPermission(allowedModels, publicationCandidates)) {
const lookupTarget = await getPublishedModelLookupTarget(effectiveModelId);
const providerOrAlias = lookupTarget?.providerId || effectiveModelId.split("/")[0];
const shortModelId = lookupTarget?.modelId || effectiveModelId.split("/").slice(1).join("/");
if (!providerOrAlias || !shortModelId) return false;

const published = await findPublishedModel(providerOrAlias, shortModelId);
if (!published) return false;
const { providerId, publishedModelId } = published;

// An effort variant dispatches to its base model, so a deny rule on the
// base model must also deny the variant.
if (publishedModelId !== shortModelId && blockedModels?.length) {
const baseCandidates = await getModelPermissionCandidates(
`${providerId}/${publishedModelId}`
);
if (blockedModels.some((pattern) => modelPatternMatches(pattern, baseCandidates))) {
return false;
}
}

// A model hidden under the alias the client used stays hidden.
if (isPublishedModelHidden(providerId, providerOrAlias, publishedModelId)) return false;
const decision = await evaluateModelAccess(
metadata,
{ modelId, resolvedModelId },
{
getCandidates: getModelPermissionCandidates,
resolveAlias: resolveModelAlias,
getPublishedTarget: getPublishedModelLookupTarget,
findPublishedModel,
isPublishedModelHidden,
checkGroupAccess: checkKeyModelAccess,
isDeniedUnderCanonicalProvider,
}
}
);

// Only explicit allow-all permits an empty list; restricted + [] is deny-all.
// No early return here: group deny rules (checked below) must still apply to
// keys with an empty per-key allow-list (#8817 regression guard — groups only
// AND-deny, so restricted+[] stays deny-all).
// Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models)
let allowed =
!allowedModels || allowedModels.length === 0
? modelAccessMode !== "restricted"
: allowedModels.some((pattern) => modelPatternMatches(pattern, modelPermissionCandidates));

// Group rules must see the requested alias and the concrete target. An allowed
// alias cannot hide a resolved target denied by group policy.
if (metadata.id) {
for (const groupModelId of new Set([modelId, ...(resolvedModelId ? [resolvedModelId] : [])])) {
const hasGroupProviderPrefix = groupModelId.includes("/");
const groupProvider = hasGroupProviderPrefix ? groupModelId.split("/")[0] : undefined;
const groupModelTarget = hasGroupProviderPrefix
? groupModelId.split("/").slice(1).join("/")
: groupModelId;
const targetOk = checkKeyModelAccess(metadata.id, groupModelTarget, groupProvider).allowed;
const fullOk = checkKeyModelAccess(metadata.id, groupModelId, groupProvider).allowed;
if (!targetOk || !fullOk) allowed = false;

if (
allowed &&
(await isDeniedUnderCanonicalProvider(metadata.id, groupProvider, groupModelTarget))
) {
allowed = false;
}
}
}
// Cache the result
if (!usesSettingDependentClaudeRouting) {
// Preserve the cache boundary: early block/publication denials were never cached.
if (decision.reason === "permissions" && !usesSettingDependentClaudeRouting) {
evictModelPermissionCache();
setCachedModelPermission(cacheKey, allowed, now, catalogGeneration);
setCachedModelPermission(cacheKey, decision.allowed, now, catalogGeneration);
}

return allowed;
return decision.allowed;
}

/**
Expand Down
Loading
Loading