Compare commits

..

20 Commits

Author SHA1 Message Date
Narciso E. Núñez Arias 53464e9096 Merge pull request #5070 from bunlongheng/fix/traefik-file-path-traversal
fix(settings): confine Traefik file read/write to the Traefik config directory
2026-10-15 17:24:35 -04:00
Narciso 136a09d016 fix(settings): check server organization in traefik file endpoints 2026-10-15 15:24:24 -04:00
Narciso E. Núñez Arias c867a3c4c9 Merge pull request #5489 from imrja8/feat/ai-custom-role-permissions
feat(permissions): add AI module to enterprise custom roles
2026-10-15 13:13:52 -04:00
Narciso E. Núñez Arias 471e91caeb Merge pull request #5684 from Dokploy/fix/dependabot-high-emails
chore(deps): fix high-severity Dependabot alerts in emails lockfile
2026-10-15 13:11:10 -04:00
Narciso 16c22c74fd chore(deps): bump brace-expansion and source-map-js in emails lockfile 2026-10-15 13:02:00 -04:00
Yash Kumar 37df4e484e fix: suppress EPIPE errors in pipeBetweenServers when target exits early 2026-10-15 21:51:48 +05:30
Yash Kumar 5ff8d05ff7 perf(ai): optimize permission evaluation and remove redundant DB queries 2026-10-15 21:51:48 +05:30
Yash Kumar a9871f3a8d refactor(ai): DRY saveAiSettings and enforce strict NOT_FOUND validation 2026-10-15 21:51:48 +05:30
Yash Kumar 0049421095 fix(ai): fix PermissionCtx type on resolveInputApiKey 2026-10-15 21:51:48 +05:30
Yash Kumar 3d67665c33 fix(ai): require update permission for stored key resolution, deduplicate resolve logic 2026-10-15 21:51:48 +05:30
Yash Kumar 2bfcac58b0 fix(ai): mask apiKey exposure, add audit logging, resolve masked-key UX regression 2026-10-15 21:51:48 +05:30
Yash Kumar a7d8378f52 fix(permissions): mask apiKey on ai.one, add audit logging, remove dead endpoint 2026-10-15 21:51:48 +05:30
Yash Kumar bdefc02e77 fix: redact apiKey from getAll AI endpoint to prevent credential exposure 2026-10-15 21:51:31 +05:30
Yash Kumar 293e19703a fix: resolve cross-tenant access and granular role UI issues for AI settings 2026-10-15 21:51:31 +05:30
Yash Kumar cd9edb290b fix(permissions): resolve AI module authorization edge cases and test failures 2026-10-15 21:51:31 +05:30
Yash Kumar b5473e63fd feat(permissions): add AI module to enterprise custom roles 2026-10-15 21:51:31 +05:30
Narciso E. Núñez Arias e0006758c0 Merge pull request #5517 from Dokploy/dependabots-alerts
Dependabots alerts fixes
2026-10-14 16:14:50 -04:00
Yash Kumar d9fe046396 Merge pull request #5661 from manozzo/fix/template-card-tags-overflow
fix(templates): cap visible tags so the Create button stays visible
2026-10-15 01:33:09 +05:30
Rodrigo Manozzo 94f3815eb4 fix(templates): cap visible tags so the Create button stays visible
Template cards have a fixed height, and the header rendered every tag.
Portabase has 27, which pushed the description and footer (Create
button) out of the card. Show the first 6 tags plus a "+N" badge whose
title lists the rest.

