From cd9edb290bfd782f8ee56d493b38fbc994e0bdc7 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sun, 20 Sep 2026 23:50:56 +0530 Subject: [PATCH] fix(permissions): resolve AI module authorization edge cases and test failures --- .../enterprise-only-resources.test.ts | 1 + apps/dokploy/server/api/routers/ai.ts | 46 +++++++++++-------- apps/dokploy/server/api/trpc.ts | 21 ++++++++- packages/server/src/services/ai.ts | 34 +++++++++++--- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index ecfbc380d..4849a0ba7 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [ "auditLog", "vaultProvider", "dnsProvider", + "ai", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 2bf3135a4..9a4502cf5 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -38,18 +38,22 @@ import { slugify } from "@/lib/slug"; import { adminProcedure, createTRPCRouter, + withAnyPermission, withPermission, } from "@/server/api/trpc"; import { generatePassword } from "@/templates/utils"; export const aiRouter = createTRPCRouter({ - one: withPermission("ai", "read") + one: withAnyPermission("ai", ["read", "update"]) .input(z.object({ aiId: z.string() })) - .query(async ({ input }) => { - return await getAiSettingById(input.aiId); + .query(async ({ input, ctx }) => { + return await getAiSettingById( + input.aiId, + ctx.session.activeOrganizationId, + ); }), - getModels: withPermission("ai", "read") + getModels: withAnyPermission("ai", ["read", "create", "update"]) .input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() })) .query(async ({ input }) => { try { @@ -197,17 +201,27 @@ export const aiRouter = createTRPCRouter({ get: withPermission("ai", "read") .input(z.object({ aiId: z.string() })) - .query(async ({ input }) => { - return await getAiSettingById(input.aiId); + .query(async ({ input, ctx }) => { + return await getAiSettingById( + input.aiId, + ctx.session.activeOrganizationId, + ); }), delete: withPermission("ai", "delete") .input(z.object({ aiId: z.string() })) - .mutation(async ({ input }) => { - return await deleteAiSettings(input.aiId); + .mutation(async ({ input, ctx }) => { + return await deleteAiSettings( + input.aiId, + ctx.session.activeOrganizationId, + ); }), - getCustomProviders: withPermission("ai", "read").query(async ({ ctx }) => { + getCustomProviders: withAnyPermission("ai", [ + "read", + "create", + "update", + ]).query(async ({ ctx }) => { return await getCustomAiProviders(ctx.session.activeOrganizationId); }), @@ -239,7 +253,10 @@ export const aiRouter = createTRPCRouter({ ) .mutation(async ({ input, ctx }) => { try { - const aiSettings = await getAiSettingById(input.aiId); + const aiSettings = await getAiSettingById( + input.aiId, + ctx.session.activeOrganizationId, + ); if (!aiSettings?.isEnabled) { throw new TRPCError({ code: "BAD_REQUEST", @@ -247,13 +264,6 @@ export const aiRouter = createTRPCRouter({ }); } - if (aiSettings.organizationId !== ctx.session.activeOrganizationId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Access denied", - }); - } - const provider = selectAIProvider(aiSettings); const model = provider(aiSettings.model); @@ -287,7 +297,7 @@ ${input.logs}`, } }), - testConnection: withPermission("ai", "create") + testConnection: withAnyPermission("ai", ["create", "update"]) .input( z.object({ apiUrl: z.string().min(1), diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index d0b43e76f..788f55a8d 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db"; import { hasValidLicense } from "@dokploy/server/index"; import type { statements } from "@dokploy/server/lib/access-control"; import { validateRequest } from "@dokploy/server/lib/auth"; -import { checkPermission } from "@dokploy/server/services/permission"; +import { + checkPermission, + hasPermission, +} from "@dokploy/server/services/permission"; import type { OpenApiMeta } from "@dokploy/trpc-openapi"; import { initTRPC, TRPCError } from "@trpc/server"; import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; @@ -263,3 +266,19 @@ export const withPermission = ( await checkPermission(ctx, { [resource]: [action] } as any); return next(); }); + +export const withAnyPermission = ( + resource: R, + actions: ActionOf[], +) => + protectedProcedure.use(async ({ ctx, next }) => { + for (const action of actions) { + if (await hasPermission(ctx, { [resource]: [action] } as any)) { + return next(); + } + } + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Permission denied", + }); + }); diff --git a/packages/server/src/services/ai.ts b/packages/server/src/services/ai.ts index 0554f10db..998a4bf3b 100644 --- a/packages/server/src/services/ai.ts +++ b/packages/server/src/services/ai.ts @@ -4,7 +4,7 @@ import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai"; import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider"; import { TRPCError } from "@trpc/server"; import { generateText, Output } from "ai"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import { IS_CLOUD } from "../constants"; import { findServerById } from "./server"; @@ -36,9 +36,12 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => { return aiSettings; }; -export const getAiSettingById = async (aiId: string) => { +export const getAiSettingById = async ( + aiId: string, + organizationId: string, +) => { const aiSetting = await db.query.ai.findFirst({ - where: eq(ai.aiId, aiId), + where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)), }); if (!aiSetting) { throw new TRPCError({ @@ -100,6 +103,18 @@ const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, ""); export const saveAiSettings = async (organizationId: string, settings: any) => { const aiId = settings.aiId; + if (aiId) { + const existing = await db.query.ai.findFirst({ + where: eq(ai.aiId, aiId), + }); + if (existing && existing.organizationId !== organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to modify this AI configuration", + }); + } + } + if (settings.apiUrl) { const customProviders = await getCustomAiProviders(organizationId); if (customProviders.length > 0) { @@ -132,8 +147,13 @@ export const saveAiSettings = async (organizationId: string, settings: any) => { }); }; -export const deleteAiSettings = async (aiId: string) => { - return db.delete(ai).where(eq(ai.aiId, aiId)); +export const deleteAiSettings = async ( + aiId: string, + organizationId: string, +) => { + return db + .delete(ai) + .where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId))); }; interface Props { @@ -144,13 +164,13 @@ interface Props { } export const suggestVariants = async ({ - organizationId: _organizationId, + organizationId, aiId, input, serverId, }: Props) => { try { - const aiSettings = await getAiSettingById(aiId); + const aiSettings = await getAiSettingById(aiId, organizationId); if (!aiSettings || !aiSettings.isEnabled) { throw new TRPCError({ code: "NOT_FOUND",