From b5473e63fdac3d9756a16786f902d643e53197a8 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sun, 20 Sep 2026 22:57:01 +0530 Subject: [PATCH 01/11] feat(permissions): add AI module to enterprise custom roles --- .../components/dashboard/settings/ai-form.tsx | 62 +++++++++++-------- apps/dokploy/components/layouts/side.tsx | 2 +- .../proprietary/roles/manage-custom-roles.tsx | 25 ++++++++ apps/dokploy/server/api/routers/ai.ts | 40 ++++++------ packages/server/src/lib/access-control.ts | 7 ++- 5 files changed, 89 insertions(+), 47 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/ai-form.tsx b/apps/dokploy/components/dashboard/settings/ai-form.tsx index 797aad23c..abe1a1c3e 100644 --- a/apps/dokploy/components/dashboard/settings/ai-form.tsx +++ b/apps/dokploy/components/dashboard/settings/ai-form.tsx @@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers"; export const AiForm = () => { const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery(); const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); const { data: currentUser } = api.user.get.useQuery(); + const isOrgAdmin = currentUser?.role === "owner" || currentUser?.role === "admin"; @@ -36,7 +38,9 @@ export const AiForm = () => {
{isOrgAdmin && } - {aiConfigs && aiConfigs?.length > 0 && } + {permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && ( + + )}
@@ -53,7 +57,7 @@ export const AiForm = () => { You don't have any AI configurations - + {permissions?.ai.create && } ) : (
@@ -70,33 +74,37 @@ export const AiForm = () => { {config.model}
- - { - await mutateAsync({ - aiId: config.aiId, - }) - .then(() => { - toast.success("AI deleted successfully"); - refetch(); + {permissions?.ai.update && ( + + )} + {permissions?.ai.delete && ( + { + await mutateAsync({ + aiId: config.aiId, }) - .catch(() => { - toast.error("Error deleting AI"); - }); - }} - > - - + + + )}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 59e628aa0..cec02762a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -351,7 +351,7 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ permissions }) => !!permissions?.organization.update, + isEnabled: ({ permissions }) => !!permissions?.ai.read, }, { isSingle: true, diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index e7d7ae316..22f1dd507 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -185,6 +185,11 @@ const RESOURCE_META: Record = { description: "Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to", }, + ai: { + label: "AI Settings", + description: + "Manage AI providers, models, and use AI features (log analysis, suggestions)", + }, }; /** Descriptions for each action within a resource */ @@ -495,6 +500,25 @@ const ACTION_META: Record< description: "Remove logs and metrics providers", }, }, + ai: { + read: { + label: "Read", + description: + "View AI providers and use AI features (log analysis, suggestions)", + }, + create: { + label: "Create", + description: "Add new AI provider configurations", + }, + update: { + label: "Update", + description: "Edit AI provider settings and custom providers", + }, + delete: { + label: "Delete", + description: "Remove AI provider configurations", + }, + }, }; /** Resources that should be hidden from the custom role editor (better-auth internals) */ @@ -606,6 +630,7 @@ const ROLE_PRESETS: { logs: ["read"], monitoring: ["read"], auditLog: ["read"], + ai: ["read", "create", "update", "delete"], }, }, ]; diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 169a32b07..2bf3135a4 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -38,18 +38,18 @@ import { slugify } from "@/lib/slug"; import { adminProcedure, createTRPCRouter, - protectedProcedure, + withPermission, } from "@/server/api/trpc"; import { generatePassword } from "@/templates/utils"; export const aiRouter = createTRPCRouter({ - one: adminProcedure + one: withPermission("ai", "read") .input(z.object({ aiId: z.string() })) .query(async ({ input }) => { return await getAiSettingById(input.aiId); }), - getModels: protectedProcedure + getModels: withPermission("ai", "read") .input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() })) .query(async ({ input }) => { try { @@ -177,33 +177,37 @@ export const aiRouter = createTRPCRouter({ }); } }), - create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => { - return await saveAiSettings(ctx.session.activeOrganizationId, input); - }), + create: withPermission("ai", "create") + .input(apiCreateAi) + .mutation(async ({ ctx, input }) => { + return await saveAiSettings(ctx.session.activeOrganizationId, input); + }), - update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => { - return await saveAiSettings(ctx.session.activeOrganizationId, input); - }), + update: withPermission("ai", "update") + .input(apiUpdateAi) + .mutation(async ({ ctx, input }) => { + return await saveAiSettings(ctx.session.activeOrganizationId, input); + }), - getAll: adminProcedure.query(async ({ ctx }) => { + getAll: withPermission("ai", "read").query(async ({ ctx }) => { return await getAiSettingsByOrganizationId( ctx.session.activeOrganizationId, ); }), - get: adminProcedure + get: withPermission("ai", "read") .input(z.object({ aiId: z.string() })) .query(async ({ input }) => { return await getAiSettingById(input.aiId); }), - delete: adminProcedure + delete: withPermission("ai", "delete") .input(z.object({ aiId: z.string() })) .mutation(async ({ input }) => { return await deleteAiSettings(input.aiId); }), - getCustomProviders: protectedProcedure.query(async ({ ctx }) => { + getCustomProviders: withPermission("ai", "read").query(async ({ ctx }) => { return await getCustomAiProviders(ctx.session.activeOrganizationId); }), @@ -216,7 +220,7 @@ export const aiRouter = createTRPCRouter({ ); }), - getEnabledProviders: protectedProcedure.query(async ({ ctx }) => { + getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => { const settings = await getAiSettingsByOrganizationId( ctx.session.activeOrganizationId, ); @@ -225,7 +229,7 @@ export const aiRouter = createTRPCRouter({ .map((s) => ({ aiId: s.aiId, name: s.name, model: s.model })); }), - analyzeLogs: protectedProcedure + analyzeLogs: withPermission("ai", "read") .input( z.object({ aiId: z.string().min(1), @@ -283,7 +287,7 @@ ${input.logs}`, } }), - testConnection: protectedProcedure + testConnection: withPermission("ai", "create") .input( z.object({ apiUrl: z.string().min(1), @@ -317,7 +321,7 @@ ${input.logs}`, } }), - suggest: protectedProcedure + suggest: withPermission("ai", "read") .input( z.object({ aiId: z.string(), @@ -338,7 +342,7 @@ ${input.logs}`, }); } }), - deploy: protectedProcedure + deploy: withPermission("ai", "read") .input(deploySuggestionSchema) .mutation(async ({ ctx, input }) => { const environment = await findEnvironmentById(input.environmentId); diff --git a/packages/server/src/lib/access-control.ts b/packages/server/src/lib/access-control.ts index 9f27b16dc..2023075f3 100644 --- a/packages/server/src/lib/access-control.ts +++ b/packages/server/src/lib/access-control.ts @@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access"; * The rest are Dokploy-specific resources. * * Enterprise-only resources (only assignable via custom roles): - * deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring + * deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai */ export const statements = { // better-auth organization plugin defaults @@ -51,6 +51,7 @@ export const statements = { auditLog: ["read"], vaultProvider: ["read", "create", "update", "delete"], dnsProvider: ["read", "create", "update", "delete"], + ai: ["read", "create", "update", "delete"], } as const; /** @@ -79,6 +80,7 @@ export const enterpriseOnlyResources = new Set([ "auditLog", "vaultProvider", "dnsProvider", + "ai", ]); export const ac = createAccessControl(statements); @@ -121,6 +123,7 @@ export const ownerRole = ac.newRole({ vaultProvider: ["read", "create", "update", "delete"], dnsProvider: ["read", "create", "update", "delete"], telemetryProvider: ["read", "create", "delete"], + ai: ["read", "create", "update", "delete"], }); /** @@ -161,6 +164,7 @@ export const adminRole = ac.newRole({ vaultProvider: ["read", "create", "update", "delete"], dnsProvider: ["read", "create", "update", "delete"], telemetryProvider: ["read", "create", "delete"], + ai: ["read", "create", "update", "delete"], }); /** @@ -207,4 +211,5 @@ export const memberRole = ac.newRole({ // Members need provider/secret names for env editor autocomplete; values are never exposed vaultProvider: ["read"], dnsProvider: [], + ai: [], }); From cd9edb290bfd782f8ee56d493b38fbc994e0bdc7 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sun, 20 Sep 2026 23:50:56 +0530 Subject: [PATCH 02/11] 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", From 293e19703a0eac50cd2a3deacd45c705bf67a694 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Mon, 21 Sep 2026 00:20:10 +0530 Subject: [PATCH 03/11] fix: resolve cross-tenant access and granular role UI issues for AI settings --- apps/dokploy/__test__/utils/remote-stream.test.ts | 6 +++--- apps/dokploy/components/layouts/side.tsx | 8 +++++++- apps/dokploy/server/api/routers/ai.ts | 12 +++++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/__test__/utils/remote-stream.test.ts b/apps/dokploy/__test__/utils/remote-stream.test.ts index 6f7a3b358..e428de9f3 100644 --- a/apps/dokploy/__test__/utils/remote-stream.test.ts +++ b/apps/dokploy/__test__/utils/remote-stream.test.ts @@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "sleep 0.3; cat > /dev/null" }, }); expect(bytes).toBe(11); - }); + }, 30_000); it("pipes data larger than the pipe buffer unchanged", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-")); @@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "echo boom >&2; exit 3" }, }), ).rejects.toThrow("target exited with code 3: boom"); - }); + }, 30_000); it("reports a source failure", async () => { await expect( @@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "cat > /dev/null" }, }), ).rejects.toThrow("source exited with code 2"); - }); + }, 30_000); }); diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index cec02762a..9cbc187b6 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -351,7 +351,13 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ permissions }) => !!permissions?.ai.read, + isEnabled: ({ permissions }) => + !!( + permissions?.ai.read || + permissions?.ai.create || + permissions?.ai.update || + permissions?.ai.delete + ), }, { isSingle: true, diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 9a4502cf5..716f09013 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -193,11 +193,13 @@ export const aiRouter = createTRPCRouter({ return await saveAiSettings(ctx.session.activeOrganizationId, input); }), - getAll: withPermission("ai", "read").query(async ({ ctx }) => { - return await getAiSettingsByOrganizationId( - ctx.session.activeOrganizationId, - ); - }), + getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query( + async ({ ctx }) => { + return await getAiSettingsByOrganizationId( + ctx.session.activeOrganizationId, + ); + }, + ), get: withPermission("ai", "read") .input(z.object({ aiId: z.string() })) From bdefc02e77a8319da6d5e44ac9974bdcaa57c293 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Mon, 21 Sep 2026 00:30:38 +0530 Subject: [PATCH 04/11] fix: redact apiKey from getAll AI endpoint to prevent credential exposure --- apps/dokploy/server/api/routers/ai.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 716f09013..956475590 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -195,9 +195,10 @@ export const aiRouter = createTRPCRouter({ getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query( async ({ ctx }) => { - return await getAiSettingsByOrganizationId( + const settings = await getAiSettingsByOrganizationId( ctx.session.activeOrganizationId, ); + return settings.map(({ apiKey, ...rest }) => rest); }, ), From a7d8378f52002432f2e6261a72801d584d54c42a Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Mon, 21 Sep 2026 01:18:28 +0530 Subject: [PATCH 05/11] fix(permissions): mask apiKey on ai.one, add audit logging, remove dead endpoint --- apps/dokploy/server/api/routers/ai.ts | 55 +++++++++++++++++----- packages/server/src/db/schema/audit-log.ts | 3 +- packages/server/src/services/ai.ts | 10 ++++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 956475590..2f1511787 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -15,6 +15,8 @@ import { getAiSettingById, getAiSettingsByOrganizationId, getCustomAiProviders, + maskAiApiKey, + mergeAiApiKey, saveAiSettings, saveCustomAiProviders, suggestVariants, @@ -41,16 +43,18 @@ import { withAnyPermission, withPermission, } from "@/server/api/trpc"; +import { audit } from "@/server/api/utils/audit"; import { generatePassword } from "@/templates/utils"; export const aiRouter = createTRPCRouter({ one: withAnyPermission("ai", ["read", "update"]) .input(z.object({ aiId: z.string() })) .query(async ({ input, ctx }) => { - return await getAiSettingById( + const setting = await getAiSettingById( input.aiId, ctx.session.activeOrganizationId, ); + return maskAiApiKey(setting); }), getModels: withAnyPermission("ai", ["read", "create", "update"]) @@ -184,13 +188,39 @@ export const aiRouter = createTRPCRouter({ create: withPermission("ai", "create") .input(apiCreateAi) .mutation(async ({ ctx, input }) => { - return await saveAiSettings(ctx.session.activeOrganizationId, input); + const result = await saveAiSettings( + ctx.session.activeOrganizationId, + input, + ); + await audit(ctx, { + action: "create", + resourceType: "ai", + resourceName: input.name, + }); + return result; }), update: withPermission("ai", "update") .input(apiUpdateAi) .mutation(async ({ ctx, input }) => { - return await saveAiSettings(ctx.session.activeOrganizationId, input); + if (input.apiKey) { + const existing = await getAiSettingById( + input.aiId, + ctx.session.activeOrganizationId, + ); + input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey); + } + const result = await saveAiSettings( + ctx.session.activeOrganizationId, + input, + ); + await audit(ctx, { + action: "update", + resourceType: "ai", + resourceId: input.aiId, + resourceName: input.name, + }); + return result; }), getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query( @@ -202,22 +232,21 @@ export const aiRouter = createTRPCRouter({ }, ), - get: withPermission("ai", "read") - .input(z.object({ aiId: z.string() })) - .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, ctx }) => { - return await deleteAiSettings( + const setting = await getAiSettingById( input.aiId, ctx.session.activeOrganizationId, ); + await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId); + await audit(ctx, { + action: "delete", + resourceType: "ai", + resourceId: input.aiId, + resourceName: setting.name, + }); + return true; }), getCustomProviders: withAnyPermission("ai", [ diff --git a/packages/server/src/db/schema/audit-log.ts b/packages/server/src/db/schema/audit-log.ts index c42839b00..587c34ce6 100644 --- a/packages/server/src/db/schema/audit-log.ts +++ b/packages/server/src/db/schema/audit-log.ts @@ -95,4 +95,5 @@ export type AuditResourceType = | "network" | "vaultProvider" | "dnsProvider" - | "telemetryProvider"; + | "telemetryProvider" + | "ai"; diff --git a/packages/server/src/services/ai.ts b/packages/server/src/services/ai.ts index 998a4bf3b..157ed3386 100644 --- a/packages/server/src/services/ai.ts +++ b/packages/server/src/services/ai.ts @@ -10,6 +10,16 @@ import { IS_CLOUD } from "../constants"; import { findServerById } from "./server"; import { getWebServerSettings } from "./web-server-settings"; +export const AI_SECRET_MASK = "********"; + +export const maskAiApiKey = (settings: T): T => ({ + ...settings, + apiKey: AI_SECRET_MASK, +}); + +export const mergeAiApiKey = (incoming: string, existing: string): string => + incoming === AI_SECRET_MASK ? existing : incoming; + interface SuggestionItem { id: string; name: string; From 2bfcac58b00281b897615b1a6c9cea54dbe7d7f0 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Mon, 21 Sep 2026 01:47:19 +0530 Subject: [PATCH 06/11] fix(ai): mask apiKey exposure, add audit logging, resolve masked-key UX regression --- .../dashboard/settings/handle-ai.tsx | 8 ++++- apps/dokploy/server/api/routers/ai.ts | 34 +++++++++++++++---- packages/server/src/services/ai.ts | 19 ++++++++++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx index b7cf186e7..0cd73d82a 100644 --- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => { { apiUrl: apiUrl ?? "", apiKey: apiKey ?? "", + ...(aiId ? { aiId } : {}), }, { enabled: !!apiUrl && (isLocalOllama || !!apiKey), @@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => { apiUrl={apiUrl} apiKey={apiKey} model={form.watch("model")} + aiId={aiId} />