Closes #5660

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:59:09 -03:00
Bunlong Heng 1f1acacf58 fix(settings): confine Traefik file read/write to the Traefik config directory 2026-08-12 17:38:48 -04:00
16 changed files with 473 additions and 107 deletions
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
];
describe("enterpriseOnlyResources set", () => {
@@ -0,0 +1,119 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
// apiReadTraefikConfig validates against the real local paths(), which outside
// production live under the working directory.
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
readDirectory: vi.fn(),
readConfigInPath: vi.fn(),
writeTraefikConfigInPath: vi.fn(),
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkPermission: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }),
...mocks,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanAllDeploymentQueue: vi.fn(),
}));
vi.mock("@/server/utils/backup", () => ({
removeJob: vi.fn(),
schedule: vi.fn(),
}));
const { settingsRouter } = await import("@/server/api/routers/settings");
const caller = settingsRouter.createCaller({
session: { activeOrganizationId: "org-a" },
user: { id: "user-1", email: "user@example.com", role: "member" },
} as Parameters<typeof settingsRouter.createCaller>[0]);
const filePath = path.join(traefikPath, "dynamic/app.yml");
describe("traefik file endpoints scope remote servers to the active organization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findServerById.mockImplementation(async (serverId: string) => ({
serverId,
organizationId: serverId === "server-a" ? "org-a" : "org-b",
}));
mocks.readDirectory.mockResolvedValue([]);
mocks.readConfigInPath.mockResolvedValue("http: {}");
});
it("rejects listing a server from another organization", async () => {
await expect(
caller.readDirectories({ serverId: "server-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.readDirectory).not.toHaveBeenCalled();
});
it("rejects writing to a server from another organization", async () => {
await expect(
caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
serverId: "server-b",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
});
it("rejects reading from a server from another organization", async () => {
await expect(
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
});
it("allows servers of the active organization", async () => {
await caller.readDirectories({ serverId: "server-a" });
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
await caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
serverId: "server-a",
});
expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a");
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
filePath,
"http: {}",
"server-a",
);
});
it("does not look up a server when none is given", async () => {
await caller.readDirectories({});
await caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
});
expect(mocks.findServerById).not.toHaveBeenCalled();
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
filePath,
"http: {}",
undefined,
);
});
});
@@ -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);
});
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
{viewMode === "detailed" &&
template?.tags?.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{template?.tags?.map((tag) => (
{template.tags.slice(0, 6).map((tag) => (
<Badge
key={tag}
variant="green"
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
{tag}
</Badge>
))}
{template.tags.length > 6 && (
<Badge
variant="green"
className="text-[10px] px-2 py-0"
title={template.tags.slice(6).join(", ")}
>
+{template.tags.length - 6}
</Badge>
)}
</div>
)}
</div>
@@ -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 = () => {
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
<HandleAi />
)}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -53,7 +57,7 @@ export const AiForm = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any AI configurations
</span>
<HandleAi />
{permissions?.ai.create && <HandleAi />}
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
@@ -70,33 +74,37 @@ export const AiForm = () => {
<CardDescription>{config.model}</CardDescription>
</div>
<div className="flex justify-between items-center">
<HandleAi aiId={config.aiId} />
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
{permissions?.ai.update && (
<HandleAi aiId={config.aiId} />
)}
{permissions?.ai.delete && (
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
@@ -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}
/>
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
@@ -498,10 +500,12 @@ function TestConnectionButton({
apiUrl,
apiKey,
model,
aiId,
}: {
apiUrl: string;
apiKey: string;
model: string;
aiId?: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
@@ -521,7 +525,9 @@ function TestConnectionButton({
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
onClick={() =>
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+7 -1
View File
@@ -351,7 +351,13 @@ const MENU: Menu = {
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
isEnabled: ({ permissions }) =>
!!(
permissions?.ai.read ||
permissions?.ai.create ||
permissions?.ai.update ||
permissions?.ai.delete
),
},
{
isSingle: true,
@@ -185,6 +185,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
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"],
},
},
];
+118 -48
View File
@@ -15,6 +15,9 @@ import {
getAiSettingById,
getAiSettingsByOrganizationId,
getCustomAiProviders,
maskAiApiKey,
mergeAiApiKey,
resolveAiApiKey,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
import {
addNewService,
checkServiceAccess,
hasPermission,
} from "@dokploy/server/services/permission";
import { findProjectById } from "@dokploy/server/services/project";
import {
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
withAnyPermission,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { generatePassword } from "@/templates/utils";
const resolveInputApiKey = (
input: { apiKey: string; apiUrl: string; aiId?: string },
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
) =>
resolveAiApiKey({
apiKey: input.apiKey,
apiUrl: input.apiUrl,
aiId: input.aiId,
organizationId: ctx.session.activeOrganizationId,
authorize: () => hasPermission(ctx, { ai: ["update"] }),
});
export const aiRouter = createTRPCRouter({
one: adminProcedure
one: withAnyPermission("ai", ["read", "update"])
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
.query(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
return maskAiApiKey(setting);
}),
getModels: protectedProcedure
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
getModels: withAnyPermission("ai", ["read", "create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
aiId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
const headers = getProviderHeaders(input.apiUrl, apiKey);
let response = null;
switch (providerName) {
case "ollama":
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
{ headers: {} },
);
break;
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
},
] as Model[];
default:
if (!input.apiKey)
if (!apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
});
}
}),
create: adminProcedure.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);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
get: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
create: withPermission("ai", "create")
.input(apiCreateAi)
.mutation(async ({ ctx, input }) => {
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
);
await audit(ctx, {
action: "create",
resourceType: "ai",
resourceName: input.name,
});
return result;
}),
delete: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
update: withPermission("ai", "update")
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
const existing = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
if (input.apiKey) {
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
}
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
false,
);
await audit(ctx, {
action: "update",
resourceType: "ai",
resourceId: input.aiId,
resourceName: input.name,
});
return result;
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
return settings.map(({ apiKey, ...rest }) => rest);
},
),
delete: withPermission("ai", "delete")
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input, ctx }) => {
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", [
"read",
"create",
"update",
]).query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
@@ -216,7 +288,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 +297,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),
@@ -235,7 +307,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",
@@ -243,13 +318,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);
@@ -283,19 +351,21 @@ ${input.logs}`,
}
}),
testConnection: protectedProcedure
testConnection: withAnyPermission("ai", ["create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
model: z.string().min(1),
aiId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const provider = selectAIProvider({
apiUrl: input.apiUrl,
apiKey: input.apiKey,
apiKey,
});
const model = provider(input.model);
const result = await generateText({
@@ -317,7 +387,7 @@ ${input.logs}`,
}
}),
suggest: protectedProcedure
suggest: withPermission("ai", "read")
.input(
z.object({
aiId: z.string(),
@@ -338,7 +408,7 @@ ${input.logs}`,
});
}
}),
deploy: protectedProcedure
deploy: withPermission("ai", "read")
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
+43 -2
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import {
CLEANUP_CRON_JOB,
checkGPUStatus,
@@ -81,6 +82,32 @@ import {
publicProcedure,
} from "../trpc";
/**
* Confines a caller-supplied Traefik file path to the Traefik configuration
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
* expose files listed under this directory, but the raw path was passed
* straight to the filesystem helpers, letting a user with the `traefikFiles`
* permission read or overwrite arbitrary files on the host (for example
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
* `../` traversal. Reject anything that escapes the directory.
*/
const resolveTraefikFilePath = (
filePath: string,
serverId?: string | null,
): string => {
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
const base = path.resolve(MAIN_TRAEFIK_PATH);
const resolved = path.resolve(base, filePath);
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Invalid path: file must be inside the Traefik configuration directory",
});
}
return resolved;
};
export const settingsRouter = createTRPCRouter({
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
try {
await checkPermission(ctx, { traefikFiles: ["read"] });
if (input?.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
return result || [];
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
.input(apiModifyTraefikConfig)
.mutation(async ({ input, ctx }) => {
await checkPermission(ctx, { traefikFiles: ["write"] });
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
await writeTraefikConfigInPath(
input.path,
safePath,
input.traefikConfig,
input?.serverId,
);
@@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({
}
}
return readConfigInPath(input.path, input.serverId);
const safePath = resolveTraefikFilePath(input.path, input.serverId);
return readConfigInPath(safePath, input.serverId);
}),
getIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
+28 -3
View File
@@ -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,
resolvePermissions,
} 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";
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
return createInnerTRPCContext({
req,
res,
// @ts-ignore
// @ts-expect-error
session: session
? {
...session,
activeOrganizationId: session.activeOrganizationId || "",
}
: null,
// @ts-ignore
// @ts-expect-error
user: user
? {
...user,
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
await checkPermission(ctx, { [resource]: [action] } as any);
return next();
});
export const withAnyPermission = <R extends Resource>(
resource: R,
actions: ActionOf<R>[],
) =>
protectedProcedure.use(async ({ ctx, next }) => {
const permissions = await resolvePermissions(ctx);
const resourcePerms = permissions[resource];
if (resourcePerms) {
for (const action of actions) {
if ((resourcePerms as Record<string, boolean>)[action as string]) {
return next();
}
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
});
+2 -1
View File
@@ -95,4 +95,5 @@ export type AuditResourceType =
| "network"
| "vaultProvider"
| "dnsProvider"
| "telemetryProvider";
| "telemetryProvider"
| "ai";
+8 -8
View File
@@ -476,8 +476,8 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
chokidar@4.0.3:
@@ -773,8 +773,8 @@ packages:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
strip-bom@3.0.0:
@@ -1166,7 +1166,7 @@ snapshots:
base64id@2.0.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -1200,7 +1200,7 @@ snapshots:
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
source-map-js: 1.2.2
csstype@3.2.3: {}
@@ -1361,7 +1361,7 @@ snapshots:
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
minimist@1.2.8: {}
@@ -1490,7 +1490,7 @@ snapshots:
- supports-color
- utf-8-validate
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
strip-bom@3.0.0: {}
+6 -1
View File
@@ -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<string>([
"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: [],
});
+60 -11
View File
@@ -4,12 +4,22 @@ 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";
import { getWebServerSettings } from "./web-server-settings";
export const AI_SECRET_MASK = "********";
export const maskAiApiKey = <T extends { apiKey: string }>(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;
@@ -36,9 +46,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({
@@ -95,11 +108,43 @@ export const saveCustomAiProviders = async (
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
export const resolveAiApiKey = async (opts: {
apiKey: string;
apiUrl: string;
aiId: string | undefined;
organizationId: string;
authorize: () => Promise<boolean>;
}): Promise<string> => {
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
if (!(await opts.authorize())) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "API URL does not match the stored configuration",
});
}
return stored.apiKey;
};
export const saveAiSettings = async (
organizationId: string,
settings: any,
checkExistence = true,
) => {
const aiId = settings.aiId;
if (aiId && checkExistence) {
await getAiSettingById(aiId, organizationId);
}
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
@@ -132,8 +177,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,14 +194,14 @@ interface Props {
}
export const suggestVariants = async ({
organizationId: _organizationId,
organizationId,
aiId,
input,
serverId,
}: Props) => {
try {
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
const aiSettings = await getAiSettingById(aiId, organizationId);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled for this configuration",
@@ -209,7 +259,6 @@ export const suggestVariants = async ({
const result = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: fullSchema }),
prompt: `
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
@@ -106,6 +106,7 @@ export const pipeBetweenServers = async ({
bytes += chunk.length;
onProgress?.(bytes);
});
from.stdout.on("error", () => {});
from.stdout.pipe(to.stdin);
const targetExit = to.exit.then((code) => {