From d5d222db1f92033443031b46db39d5974baa3657 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:53:48 +0000 Subject: [PATCH] fix(deployment): reject cross-tenant access to dokploy-server schedule deployments --- .../permissions/deployment-access.test.ts | 213 ++++++++++++++++++ apps/dokploy/server/api/routers/deployment.ts | 84 +++---- packages/server/src/services/deployment.ts | 36 +++ 3 files changed, 275 insertions(+), 58 deletions(-) create mode 100644 apps/dokploy/__test__/permissions/deployment-access.test.ts diff --git a/apps/dokploy/__test__/permissions/deployment-access.test.ts b/apps/dokploy/__test__/permissions/deployment-access.test.ts new file mode 100644 index 000000000..b8c4ed950 --- /dev/null +++ b/apps/dokploy/__test__/permissions/deployment-access.test.ts @@ -0,0 +1,213 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// `assertDeploymentAccess` delegates the service-id branch to +// `checkServicePermissionAndAccess` and the server branch to `findServerById`. +// Mock those one layer down so we can drive each branch of the guard and assert +// which path was taken, without touching the DB or a real server record. +const mockCheckServicePermissionAndAccess = vi.hoisted(() => vi.fn()); +const mockFindServerById = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: mockCheckServicePermissionAndAccess, +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mockFindServerById, +})); + +import { assertDeploymentAccess } from "@dokploy/server/services/deployment"; + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockCheckServicePermissionAndAccess.mockResolvedValue(undefined); +}); + +const rejectAsUnauthorized = async (p: Promise) => { + const err = await p.catch((e: unknown) => e); + expect(err).toBeInstanceOf(TRPCError); + expect(err).toMatchObject({ code: "UNAUTHORIZED" }); +}; + +describe("assertDeploymentAccess (cross-tenant deployment/schedule guard)", () => { + describe("service-id branch (application/compose deployments)", () => { + it("delegates to checkServicePermissionAndAccess and resolves when allowed", async () => { + await expect( + assertDeploymentAccess(ctx, "app-1", null, "cancel"), + ).resolves.toBeUndefined(); + + expect(mockCheckServicePermissionAndAccess).toHaveBeenCalledWith( + ctx, + "app-1", + { deployment: ["cancel"] }, + ); + expect(mockFindServerById).not.toHaveBeenCalled(); + }); + + it("forwards the 'read' permission for log/list procedures", async () => { + await expect( + assertDeploymentAccess(ctx, "compose-1", null, "read"), + ).resolves.toBeUndefined(); + + expect(mockCheckServicePermissionAndAccess).toHaveBeenCalledWith( + ctx, + "compose-1", + { deployment: ["read"] }, + ); + }); + + it("propagates the authorization error from the service check", async () => { + mockCheckServicePermissionAndAccess.mockRejectedValue( + new TRPCError({ code: "FORBIDDEN", message: "nope" }), + ); + + await expect( + assertDeploymentAccess(ctx, "app-1", null, "read"), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockFindServerById).not.toHaveBeenCalled(); + }); + }); + + describe("remote-server schedule branch (schedule.serverId set)", () => { + it("resolves when the server belongs to the caller's organization", async () => { + mockFindServerById.mockResolvedValue({ + serverId: "srv-1", + organizationId: "org-1", + }); + + await expect( + assertDeploymentAccess( + ctx, + null, + { serverId: "srv-1", organizationId: null }, + "cancel", + ), + ).resolves.toBeUndefined(); + + expect(mockFindServerById).toHaveBeenCalledWith("srv-1"); + expect(mockCheckServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + + it("rejects when the server belongs to another organization (cross-tenant)", async () => { + mockFindServerById.mockResolvedValue({ + serverId: "srv-1", + organizationId: "org-2", + }); + + await rejectAsUnauthorized( + assertDeploymentAccess( + ctx, + null, + { serverId: "srv-1", organizationId: null }, + "read", + ), + ); + expect(mockCheckServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + + it("prefers the remote-server branch over the organizationId branch when both are present", async () => { + mockFindServerById.mockResolvedValue({ + serverId: "srv-1", + organizationId: "org-1", + }); + + await assertDeploymentAccess( + ctx, + null, + { serverId: "srv-1", organizationId: "org-2" }, + "read", + ); + + expect(mockFindServerById).toHaveBeenCalledWith("srv-1"); + }); + }); + + describe("host-level schedule branch (dokploy-server: no serviceId, no serverId)", () => { + it("resolves when schedule.organizationId matches the caller's organization", async () => { + await expect( + assertDeploymentAccess( + ctx, + null, + { serverId: null, organizationId: "org-1" }, + "cancel", + ), + ).resolves.toBeUndefined(); + + expect(mockFindServerById).not.toHaveBeenCalled(); + expect(mockCheckServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + + it("rejects when schedule.organizationId belongs to another organization (the cross-tenant bug)", async () => { + await rejectAsUnauthorized( + assertDeploymentAccess( + ctx, + null, + { serverId: null, organizationId: "org-2" }, + "read", + ), + ); + expect(mockFindServerById).not.toHaveBeenCalled(); + expect(mockCheckServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + }); + + describe("deny-by-default (no recognized owner)", () => { + it("rejects when there is no serviceId and no schedule at all", async () => { + await rejectAsUnauthorized( + assertDeploymentAccess(ctx, null, null, "cancel"), + ); + expect(mockFindServerById).not.toHaveBeenCalled(); + expect(mockCheckServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + + it("rejects when the schedule has neither serverId nor organizationId", async () => { + await rejectAsUnauthorized( + assertDeploymentAccess( + ctx, + null, + { serverId: null, organizationId: null }, + "read", + ), + ); + }); + + it("rejects when serviceId is empty string (falsy) and schedule is null", async () => { + await rejectAsUnauthorized( + assertDeploymentAccess(ctx, "", null, "cancel"), + ); + // An empty/falsy serviceId must NOT be treated as a real service id. + expect(mockCheckServicePermissionAndAccess).not.toHaveBeenCalled(); + }); + }); + + describe("error shape and message", () => { + it("uses the custom message when provided (allByType schedule branch)", async () => { + await expect( + assertDeploymentAccess( + ctx, + null, + { serverId: null, organizationId: "org-2" }, + "read", + "You don't have access to this schedule.", + ), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "You don't have access to this schedule.", + }); + }); + + it("defaults to the deployment message for deployment procedures", async () => { + await expect( + assertDeploymentAccess(ctx, null, null, "cancel"), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "You don't have access to this deployment.", + }); + }); + }); +}); diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index dcc74226a..ebb20fced 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -1,4 +1,5 @@ import { + assertDeploymentAccess, execAsync, execAsyncRemote, findAllDeploymentsByApplicationId, @@ -129,22 +130,13 @@ export const deploymentRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { if (input.type === "schedule") { const schedule = await findScheduleById(input.id); - const serviceId = schedule.applicationId || schedule.composeId; - if (serviceId) { - await checkServicePermissionAndAccess(ctx, serviceId, { - deployment: ["read"], - }); - } else if (schedule.serverId) { - const targetServer = await findServerById(schedule.serverId); - if ( - targetServer.organizationId !== ctx.session.activeOrganizationId - ) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this schedule.", - }); - } - } + await assertDeploymentAccess( + ctx, + schedule.applicationId || schedule.composeId, + schedule, + "read", + "You don't have access to this schedule.", + ); } else { await checkServicePermissionAndAccess(ctx, input.id, { deployment: ["read"], @@ -167,20 +159,12 @@ export const deploymentRouter = createTRPCRouter({ ) .mutation(async ({ input, ctx }) => { const deployment = await findDeploymentById(input.deploymentId); - const serviceId = deployment.applicationId || deployment.composeId; - if (serviceId) { - await checkServicePermissionAndAccess(ctx, serviceId, { - deployment: ["cancel"], - }); - } else if (deployment.schedule?.serverId) { - const targetServer = await findServerById(deployment.schedule.serverId); - if (targetServer.organizationId !== ctx.session.activeOrganizationId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this deployment.", - }); - } - } + await assertDeploymentAccess( + ctx, + deployment.applicationId || deployment.composeId, + deployment.schedule, + "cancel", + ); if (!deployment.pid) { throw new TRPCError({ @@ -212,20 +196,12 @@ export const deploymentRouter = createTRPCRouter({ ) .mutation(async ({ input, ctx }) => { const deployment = await findDeploymentById(input.deploymentId); - const serviceId = deployment.applicationId || deployment.composeId; - if (serviceId) { - await checkServicePermissionAndAccess(ctx, serviceId, { - deployment: ["cancel"], - }); - } else if (deployment.schedule?.serverId) { - const targetServer = await findServerById(deployment.schedule.serverId); - if (targetServer.organizationId !== ctx.session.activeOrganizationId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this deployment.", - }); - } - } + await assertDeploymentAccess( + ctx, + deployment.applicationId || deployment.composeId, + deployment.schedule, + "cancel", + ); const result = await removeDeployment(input.deploymentId); await audit(ctx, { action: "delete", @@ -244,20 +220,12 @@ export const deploymentRouter = createTRPCRouter({ ) .query(async ({ input, ctx }) => { const deployment = await findDeploymentById(input.deploymentId); - const serviceId = deployment.applicationId || deployment.composeId; - if (serviceId) { - await checkServicePermissionAndAccess(ctx, serviceId, { - deployment: ["read"], - }); - } else if (deployment.schedule?.serverId) { - const targetServer = await findServerById(deployment.schedule.serverId); - if (targetServer.organizationId !== ctx.session.activeOrganizationId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You don't have access to this deployment.", - }); - } - } + await assertDeploymentAccess( + ctx, + deployment.applicationId || deployment.composeId, + deployment.schedule, + "read", + ); if (!deployment.logPath) { return ""; diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index fd61fc106..4b96cb582 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -32,6 +32,10 @@ import { } from "./application"; import { findBackupById } from "./backup"; import { type Compose, findComposeById, updateCompose } from "./compose"; +import { + checkServicePermissionAndAccess, + type PermissionCtx, +} from "./permission"; import { findPreviewDeploymentById, type PreviewDeployment, @@ -106,6 +110,38 @@ export const findDeploymentById = async (deploymentId: string) => { return deployment; }; +// Authorizes a deployment that may be owned by an application/compose service, +// a remote-server schedule, or a host-level (dokploy-server) schedule. Falls +// back to reject-by-default when no ownership relation can be established. +export const assertDeploymentAccess = async ( + ctx: PermissionCtx, + serviceId: string | null | undefined, + schedule: { serverId: string | null; organizationId: string | null } | null, + permission: "cancel" | "read", + message = "You don't have access to this deployment.", +) => { + if (serviceId) { + await checkServicePermissionAndAccess(ctx, serviceId, { + deployment: [permission], + }); + return; + } + if (schedule?.serverId) { + const targetServer = await findServerById(schedule.serverId); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED", message }); + } + return; + } + if (schedule?.organizationId) { + if (schedule.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED", message }); + } + return; + } + throw new TRPCError({ code: "UNAUTHORIZED", message }); +}; + export const findDeploymentByApplicationId = async (applicationId: string) => { const deployment = await db.query.deployments.findFirst({ where: eq(deployments.applicationId, applicationId),