diff --git a/apps/dokploy/__test__/permissions/schedule-access.test.ts b/apps/dokploy/__test__/permissions/schedule-access.test.ts new file mode 100644 index 000000000..7e28a27fd --- /dev/null +++ b/apps/dokploy/__test__/permissions/schedule-access.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ---- Shared mutable mock state (hoisted so vi.mock closures can read it) ---- +const mockState = vi.hoisted(() => ({ + IS_CLOUD: false, + member: { + id: "member-1", + role: "owner", + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, + }, + server: { organizationId: "org-1" }, +})); + +vi.mock("@dokploy/server/constants", () => ({ + get IS_CLOUD() { + return mockState.IS_CLOUD; + }, + paths: () => ({ SCHEDULES_PATH: "/tmp/schedules" }), +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(() => Promise.resolve(mockState.server)), +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + encodeBase64: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(mockState.member)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +const { assertHostScheduleAccess } = await import( + "@dokploy/server/services/schedule" +); +const { updateScheduleSchema } = await import( + "@dokploy/server/db/schema/schedule" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockState.IS_CLOUD = false; + mockState.member = { + ...mockState.member, + role: "owner", + }; + mockState.server = { organizationId: "org-1" }; +}); + +describe("assertHostScheduleAccess — dokploy-server cross-org isolation", () => { + it("owner in own org with matching scheduleOrganizationId passes", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"), + ).resolves.toBeUndefined(); + }); + + it("admin in own org with matching scheduleOrganizationId passes", async () => { + mockState.member = { ...mockState.member, role: "admin" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"), + ).resolves.toBeUndefined(); + }); + + it("owner targeting a FOREIGN org schedule is rejected (IDOR fix)", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-2"), + ).rejects.toThrow("You don't have access to this schedule."); + }); + + it("admin targeting a FOREIGN org schedule is rejected (IDOR fix)", async () => { + mockState.member = { ...mockState.member, role: "admin" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-2"), + ).rejects.toThrow("You don't have access to this schedule."); + }); + + it("member is rejected even when the org matches (owner/admin gate)", async () => { + mockState.member = { ...mockState.member, role: "member" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"), + ).rejects.toThrow( + "Only owners and admins can manage server-level schedules.", + ); + }); + + it("member targeting a foreign org is rejected at the role gate, not the org check", async () => { + mockState.member = { ...mockState.member, role: "member" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-2"), + ).rejects.toThrow( + "Only owners and admins can manage server-level schedules.", + ); + }); + + it("create path (no scheduleOrganizationId) is allowed for owner", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, undefined), + ).resolves.toBeUndefined(); + }); + + it("null scheduleOrganizationId is treated as no-op (allowed)", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, null), + ).resolves.toBeUndefined(); + }); + + it("rejects dokploy-server on the cloud version", async () => { + mockState.IS_CLOUD = true; + mockState.member = { ...mockState.member, role: "owner" }; + await expect( + assertHostScheduleAccess(ctx, "dokploy-server", null, "org-1"), + ).rejects.toThrow( + "Host-level schedules are not available in the cloud version.", + ); + }); + + it("is a no-op for application/compose scheduleType", async () => { + await expect( + assertHostScheduleAccess(ctx, "application", null, "org-2"), + ).resolves.toBeUndefined(); + await expect( + assertHostScheduleAccess(ctx, "compose", null, "org-2"), + ).resolves.toBeUndefined(); + }); +}); + +describe("assertHostScheduleAccess — server branch (regression)", () => { + it("owner with a server in their own org passes", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + mockState.server = { organizationId: "org-1" }; + await expect( + assertHostScheduleAccess(ctx, "server", "server-1", undefined), + ).resolves.toBeUndefined(); + }); + + it("owner with a server in a FOREIGN org is rejected", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + mockState.server = { organizationId: "org-2" }; + await expect( + assertHostScheduleAccess(ctx, "server", "server-1", undefined), + ).rejects.toThrow("You don't have access to this server."); + }); + + it("member cannot manage server schedules (owner/admin gate)", async () => { + mockState.member = { ...mockState.member, role: "member" }; + mockState.server = { organizationId: "org-1" }; + await expect( + assertHostScheduleAccess(ctx, "server", "server-1", undefined), + ).rejects.toThrow( + "Only owners and admins can manage server-level schedules.", + ); + }); + + it("passing a foreign scheduleOrganizationId is ignored for the server branch (server org is authoritative)", async () => { + mockState.member = { ...mockState.member, role: "owner" }; + mockState.server = { organizationId: "org-1" }; + await expect( + assertHostScheduleAccess(ctx, "server", "server-1", "org-2"), + ).resolves.toBeUndefined(); + }); +}); + +describe("updateScheduleSchema strips ownership/identity fields", () => { + const validPayload = { + scheduleId: "sched-1", + name: "nightly backup", + command: "echo hello", + cronExpression: "* * * * *", + enabled: true, + shellType: "bash" as const, + script: "echo hello", + description: "d", + timezone: "UTC", + serviceName: null, + applicationId: null, + composeId: null, + }; + + const strippedKeys = [ + "organizationId", + "scheduleType", + "serverId", + "appName", + "createdAt", + ] as const; + + for (const key of strippedKeys) { + it(`strips ${key} from a crafted update payload`, () => { + const parsed = updateScheduleSchema.safeParse({ + ...validPayload, + [key]: key === "scheduleType" ? "server" : "attacker-value", + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data).not.toHaveProperty(key); + } + }); + } + + it("keeps the legitimately mutable fields", () => { + const parsed = updateScheduleSchema.safeParse(validPayload); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.scheduleId).toBe("sched-1"); + expect(parsed.data.name).toBe("nightly backup"); + expect(parsed.data.command).toBe("echo hello"); + expect(parsed.data.cronExpression).toBe("* * * * *"); + expect(parsed.data.enabled).toBe(true); + expect(parsed.data.script).toBe("echo hello"); + } + }); + + it("does not require organizationId/scheduleType/serverId/appName/createdAt", () => { + const minimal = { + scheduleId: "sched-2", + name: "n", + command: "c", + cronExpression: "0 * * * *", + }; + const parsed = updateScheduleSchema.safeParse(minimal); + expect(parsed.success).toBe(true); + }); +}); diff --git a/apps/dokploy/server/api/routers/schedule.ts b/apps/dokploy/server/api/routers/schedule.ts index a7417a92c..441a680e5 100644 --- a/apps/dokploy/server/api/routers/schedule.ts +++ b/apps/dokploy/server/api/routers/schedule.ts @@ -90,32 +90,12 @@ export const scheduleRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const existingSchedule = await findScheduleById(input.scheduleId); - if ( - IS_CLOUD && - input.scheduleType && - input.scheduleType !== existingSchedule.scheduleType - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Changing scheduleType is not allowed in the cloud version.", - }); - } - await assertHostScheduleAccess( ctx, existingSchedule.scheduleType, existingSchedule.serverId, + existingSchedule.organizationId, ); - if ( - input.scheduleType && - input.scheduleType !== existingSchedule.scheduleType - ) { - await assertHostScheduleAccess( - ctx, - input.scheduleType, - input.serverId ?? existingSchedule.serverId, - ); - } const serviceId = existingSchedule.applicationId || existingSchedule.composeId; @@ -169,6 +149,7 @@ export const scheduleRouter = createTRPCRouter({ ctx, scheduleItem.scheduleType, scheduleItem.serverId, + scheduleItem.organizationId, ); const serviceId = scheduleItem.applicationId || scheduleItem.composeId; @@ -308,6 +289,25 @@ export const scheduleRouter = createTRPCRouter({ }); } } + + if (schedule.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 read host-level schedules.", + }); + } + if (schedule.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this schedule.", + }); + } + } } return schedule; }), @@ -320,6 +320,7 @@ export const scheduleRouter = createTRPCRouter({ ctx, scheduleItem.scheduleType, scheduleItem.serverId, + scheduleItem.organizationId, ); const serviceId = scheduleItem.applicationId || scheduleItem.composeId; diff --git a/packages/server/src/db/schema/schedule.ts b/packages/server/src/db/schema/schedule.ts index 55c831f54..ece155019 100644 --- a/packages/server/src/db/schema/schedule.ts +++ b/packages/server/src/db/schema/schedule.ts @@ -83,6 +83,18 @@ export const createScheduleSchema = createInsertSchema(schedules, { scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]), }); -export const updateScheduleSchema = createScheduleSchema.extend({ - scheduleId: z.string().min(1), -}); +export const updateScheduleSchema = createScheduleSchema + .extend({ scheduleId: z.string().min(1) }) + .omit({ + // Ownership anchor — must never be caller-mutable on update. Allowing it + // would let a caller reassign a schedule into their own org (schedule theft). + organizationId: true, + // A schedule's type and target server are fixed at creation time. Allowing + // them to change on update enables re-typing a schedule onto a server the + // caller controls after passing the gate on the existing schedule's org. + scheduleType: true, + serverId: true, + // On-disk path identifier and creation timestamp are managed internally. + appName: true, + createdAt: true, + }); diff --git a/packages/server/src/services/schedule.ts b/packages/server/src/services/schedule.ts index 59b75cc0d..0eb018fb4 100644 --- a/packages/server/src/services/schedule.ts +++ b/packages/server/src/services/schedule.ts @@ -20,10 +20,18 @@ export type ScheduleExtended = Awaited>; // 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. +// +// `scheduleOrganizationId` is the owning organization of the targeted schedule +// row (for dokploy-server schedules, the `organizationId` column). It is used to +// enforce cross-organization isolation: callers may only act on dokploy-server +// schedules owned by their own active organization. It is left optional so the +// same helper can be used on `create` (where no schedule row exists yet — the +// org is assigned to the caller's active org by the router). export const assertHostScheduleAccess = async ( ctx: { user: { id: string }; session: { activeOrganizationId: string } }, scheduleType: Schedule["scheduleType"] | null | undefined, serverId: string | null | undefined, + scheduleOrganizationId?: string | null, ) => { if (scheduleType !== "server" && scheduleType !== "dokploy-server") return; @@ -54,6 +62,18 @@ export const assertHostScheduleAccess = async ( }); } } + + if (scheduleType === "dokploy-server") { + if ( + scheduleOrganizationId && + scheduleOrganizationId !== ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this schedule.", + }); + } + } }; export const createSchedule = async (