mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 16:30:15 +02:00
add virtual api key validation in verifySession
This commit is contained in:
@@ -38,7 +38,7 @@ import {
|
||||
authTypeRequiresApiKey,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -49,7 +49,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type {
|
||||
AiProviderAuthType,
|
||||
AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
type AiProviderFormValues
|
||||
} from "@app/lib/aiProviderFormSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults";
|
||||
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
AI_PROVIDER_AUTH_TYPES,
|
||||
type AiProviderAuthType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
import {
|
||||
MultiResourcesSelector,
|
||||
formatMultiResourcesSelectorLabel
|
||||
@@ -146,7 +147,12 @@ export default function CreateVirtualApiKeyForm({
|
||||
if (res?.data.data.virtualApiKey) {
|
||||
const key = res.data.data.virtualApiKey;
|
||||
if (key.secret) {
|
||||
setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`);
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(
|
||||
key.virtualApiKeyId,
|
||||
key.secret
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resourceLookup = new Map(
|
||||
|
||||
@@ -40,6 +40,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
import {
|
||||
MultiResourcesSelector,
|
||||
formatMultiResourcesSelectorLabel
|
||||
@@ -151,7 +152,10 @@ export default function EditVirtualApiKeyForm({
|
||||
const secret = res.data.data.virtualApiKey.secret;
|
||||
if (secret) {
|
||||
setCredential(
|
||||
`vk-${virtualApiKey.virtualApiKeyId}.${secret}`
|
||||
formatVirtualApiKeyCredential(
|
||||
virtualApiKey.virtualApiKeyId,
|
||||
secret
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
|
||||
@@ -27,6 +27,10 @@ import type {
|
||||
ListMyVirtualApiKeysResponse,
|
||||
VirtualApiKeyWithResources
|
||||
} from "@server/routers/virtualApiKey/types";
|
||||
import {
|
||||
formatVirtualApiKeyCredential,
|
||||
formatVirtualApiKeyPreview
|
||||
} from "@app/lib/virtualApiKeyFormat";
|
||||
|
||||
type UserVirtualApiKeysProps = {
|
||||
orgId: string;
|
||||
@@ -34,10 +38,6 @@ type UserVirtualApiKeysProps = {
|
||||
initialData: ListMyVirtualApiKeysResponse;
|
||||
};
|
||||
|
||||
function keyPreview(virtualApiKeyId: string, lastChars: string): string {
|
||||
return `vk-${virtualApiKeyId}••••${lastChars}`;
|
||||
}
|
||||
|
||||
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
@@ -56,7 +56,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
||||
.then((res) => {
|
||||
const secret = res.data.data.virtualApiKey.secret;
|
||||
if (secret) {
|
||||
setCredential(`vk-${virtualApiKeyId}.${secret}`);
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -95,7 +97,7 @@ function OwnedKeySecret({
|
||||
lastChars: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const preview = keyPreview(virtualApiKeyId, lastChars);
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const { credential, loading, revealSecret } = useRevealSecret(
|
||||
orgId,
|
||||
virtualApiKeyId
|
||||
@@ -137,7 +139,7 @@ function IdentityKeyCenterpiece({
|
||||
resourceGuid?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const preview = keyPreview(virtualApiKeyId, lastChars);
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const { credential, loading, revealSecret } = useRevealSecret(
|
||||
orgId,
|
||||
virtualApiKeyId
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
|
||||
type ViewVirtualApiKeySecretProps = {
|
||||
open: boolean;
|
||||
@@ -56,7 +57,12 @@ export default function ViewVirtualApiKeySecret({
|
||||
}
|
||||
const key = res.data.data.virtualApiKey;
|
||||
if (key.secret) {
|
||||
setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`);
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(
|
||||
key.virtualApiKeyId,
|
||||
key.secret
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import {
|
||||
formatVirtualApiKeyCredential,
|
||||
formatVirtualApiKeyPreview
|
||||
} from "@app/lib/virtualApiKeyFormat";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export type VirtualApiKeyRow = CreatedVirtualApiKey;
|
||||
@@ -507,7 +511,7 @@ function VirtualApiKeySecretCell({
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const preview = `vk-${virtualApiKeyId}••••${lastChars}`;
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const [credential, setCredential] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -522,7 +526,9 @@ function VirtualApiKeySecretCell({
|
||||
}
|
||||
const secret = res.data.data.virtualApiKey.secret;
|
||||
if (secret) {
|
||||
setCredential(`vk-${virtualApiKeyId}.${secret}`);
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
GetMyVirtualApiKeyResponse,
|
||||
VirtualApiKeyWithResources
|
||||
} from "@server/routers/virtualApiKey/types";
|
||||
import {
|
||||
formatVirtualApiKeyCredential,
|
||||
formatVirtualApiKeyPreview
|
||||
} from "@app/lib/virtualApiKeyFormat";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -31,10 +35,6 @@ type LauncherInferenceApiKeysSectionProps = {
|
||||
resourceGuid: string;
|
||||
};
|
||||
|
||||
function keyPreview(virtualApiKeyId: string, lastChars: string): string {
|
||||
return `vk-${virtualApiKeyId}••••${lastChars}`;
|
||||
}
|
||||
|
||||
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
@@ -53,7 +53,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
||||
.then((res) => {
|
||||
const secret = res.data.data.virtualApiKey.secret;
|
||||
if (secret) {
|
||||
setCredential(`vk-${virtualApiKeyId}.${secret}`);
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -92,7 +94,7 @@ function PanelKeySecret({
|
||||
lastChars: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const preview = keyPreview(virtualApiKeyId, lastChars);
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const { credential, loading, revealSecret } = useRevealSecret(
|
||||
orgId,
|
||||
virtualApiKeyId
|
||||
|
||||
@@ -46,7 +46,7 @@ export function UserSelector({
|
||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||
|
||||
const { data: users = [] } = useQuery(
|
||||
orgQueries.users({ orgId, perPage: 10, term: debouncedValue })
|
||||
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
const usersShown = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const AI_CAPABILITIES = [
|
||||
"openai_chat",
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"gemini_generate_content",
|
||||
"bedrock_model_invoke",
|
||||
"google_generate_content",
|
||||
"google_raw_predict",
|
||||
"bedrock_converse"
|
||||
] as const;
|
||||
|
||||
export type AiCapability = (typeof AI_CAPABILITIES)[number];
|
||||
@@ -0,0 +1,107 @@
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
|
||||
export type AiProviderType =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom";
|
||||
|
||||
export const AI_PROVIDER_AUTH_TYPES = [
|
||||
"bearer",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"hec",
|
||||
"cf-aig-authorization",
|
||||
"none",
|
||||
"passthrough"
|
||||
] as const;
|
||||
|
||||
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
|
||||
export type AiBudgetUnit = "usd" | "tokens";
|
||||
export type AiProviderRoutingMode = "url" | "target";
|
||||
|
||||
type AiProviderDefaults = {
|
||||
upstreamUrl: string | null;
|
||||
authType: AiProviderAuthType;
|
||||
capabilities: readonly AiCapability[];
|
||||
};
|
||||
|
||||
export const AI_PROVIDER_DEFAULTS: Record<
|
||||
Exclude<AiProviderType, "custom">,
|
||||
AiProviderDefaults
|
||||
> = {
|
||||
openai: {
|
||||
upstreamUrl: "https://api.openai.com/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses"]
|
||||
},
|
||||
anthropic: {
|
||||
upstreamUrl: "https://api.anthropic.com",
|
||||
authType: "x-api-key",
|
||||
capabilities: ["anthropic_messages"]
|
||||
},
|
||||
googleGemini: {
|
||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||
authType: "x-goog-api-key",
|
||||
capabilities: ["gemini_generate_content"]
|
||||
},
|
||||
vertexAi: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer",
|
||||
capabilities: ["google_generate_content", "google_raw_predict"]
|
||||
},
|
||||
bedrock: {
|
||||
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
authType: "bearer",
|
||||
capabilities: ["bedrock_converse"]
|
||||
},
|
||||
microsoftFoundry: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
||||
},
|
||||
openRouter: {
|
||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat"]
|
||||
},
|
||||
vercelAiGateway: {
|
||||
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses"]
|
||||
}
|
||||
};
|
||||
|
||||
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
|
||||
return authType !== "none" && authType !== "passthrough";
|
||||
}
|
||||
|
||||
export function providerRequiresUpstreamUrl(
|
||||
type: AiProviderType,
|
||||
routingMode: AiProviderRoutingMode = "url"
|
||||
): boolean {
|
||||
if (routingMode === "target") {
|
||||
return false;
|
||||
}
|
||||
if (type === "custom") {
|
||||
return true;
|
||||
}
|
||||
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
||||
}
|
||||
|
||||
export function defaultsForProviderType(
|
||||
type: AiProviderType
|
||||
): readonly AiCapability[] {
|
||||
if (type === "custom") {
|
||||
return [];
|
||||
}
|
||||
return AI_PROVIDER_DEFAULTS[type].capabilities;
|
||||
}
|
||||
|
||||
export { AI_CAPABILITIES };
|
||||
export type { AiCapability };
|
||||
@@ -1,14 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_PROVIDER_AUTH_TYPES,
|
||||
AI_PROVIDER_DEFAULTS,
|
||||
authTypeRequiresApiKey,
|
||||
defaultsForProviderType,
|
||||
providerRequiresUpstreamUrl,
|
||||
type AiCapability,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export const VIRTUAL_API_KEY_PREFIX = "vk-";
|
||||
|
||||
const VIRTUAL_API_KEY_AUTH_HEADER_NAMES = [
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"cf-aig-authorization"
|
||||
] as const;
|
||||
|
||||
export function formatVirtualApiKeyCredential(
|
||||
virtualApiKeyId: string,
|
||||
secret: string
|
||||
): string {
|
||||
return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}.${secret}`;
|
||||
}
|
||||
|
||||
export function formatVirtualApiKeyPreview(
|
||||
virtualApiKeyId: string,
|
||||
lastChars: string
|
||||
): string {
|
||||
return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}••••${lastChars}`;
|
||||
}
|
||||
|
||||
export function looksLikeVirtualApiKeyCredential(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith(VIRTUAL_API_KEY_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const withoutPrefix = trimmed.slice(VIRTUAL_API_KEY_PREFIX.length);
|
||||
const dot = withoutPrefix.indexOf(".");
|
||||
return dot > 0 && dot < withoutPrefix.length - 1;
|
||||
}
|
||||
|
||||
function headerValueCarriesVirtualApiKey(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
const bearerMatch = trimmed.match(/^(?:Bearer|Splunk)\s+(.+)$/i);
|
||||
if (bearerMatch) {
|
||||
return looksLikeVirtualApiKeyCredential(bearerMatch[1]);
|
||||
}
|
||||
return looksLikeVirtualApiKeyCredential(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove client headers that carry a Pangolin virtual API key so they are
|
||||
* never forwarded to upstream providers (including passthrough auth).
|
||||
*/
|
||||
export function stripVirtualApiKeyAuthHeaders(
|
||||
headers: Record<string, string>
|
||||
): void {
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (
|
||||
!(VIRTUAL_API_KEY_AUTH_HEADER_NAMES as readonly string[]).includes(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (headerValueCarriesVirtualApiKey(headers[key])) {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user