fix(compose): restore cross-tenant org guard on all compose router procedures

This commit is contained in:
detail-app[bot] 2026-09-04 02:51:23 +00:00 committed by GitHub
parent 1572008cdf
commit 0abd85a59b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 584 additions and 21 deletions

View File

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

View File

@ -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);
});
});
});

View File

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

View File

@ -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 {

View File

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

View File

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