fix(deployment): reject cross-tenant access to dokploy-server schedule deployments

This commit is contained in:
detail-app[bot] 2026-09-04 02:53:48 +00:00 committed by GitHub
parent 1572008cdf
commit d5d222db1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 275 additions and 58 deletions

View File

@ -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<unknown>) => {
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.",
});
});
});
});

View File

@ -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 "";

View File

@ -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),