mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(deployment): reject cross-tenant access to dokploy-server schedule deployments
This commit is contained in:
parent
1572008cdf
commit
d5d222db1f
213
apps/dokploy/__test__/permissions/deployment-access.test.ts
Normal file
213
apps/dokploy/__test__/permissions/deployment-access.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 "";
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user