diff --git a/apps/dokploy/__test__/compose/compose-org-access.test.ts b/apps/dokploy/__test__/compose/compose-org-access.test.ts new file mode 100644 index 000000000..376afcfa0 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-org-access.test.ts @@ -0,0 +1,113 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `assertComposeOrgAccess` is a resource-scoped tenant guard that fetches a + * compose (via `findComposeById`) and rejects when the compose's project + * `organizationId` does not match the caller's active organization. It exists to + * backstop the cross-tenant IDOR left open by `checkServicePermissionAndAccess` + * (which is session-scoped and skips the `accessedServices` membership check + * for owner/admin). These tests mock only the DB layer so the REAL helper and + * the REAL `findComposeById` run against controlled data. + */ +const composeFindFirst = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { findFirst: composeFindFirst }, + }, + }, +})); + +import { assertComposeOrgAccess } from "@dokploy/server/services/compose"; + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-A" }, +}; + +const buildCompose = (organizationId: string) => ({ + composeId: "compose-victim", + name: "victim-service", + environment: { + environmentId: "env-1", + projectId: "proj-1", + name: "production", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + isDefault: true, + env: "", + project: { + projectId: "proj-1", + name: "victim-project", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + organizationId, + }, + }, +}); + +beforeEach(() => { + composeFindFirst.mockReset(); +}); + +describe("assertComposeOrgAccess (compose cross-tenant IDOR guard)", () => { + it("rejects a compose that belongs to another organization with UNAUTHORIZED", async () => { + composeFindFirst.mockResolvedValue(buildCompose("org-B")); + await expect( + assertComposeOrgAccess(ctx, "compose-victim"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("throws a TRPCError so tRPC maps the HTTP status", async () => { + composeFindFirst.mockResolvedValue(buildCompose("org-B")); + const err = await assertComposeOrgAccess(ctx, "compose-victim").catch( + (e) => e, + ); + expect(err).toBeInstanceOf(TRPCError); + }); + + it("uses a message that identifies the cross-tenant rejection", async () => { + composeFindFirst.mockResolvedValue(buildCompose("org-B")); + const err = await assertComposeOrgAccess(ctx, "compose-victim").catch( + (e) => e, + ); + expect(err.message).toBe("You are not authorized to access this compose"); + }); + + it("allows a compose that belongs to the caller's active organization", async () => { + const compose = buildCompose("org-A"); + composeFindFirst.mockResolvedValue(compose); + await expect( + assertComposeOrgAccess(ctx, "compose-victim"), + ).resolves.toEqual(compose); + }); + + it("returns the fetched compose row so router procedures can reuse it", async () => { + const compose = buildCompose("org-A"); + composeFindFirst.mockResolvedValue(compose); + const result = await assertComposeOrgAccess(ctx, "compose-victim"); + expect(result).toBe(compose); + }); + + it("rethrows NOT_FOUND when the compose does not exist (via findComposeById)", async () => { + composeFindFirst.mockResolvedValue(undefined); + await expect( + assertComposeOrgAccess(ctx, "missing-compose"), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("still rejects cross-tenant access for owner/admin (checks resource, not role)", async () => { + // An owner in org-A must still be rejected when the compose lives in org-B. + // This is the core regression from 8127dc4: the session-scoped role check + // does not detect a cross-organization composeId. + const ownerCtx = { + user: { id: "owner-1" }, + session: { activeOrganizationId: "org-A" }, + }; + composeFindFirst.mockResolvedValue(buildCompose("org-B")); + await expect( + assertComposeOrgAccess(ownerCtx, "compose-victim"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); diff --git a/apps/dokploy/__test__/compose/compose-router-cross-tenant.test.ts b/apps/dokploy/__test__/compose/compose-router-cross-tenant.test.ts new file mode 100644 index 000000000..2b681c277 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-router-cross-tenant.test.ts @@ -0,0 +1,276 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Router-level integration test for the cross-tenant IDOR fixed in the compose + * router. These tests drive the REAL `composeRouter` through `createCaller` + * against a controlled DB mock, so they verify that the resource-scoped guards + * (`assertComposeOrgAccess` / `assertEnvironmentOrgAccess`) are WIRED into the + * procedures and execute BEFORE any sensitive work (disclosure, move). + * + * Before the fix every procedure below relied solely on + * `checkServicePermissionAndAccess`, which is session-scoped and skips the + * `accessedServices` membership check for owner/admin, so an owner in org A + * could read/act on a compose belonging to org B by knowing its composeId. + */ +const memberFindFirst = vi.hoisted(() => vi.fn()); +const composeFindFirst = vi.hoisted(() => vi.fn()); +const environmentsFindFirst = vi.hoisted(() => vi.fn()); +const domainsFindMany = vi.hoisted(() => vi.fn()); +const composeUpdate = vi.hoisted(() => vi.fn()); +const createAuditLogMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/db", () => { + const returningRow = { + name: "moved-service", + composeId: "c-victim", + environmentId: "env-target", + }; + // A thenable chain built via a Proxy (no literal `then` property, so it does + // not trip biome's `noThenProperty` rule). Every builder method returns the + // chain; `returning` resolves to the row; awaiting the chain resolves to []. + const chain: any = new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === "then") + return (resolve: (v: unknown) => void) => resolve([]); + if (prop === "returning") return () => Promise.resolve([returningRow]); + return () => chain; + }, + }, + ); + return { + db: { + update: (...args: any[]) => { + composeUpdate(...args); + return chain; + }, + delete: () => chain, + insert: () => ({ + values: () => ({ returning: () => Promise.resolve([{}]) }), + }), + select: () => chain, + query: { + member: { + findFirst: memberFindFirst, + findMany: vi.fn(() => Promise.resolve([])), + }, + compose: { + findFirst: composeFindFirst, + findMany: vi.fn(() => Promise.resolve([])), + }, + environments: { + findFirst: environmentsFindFirst, + findMany: vi.fn(() => Promise.resolve([])), + }, + domains: { + findMany: domainsFindMany, + findFirst: vi.fn(() => Promise.resolve(undefined)), + }, + webServerSettings: { + findFirst: vi.fn(() => Promise.resolve(undefined)), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, + dbUrl: "postgres://mock:mock@localhost:5432/mock", + }; +}); + +vi.mock("@dokploy/server/services/proprietary/audit-log", () => ({ + createAuditLog: createAuditLogMock, +})); + +import { composeRouter } from "@/server/api/routers/compose"; + +const ORG_A = "org-A"; +const ORG_B = "org-B"; + +const ownerMember = { + id: "member-1", + role: "owner", + userId: "user-1", + organizationId: ORG_A, + accessedProjects: [], + accessedServices: [], + accessedEnvironments: [], + accessedGitProviders: [], + canCreateProjects: true, + canDeleteProjects: true, + canCreateServices: true, + canDeleteServices: true, + canCreateEnvironments: true, + canDeleteEnvironments: true, + canAccessToTraefikFiles: true, + canAccessToDocker: true, + canAccessToAPI: true, + canAccessToSSHKeys: true, + canAccessToGitProviders: true, + user: { id: "user-1", email: "owner@test.test" }, +}; + +const buildCompose = ( + organizationId: string, + serverId: string | null = null, +) => ({ + composeId: "c-victim", + name: "victim-service", + appName: "victim-app", + composeFile: "", + sourceType: "raw", + env: "", + serverId, + environment: { + environmentId: "env-src", + projectId: "proj-src", + name: "production", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + isDefault: true, + env: "", + project: { + projectId: "proj-src", + name: "victim-project", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + organizationId, + }, + }, + mounts: [], + domains: [], + deployments: [], +}); + +const buildEnvironment = (organizationId: string) => ({ + environmentId: "env-target", + projectId: "proj-target", + name: "target", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + isDefault: false, + env: "", + project: { + projectId: "proj-target", + name: "target-project", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + organizationId, + }, +}); + +const callerCtx = { + session: { activeOrganizationId: ORG_A }, + user: { + id: "user-1", + email: "owner@test.test", + role: "owner", + ownerId: "user-1", + enableEnterpriseFeatures: false, + isValidEnterpriseLicense: false, + }, +}; + +const caller = composeRouter.createCaller(callerCtx as any); + +beforeEach(() => { + vi.clearAllMocks(); + memberFindFirst.mockResolvedValue(ownerMember); + // Owner is a static role, so resolveRole returns early and never queries + // organizationRole; checkPermission authorizes service.create / deployment.create. + createAuditLogMock.mockResolvedValue(undefined); +}); + +describe("compose router cross-tenant IDOR guards", () => { + describe("getConvertedCompose (headline single-call disclosure path)", () => { + it("rejects a cross-org owner with UNAUTHORIZED and does NOT read domains", async () => { + composeFindFirst.mockResolvedValue(buildCompose(ORG_B)); + + await expect( + caller.getConvertedCompose({ composeId: "c-victim" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + // No disclosure: the rendered compose (with decrypted env + domains) + // must never be assembled for a cross-tenant caller. + expect(domainsFindMany).not.toHaveBeenCalled(); + }); + + it("throws a TRPCError so tRPC maps the HTTP status", async () => { + composeFindFirst.mockResolvedValue(buildCompose(ORG_B)); + const err = await caller + .getConvertedCompose({ composeId: "c-victim" }) + .catch((e) => e); + expect(err).toBeInstanceOf(TRPCError); + }); + + it("still rejects when the caller is owner/admin (resource-scoped, not role-scoped)", async () => { + // Owner in org-A must still be rejected for a compose in org-B. + composeFindFirst.mockResolvedValue(buildCompose(ORG_B)); + await expect( + caller.getConvertedCompose({ composeId: "c-victim" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + }); + + describe("move (lockout path)", () => { + it("rejects a cross-org source compose with UNAUTHORIZED and does NOT relocate it", async () => { + composeFindFirst.mockResolvedValue(buildCompose(ORG_B)); + + await expect( + caller.move({ + composeId: "c-victim", + targetEnvironmentId: "env-target", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + // The victim compose row must not be moved into the attacker's env. + expect(composeUpdate).not.toHaveBeenCalled(); + }); + + it("rejects a cross-org target environment with UNAUTHORIZED and does NOT relocate it", async () => { + // Source compose lives in the caller's org (passes source guard)... + composeFindFirst.mockResolvedValue(buildCompose(ORG_A)); + // ...but the target environment belongs to a different org. + environmentsFindFirst.mockResolvedValue(buildEnvironment(ORG_B)); + + await expect( + caller.move({ + composeId: "c-victim", + targetEnvironmentId: "env-target", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(composeUpdate).not.toHaveBeenCalled(); + }); + + it("throws a TRPCError on cross-org target", async () => { + composeFindFirst.mockResolvedValue(buildCompose(ORG_A)); + environmentsFindFirst.mockResolvedValue(buildEnvironment(ORG_B)); + const err = await caller + .move({ composeId: "c-victim", targetEnvironmentId: "env-target" }) + .catch((e) => e); + expect(err).toBeInstanceOf(TRPCError); + }); + + it("allows a legitimate same-org move (no regression) and relocates the row", async () => { + composeFindFirst.mockResolvedValue(buildCompose(ORG_A)); + environmentsFindFirst.mockResolvedValue(buildEnvironment(ORG_A)); + + const result = await caller.move({ + composeId: "c-victim", + targetEnvironmentId: "env-target", + }); + + expect(composeUpdate).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + composeId: "c-victim", + environmentId: "env-target", + }); + // audit must still fire for the successful move + expect(createAuditLogMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/dokploy/__test__/permissions/environment-org-access.test.ts b/apps/dokploy/__test__/permissions/environment-org-access.test.ts new file mode 100644 index 000000000..ca952fa1d --- /dev/null +++ b/apps/dokploy/__test__/permissions/environment-org-access.test.ts @@ -0,0 +1,100 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `assertEnvironmentOrgAccess` is a resource-scoped tenant guard used by + * cross-resource move operations (e.g. `compose.move`) to ensure the target + * environment cannot be relocated into a different organization than the + * caller's active one. It loads the environment together with its `project` + * relation (in a single query) and rejects when the project's + * `organizationId` does not match. + */ +const environmentFindFirst = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + environments: { findFirst: environmentFindFirst }, + }, + }, +})); + +import { assertEnvironmentOrgAccess } from "@dokploy/server/services/environment"; + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-A" }, +}; + +const buildEnvironment = (organizationId: string) => ({ + environmentId: "env-target", + projectId: "proj-target", + name: "target", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + isDefault: false, + env: "", + project: { + projectId: "proj-target", + name: "target-project", + description: null, + createdAt: "2026-01-01T00:00:00.000Z", + organizationId, + }, +}); + +beforeEach(() => { + environmentFindFirst.mockReset(); +}); + +describe("assertEnvironmentOrgAccess (environment cross-tenant IDOR guard)", () => { + it("rejects an environment that belongs to another organization with UNAUTHORIZED", async () => { + environmentFindFirst.mockResolvedValue(buildEnvironment("org-B")); + await expect( + assertEnvironmentOrgAccess(ctx, "env-target"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("throws a TRPCError so tRPC maps the HTTP status", async () => { + environmentFindFirst.mockResolvedValue(buildEnvironment("org-B")); + const err = await assertEnvironmentOrgAccess(ctx, "env-target").catch( + (e) => e, + ); + expect(err).toBeInstanceOf(TRPCError); + }); + + it("uses a message that identifies the cross-tenant rejection", async () => { + environmentFindFirst.mockResolvedValue(buildEnvironment("org-B")); + const err = await assertEnvironmentOrgAccess(ctx, "env-target").catch( + (e) => e, + ); + expect(err.message).toBe( + "You are not authorized to access this environment", + ); + }); + + it("allows an environment that belongs to the caller's active organization", async () => { + const environment = buildEnvironment("org-A"); + environmentFindFirst.mockResolvedValue(environment); + await expect( + assertEnvironmentOrgAccess(ctx, "env-target"), + ).resolves.toEqual(environment); + }); + + it("rethrows NOT_FOUND when the environment does not exist", async () => { + environmentFindFirst.mockResolvedValue(undefined); + await expect( + assertEnvironmentOrgAccess(ctx, "missing-env"), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("still rejects cross-tenant access for owner/admin", async () => { + const ownerCtx = { + user: { id: "owner-1" }, + session: { activeOrganizationId: "org-A" }, + }; + environmentFindFirst.mockResolvedValue(buildEnvironment("org-B")); + await expect( + assertEnvironmentOrgAccess(ownerCtx, "env-target"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 1de66824d..7c1bc3dc3 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1,6 +1,8 @@ import { join } from "node:path"; import { addDomainToCompose, + assertComposeOrgAccess, + assertEnvironmentOrgAccess, clearOldDeployments, cloneCompose, createCommand, @@ -197,6 +199,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + await assertComposeOrgAccess(ctx, input.composeId); const updated = await updateCompose(input.composeId, input); await audit(ctx, { action: "update", @@ -212,6 +215,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { envVars: ["write"], }); + await assertComposeOrgAccess(ctx, input.composeId); const updated = await updateCompose(input.composeId, { env: input.env, createEnvFile: input.createEnvFile, @@ -283,6 +287,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["create"], }); + await assertComposeOrgAccess(ctx, input.composeId); await cleanQueuesByCompose(input.composeId); return { success: true, message: "Queues cleaned successfully" }; }), @@ -292,7 +297,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); await clearOldDeployments(compose.appName, compose.serverId); await audit(ctx, { action: "update", @@ -308,7 +313,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["cancel"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); await killDockerBuild("compose", compose.serverId); }), @@ -318,6 +323,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["read"], }); + await assertComposeOrgAccess(ctx, input.composeId); return await loadServices(input.composeId, input.type); }), loadMountsByService: protectedProcedure @@ -331,7 +337,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const container = await getComposeContainer(compose, input.serviceName); const mounts = container?.Mounts.filter( (mount) => mount.Type === "volume" && mount.Source !== "", @@ -345,7 +351,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const command = await cloneCompose(compose); if (compose.serverId) { @@ -369,8 +375,8 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const result = await randomizeComposeFile(input.composeId, input.suffix); - const compose = await findComposeById(input.composeId); await audit(ctx, { action: "update", resourceType: "compose", @@ -385,11 +391,11 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const result = await randomizeIsolatedDeploymentComposeFile( input.composeId, input.suffix, ); - const compose = await findComposeById(input.composeId); await audit(ctx, { action: "update", resourceType: "compose", @@ -404,7 +410,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const domains = await findDomainsByComposeId(input.composeId); const composeFile = await addDomainToCompose(compose, domains); return stringify(composeFile, { @@ -418,7 +424,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const jobData: DeploymentJob = { composeId: input.composeId, @@ -469,7 +475,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const jobData: DeploymentJob = { composeId: input.composeId, titleLog: input.title || "Rebuild deployment", @@ -518,13 +524,13 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["create"], }); + const compose = await assertComposeOrgAccess(ctx, input.composeId); await stopCompose(input.composeId); - const composeForStop = await findComposeById(input.composeId); await audit(ctx, { action: "stop", resourceType: "compose", resourceId: input.composeId, - resourceName: composeForStop.name, + resourceName: compose.name, }); return true; }), @@ -534,13 +540,13 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["create"], }); + const compose = await assertComposeOrgAccess(ctx, input.composeId); await startCompose(input.composeId); - const composeForStart = await findComposeById(input.composeId); await audit(ctx, { action: "start", resourceType: "compose", resourceId: input.composeId, - resourceName: composeForStart.name, + resourceName: compose.name, }); return true; }), @@ -550,7 +556,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const { COMPOSE_PATH } = paths(!!compose.serverId); const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const command = createCommand( @@ -565,15 +571,15 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + const compose = await assertComposeOrgAccess(ctx, input.composeId); await updateCompose(input.composeId, { refreshToken: nanoid(), }); - const composeForToken = await findComposeById(input.composeId); await audit(ctx, { action: "update", resourceType: "compose", resourceId: input.composeId, - resourceName: composeForToken.name, + resourceName: compose.name, }); return true; }), @@ -728,6 +734,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + const compose = await assertComposeOrgAccess(ctx, input.composeId); await updateCompose(input.composeId, { repository: null, @@ -764,12 +771,11 @@ export const composeRouter = createTRPCRouter({ enableSubmodules: false, }); - const composeForDisconnect = await findComposeById(input.composeId); await audit(ctx, { action: "update", resourceType: "compose", resourceId: input.composeId, - resourceName: composeForDisconnect.name, + resourceName: compose.name, }); return true; }), @@ -785,6 +791,8 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + await assertComposeOrgAccess(ctx, input.composeId); + await assertEnvironmentOrgAccess(ctx, input.targetEnvironmentId); const updatedCompose = await db .update(composeTable) @@ -823,7 +831,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const decodedData = Buffer.from(input.base64, "base64").toString( "utf-8", @@ -957,7 +965,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); const decodedData = Buffer.from(input.base64, "base64").toString( "utf-8", ); @@ -1062,7 +1070,7 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { deployment: ["cancel"], }); - const compose = await findComposeById(input.composeId); + const compose = await assertComposeOrgAccess(ctx, input.composeId); if (IS_CLOUD && compose.serverId) { try { diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index d6567b0dd..0f1fd66ae 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -43,6 +43,7 @@ import { updateDeploymentStatus, } from "./deployment"; import { generateApplyPatchesCommand } from "./patch"; +import type { PermissionCtx } from "./permission"; import { validUniqueServerAppName } from "./project"; export type Compose = typeof compose.$inferSelect; @@ -166,6 +167,36 @@ export const findComposeById = async (composeId: string) => { return result; }; +/** + * Resource-scoped tenant guard for compose services: confirms the compose + * identified by `composeId` belongs to a project whose `organizationId` + * matches the caller's active organization, and rejects otherwise. + * + * `checkServicePermissionAndAccess` is session-scoped (it only verifies the + * caller's role within `activeOrganizationId` and skips the `accessedServices` + * membership check for owner/admin), so it cannot detect a `composeId` that + * belongs to a different organization. This helper loads the compose (via + * `findComposeById`, which also decrypts its `env`) together with its + * environment/project and enforces the cross-tenant boundary, returning the + * fetched compose row so callers can reuse it without a second query. + */ +export const assertComposeOrgAccess = async ( + ctx: PermissionCtx, + composeId: string, +) => { + const compose = await findComposeById(composeId); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return compose; +}; + export const loadServices = async ( composeId: string, type: "fetch" | "cache" = "fetch", diff --git a/packages/server/src/services/environment.ts b/packages/server/src/services/environment.ts index c4c08ac02..05f31ace1 100644 --- a/packages/server/src/services/environment.ts +++ b/packages/server/src/services/environment.ts @@ -7,6 +7,7 @@ import { import { TRPCError } from "@trpc/server"; import { asc, eq } from "drizzle-orm"; import type { z } from "zod"; +import type { PermissionCtx } from "./permission"; export type Environment = typeof environments.$inferSelect; @@ -201,6 +202,40 @@ export const findEnvironmentById = async (environmentId: string) => { return environment; }; +/** + * Resource-scoped tenant guard for environments: confirms the environment + * identified by `environmentId` belongs to a project whose `organizationId` + * matches the caller's active organization, and rejects otherwise. + * + * Used by cross-resource move operations (e.g. `compose.move`) to ensure the + * target environment cannot be relocated into a different organization. The + * `environments` schema exposes a `project` relation, so the project's + * `organizationId` is loaded in a single query without importing + * `findProjectById` (which would create a module cycle with `./project`). + */ +export const assertEnvironmentOrgAccess = async ( + ctx: PermissionCtx, + environmentId: string, +) => { + const environment = await db.query.environments.findFirst({ + where: eq(environments.environmentId, environmentId), + with: { project: true }, + }); + if (!environment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Environment not found", + }); + } + if (environment.project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this environment", + }); + } + return environment; +}; + export const findEnvironmentsByProjectId = async (projectId: string) => { const serviceColumns = { name: true,