fix(api): restore organization ownership checks in service move procedures

This commit is contained in:
detail-app[bot] 2026-09-04 02:54:18 +00:00 committed by GitHub
parent 1572008cdf
commit 2fb4d3787a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 613 additions and 0 deletions

View File

@ -0,0 +1,421 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted mock functions for the collaborators the `move` procedures depend on.
const mockFindLibsqlById = vi.hoisted(() => vi.fn());
const mockFindPostgresById = vi.hoisted(() => vi.fn());
const mockFindRedisById = vi.hoisted(() => vi.fn());
const mockFindMongoById = vi.hoisted(() => vi.fn());
const mockFindMySqlById = vi.hoisted(() => vi.fn());
const mockFindMariadbById = vi.hoisted(() => vi.fn());
const mockFindComposeById = vi.hoisted(() => vi.fn());
const mockFindApplicationById = vi.hoisted(() => vi.fn());
const mockFindEnvironmentById = vi.hoisted(() => vi.fn());
const mockCheckServicePermissionAndAccess = vi.hoisted(() => vi.fn());
const mockDbUpdate = vi.hoisted(() => vi.fn());
const mockAudit = vi.hoisted(() => vi.fn());
// Preserve the real `@dokploy/server` barrel (so every router's named import
// resolves) but override the finders that `move` uses to assert org ownership.
vi.mock("@dokploy/server", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return {
...actual,
findLibsqlById: mockFindLibsqlById,
findPostgresById: mockFindPostgresById,
findRedisById: mockFindRedisById,
findMongoById: mockFindMongoById,
findMySqlById: mockFindMySqlById,
findMariadbById: mockFindMariadbById,
findComposeById: mockFindComposeById,
findApplicationById: mockFindApplicationById,
findEnvironmentById: mockFindEnvironmentById,
};
});
vi.mock("@dokploy/server/services/permission", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return {
...actual,
checkServicePermissionAndAccess: mockCheckServicePermissionAndAccess,
};
});
// Avoid a real postgres connection from the apps/dokploy-local db module.
vi.mock("@/server/db", () => ({
db: { update: mockDbUpdate },
}));
// The sibling routers (postgres, redis, ...) import `db` from the server
// package's db module instead of the apps-local one. Reuse the global
// setup mock's shape (so unrelated drizzle/better-auth init keeps working)
// but route `update` through the controllable hoisted mock so each `move`
// happy-path can return a precise row.
vi.mock("@dokploy/server/db", () => {
// A chainable built on a real Promise (no user-defined `then`): any
// terminal `await` resolves to [], satisfying the drizzle chains the
// server barrel runs at import (e.g. better-auth trusted-origins init)
// without noise and without tripping the `noThenProperty` lint rule.
type Chainable = Promise<unknown[]> & {
[key: string]: (...args: unknown[]) => unknown;
};
const chainable = () => {
const p = Promise.resolve([]) as unknown as Chainable;
const next = () => chainable();
p.from = next;
p.innerJoin = next;
p.leftJoin = next;
p.where = next;
p.limit = next;
p.orderBy = next;
p.groupBy = next;
p.having = next;
p.set = next;
p.values = next;
p.returning = () => Promise.resolve([{}]);
return p;
};
const tableMock = {
findFirst: () => Promise.resolve(undefined),
findMany: () => Promise.resolve([]),
};
return {
db: {
select: () => chainable(),
insert: () => ({
values: () => ({ returning: () => Promise.resolve([{}]) }),
}),
update: mockDbUpdate,
delete: () => chainable(),
query: new Proxy({} as Record<string, typeof tableMock>, {
get: () => tableMock,
}),
},
dbUrl: "postgres://mock:mock@localhost:5432/mock",
};
});
vi.mock("@/server/api/utils/audit", () => ({
audit: mockAudit,
}));
import { applicationRouter } from "@/server/api/routers/application";
import { composeRouter } from "@/server/api/routers/compose";
import { libsqlRouter } from "@/server/api/routers/libsql";
import { mariadbRouter } from "@/server/api/routers/mariadb";
import { mongoRouter } from "@/server/api/routers/mongo";
import { mysqlRouter } from "@/server/api/routers/mysql";
import { postgresRouter } from "@/server/api/routers/postgres";
import { redisRouter } from "@/server/api/routers/redis";
const buildCtx = (organizationId: string) => ({
user: {
id: "user-1",
email: "u@example.com",
role: "owner" as const,
ownerId: "user-1",
enableEnterpriseFeatures: false,
isValidEnterpriseLicense: false,
},
session: {
activeOrganizationId: organizationId,
},
});
// `db.update(table).set(...).where(...).returning()` resolves to a row array;
// the procedure then does `.then((res) => res[0])`. Mock that chain once.
const mockUpdateChainOnce = (row: Record<string, unknown>) => {
mockDbUpdate.mockReturnValueOnce({
set: () => ({
where: () => ({
returning: () => Promise.resolve([row]),
}),
}),
});
};
beforeEach(() => {
vi.clearAllMocks();
mockCheckServicePermissionAndAccess.mockResolvedValue(undefined);
mockDbUpdate.mockImplementation(() => {
throw new Error("db.update should not be reached");
});
});
describe("libsql.move — cross-organization reparenting (IDOR)", () => {
const callMove = (orgId: string) =>
libsqlRouter.createCaller(buildCtx(orgId) as never);
it("rejects when the source libsql belongs to another organization", async () => {
mockFindLibsqlById.mockResolvedValue({
libsqlId: "L",
appName: "a",
environment: { project: { organizationId: "org-other" } },
});
await expect(
callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
// The source check must short-circuit before fetching the target or
// touching the database.
expect(mockFindEnvironmentById).not.toHaveBeenCalled();
expect(mockDbUpdate).not.toHaveBeenCalled();
expect(mockAudit).not.toHaveBeenCalled();
});
it("rejects when the target environment belongs to another organization", async () => {
mockFindLibsqlById.mockResolvedValue({
libsqlId: "L",
appName: "a",
environment: { project: { organizationId: "org-1" } },
});
mockFindEnvironmentById.mockResolvedValue({
environmentId: "E",
project: { organizationId: "org-other" },
});
await expect(
callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mockDbUpdate).not.toHaveBeenCalled();
expect(mockAudit).not.toHaveBeenCalled();
});
it("moves the service when both source and target are in the active org", async () => {
const moved = { libsqlId: "L", appName: "a", environmentId: "E" };
mockFindLibsqlById.mockResolvedValue({
libsqlId: "L",
appName: "a",
environment: { project: { organizationId: "org-1" } },
});
mockFindEnvironmentById.mockResolvedValue({
environmentId: "E",
project: { organizationId: "org-1" },
});
mockUpdateChainOnce(moved);
const result = await callMove("org-1").move({
libsqlId: "L",
targetEnvironmentId: "E",
});
expect(result).toEqual(moved);
expect(mockDbUpdate).toHaveBeenCalledTimes(1);
expect(mockFindEnvironmentById).toHaveBeenCalledWith("E");
expect(mockAudit).toHaveBeenCalledTimes(1);
});
it("still enforces checkServicePermissionAndAccess before the org checks", async () => {
mockCheckServicePermissionAndAccess.mockRejectedValueOnce(
new Error("permission denied"),
);
await expect(
callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }),
).rejects.toThrow("permission denied");
expect(mockFindLibsqlById).not.toHaveBeenCalled();
expect(mockDbUpdate).not.toHaveBeenCalled();
});
it("detects a same-name organization id collision only by exact match", async () => {
// Distinct orgs must not be treated as equal even when the caller's
// active org and the source org are both non-empty strings.
mockFindLibsqlById.mockResolvedValue({
libsqlId: "L",
appName: "a",
environment: { project: { organizationId: "ORG-1" } },
});
await expect(
callMove("org-1").move({ libsqlId: "L", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("postgres.move — same cross-organization guard", () => {
const callMove = (orgId: string) =>
postgresRouter.createCaller(buildCtx(orgId) as never);
it("rejects when the source postgres belongs to another organization", async () => {
mockFindPostgresById.mockResolvedValue({
postgresId: "P",
appName: "a",
environment: { project: { organizationId: "org-other" } },
});
await expect(
callMove("org-1").move({ postgresId: "P", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mockFindEnvironmentById).not.toHaveBeenCalled();
expect(mockDbUpdate).not.toHaveBeenCalled();
});
it("rejects when the target environment belongs to another organization", async () => {
mockFindPostgresById.mockResolvedValue({
postgresId: "P",
appName: "a",
environment: { project: { organizationId: "org-1" } },
});
mockFindEnvironmentById.mockResolvedValue({
environmentId: "E",
project: { organizationId: "org-other" },
});
await expect(
callMove("org-1").move({ postgresId: "P", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mockDbUpdate).not.toHaveBeenCalled();
});
it("moves the service when both source and target are in the active org", async () => {
const moved = { postgresId: "P", appName: "a", environmentId: "E" };
mockFindPostgresById.mockResolvedValue({
postgresId: "P",
appName: "a",
environment: { project: { organizationId: "org-1" } },
});
mockFindEnvironmentById.mockResolvedValue({
environmentId: "E",
project: { organizationId: "org-1" },
});
mockUpdateChainOnce(moved);
const result = await callMove("org-1").move({
postgresId: "P",
targetEnvironmentId: "E",
});
expect(result).toEqual(moved);
expect(mockDbUpdate).toHaveBeenCalledTimes(1);
expect(mockAudit).toHaveBeenCalledTimes(1);
});
});
// Shared factory that exercises G1G3 (source cross-org reject, target
// cross-org reject, same-org succeed) for one router's `move`. Keeps the six
// sibling routers covered without copy-pasting the three-case body each time.
const runCrossOrgGuardCases = (options: {
name: string;
router: ReturnType<typeof libsqlRouter.createCaller> extends never
? never
: {
createCaller: (ctx: never) => {
move: (input: unknown) => Promise<unknown>;
};
};
idKey: string;
findById: ReturnType<typeof mockFindLibsqlById>;
movedRow: Record<string, unknown>;
}) => {
const { name, router, idKey, findById, movedRow } = options;
const callMove = (orgId: string) =>
router.createCaller(buildCtx(orgId) as never);
describe(`${name}.move — same cross-organization guard`, () => {
it("rejects when the source service belongs to another organization", async () => {
findById.mockResolvedValue({
[idKey]: "S",
environment: { project: { organizationId: "org-other" } },
});
await expect(
callMove("org-1").move({ [idKey]: "S", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mockFindEnvironmentById).not.toHaveBeenCalled();
expect(mockDbUpdate).not.toHaveBeenCalled();
expect(mockAudit).not.toHaveBeenCalled();
});
it("rejects when the target environment belongs to another organization", async () => {
findById.mockResolvedValue({
[idKey]: "S",
environment: { project: { organizationId: "org-1" } },
});
mockFindEnvironmentById.mockResolvedValue({
environmentId: "E",
project: { organizationId: "org-other" },
});
await expect(
callMove("org-1").move({ [idKey]: "S", targetEnvironmentId: "E" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mockDbUpdate).not.toHaveBeenCalled();
expect(mockAudit).not.toHaveBeenCalled();
});
it("moves the service when both source and target are in the active org", async () => {
findById.mockResolvedValue({
[idKey]: "S",
environment: { project: { organizationId: "org-1" } },
});
mockFindEnvironmentById.mockResolvedValue({
environmentId: "E",
project: { organizationId: "org-1" },
});
mockUpdateChainOnce(movedRow);
const result = await callMove("org-1").move({
[idKey]: "S",
targetEnvironmentId: "E",
});
expect(result).toEqual(movedRow);
expect(mockDbUpdate).toHaveBeenCalledTimes(1);
expect(mockAudit).toHaveBeenCalledTimes(1);
});
});
};
runCrossOrgGuardCases({
name: "redis",
router: redisRouter as never,
idKey: "redisId",
findById: mockFindRedisById,
movedRow: { redisId: "S", appName: "a", environmentId: "E" },
});
runCrossOrgGuardCases({
name: "mongo",
router: mongoRouter as never,
idKey: "mongoId",
findById: mockFindMongoById,
movedRow: { mongoId: "S", appName: "a", environmentId: "E" },
});
runCrossOrgGuardCases({
name: "mysql",
router: mysqlRouter as never,
idKey: "mysqlId",
findById: mockFindMySqlById,
movedRow: { mysqlId: "S", appName: "a", environmentId: "E" },
});
runCrossOrgGuardCases({
name: "mariadb",
router: mariadbRouter as never,
idKey: "mariadbId",
findById: mockFindMariadbById,
movedRow: { mariadbId: "S", appName: "a", environmentId: "E" },
});
runCrossOrgGuardCases({
name: "compose",
router: composeRouter as never,
idKey: "composeId",
findById: mockFindComposeById,
movedRow: { composeId: "S", name: "a", environmentId: "E" },
});
runCrossOrgGuardCases({
name: "application",
router: applicationRouter as never,
idKey: "applicationId",
findById: mockFindApplicationById,
movedRow: { applicationId: "S", appName: "a", environmentId: "E" },
});

View File

@ -1018,6 +1018,30 @@ export const applicationRouter = createTRPCRouter({
service: ["create"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this application",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedApplication = await db
.update(applications)
.set({

View File

@ -786,6 +786,30 @@ export const composeRouter = createTRPCRouter({
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this compose",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedCompose = await db
.update(composeTable)
.set({

View File

@ -433,6 +433,30 @@ export const libsqlRouter = createTRPCRouter({
service: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
if (
libsql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this libsql",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedLibsql = await db
.update(libsqlTable)
.set({

View File

@ -460,6 +460,30 @@ export const mariadbRouter = createTRPCRouter({
service: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mariadb",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedMariadb = await db
.update(mariadbTable)
.set({

View File

@ -475,6 +475,30 @@ export const mongoRouter = createTRPCRouter({
service: ["create"],
});
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mongo",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedMongo = await db
.update(mongoTable)
.set({

View File

@ -478,6 +478,30 @@ export const mysqlRouter = createTRPCRouter({
service: ["create"],
});
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mysql",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedMysql = await db
.update(mysqlTable)
.set({

View File

@ -481,6 +481,30 @@ export const postgresRouter = createTRPCRouter({
service: ["create"],
});
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this postgres",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedPostgres = await db
.update(postgresTable)
.set({

View File

@ -462,6 +462,30 @@ export const redisRouter = createTRPCRouter({
service: ["create"],
});
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this redis",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
const updatedRedis = await db
.update(redisTable)
.set({