From 1e3a9fb92112c4febd68891d6bc675eeefd0802c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 17:19:58 -0400 Subject: [PATCH] add virtual api key schema and crud endpoints --- cli/commands/rotateServerSecret.ts | 49 +++- server/auth/actions.ts | 7 +- server/db/pg/schema/schema.ts | 51 ++++ server/db/sqlite/schema/schema.ts | 55 ++++- server/index.ts | 4 +- server/lib/virtualApiKey.ts | 209 +++++++++++++++++ server/middlewares/index.ts | 1 + server/middlewares/integration/index.ts | 1 + .../integration/verifyVirtualApiKeyAccess.ts | 87 +++++++ .../middlewares/verifyVirtualApiKeyAccess.ts | 101 ++++++++ server/openApi.ts | 3 +- server/routers/external.ts | 42 +++- server/routers/integration.ts | 40 ++++ .../virtualApiKey/createVirtualApiKey.ts | 177 ++++++++++++++ .../virtualApiKey/deleteVirtualApiKey.ts | 87 +++++++ .../routers/virtualApiKey/getVirtualApiKey.ts | 96 ++++++++ server/routers/virtualApiKey/index.ts | 6 + .../virtualApiKey/listVirtualApiKeys.ts | 218 ++++++++++++++++++ server/routers/virtualApiKey/types.ts | 16 ++ .../virtualApiKey/updateVirtualApiKey.ts | 218 ++++++++++++++++++ server/routers/virtualApiKey/validation.ts | 23 ++ 21 files changed, 1485 insertions(+), 6 deletions(-) create mode 100644 server/lib/virtualApiKey.ts create mode 100644 server/middlewares/integration/verifyVirtualApiKeyAccess.ts create mode 100644 server/middlewares/verifyVirtualApiKeyAccess.ts create mode 100644 server/routers/virtualApiKey/createVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/deleteVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/getVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/index.ts create mode 100644 server/routers/virtualApiKey/listVirtualApiKeys.ts create mode 100644 server/routers/virtualApiKey/types.ts create mode 100644 server/routers/virtualApiKey/updateVirtualApiKey.ts create mode 100644 server/routers/virtualApiKey/validation.ts diff --git a/cli/commands/rotateServerSecret.ts b/cli/commands/rotateServerSecret.ts index 2edb7592d..1bacfa99c 100644 --- a/cli/commands/rotateServerSecret.ts +++ b/cli/commands/rotateServerSecret.ts @@ -1,5 +1,5 @@ import { CommandModule } from "yargs"; -import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders } from "@server/db"; +import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db"; import { encrypt, decrypt } from "@server/lib/crypto"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq } from "drizzle-orm"; @@ -133,6 +133,7 @@ export const rotateServerSecret: CommandModule< const streamingDestinations = await db.select().from(eventStreamingDestinations); const webhookActions = await db.select().from(alertWebhookActions); const providers = await db.select().from(aiProviders); + const virtualKeys = await db.select().from(virtualApiKeys); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${licenseKeys.length} license key(s)`); @@ -140,6 +141,7 @@ export const rotateServerSecret: CommandModule< console.log(`Found ${streamingDestinations.length} event streaming destination(s)`); console.log(`Found ${webhookActions.length} alert webhook action(s)`); console.log(`Found ${providers.length} AI provider(s)`); + console.log(`Found ${virtualKeys.length} virtual API key(s)`); // Prepare all decrypted and re-encrypted values console.log("\nDecrypting and re-encrypting values..."); @@ -179,12 +181,18 @@ export const rotateServerSecret: CommandModule< encryptedHeaders: string | null; }; + type VirtualApiKeyUpdate = { + virtualApiKeyId: string; + encryptedToken: string; + }; + const idpUpdates: IdpUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = []; const certUpdates: CertUpdate[] = []; const streamingDestinationUpdates: StreamingDestinationUpdate[] = []; const webhookActionUpdates: WebhookActionUpdate[] = []; const aiProviderUpdates: AiProviderUpdate[] = []; + const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = []; // Process idpOidcConfig entries for (const idpConfig of idpConfigs) { @@ -346,6 +354,29 @@ export const rotateServerSecret: CommandModule< } } + // Process virtualApiKeys entries (token) + for (const key of virtualKeys) { + try { + if (!key.token) { + continue; + } + + virtualApiKeyUpdates.push({ + virtualApiKeyId: key.virtualApiKeyId, + encryptedToken: encrypt( + decrypt(key.token, oldSecret), + newSecret + ) + }); + } catch (error) { + console.error( + `Error processing virtual API key ${key.virtualApiKeyId}:`, + error + ); + throw error; + } + } + // Perform all database updates in a single transaction console.log("\nUpdating database in transaction..."); await db.transaction(async (trx) => { @@ -427,6 +458,21 @@ export const rotateServerSecret: CommandModule< }) .where(eq(aiProviders.providerId, update.providerId)); } + + // Update virtual API key entries + for (const update of virtualApiKeyUpdates) { + await trx + .update(virtualApiKeys) + .set({ + token: update.encryptedToken + }) + .where( + eq( + virtualApiKeys.virtualApiKeyId, + update.virtualApiKeyId + ) + ); + } }); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); @@ -435,6 +481,7 @@ export const rotateServerSecret: CommandModule< console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`); console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`); console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`); + console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`); // Update config file with new secret console.log("\nUpdating config file..."); diff --git a/server/auth/actions.ts b/server/auth/actions.ts index f9d781169..385238e87 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -199,7 +199,12 @@ export enum ActionsEnum { deleteAiBudget = "deleteAiBudget", getAiBudget = "getAiBudget", listAiBudgets = "listAiBudgets", - updateAiBudget = "updateAiBudget" + updateAiBudget = "updateAiBudget", + createVirtualApiKey = "createVirtualApiKey", + deleteVirtualApiKey = "deleteVirtualApiKey", + getVirtualApiKey = "getVirtualApiKey", + listVirtualApiKeys = "listVirtualApiKeys", + updateVirtualApiKey = "updateVirtualApiKey" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index c9a45618c..39dfb889e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -12,6 +12,7 @@ import { serial, text, unique, + uniqueIndex, varchar } from "drizzle-orm/pg-core"; @@ -1242,6 +1243,52 @@ export const apiKeyOrg = pgTable("apiKeyOrg", { .notNull() }); +export const virtualApiKeys = pgTable( + "virtualApiKeys", + { + virtualApiKeyId: varchar("virtualApiKeyId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + kind: varchar("kind").$type<"user" | "manual">().notNull(), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: varchar("name"), + description: varchar("description"), + token: varchar("token").notNull(), + lastChars: varchar("lastChars").notNull(), + allResources: boolean("allResources").notNull().default(false), + expiresAt: bigint("expiresAt", { mode: "number" }), + lastUsedAt: bigint("lastUsedAt", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + createdByUserId: varchar("createdByUserId").references( + () => users.userId, + { onDelete: "set null" } + ) + }, + (t) => [ + uniqueIndex("virtual_api_key_user_identity_uniq") + .on(t.orgId, t.userId) + .where(sql`${t.kind} = 'user'`) + ] +); + +export const virtualApiKeyResources = pgTable( + "virtualApiKeyResources", + { + virtualApiKeyId: varchar("virtualApiKeyId") + .notNull() + .references(() => virtualApiKeys.virtualApiKeyId, { + onDelete: "cascade" + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })] +); + export const idpOrg = pgTable("idpOrg", { idpId: integer("idpId") .notNull() @@ -1907,6 +1954,10 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type VirtualApiKey = InferSelectModel; +export type VirtualApiKeyResource = InferSelectModel< + typeof virtualApiKeyResources +>; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type Olm = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8017cd3b7..1c2236dfe 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -8,7 +8,8 @@ import { real, sqliteTable, text, - unique + unique, + uniqueIndex } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { @@ -1499,6 +1500,54 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", { .notNull() }); +export const virtualApiKeys = sqliteTable( + "virtualApiKeys", + { + virtualApiKeyId: text("virtualApiKeyId").primaryKey(), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + kind: text("kind").$type<"user" | "manual">().notNull(), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: text("name"), + description: text("description"), + token: text("token").notNull(), + lastChars: text("lastChars").notNull(), + allResources: integer("allResources", { mode: "boolean" }) + .notNull() + .default(false), + expiresAt: integer("expiresAt"), + lastUsedAt: integer("lastUsedAt"), + createdAt: integer("createdAt").notNull(), + createdByUserId: text("createdByUserId").references( + () => users.userId, + { onDelete: "set null" } + ) + }, + (t) => [ + uniqueIndex("virtual_api_key_user_identity_uniq") + .on(t.orgId, t.userId) + .where(sql`${t.kind} = 'user'`) + ] +); + +export const virtualApiKeyResources = sqliteTable( + "virtualApiKeyResources", + { + virtualApiKeyId: text("virtualApiKeyId") + .notNull() + .references(() => virtualApiKeys.virtualApiKeyId, { + onDelete: "cascade" + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })] +); + export const idpOrg = sqliteTable("idpOrg", { idpId: integer("idpId") .notNull() @@ -1891,6 +1940,10 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type VirtualApiKey = InferSelectModel; +export type VirtualApiKeyResource = InferSelectModel< + typeof virtualApiKeyResources +>; export type SiteResource = InferSelectModel; export type Network = InferSelectModel; export type OrgDomains = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index 3eb16092a..c7b0a5b6e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,7 +17,8 @@ import { Session, SiteResource, User, - UserOrg + UserOrg, + VirtualApiKey } from "@server/db"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; @@ -94,6 +95,7 @@ declare global { aiProvider?: AiProvider; aiModel?: AiModel; aiBudget?: AiBudget; + virtualApiKey?: VirtualApiKey; orgPolicyAllowed?: boolean; } } diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts new file mode 100644 index 000000000..15f9b52ff --- /dev/null +++ b/server/lib/virtualApiKey.ts @@ -0,0 +1,209 @@ +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import { + db, + resources, + virtualApiKeyResources, + virtualApiKeys, + type Transaction, + type VirtualApiKey +} from "@server/db"; +import config from "@server/lib/config"; +import { decrypt, encrypt } from "@server/lib/crypto"; +import { and, eq, inArray } from "drizzle-orm"; + +export type MintedVirtualApiKeySecret = { + virtualApiKeyId: string; + secret: string; + lastChars: string; +}; + +export type PublicVirtualApiKey = Omit & { + secret?: string; +}; + +export function mintVirtualApiKeySecret(): MintedVirtualApiKeySecret { + const secret = generateIdFromEntropySize(16); + return { + virtualApiKeyId: generateId(8), + secret, + lastChars: secret.slice(-4) + }; +} + +export function encryptVirtualApiKeyToken(secret: string): string { + return encrypt(secret, config.getRawConfig().server.secret!); +} + +export function decryptVirtualApiKeyToken(ciphertext: string): string { + return decrypt(ciphertext, config.getRawConfig().server.secret!); +} + +export function toPublicVirtualApiKey( + row: VirtualApiKey, + options?: { includeSecret?: boolean } +): PublicVirtualApiKey { + const { token, ...rest } = row; + if (!options?.includeSecret) { + return rest; + } + return { + ...rest, + secret: decryptVirtualApiKeyToken(token) + }; +} + +export async function assertManualKeyResourcesInOrg(params: { + allResources: boolean; + resourceIds: number[]; + orgId: string; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const { allResources, resourceIds, orgId } = params; + + if (allResources || resourceIds.length === 0) { + return { ok: true }; + } + + const uniqueIds = [...new Set(resourceIds)]; + const rows = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.orgId, orgId), + inArray(resources.resourceId, uniqueIds) + ) + ); + + if (rows.length !== uniqueIds.length) { + return { + ok: false, + message: "One or more resources are invalid for this organization" + }; + } + + return { ok: true }; +} + +export async function replaceVirtualApiKeyResources( + trx: Transaction | typeof db, + virtualApiKeyId: string, + resourceIds: number[] +): Promise { + await trx + .delete(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + const uniqueIds = [...new Set(resourceIds)]; + if (uniqueIds.length === 0) { + return; + } + + await trx.insert(virtualApiKeyResources).values( + uniqueIds.map((resourceId) => ({ + virtualApiKeyId, + resourceId + })) + ); +} + +async function selectUserVirtualApiKey( + orgId: string, + userId: string +): Promise { + const [existing] = await db + .select() + .from(virtualApiKeys) + .where( + and( + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.userId, userId), + eq(virtualApiKeys.kind, "user") + ) + ) + .limit(1); + + return existing ?? null; +} + +export async function getOrCreateUserVirtualApiKey(params: { + orgId: string; + userId: string; + createdByUserId?: string | null; +}): Promise<{ key: VirtualApiKey; secret: string }> { + const { orgId, userId, createdByUserId } = params; + + const existing = await selectUserVirtualApiKey(orgId, userId); + if (existing) { + return { + key: existing, + secret: decryptVirtualApiKeyToken(existing.token) + }; + } + + const minted = mintVirtualApiKeySecret(); + const now = Date.now(); + + try { + const [created] = await db + .insert(virtualApiKeys) + .values({ + virtualApiKeyId: minted.virtualApiKeyId, + orgId, + kind: "user", + userId, + name: null, + description: null, + token: encryptVirtualApiKeyToken(minted.secret), + lastChars: minted.lastChars, + allResources: false, + expiresAt: null, + lastUsedAt: null, + createdAt: now, + createdByUserId: createdByUserId ?? null + }) + .returning(); + + return { key: created, secret: minted.secret }; + } catch { + const raced = await selectUserVirtualApiKey(orgId, userId); + if (raced) { + return { + key: raced, + secret: decryptVirtualApiKeyToken(raced.token) + }; + } + throw new Error("Failed to create user virtual API key"); + } +} + +export async function rotateUserVirtualApiKey(params: { + orgId: string; + userId: string; + createdByUserId?: string | null; +}): Promise<{ key: VirtualApiKey; secret: string }> { + const { orgId, userId, createdByUserId } = params; + const existing = await selectUserVirtualApiKey(orgId, userId); + + if (!existing) { + return getOrCreateUserVirtualApiKey(params); + } + + const minted = mintVirtualApiKeySecret(); + const [updated] = await db + .update(virtualApiKeys) + .set({ + token: encryptVirtualApiKeyToken(minted.secret), + lastChars: minted.lastChars, + createdByUserId: + createdByUserId !== undefined + ? createdByUserId + : existing.createdByUserId + }) + .where(eq(virtualApiKeys.virtualApiKeyId, existing.virtualApiKeyId)) + .returning(); + + return { key: updated, secret: minted.secret }; +} diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 7242ea0cf..cd09add8a 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -30,6 +30,7 @@ export * from "./verifyDomainAccess"; export * from "./verifyAiProviderAccess"; export * from "./verifyAiModelAccess"; export * from "./verifyAiBudgetAccess"; +export * from "./verifyVirtualApiKeyAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserFromResourceSession"; export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 63df4b2bb..99a76cdf8 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -20,3 +20,4 @@ export * from "./verifyApiKeyAiProviderAccess"; export * from "./verifyApiKeyAiModelAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess"; +export * from "./verifyVirtualApiKeyAccess"; diff --git a/server/middlewares/integration/verifyVirtualApiKeyAccess.ts b/server/middlewares/integration/verifyVirtualApiKeyAccess.ts new file mode 100644 index 000000000..a7dd4706a --- /dev/null +++ b/server/middlewares/integration/verifyVirtualApiKeyAccess.ts @@ -0,0 +1,87 @@ +import { Request, Response, NextFunction } from "express"; +import { apiKeyOrg, db, virtualApiKeys } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyVirtualApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const virtualApiKeyId = getFirstString(req.params.virtualApiKeyId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!virtualApiKeyId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid virtual API key ID" + ) + ); + } + + const [key] = await db + .select() + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .limit(1); + + if (!key || key.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + if (apiKey.isRoot) { + req.virtualApiKey = key; + return next(); + } + + const orgId = key.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.virtualApiKey = key; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying virtual API key access" + ) + ); + } +} diff --git a/server/middlewares/verifyVirtualApiKeyAccess.ts b/server/middlewares/verifyVirtualApiKeyAccess.ts new file mode 100644 index 000000000..a2c27fd90 --- /dev/null +++ b/server/middlewares/verifyVirtualApiKeyAccess.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { db, userOrgs, virtualApiKeys } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyVirtualApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const virtualApiKeyId = getFirstString(req.params.virtualApiKeyId); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!virtualApiKeyId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid virtual API key ID" + ) + ); + } + + const [key] = await db + .select() + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .limit(1); + + if (!key || key.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + const orgId = key.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgId = orgId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.virtualApiKey = key; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying virtual API key access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 920aaa471..b4b7c1a7a 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -31,7 +31,8 @@ export enum OpenAPITags { PrivateResourceLegacy = "Private Resource (Legacy)", AiProvider = "AI Provider", AiModel = "AI Model", - AiBudget = "AI Budget" + AiBudget = "AI Budget", + VirtualApiKey = "Virtual API Key" } // Order here controls the order tags are displayed in Swagger UI diff --git a/server/routers/external.ts b/server/routers/external.ts index 9e1a5f210..1eb32ec0f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -48,7 +48,8 @@ import { verifyResourcePolicyAccess, verifyAiProviderAccess, verifyAiModelAccess, - verifyAiBudgetAccess + verifyAiBudgetAccess, + verifyVirtualApiKeyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -60,6 +61,7 @@ import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; import * as aiBudget from "@server/routers/aiBudget"; +import * as virtualApiKey from "@server/routers/virtualApiKey"; // Root routes export const unauthenticated = Router(); @@ -1633,6 +1635,44 @@ authenticated.delete( aiBudget.deleteAiBudget ); +authenticated.put( + "/org/:orgId/virtual-api-key", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createVirtualApiKey), + logActionAudit(ActionsEnum.createVirtualApiKey), + virtualApiKey.createVirtualApiKey +); + +authenticated.get( + "/org/:orgId/virtual-api-keys", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listVirtualApiKeys), + virtualApiKey.listVirtualApiKeys +); + +authenticated.get( + "/virtual-api-key/:virtualApiKeyId", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.getVirtualApiKey +); + +authenticated.post( + "/virtual-api-key/:virtualApiKeyId", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.updateVirtualApiKey), + logActionAudit(ActionsEnum.updateVirtualApiKey), + virtualApiKey.updateVirtualApiKey +); + +authenticated.delete( + "/virtual-api-key/:virtualApiKeyId", + verifyVirtualApiKeyAccess, + verifyUserHasAction(ActionsEnum.deleteVirtualApiKey), + logActionAudit(ActionsEnum.deleteVirtualApiKey), + virtualApiKey.deleteVirtualApiKey +); + authenticated.get( "/ai-provider/:providerId/ai-budgets", verifyAiProviderAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index e08c14920..24c9955da 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -14,6 +14,7 @@ import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; import * as aiProvider from "./aiProvider"; +import * as virtualApiKey from "./virtualApiKey"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -34,6 +35,7 @@ import { verifyApiKeyResourcePolicyAccess, verifyApiKeyAiProviderAccess, verifyApiKeyAiModelAccess, + verifyApiKeyVirtualApiKeyAccess, verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; @@ -1633,3 +1635,41 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteAiModel), aiProvider.deleteAiModel ); + +authenticated.put( + "/org/:orgId/virtual-api-key", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createVirtualApiKey), + logActionAudit(ActionsEnum.createVirtualApiKey), + virtualApiKey.createVirtualApiKey +); + +authenticated.get( + "/org/:orgId/virtual-api-keys", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listVirtualApiKeys), + virtualApiKey.listVirtualApiKeys +); + +authenticated.get( + "/virtual-api-key/:virtualApiKeyId", + verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.getVirtualApiKey +); + +authenticated.post( + "/virtual-api-key/:virtualApiKeyId", + verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyHasAction(ActionsEnum.updateVirtualApiKey), + logActionAudit(ActionsEnum.updateVirtualApiKey), + virtualApiKey.updateVirtualApiKey +); + +authenticated.delete( + "/virtual-api-key/:virtualApiKeyId", + verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyHasAction(ActionsEnum.deleteVirtualApiKey), + logActionAudit(ActionsEnum.deleteVirtualApiKey), + virtualApiKey.deleteVirtualApiKey +); diff --git a/server/routers/virtualApiKey/createVirtualApiKey.ts b/server/routers/virtualApiKey/createVirtualApiKey.ts new file mode 100644 index 000000000..5e49a5717 --- /dev/null +++ b/server/routers/virtualApiKey/createVirtualApiKey.ts @@ -0,0 +1,177 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, userOrgs, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { createDate, TimeSpan } from "oslo"; +import { + assertManualKeyResourcesInOrg, + encryptVirtualApiKeyToken, + mintVirtualApiKeySecret, + replaceVirtualApiKeyResources, + toPublicVirtualApiKey +} from "@server/lib/virtualApiKey"; +import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { createVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/virtual-api-key", + description: "Create a manual virtual API key for an organization.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: createVirtualApiKeyBodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createVirtualApiKeyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + name, + description, + userId, + allResources, + resourceIds, + validForSeconds + } = parsedBody.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + + const assignedResourceIds = allResources ? [] : (resourceIds ?? []); + const resourceCheck = await assertManualKeyResourcesInOrg({ + allResources, + resourceIds: assignedResourceIds, + orgId + }); + if (!resourceCheck.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, resourceCheck.message) + ); + } + + const minted = mintVirtualApiKeySecret(); + const expiresAt = validForSeconds + ? createDate(new TimeSpan(validForSeconds, "s")).getTime() + : null; + const now = Date.now(); + + const created = await db.transaction(async (trx) => { + const [row] = await trx + .insert(virtualApiKeys) + .values({ + virtualApiKeyId: minted.virtualApiKeyId, + orgId, + kind: "manual", + userId: userId ?? null, + name, + description: description ?? null, + token: encryptVirtualApiKeyToken(minted.secret), + lastChars: minted.lastChars, + allResources, + expiresAt, + lastUsedAt: null, + createdAt: now, + createdByUserId: req.user?.userId ?? null + }) + .returning(); + + await replaceVirtualApiKeyResources( + trx, + row.virtualApiKeyId, + assignedResourceIds + ); + + return row; + }); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(created, { includeSecret: true }), + resourceIds: assignedResourceIds + } + }, + success: true, + error: false, + message: "Virtual API key created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/deleteVirtualApiKey.ts b/server/routers/virtualApiKey/deleteVirtualApiKey.ts new file mode 100644 index 000000000..c5223633a --- /dev/null +++ b/server/routers/virtualApiKey/deleteVirtualApiKey.ts @@ -0,0 +1,87 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "delete", + path: "/virtual-api-key/{virtualApiKeyId}", + description: "Delete a manual virtual API key.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + + const [existing] = + req.virtualApiKey && + req.virtualApiKey.virtualApiKeyId === virtualApiKeyId + ? [req.virtualApiKey] + : await db + .select() + .from(virtualApiKeys) + .where( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId) + ) + .limit(1); + + if (!existing || existing.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + await db + .delete(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Virtual API key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/getVirtualApiKey.ts b/server/routers/virtualApiKey/getVirtualApiKey.ts new file mode 100644 index 000000000..fddae3aef --- /dev/null +++ b/server/routers/virtualApiKey/getVirtualApiKey.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, virtualApiKeyResources, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; +import { toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "get", + path: "/virtual-api-key/{virtualApiKeyId}", + description: + "Get a manual virtual API key by ID, including the decrypted secret.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + + const [key] = + req.virtualApiKey && + req.virtualApiKey.virtualApiKeyId === virtualApiKeyId + ? [req.virtualApiKey] + : await db + .select() + .from(virtualApiKeys) + .where( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId) + ) + .limit(1); + + if (!key || key.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + const resourceRows = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(key, { includeSecret: true }), + resourceIds: resourceRows.map((row) => row.resourceId) + } + }, + success: true, + error: false, + message: "Virtual API key retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/index.ts b/server/routers/virtualApiKey/index.ts new file mode 100644 index 000000000..b80fba765 --- /dev/null +++ b/server/routers/virtualApiKey/index.ts @@ -0,0 +1,6 @@ +export * from "./createVirtualApiKey"; +export * from "./listVirtualApiKeys"; +export * from "./getVirtualApiKey"; +export * from "./updateVirtualApiKey"; +export * from "./deleteVirtualApiKey"; +export * from "./types"; diff --git a/server/routers/virtualApiKey/listVirtualApiKeys.ts b/server/routers/virtualApiKey/listVirtualApiKeys.ts new file mode 100644 index 000000000..be7250f1e --- /dev/null +++ b/server/routers/virtualApiKey/listVirtualApiKeys.ts @@ -0,0 +1,218 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + virtualApiKeyResources, + virtualApiKeys, + type VirtualApiKey +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, exists, inArray, like, or, sql } from "drizzle-orm"; +import { toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + search: z.string().optional(), + userId: z.string().optional(), + resourceId: z.coerce.number().int().positive().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/virtual-api-keys", + description: "List manual virtual API keys for an organization.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listVirtualApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, search, userId, resourceId } = parsedQuery.data; + const conditions = [ + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.kind, "manual") + ]; + + if (userId) { + conditions.push(eq(virtualApiKeys.userId, userId)); + } + + if (search) { + const term = "%" + search.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${virtualApiKeys.name})`, term), + like(sql`LOWER(${virtualApiKeys.description})`, term), + like(sql`LOWER(${virtualApiKeys.lastChars})`, term) + )! + ); + } + + if (resourceId !== undefined) { + conditions.push( + or( + eq(virtualApiKeys.allResources, true), + exists( + db + .select() + .from(virtualApiKeyResources) + .where( + and( + eq( + virtualApiKeyResources.virtualApiKeyId, + virtualApiKeys.virtualApiKeyId + ), + eq( + virtualApiKeyResources.resourceId, + resourceId + ) + ) + ) + ) + )! + ); + } + + const whereClause = and(...conditions); + + const [totalCount, rows] = await Promise.all([ + db.$count( + db + .select() + .from(virtualApiKeys) + .where(whereClause) + .as("filtered_virtual_api_keys") + ), + db + .select() + .from(virtualApiKeys) + .where(whereClause) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + asc(virtualApiKeys.name), + asc(virtualApiKeys.createdAt) + ) + ]); + + const keyIds = rows.map((row) => row.virtualApiKeyId); + const resourceRows = + keyIds.length === 0 + ? [] + : await db + .select() + .from(virtualApiKeyResources) + .where( + inArray( + virtualApiKeyResources.virtualApiKeyId, + keyIds + ) + ); + + const resourceIdsByKey = new Map(); + for (const row of resourceRows) { + const existing = resourceIdsByKey.get(row.virtualApiKeyId) ?? []; + existing.push(row.resourceId); + resourceIdsByKey.set(row.virtualApiKeyId, existing); + } + + return response(res, { + data: { + virtualApiKeys: rows.map((row: VirtualApiKey) => ({ + ...toPublicVirtualApiKey(row), + resourceIds: resourceIdsByKey.get(row.virtualApiKeyId) ?? [] + })), + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Virtual API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts new file mode 100644 index 000000000..28823577f --- /dev/null +++ b/server/routers/virtualApiKey/types.ts @@ -0,0 +1,16 @@ +import type { PublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type { PublicVirtualApiKey }; + +export type ListVirtualApiKeysResponse = PaginatedResponse<{ + virtualApiKeys: (PublicVirtualApiKey & { resourceIds: number[] })[]; +}>; + +export type GetVirtualApiKeyResponse = { + virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; +}; + +export type CreateOrEditVirtualApiKeyResponse = { + virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; +}; diff --git a/server/routers/virtualApiKey/updateVirtualApiKey.ts b/server/routers/virtualApiKey/updateVirtualApiKey.ts new file mode 100644 index 000000000..42554803f --- /dev/null +++ b/server/routers/virtualApiKey/updateVirtualApiKey.ts @@ -0,0 +1,218 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + userOrgs, + virtualApiKeyResources, + virtualApiKeys +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { createDate, TimeSpan } from "oslo"; +import { + assertManualKeyResourcesInOrg, + replaceVirtualApiKeyResources, + toPublicVirtualApiKey +} from "@server/lib/virtualApiKey"; +import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { updateVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; + +const paramsSchema = z.strictObject({ + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "post", + path: "/virtual-api-key/{virtualApiKeyId}", + description: + "Update a manual virtual API key metadata and resource assignment.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: updateVirtualApiKeyBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateVirtualApiKeyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { virtualApiKeyId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.virtualApiKey && + req.virtualApiKey.virtualApiKeyId === virtualApiKeyId + ? [req.virtualApiKey] + : await db + .select() + .from(virtualApiKeys) + .where( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId) + ) + .limit(1); + + if (!existing || existing.kind !== "manual") { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + if (body.userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, body.userId), + eq(userOrgs.orgId, existing.orgId) + ) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + + const nextAllResources = + body.allResources !== undefined + ? body.allResources + : existing.allResources; + + let nextResourceIds: number[] | undefined; + if (nextAllResources) { + nextResourceIds = []; + } else if (body.resourceIds !== undefined) { + nextResourceIds = body.resourceIds; + } + + if (nextResourceIds !== undefined) { + const resourceCheck = await assertManualKeyResourcesInOrg({ + allResources: nextAllResources, + resourceIds: nextResourceIds, + orgId: existing.orgId + }); + if (!resourceCheck.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, resourceCheck.message) + ); + } + } + + const updates: Partial = {}; + + if (body.name !== undefined) { + updates.name = body.name; + } + if (body.description !== undefined) { + updates.description = body.description; + } + if (body.userId !== undefined) { + updates.userId = body.userId; + } + if (body.allResources !== undefined) { + updates.allResources = body.allResources; + } + if (body.validForSeconds !== undefined) { + updates.expiresAt = + body.validForSeconds === null + ? null + : createDate( + new TimeSpan(body.validForSeconds, "s") + ).getTime(); + } + + const updated = await db.transaction(async (trx) => { + let row = existing; + + if (Object.keys(updates).length > 0) { + const [updatedRow] = await trx + .update(virtualApiKeys) + .set(updates) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)) + .returning(); + row = updatedRow; + } + + if (nextResourceIds !== undefined) { + await replaceVirtualApiKeyResources( + trx, + virtualApiKeyId, + nextResourceIds + ); + } + + return row; + }); + + const resourceRows = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(updated), + resourceIds: resourceRows.map((row) => row.resourceId) + } + }, + success: true, + error: false, + message: "Virtual API key updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/validation.ts b/server/routers/virtualApiKey/validation.ts new file mode 100644 index 000000000..a1c9ae671 --- /dev/null +++ b/server/routers/virtualApiKey/validation.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const virtualApiKeyResourceIdsSchema = z + .array(z.coerce.number().int().positive()) + .optional(); + +export const createVirtualApiKeyBodySchema = z.strictObject({ + name: z.string().nonempty(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional().default(false), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional() +}); + +export const updateVirtualApiKeyBodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional(), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional().nullable() +});