fix: restrict access to docker containers page to admins (#912)
This commit is contained in:
@@ -1,12 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { DockerTable } from "./docker-table";
|
import { DockerTable } from "./docker-table";
|
||||||
|
|
||||||
export default async function DockerPage() {
|
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 { containers, timestamp } = await api.docker.getContainers();
|
||||||
const tDocker = await getScopedI18n("docker");
|
const tDocker = await getScopedI18n("docker");
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { DockerContainerState } from "@homarr/definitions";
|
|||||||
import { createCacheChannel } from "@homarr/redis";
|
import { createCacheChannel } from "@homarr/redis";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||||
import { DockerSingleton } from "./docker-singleton";
|
import { DockerSingleton } from "./docker-singleton";
|
||||||
|
|
||||||
const dockerCache = createCacheChannel<{
|
const dockerCache = createCacheChannel<{
|
||||||
@@ -16,7 +16,7 @@ const dockerCache = createCacheChannel<{
|
|||||||
}>("docker-containers", 5 * 60 * 1000);
|
}>("docker-containers", 5 * 60 * 1000);
|
||||||
|
|
||||||
export const dockerRouter = createTRPCRouter({
|
export const dockerRouter = createTRPCRouter({
|
||||||
getContainers: publicProcedure.query(async () => {
|
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||||
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
|
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
|
||||||
const dockerInstances = DockerSingleton.getInstance();
|
const dockerInstances = DockerSingleton.getInstance();
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
|
|||||||
90
packages/api/src/router/test/docker/docker-router.spec.ts
Normal file
90
packages/api/src/router/test/docker/docker-router.spec.ts
Normal file
@@ -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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user