diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 482701639..58b234d24 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -19,6 +19,7 @@ import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/i import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { UserAvatar } from "@homarr/ui"; @@ -30,8 +31,9 @@ import { CreateBoardButton } from "./_components/create-board-button"; export default async function ManageBoardsPage() { const t = await getScopedI18n("management.page.board"); - + const session = await auth(); const boards = await api.board.getAllBoards(); + const canCreateBoards = session?.user.permissions.includes("board-create"); return ( @@ -39,7 +41,7 @@ export default async function ManageBoardsPage() { {t("title")} - + {canCreateBoards && } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index 7fa82f019..4783014e2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -6,6 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { IntegrationAvatar } from "@homarr/ui"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { catchTrpcNotFound } from "~/errors/trpc-not-found"; import { IntegrationAccessSettings } from "../../_components/integration-access-settings"; import { EditIntegrationForm } from "./_integration-edit-form"; @@ -16,7 +17,7 @@ interface EditIntegrationPageProps { export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) { const editT = await getScopedI18n("integration.page.edit"); const t = await getI18n(); - const integration = await api.integration.byId({ id: params.id }); + const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound); const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id }); return ( diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 6665a2417..fc77ba479 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { Container, Group, Stack, Title } from "@mantine/core"; +import { auth } from "@homarr/auth/next"; import type { IntegrationKind } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; @@ -18,6 +19,11 @@ interface NewIntegrationPageProps { } export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) { + const session = await auth(); + if (!session?.user.permissions.includes("integration-create")) { + notFound(); + } + const result = z.enum(integrationKinds).safeParse(searchParams.kind); if (!result.success) { notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 7df483661..ad03dc244 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -30,6 +30,7 @@ import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react" import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { objectEntries } from "@homarr/common"; import type { IntegrationKind } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions"; @@ -50,8 +51,11 @@ interface IntegrationsPageProps { export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) { const integrations = await api.integration.all(); + const session = await auth(); const t = await getScopedI18n("integration"); + const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false; + return ( @@ -59,23 +63,27 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag {t("page.list.title")} - - - - - - - - - + {canCreateIntegrations && ( + <> + + + + + + + + + - - - - - - - + + + + + + + + + )} @@ -102,6 +110,8 @@ interface IntegrationListProps { const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => { const t = await getScopedI18n("integration"); + const session = await auth(); + const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false; if (integrations.length === 0) { return
{t("page.list.empty")}
; @@ -151,18 +161,21 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - - - - - - + {hasFullAccess || + (integration.permissions.hasFullAccess && ( + + + + + + + ))} @@ -177,18 +190,21 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps {integration.name} - - - - - - + {hasFullAccess || + (integration.permissions.hasFullAccess && ( + + + + + + + ))} {integration.url} diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index b4d1564c4..e0bd9fcb5 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -23,6 +23,7 @@ import { IconUsersGroup, } from "@tabler/icons-react"; +import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; @@ -33,6 +34,7 @@ import { ClientShell } from "~/components/layout/shell"; export default async function ManageLayout({ children }: PropsWithChildren) { const t = await getScopedI18n("management.navbar"); + const session = await auth(); const navigationLinks: NavigationLink[] = [ { label: t("items.home"), @@ -62,6 +64,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { { icon: IconUser, label: t("items.users.label"), + hidden: !session?.user.permissions.includes("admin"), items: [ { label: t("items.users.items.manage"), @@ -84,6 +87,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { { label: t("items.tools.label"), icon: IconTool, + hidden: !session?.user.permissions.includes("admin"), items: [ { label: t("items.tools.items.docker"), @@ -111,6 +115,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { label: t("items.settings"), href: "/manage/settings", icon: IconSettings, + hidden: !session?.user.permissions.includes("admin"), }, { label: t("items.help.label"), diff --git a/apps/nextjs/src/app/[locale]/manage/page.tsx b/apps/nextjs/src/app/[locale]/manage/page.tsx index 8e25ffb92..31137d121 100644 --- a/apps/nextjs/src/app/[locale]/manage/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/page.tsx @@ -3,6 +3,7 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; @@ -28,6 +29,7 @@ export async function generateMetadata() { export default async function ManagementPage() { const statistics = await api.home.getStats(); + const session = await auth(); const t = await getScopedI18n("management.page.home"); const links: LinkProps[] = [ @@ -35,38 +37,40 @@ export default async function ManagementPage() { count: statistics.countBoards, href: "/manage/boards", subtitle: t("statisticLabel.boards"), - title: t("statistic.countBoards"), + title: t("statistic.board"), }, { count: statistics.countUsers, href: "/manage/users", subtitle: t("statisticLabel.authentication"), - title: t("statistic.createUser"), + title: t("statistic.user"), + hidden: !session?.user.permissions.includes("admin"), }, { - hidden: !isProviderEnabled("credentials"), count: statistics.countInvites, href: "/manage/users/invites", subtitle: t("statisticLabel.authentication"), - title: t("statistic.createInvite"), + title: t("statistic.invite"), + hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"), }, { count: statistics.countIntegrations, href: "/manage/integrations", subtitle: t("statisticLabel.resources"), - title: t("statistic.addIntegration"), + title: t("statistic.integration"), }, { count: statistics.countApps, href: "/manage/apps", subtitle: t("statisticLabel.resources"), - title: t("statistic.addApp"), + title: t("statistic.app"), }, { count: statistics.countGroups, href: "/manage/users/groups", subtitle: t("statisticLabel.authorization"), - title: t("statistic.manageRoles"), + title: t("statistic.group"), + hidden: !session?.user.permissions.includes("admin"), }, ]; return ( diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index dbc451dfb..d91fdd3f7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -1,8 +1,10 @@ import { headers } from "next/headers"; +import { notFound } from "next/navigation"; import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; import { openApiDocument } from "@homarr/api"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { extractBaseUrlFromHeaders } from "@homarr/common"; import { getScopedI18n } from "@homarr/translation/server"; @@ -11,6 +13,11 @@ import { createMetaTitle } from "~/metadata"; import { ApiKeysManagement } from "./components/api-keys"; export async function generateMetadata() { + const session = await auth(); + if (!session?.user || !session.user.permissions.includes("admin")) { + return {}; + } + const t = await getScopedI18n("management"); return { @@ -19,6 +26,10 @@ export async function generateMetadata() { } export default async function ApiPage() { + const session = await auth(); + if (!session?.user || !session.user.permissions.includes("admin")) { + notFound(); + } const document = openApiDocument(extractBaseUrlFromHeaders(headers())); const apiKeys = await api.apiKeys.getAll(); const t = await getScopedI18n("management.page.tool.api.tab"); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index da45b84bb..131ce7859 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -4,12 +4,20 @@ import { getScopedI18n } from "@homarr/translation/server"; import "@xterm/xterm/css/xterm.css"; +import { notFound } from "next/navigation"; + +import { auth } from "@homarr/auth/next"; + import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { createMetaTitle } from "~/metadata"; import { ClientSideTerminalComponent } from "./client"; export async function generateMetadata() { + const session = await auth(); + if (!session?.user || !session.user.permissions.includes("admin")) { + return {}; + } const t = await getScopedI18n("management"); return { @@ -17,7 +25,12 @@ export async function generateMetadata() { }; } -export default function LogsManagementPage() { +export default async function LogsManagementPage() { + const session = await auth(); + if (!session?.user || !session.user.permissions.includes("admin")) { + notFound(); + } + return ( <> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx index 834935659..94def6599 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx @@ -1,12 +1,18 @@ +import { notFound } from "next/navigation"; import { Box, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { createMetaTitle } from "~/metadata"; import { JobsList } from "./_components/jobs-list"; export async function generateMetadata() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return {}; + } const t = await getScopedI18n("management"); return { @@ -15,6 +21,11 @@ export async function generateMetadata() { } export default async function TasksPage() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + notFound(); + } + const jobs = await api.cronJobs.getJobs(); return ( diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx index f7a6881e2..d076c2e37 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx @@ -1,5 +1,6 @@ import { notFound } from "next/navigation"; +import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; @@ -10,6 +11,11 @@ import { UserCreateStepperComponent } from "./_components/create-user-stepper"; export async function generateMetadata() { if (!isProviderEnabled("credentials")) return {}; + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return {}; + } + const t = await getScopedI18n("management.page.user.create"); return { @@ -17,11 +23,16 @@ export async function generateMetadata() { }; } -export default function CreateUserPage() { +export default async function CreateUserPage() { if (!isProviderEnabled("credentials")) { notFound(); } + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return notFound(); + } + return ( <> diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx index 01614c186..3fce6db2a 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx @@ -1,8 +1,10 @@ import Link from "next/link"; +import { notFound } from "next/navigation"; import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { getI18n } from "@homarr/translation/server"; import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui"; import { z } from "@homarr/validation"; @@ -26,6 +28,12 @@ interface GroupsListPageProps { } export default async function GroupsListPage(props: GroupsListPageProps) { + const session = await auth(); + + if (!session?.user.permissions.includes("admin")) { + return notFound(); + } + const t = await getI18n(); const searchParams = searchParamsSchema.parse(props.searchParams); const { items: groups, totalCount } = await api.group.getPaginated(searchParams); diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx index 4044fb5b5..f392e8282 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; @@ -11,6 +12,11 @@ export default async function InvitesOverviewPage() { notFound(); } + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return notFound(); + } + const initialInvites = await api.invite.getAll(); return ( <> diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx index f77be0eeb..94ae5c103 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx @@ -1,4 +1,7 @@ +import { notFound } from "next/navigation"; + import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; @@ -7,6 +10,10 @@ import { createMetaTitle } from "~/metadata"; import { UserListComponent } from "./_components/user-list"; export async function generateMetadata() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return {}; + } const t = await getScopedI18n("management.page.user.list"); return { @@ -15,6 +22,11 @@ export async function generateMetadata() { } export default async function UsersPage() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return notFound(); + } + const userList = await api.user.getAll(); const credentialsProviderEnabled = isProviderEnabled("credentials"); diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index b7a7fe6d9..1987671fd 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -4,7 +4,7 @@ import { asc, createId, eq, like } from "@homarr/db"; import { apps } from "@homarr/db/schema/sqlite"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const appRouter = createTRPCRouter({ all: publicProcedure @@ -102,7 +102,7 @@ export const appRouter = createTRPCRouter({ return app; }), - create: publicProcedure + create: protectedProcedure .input(validation.app.manage) .output(z.void()) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) @@ -115,7 +115,7 @@ export const appRouter = createTRPCRouter({ href: input.href, }); }), - update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { + update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { const app = await ctx.db.query.apps.findFirst({ where: eq(apps.id, input.id), }); @@ -137,7 +137,7 @@ export const appRouter = createTRPCRouter({ }) .where(eq(apps.id, input.id)); }), - delete: publicProcedure + delete: protectedProcedure .output(z.void()) .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) .input(validation.common.byId) diff --git a/packages/api/src/router/cron-jobs.ts b/packages/api/src/router/cron-jobs.ts index a63c72ff9..e8c582f24 100644 --- a/packages/api/src/router/cron-jobs.ts +++ b/packages/api/src/router/cron-jobs.ts @@ -6,20 +6,23 @@ import { createCronJobStatusChannel } from "@homarr/cron-job-status"; import { jobGroup } from "@homarr/cron-jobs"; import { logger } from "@homarr/log"; -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; export const cronJobsRouter = createTRPCRouter({ - triggerJob: publicProcedure.input(jobNameSchema).mutation(async ({ input }) => { - await triggerCronJobAsync(input); - }), - getJobs: publicProcedure.query(() => { + triggerJob: permissionRequiredProcedure + .requiresPermission("admin") + .input(jobNameSchema) + .mutation(async ({ input }) => { + await triggerCronJobAsync(input); + }), + getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => { const registry = jobGroup.getJobRegistry(); return [...registry.values()].map((job) => ({ name: job.name, expression: job.cronExpression, })); }), - subscribeToStatusUpdates: publicProcedure.subscription(() => { + subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => { return observable((emit) => { const unsubscribes: (() => void)[] = []; diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 1397abdd0..d14083995 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -5,84 +5,92 @@ import { and, createId, eq, like, not, sql } from "@homarr/db"; import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc"; +import { throwIfCredentialsDisabled } from "./invite/checks"; export const groupRouter = createTRPCRouter({ - getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => { - const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined; - const groupCount = await ctx.db - .select({ - count: sql`count(*)`, - }) - .from(groups) - .where(whereQuery); + getPaginated: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.common.paginated) + .query(async ({ input, ctx }) => { + const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined; + const groupCount = await ctx.db + .select({ + count: sql`count(*)`, + }) + .from(groups) + .where(whereQuery); - const dbGroups = await ctx.db.query.groups.findMany({ - with: { - members: { - with: { - user: { - columns: { - id: true, - name: true, - email: true, - image: true, + const dbGroups = await ctx.db.query.groups.findMany({ + with: { + members: { + with: { + user: { + columns: { + id: true, + name: true, + email: true, + image: true, + }, }, }, }, }, - }, - limit: input.pageSize, - offset: (input.page - 1) * input.pageSize, - where: whereQuery, - }); + limit: input.pageSize, + offset: (input.page - 1) * input.pageSize, + where: whereQuery, + }); - return { - items: dbGroups.map((group) => ({ + return { + items: dbGroups.map((group) => ({ + ...group, + members: group.members.map((member) => member.user), + })), + totalCount: groupCount[0]?.count ?? 0, + }; + }), + getById: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.common.byId) + .query(async ({ input, ctx }) => { + const group = await ctx.db.query.groups.findFirst({ + where: eq(groups.id, input.id), + with: { + members: { + with: { + user: { + columns: { + id: true, + name: true, + email: true, + image: true, + provider: true, + }, + }, + }, + }, + permissions: { + columns: { + permission: true, + }, + }, + }, + }); + + if (!group) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Group not found", + }); + } + + return { ...group, members: group.members.map((member) => member.user), - })), - totalCount: groupCount[0]?.count ?? 0, - }; - }), - getById: protectedProcedure.input(validation.common.byId).query(async ({ input, ctx }) => { - const group = await ctx.db.query.groups.findFirst({ - where: eq(groups.id, input.id), - with: { - members: { - with: { - user: { - columns: { - id: true, - name: true, - email: true, - image: true, - provider: true, - }, - }, - }, - }, - permissions: { - columns: { - permission: true, - }, - }, - }, - }); - - if (!group) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Group not found", - }); - } - - return { - ...group, - members: group.members.map((member) => member.user), - permissions: group.permissions.map((permission) => permission.permission), - }; - }), + permissions: group.permissions.map((permission) => permission.permission), + }; + }), + // Is protected because also used in board access / integration access forms selectable: protectedProcedure.query(async ({ ctx }) => { return await ctx.db.query.groups.findMany({ columns: { @@ -91,7 +99,8 @@ export const groupRouter = createTRPCRouter({ }, }); }), - search: publicProcedure + search: permissionRequiredProcedure + .requiresPermission("admin") .input( z.object({ query: z.string(), @@ -108,85 +117,108 @@ export const groupRouter = createTRPCRouter({ limit: input.limit, }); }), - createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => { - const normalizedName = normalizeName(input.name); - await checkSimilarNameAndThrowAsync(ctx.db, normalizedName); + createGroup: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.group.create) + .mutation(async ({ input, ctx }) => { + const normalizedName = normalizeName(input.name); + await checkSimilarNameAndThrowAsync(ctx.db, normalizedName); - const id = createId(); - await ctx.db.insert(groups).values({ - id, - name: normalizedName, - ownerId: ctx.session.user.id, - }); - - return id; - }), - updateGroup: protectedProcedure.input(validation.group.update).mutation(async ({ input, ctx }) => { - await throwIfGroupNotFoundAsync(ctx.db, input.id); - - const normalizedName = normalizeName(input.name); - await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id); - - await ctx.db - .update(groups) - .set({ + const id = createId(); + await ctx.db.insert(groups).values({ + id, name: normalizedName, - }) - .where(eq(groups.id, input.id)); - }), - savePermissions: protectedProcedure.input(validation.group.savePermissions).mutation(async ({ input, ctx }) => { - await throwIfGroupNotFoundAsync(ctx.db, input.groupId); - - await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)); - - await ctx.db.insert(groupPermissions).values( - input.permissions.map((permission) => ({ - groupId: input.groupId, - permission, - })), - ); - }), - transferOwnership: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => { - await throwIfGroupNotFoundAsync(ctx.db, input.groupId); - - await ctx.db - .update(groups) - .set({ - ownerId: input.userId, - }) - .where(eq(groups.id, input.groupId)); - }), - deleteGroup: protectedProcedure.input(validation.common.byId).mutation(async ({ input, ctx }) => { - await throwIfGroupNotFoundAsync(ctx.db, input.id); - - await ctx.db.delete(groups).where(eq(groups.id, input.id)); - }), - addMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => { - await throwIfGroupNotFoundAsync(ctx.db, input.groupId); - - const user = await ctx.db.query.users.findFirst({ - where: eq(groups.id, input.userId), - }); - - if (!user) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "User not found", + ownerId: ctx.session.user.id, }); - } - await ctx.db.insert(groupMembers).values({ - groupId: input.groupId, - userId: input.userId, - }); - }), - removeMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => { - await throwIfGroupNotFoundAsync(ctx.db, input.groupId); + return id; + }), + updateGroup: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.group.update) + .mutation(async ({ input, ctx }) => { + await throwIfGroupNotFoundAsync(ctx.db, input.id); - await ctx.db - .delete(groupMembers) - .where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId))); - }), + const normalizedName = normalizeName(input.name); + await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id); + + await ctx.db + .update(groups) + .set({ + name: normalizedName, + }) + .where(eq(groups.id, input.id)); + }), + savePermissions: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.group.savePermissions) + .mutation(async ({ input, ctx }) => { + await throwIfGroupNotFoundAsync(ctx.db, input.groupId); + + await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)); + + await ctx.db.insert(groupPermissions).values( + input.permissions.map((permission) => ({ + groupId: input.groupId, + permission, + })), + ); + }), + transferOwnership: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.group.groupUser) + .mutation(async ({ input, ctx }) => { + await throwIfGroupNotFoundAsync(ctx.db, input.groupId); + + await ctx.db + .update(groups) + .set({ + ownerId: input.userId, + }) + .where(eq(groups.id, input.groupId)); + }), + deleteGroup: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.common.byId) + .mutation(async ({ input, ctx }) => { + await throwIfGroupNotFoundAsync(ctx.db, input.id); + + await ctx.db.delete(groups).where(eq(groups.id, input.id)); + }), + addMember: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.group.groupUser) + .mutation(async ({ input, ctx }) => { + await throwIfGroupNotFoundAsync(ctx.db, input.groupId); + throwIfCredentialsDisabled(); + + const user = await ctx.db.query.users.findFirst({ + where: eq(groups.id, input.userId), + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + await ctx.db.insert(groupMembers).values({ + groupId: input.groupId, + userId: input.userId, + }); + }), + removeMember: permissionRequiredProcedure + .requiresPermission("admin") + .input(validation.group.groupUser) + .mutation(async ({ input, ctx }) => { + await throwIfGroupNotFoundAsync(ctx.db, input.groupId); + throwIfCredentialsDisabled(); + + await ctx.db + .delete(groupMembers) + .where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId))); + }), }); const normalizeName = (name: string) => name.trim(); diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index 191698566..2174a2968 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -1,17 +1,32 @@ +import type { AnySQLiteTable } from "drizzle-orm/sqlite-core"; + +import { isProviderEnabled } from "@homarr/auth/server"; +import type { Database } from "@homarr/db"; import { count } from "@homarr/db"; import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema/sqlite"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, publicProcedure } from "../trpc"; export const homeRouter = createTRPCRouter({ - getStats: protectedProcedure.query(async ({ ctx }) => { + getStats: publicProcedure.query(async ({ ctx }) => { + const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false; + const isCredentialsEnabled = isProviderEnabled("credentials"); + return { - countBoards: (await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0, - countUsers: (await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0, - countGroups: (await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0, - countInvites: (await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0, - countIntegrations: (await ctx.db.select({ count: count() }).from(integrations))[0]?.count ?? 0, - countApps: (await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0, + countBoards: await getCountForTableAsync(ctx.db, boards, true), + countUsers: await getCountForTableAsync(ctx.db, users, isAdmin), + countGroups: await getCountForTableAsync(ctx.db, groups, true), + countInvites: await getCountForTableAsync(ctx.db, invites, isAdmin), + countIntegrations: await getCountForTableAsync(ctx.db, integrations, isCredentialsEnabled && isAdmin), + countApps: await getCountForTableAsync(ctx.db, apps, true), }; }), }); + +const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canView: boolean) => { + if (!canView) { + return 0; + } + + return (await db.select({ count: count() }).from(table))[0]?.count ?? 0; +}; diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 196c30761..00aa72e34 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -4,6 +4,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server"; import type { Database } from "@homarr/db"; import { and, asc, createId, eq, inArray, like } from "@homarr/db"; import { + groupMembers, groupPermissions, integrationGroupPermissions, integrations, @@ -14,20 +15,48 @@ import type { IntegrationSecretKind } from "@homarr/definitions"; import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "./integration-access"; import { testConnectionAsync } from "./integration-test-connection"; export const integrationRouter = createTRPCRouter({ - all: protectedProcedure.query(async ({ ctx }) => { - const integrations = await ctx.db.query.integrations.findMany(); + all: publicProcedure.query(async ({ ctx }) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrations = await ctx.db.query.integrations.findMany({ + with: { + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); return integrations - .map((integration) => ({ - id: integration.id, - name: integration.name, - kind: integration.kind, - url: integration.url, - })) + .map((integration) => { + const permissions = integration.userPermissions + .map(({ permission }) => permission) + .concat(integration.groupPermissions.map(({ permission }) => permission)); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasUseAccess: + permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"), + hasInteractAccess: permissions.includes("interact") || permissions.includes("full"), + hasFullAccess: permissions.includes("full"), + }, + }; + }) .sort( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), diff --git a/packages/api/src/router/log.ts b/packages/api/src/router/log.ts index ba5690657..d48196a19 100644 --- a/packages/api/src/router/log.ts +++ b/packages/api/src/router/log.ts @@ -4,10 +4,10 @@ import { logger } from "@homarr/log"; import type { LoggerMessage } from "@homarr/redis"; import { loggingChannel } from "@homarr/redis"; -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; export const logRouter = createTRPCRouter({ - subscribe: publicProcedure.subscription(() => { + subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => { return observable((emit) => { const unsubscribe = loggingChannel.subscribe((data) => { emit.next(data); diff --git a/packages/api/src/router/test/app.spec.ts b/packages/api/src/router/test/app.spec.ts index 1ab6ee559..4fdb07d56 100644 --- a/packages/api/src/router/test/app.spec.ts +++ b/packages/api/src/router/test/app.spec.ts @@ -11,6 +11,11 @@ import { appRouter } from "../app"; // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); +const defaultSession: Session = { + user: { id: createId(), permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}; + describe("all should return all apps", () => { test("should return all apps", async () => { const db = createDb(); @@ -89,7 +94,7 @@ describe("create should create a new app with all arguments", () => { const db = createDb(); const caller = appRouter.createCaller({ db, - session: null, + session: defaultSession, }); const input = { name: "Mantine", @@ -112,7 +117,7 @@ describe("create should create a new app with all arguments", () => { const db = createDb(); const caller = appRouter.createCaller({ db, - session: null, + session: defaultSession, }); const input = { name: "Mantine", @@ -137,7 +142,7 @@ describe("update should update an app", () => { const db = createDb(); const caller = appRouter.createCaller({ db, - session: null, + session: defaultSession, }); const appId = createId(); @@ -172,7 +177,7 @@ describe("update should update an app", () => { const db = createDb(); const caller = appRouter.createCaller({ db, - session: null, + session: defaultSession, }); const actAsync = async () => @@ -192,7 +197,7 @@ describe("delete should delete an app", () => { const db = createDb(); const caller = appRouter.createCaller({ db, - session: null, + session: defaultSession, }); const appId = createId(); diff --git a/packages/api/src/router/test/group.spec.ts b/packages/api/src/router/test/group.spec.ts index 5e3ca35a4..b552e3bbd 100644 --- a/packages/api/src/router/test/group.spec.ts +++ b/packages/api/src/router/test/group.spec.ts @@ -1,21 +1,26 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; +import * as env from "@homarr/auth/env.mjs"; import { createId, eq } from "@homarr/db"; import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { groupRouter } from "../group"; const defaultOwnerId = createId(); -const defaultSession = { - user: { - id: defaultOwnerId, - permissions: [], - colorScheme: "light", - }, - expires: new Date().toISOString(), -} satisfies Session; +const createSession = (permissions: GroupPermissionKey[]) => + ({ + user: { + id: defaultOwnerId, + permissions, + colorScheme: "light", + }, + expires: new Date().toISOString(), + }) satisfies Session; +const defaultSession = createSession([]); +const adminSession = createSession(["admin"]); // Mock the auth module to return an empty session vi.mock("@homarr/auth", async () => { @@ -32,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => { async (page, expectedCount) => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values( [1, 2, 3, 4, 5].map((number) => ({ @@ -55,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => { test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values( [1, 2, 3, 4, 5].map((number) => ({ @@ -76,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => { test("groups should contain id, name, email and image of members", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const user = createDummyUser(); await db.insert(users).values(user); @@ -112,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => { async (query, expectedCount, firstKey) => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values( ["first", "second", "third", "forth", "fifth"].map((key, index) => ({ @@ -131,13 +136,25 @@ describe("paginated should return a list of groups with pagination", () => { expect(result.items.at(0)?.name).toBe(firstKey); }, ); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => await caller.getPaginated({}); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("byId should return group by id including members and permissions", () => { test('should return group with id "1" with members and permissions', async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const user = createDummyUser(); const groupId = "1"; @@ -180,7 +197,7 @@ describe("byId should return group by id including members and permissions", () test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values({ id: "2", @@ -193,13 +210,25 @@ describe("byId should return group by id including members and permissions", () // Assert await expect(actAsync()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => await caller.getById({ id: "1" }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("create should create group in database", () => { test("with valid input (64 character name) and non existing name it should be successful", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const name = "a".repeat(64); await db.insert(users).values(defaultSession.user); @@ -223,7 +252,7 @@ describe("create should create group in database", () => { test("with more than 64 characters name it should fail while validation", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const longName = "a".repeat(65); // Act @@ -244,7 +273,7 @@ describe("create should create group in database", () => { ])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values({ id: createId(), @@ -257,6 +286,18 @@ describe("create should create group in database", () => { // Assert await expect(actAsync()).rejects.toThrow("similar name"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => await caller.createGroup({ name: "test" }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("update should update name with value that is no duplicate", () => { @@ -266,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => { ])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); await db.insert(groups).values([ @@ -299,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => { ])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); await db.insert(groups).values([ @@ -327,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => { test("with non existing id it should throw not found error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values({ id: createId(), @@ -344,13 +385,29 @@ describe("update should update name with value that is no duplicate", () => { // Assert await expect(act()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => + await caller.updateGroup({ + id: createId(), + name: "test", + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("savePermissions should save permissions for group", () => { test("with existing group and permissions it should save permissions", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); await db.insert(groups).values({ @@ -380,7 +437,7 @@ describe("savePermissions should save permissions for group", () => { test("with non existing group it should throw not found error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values({ id: createId(), @@ -397,13 +454,29 @@ describe("savePermissions should save permissions for group", () => { // Assert await expect(actAsync()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => + await caller.savePermissions({ + groupId: createId(), + permissions: ["integration-create", "board-full-all"], + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("transferOwnership should transfer ownership of group", () => { test("with existing group and user it should transfer ownership", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); const newUserId = createId(); @@ -440,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => { test("with non existing group it should throw not found error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values({ id: createId(), @@ -457,13 +530,29 @@ describe("transferOwnership should transfer ownership of group", () => { // Assert await expect(actAsync()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => + await caller.transferOwnership({ + groupId: createId(), + userId: createId(), + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("deleteGroup should delete group", () => { test("with existing group it should delete group", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); await db.insert(groups).values([ @@ -492,7 +581,7 @@ describe("deleteGroup should delete group", () => { test("with non existing group it should throw not found error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(groups).values({ id: createId(), @@ -508,13 +597,30 @@ describe("deleteGroup should delete group", () => { // Assert await expect(actAsync()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => + await caller.deleteGroup({ + id: createId(), + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); }); describe("addMember should add member to group", () => { test("with existing group and user it should add member", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(env, "env", "get"); + spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); const userId = createId(); @@ -552,7 +658,7 @@ describe("addMember should add member to group", () => { test("with non existing group it should throw not found error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(users).values({ id: createId(), @@ -569,13 +675,67 @@ describe("addMember should add member to group", () => { // Assert await expect(actAsync()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => + await caller.addMember({ + groupId: createId(), + userId: createId(), + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); + + test("without credentials provider it should throw FORBIDDEN error", async () => { + // Arrange + const db = createDb(); + const spy = vi.spyOn(env, "env", "get"); + spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never); + const caller = groupRouter.createCaller({ db, session: adminSession }); + + const groupId = createId(); + const userId = createId(); + await db.insert(users).values([ + { + id: userId, + name: "User", + }, + { + id: defaultOwnerId, + name: "Creator", + }, + ]); + await db.insert(groups).values({ + id: groupId, + name: "Group", + ownerId: defaultOwnerId, + }); + + // Act + const actAsync = async () => + await caller.addMember({ + groupId, + userId, + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Credentials provider is disabled"); + }); }); describe("removeMember should remove member from group", () => { test("with existing group and user it should remove member", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(env, "env", "get"); + spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never); + const caller = groupRouter.createCaller({ db, session: adminSession }); const groupId = createId(); const userId = createId(); @@ -616,7 +776,7 @@ describe("removeMember should remove member from group", () => { test("with non existing group it should throw not found error", async () => { // Arrange const db = createDb(); - const caller = groupRouter.createCaller({ db, session: defaultSession }); + const caller = groupRouter.createCaller({ db, session: adminSession }); await db.insert(users).values({ id: createId(), @@ -633,6 +793,62 @@ describe("removeMember should remove member from group", () => { // Assert await expect(actAsync()).rejects.toThrow("Group not found"); }); + + test("without admin permissions it should throw unauthorized error", async () => { + // Arrange + const db = createDb(); + const caller = groupRouter.createCaller({ db, session: defaultSession }); + + // Act + const actAsync = async () => + await caller.removeMember({ + groupId: createId(), + userId: createId(), + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Permission denied"); + }); + + test("without credentials provider it should throw FORBIDDEN error", async () => { + // Arrange + const db = createDb(); + const spy = vi.spyOn(env, "env", "get"); + spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never); + const caller = groupRouter.createCaller({ db, session: adminSession }); + + const groupId = createId(); + const userId = createId(); + await db.insert(users).values([ + { + id: userId, + name: "User", + }, + { + id: defaultOwnerId, + name: "Creator", + }, + ]); + await db.insert(groups).values({ + id: groupId, + name: "Group", + ownerId: defaultOwnerId, + }); + await db.insert(groupMembers).values({ + groupId, + userId, + }); + + // Act + const actAsync = async () => + await caller.removeMember({ + groupId, + userId, + }); + + // Assert + await expect(actAsync()).rejects.toThrow("Credentials provider is disabled"); + }); }); const createDummyUser = () => ({ diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index e27074426..237d32968 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -4,9 +4,22 @@ import type { Session } from "@homarr/auth"; import { createId, eq, schema } from "@homarr/db"; import { users } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { userRouter } from "../user"; +const defaultOwnerId = createId(); +const createSession = (permissions: GroupPermissionKey[]) => + ({ + user: { + id: defaultOwnerId, + permissions, + colorScheme: "light", + }, + expires: new Date().toISOString(), + }) satisfies Session; +const defaultSession = createSession([]); + // Mock the auth module to return an empty session vi.mock("@homarr/auth", async () => { const mod = await import("@homarr/auth/security"); @@ -212,14 +225,13 @@ describe("editProfile shoud update user", () => { const db = createDb(); const caller = userRouter.createCaller({ db, - session: null, + session: defaultSession, }); - const id = createId(); const emailVerified = new Date(2024, 0, 5); await db.insert(schema.users).values({ - id, + id: defaultOwnerId, name: "TEST 1", email: "abc@gmail.com", emailVerified, @@ -227,17 +239,17 @@ describe("editProfile shoud update user", () => { // act await caller.editProfile({ - id: id, + id: defaultOwnerId, name: "ABC", email: "", }); // assert - const user = await db.select().from(schema.users).where(eq(schema.users.id, id)); + const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId)); expect(user).toHaveLength(1); expect(user[0]).toStrictEqual({ - id, + id: defaultOwnerId, name: "ABC", email: "abc@gmail.com", emailVerified, @@ -255,13 +267,11 @@ describe("editProfile shoud update user", () => { const db = createDb(); const caller = userRouter.createCaller({ db, - session: null, + session: defaultSession, }); - const id = createId(); - await db.insert(schema.users).values({ - id, + id: defaultOwnerId, name: "TEST 1", email: "abc@gmail.com", emailVerified: new Date(2024, 0, 5), @@ -269,17 +279,17 @@ describe("editProfile shoud update user", () => { // act await caller.editProfile({ - id, + id: defaultOwnerId, name: "ABC", email: "myNewEmail@gmail.com", }); // assert - const user = await db.select().from(schema.users).where(eq(schema.users.id, id)); + const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId)); expect(user).toHaveLength(1); expect(user[0]).toStrictEqual({ - id, + id: defaultOwnerId, name: "ABC", email: "myNewEmail@gmail.com", emailVerified: null, @@ -298,11 +308,9 @@ describe("delete should delete user", () => { const db = createDb(); const caller = userRouter.createCaller({ db, - session: null, + session: defaultSession, }); - const userToDelete = createId(); - const initialUsers = [ { id: createId(), @@ -317,7 +325,7 @@ describe("delete should delete user", () => { colorScheme: "auto" as const, }, { - id: userToDelete, + id: defaultOwnerId, name: "User 2", email: null, emailVerified: null, @@ -343,7 +351,7 @@ describe("delete should delete user", () => { await db.insert(schema.users).values(initialUsers); - await caller.delete(userToDelete); + await caller.delete(defaultOwnerId); const usersInDb = await db.select().from(schema.users); expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 7c8f9489e..1f28db126 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions"; import { logger } from "@homarr/log"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { throwIfCredentialsDisabled } from "./invite/checks"; export const userRouter = createTRPCRouter({ @@ -69,7 +69,8 @@ export const userRouter = createTRPCRouter({ // Delete invite as it's used await ctx.db.delete(invites).where(inviteWhere); }), - create: publicProcedure + create: permissionRequiredProcedure + .requiresPermission("admin") .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } }) .input(validation.user.create) .output(z.void()) @@ -130,7 +131,8 @@ export const userRouter = createTRPCRouter({ }) .where(eq(users.id, input.userId)); }), - getAll: publicProcedure + getAll: permissionRequiredProcedure + .requiresPermission("admin") .input(z.void()) .output( z.array( @@ -155,7 +157,8 @@ export const userRouter = createTRPCRouter({ }, }); }), - selectable: publicProcedure.query(({ ctx }) => { + // Is protected because also used in board access / integration access forms + selectable: protectedProcedure.query(({ ctx }) => { return ctx.db.query.users.findMany({ columns: { id: true, @@ -164,7 +167,8 @@ export const userRouter = createTRPCRouter({ }, }); }), - search: publicProcedure + search: permissionRequiredProcedure + .requiresPermission("admin") .input( z.object({ query: z.string(), @@ -187,7 +191,14 @@ export const userRouter = createTRPCRouter({ image: user.image, })); }), - getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => { + getById: protectedProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => { + // Only admins can view other users details + if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to view other users details", + }); + } const user = await ctx.db.query.users.findFirst({ columns: { id: true, @@ -210,7 +221,15 @@ export const userRouter = createTRPCRouter({ return user; }), - editProfile: publicProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => { + editProfile: protectedProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => { + // Only admins can view other users details + if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to edit other users details", + }); + } + const user = await ctx.db.query.users.findFirst({ columns: { email: true, provider: true }, where: eq(users.id, input.id), @@ -242,7 +261,15 @@ export const userRouter = createTRPCRouter({ }) .where(eq(users.id, input.id)); }), - delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => { + delete: protectedProcedure.input(z.string()).mutation(async ({ input, ctx }) => { + // Only admins and user itself can delete a user + if (ctx.session.user.id !== input && !ctx.session.user.permissions.includes("admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to delete other users", + }); + } + await ctx.db.delete(users).where(eq(users.id, input)); }), changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => { @@ -311,7 +338,7 @@ export const userRouter = createTRPCRouter({ .input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))) .mutation(async ({ input, ctx }) => { const user = ctx.session.user; - // Only admins can change other users' passwords + // Only admins can change other users passwords if (!user.permissions.includes("admin") && user.id !== input.userId) { throw new TRPCError({ code: "NOT_FOUND", diff --git a/packages/auth/permissions/integration-permissions.ts b/packages/auth/permissions/integration-permissions.ts index 0ab30322d..cde191996 100644 --- a/packages/auth/permissions/integration-permissions.ts +++ b/packages/auth/permissions/integration-permissions.ts @@ -12,15 +12,17 @@ export interface IntegrationPermissionsProps { } export const constructIntegrationPermissions = (integration: IntegrationPermissionsProps, session: Session | null) => { + const permissions = integration.userPermissions + .concat(integration.groupPermissions) + .map(({ permission }) => permission); + return { - hasFullAccess: session?.user.permissions.includes("integration-full-all") ?? false, + hasFullAccess: + (session?.user.permissions.includes("integration-full-all") ?? false) || permissions.includes("full"), hasInteractAccess: - integration.userPermissions.some(({ permission }) => permission === "interact") || - integration.groupPermissions.some(({ permission }) => permission === "interact") || + permissions.includes("full") || + permissions.includes("interact") || (session?.user.permissions.includes("integration-interact-all") ?? false), - hasUseAccess: - integration.userPermissions.length >= 1 || - integration.groupPermissions.length >= 1 || - (session?.user.permissions.includes("integration-use-all") ?? false), + hasUseAccess: permissions.length >= 1 || (session?.user.permissions.includes("integration-use-all") ?? false), }; }; diff --git a/packages/auth/test/callbacks.spec.ts b/packages/auth/test/callbacks.spec.ts index 20dbf17f2..b7002fb59 100644 --- a/packages/auth/test/callbacks.spec.ts +++ b/packages/auth/test/callbacks.spec.ts @@ -1,6 +1,4 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; -import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import { cookies } from "next/headers"; import type { Adapter, AdapterUser } from "@auth/core/adapters"; import type { Account } from "next-auth"; @@ -13,6 +11,14 @@ import * as definitions from "@homarr/definitions"; import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsAsync } from "../callbacks"; +// This one is placed here because it's used in multiple tests and needs to be the same reference +const setCookies = vi.fn(); +vi.mock("next/headers", () => ({ + cookies: () => ({ + set: setCookies, + }), +})); + describe("getCurrentUserPermissions", () => { test("should return empty permissions when non existing user requested", async () => { // Arrange @@ -170,21 +176,6 @@ vi.mock("../session", async (importOriginal) => { expireDateAfter, } satisfies SessionExport; }); -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -type HeadersExport = typeof import("next/headers"); -vi.mock("next/headers", async (importOriginal) => { - const mod = await importOriginal(); - - const result = { - set: (name: string, value: string, options: Partial) => options as ResponseCookie, - } as unknown as ReadonlyRequestCookies; - - vi.spyOn(result, "set"); - - const cookies = () => result; - - return { ...mod, cookies } satisfies HeadersExport; -}); describe("createSignInCallback", () => { test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => { @@ -232,7 +223,6 @@ describe("createSignInCallback", () => { const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest); const user = { id: "1", emailVerified: new Date("2023-01-13") }; const account = {} as Account; - // Act await signInCallback({ user, account }); @@ -253,7 +243,7 @@ describe("createSignInCallback", () => { test("should set colorScheme from db as cookie", async () => { // Arrange - const isCredentialsRequest = false; + const isCredentialsRequest = true; const db = await prepareDbForSigninAsync("1"); const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest); diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index f3efe677f..e0b268a12 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -93,7 +93,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconUsers, path: "/manage/users", name: t("manageUser.label"), - hidden: !session, + hidden: !session?.user.permissions.includes("admin"), }, { icon: IconMailForward, @@ -105,7 +105,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconUsersGroup, path: "/manage/users/groups", name: t("manageGroup.label"), - hidden: !session, + hidden: !session?.user.permissions.includes("admin"), }, { icon: IconBrandDocker, @@ -117,7 +117,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconPlug, path: "/manage/tools/api", name: t("manageApi.label"), - hidden: !session, + hidden: !session?.user.permissions.includes("admin"), }, { icon: IconLogs, diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index bc2b2f8f3..06d805596 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1627,12 +1627,12 @@ export default { page: { home: { statistic: { - countBoards: "Boards", - createUser: "Create new user", - createInvite: "Create new invite", - addIntegration: "Create integration", - addApp: "Add app", - manageRoles: "Manage roles", + board: "Boards", + user: "Users", + invite: "Invites", + integration: "Integrations", + app: "Apps", + group: "Groups", }, statisticLabel: { boards: "Boards", diff --git a/vitest.config.mts b/vitest.config.mts index 5a9efb391..5c07aef12 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,6 +8,7 @@ export default defineConfig({ setupFiles: ["./vitest.setup.ts"], environment: "jsdom", include: ["**/*.spec.ts"], + clearMocks: true, poolOptions: { threads: { singleThread: false, @@ -18,7 +19,7 @@ export default defineConfig({ reporter: ["html", "json-summary", "json"], all: true, exclude: ["apps/nextjs/.next/"], - reportOnFailure: true + reportOnFailure: true, }, exclude: [...configDefaults.exclude, "apps/nextjs/.next"],