mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
fix(compose): restore cross-tenant org guard on all compose router procedures
This commit is contained in:
parent
1572008cdf
commit
0abd85a59b
113
apps/dokploy/__test__/compose/compose-org-access.test.ts
Normal file
113
apps/dokploy/__test__/compose/compose-org-access.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
apps/dokploy/__test__/permissions/environment-org-access.test.ts
Normal file
100
apps/dokploy/__test__/permissions/environment-org-access.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user