diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx index c9054efe2..7af012d53 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx @@ -1,12 +1,19 @@ +import { notFound } from "next/navigation"; import { Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DockerTable } from "./docker-table"; export default async function DockerPage() { + const session = await auth(); + if (!session?.user || !session.user.permissions.includes("admin")) { + notFound(); + } + const { containers, timestamp } = await api.docker.getContainers(); const tDocker = await getScopedI18n("docker"); diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 186d47286..876ab683d 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -8,7 +8,7 @@ import type { DockerContainerState } from "@homarr/definitions"; import { createCacheChannel } from "@homarr/redis"; import { z } from "@homarr/validation"; -import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../../trpc"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; import { DockerSingleton } from "./docker-singleton"; const dockerCache = createCacheChannel<{ @@ -16,7 +16,7 @@ const dockerCache = createCacheChannel<{ }>("docker-containers", 5 * 60 * 1000); export const dockerRouter = createTRPCRouter({ - getContainers: publicProcedure.query(async () => { + getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => { const { timestamp, data } = await dockerCache.consumeAsync(async () => { const dockerInstances = DockerSingleton.getInstance(); const containers = await Promise.all( diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts new file mode 100644 index 000000000..a166b46a2 --- /dev/null +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -0,0 +1,90 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { objectKeys } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { getPermissionsWithChildren } from "@homarr/definitions"; +import type { GroupPermissionKey } from "@homarr/definitions"; + +import type { RouterInputs } from "../../.."; +import { dockerRouter } from "../../docker/docker-router"; + +// Mock the auth module to return an empty session +vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); +vi.mock("@homarr/redis", () => ({ + createCacheChannel: () => ({ + // eslint-disable-next-line @typescript-eslint/require-await + consumeAsync: async () => ({ + timestamp: new Date().toISOString(), + data: { containers: [] }, + }), + // eslint-disable-next-line @typescript-eslint/no-empty-function + invalidateAsync: async () => {}, + }), +})); + +const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) => + ({ + user: { + id: "1", + permissions, + }, + expires: new Date().toISOString(), + }) satisfies Session; + +const procedureKeys = objectKeys(dockerRouter._def.procedures); + +const validInputs: { + [key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key]; +} = { + getContainers: undefined, + startAll: { ids: ["1"] }, + stopAll: { ids: ["1"] }, + restartAll: { ids: ["1"] }, + removeAll: { ids: ["1"] }, +}; + +describe("All procedures should only be accessible for users with admin permission", () => { + test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => { + // Arrange + const caller = dockerRouter.createCaller({ + db: null as unknown as Database, + session: createSessionWithPermissions("admin"), + }); + + // Act + const act = () => caller[procedure](validInputs[procedure] as never); + + await expect(act()).resolves.not.toThrow(); + }); + + test.each(procedureKeys)("Procedure %s should not be accessible with other permissions", async (procedure) => { + // Arrange + const groupPermissionsWithoutAdmin = getPermissionsWithChildren(["admin"]).filter( + (permission) => permission !== "admin", + ); + const caller = dockerRouter.createCaller({ + db: null as unknown as Database, + session: createSessionWithPermissions(...groupPermissionsWithoutAdmin), + }); + + // Act + const act = () => caller[procedure](validInputs[procedure] as never); + + await expect(act()).rejects.toThrow(new TRPCError({ code: "FORBIDDEN", message: "Permission denied" })); + }); + + test.each(procedureKeys)("Procedure %s should not be accessible without session", async (procedure) => { + // Arrange + const caller = dockerRouter.createCaller({ + db: null as unknown as Database, + session: null, + }); + + // Act + const act = () => caller[procedure](validInputs[procedure] as never); + + await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" })); + }); +});