diff --git a/apps/dokploy/server/api/routers/schedule.ts b/apps/dokploy/server/api/routers/schedule.ts index 2bec813a7..9f0c16b1e 100644 --- a/apps/dokploy/server/api/routers/schedule.ts +++ b/apps/dokploy/server/api/routers/schedule.ts @@ -13,6 +13,7 @@ import { findMemberByUserId, } from "@dokploy/server/services/permission"; import { + assertHostScheduleAccess, createSchedule, deleteSchedule, findScheduleById, @@ -31,6 +32,8 @@ export const scheduleRouter = createTRPCRouter({ create: protectedProcedure .input(createScheduleSchema) .mutation(async ({ input, ctx }) => { + await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId); + const serviceId = input.applicationId || input.composeId; if (serviceId) { await checkServicePermissionAndAccess(ctx, serviceId, { @@ -44,51 +47,14 @@ export const scheduleRouter = createTRPCRouter({ ); } } else { - if (input.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["create"] }); - if ( - input.scheduleType === "server" || - input.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, + if (IS_CLOUD && input.scheduleType === "server" && input.serverId) { + await assertScheduledJobLimit( ctx.session.activeOrganizationId, + "server", + input.serverId, ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if (input.scheduleType === "server" && input.serverId) { - const targetServer = await findServerById(input.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - - if (IS_CLOUD) { - await assertScheduledJobLimit( - ctx.session.activeOrganizationId, - "server", - input.serverId, - ); - } } } const newSchedule = await createSchedule({ @@ -135,6 +101,22 @@ export const scheduleRouter = createTRPCRouter({ }); } + await assertHostScheduleAccess( + ctx, + existingSchedule.scheduleType, + existingSchedule.serverId, + ); + if ( + input.scheduleType && + input.scheduleType !== existingSchedule.scheduleType + ) { + await assertHostScheduleAccess( + ctx, + input.scheduleType, + input.serverId ?? existingSchedule.serverId, + ); + } + const serviceId = existingSchedule.applicationId || existingSchedule.composeId; if (serviceId) { @@ -142,47 +124,7 @@ export const scheduleRouter = createTRPCRouter({ schedule: ["update"], }); } else { - if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["update"] }); - - if ( - existingSchedule.scheduleType === "server" || - existingSchedule.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, - ctx.session.activeOrganizationId, - ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if ( - existingSchedule.scheduleType === "server" && - existingSchedule.serverId - ) { - const targetServer = await findServerById(existingSchedule.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - } } const updatedSchedule = await updateSchedule(input); @@ -222,50 +164,19 @@ export const scheduleRouter = createTRPCRouter({ .input(z.object({ scheduleId: z.string() })) .mutation(async ({ input, ctx }) => { const scheduleItem = await findScheduleById(input.scheduleId); + await assertHostScheduleAccess( + ctx, + scheduleItem.scheduleType, + scheduleItem.serverId, + ); + const serviceId = scheduleItem.applicationId || scheduleItem.composeId; if (serviceId) { await checkServicePermissionAndAccess(ctx, serviceId, { schedule: ["delete"], }); } else { - if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["delete"] }); - - if ( - scheduleItem.scheduleType === "server" || - scheduleItem.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, - ctx.session.activeOrganizationId, - ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) { - const targetServer = await findServerById(scheduleItem.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - } } await deleteSchedule(input.scheduleId); @@ -389,50 +300,19 @@ export const scheduleRouter = createTRPCRouter({ .input(z.object({ scheduleId: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const scheduleItem = await findScheduleById(input.scheduleId); + await assertHostScheduleAccess( + ctx, + scheduleItem.scheduleType, + scheduleItem.serverId, + ); + const serviceId = scheduleItem.applicationId || scheduleItem.composeId; if (serviceId) { await checkServicePermissionAndAccess(ctx, serviceId, { schedule: ["create"], }); } else { - if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Host-level schedules are not available in the cloud version.", - }); - } - await checkPermission(ctx, { schedule: ["create"] }); - - if ( - scheduleItem.scheduleType === "server" || - scheduleItem.scheduleType === "dokploy-server" - ) { - const member = await findMemberByUserId( - ctx.user.id, - ctx.session.activeOrganizationId, - ); - if (member.role !== "owner" && member.role !== "admin") { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Only owners and admins can manage server-level schedules.", - }); - } - } - - if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) { - const targetServer = await findServerById(scheduleItem.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this server.", - }); - } - } } try { await runCommand(input.scheduleId); diff --git a/packages/server/src/services/schedule.ts b/packages/server/src/services/schedule.ts index b8751671b..db6d51ecd 100644 --- a/packages/server/src/services/schedule.ts +++ b/packages/server/src/services/schedule.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; -import { paths } from "../constants"; +import { IS_CLOUD, paths } from "../constants"; import { db } from "../db"; import type { createScheduleSchema, @@ -11,9 +11,51 @@ import type { import { type Schedule, schedules } from "../db/schema/schedule"; import { encodeBase64 } from "../utils/docker/utils"; import { execAsync, execAsyncRemote } from "../utils/process/execAsync"; +import { findMemberByUserId } from "./permission"; +import { findServerById } from "./server"; export type ScheduleExtended = Awaited>; +// Host-level schedules (server / dokploy-server) run their script as root on the +// host and must stay restricted to owners/admins, regardless of whether the +// request is also tied to a service. Attaching an accessible applicationId must +// not downgrade this to a service-access check. +export const assertHostScheduleAccess = async ( + ctx: { user: { id: string }; session: { activeOrganizationId: string } }, + scheduleType: string | undefined, + serverId: string | null | undefined, +) => { + if (scheduleType !== "server" && scheduleType !== "dokploy-server") return; + + if (scheduleType === "dokploy-server" && IS_CLOUD) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Host-level schedules are not available in the cloud version.", + }); + } + + const member = await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (member.role !== "owner" && member.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owners and admins can manage server-level schedules.", + }); + } + + if (scheduleType === "server" && serverId) { + const targetServer = await findServerById(serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this server.", + }); + } + } +}; + export const createSchedule = async ( input: z.infer, ) => {