From 2f6b96e5ba92d950d5280d9ec3566cbc30c0c2c1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 17:04:35 +0200 Subject: [PATCH] feat: restrict integrations management to integration-full-all permission (#6509) --- .../app/[locale]/manage/integrations/edit/[id]/page.tsx | 7 +++++++ .../src/app/[locale]/manage/integrations/new/page.tsx | 5 ++++- apps/nextjs/src/app/[locale]/manage/integrations/page.tsx | 5 ++++- apps/nextjs/src/app/[locale]/manage/layout.tsx | 2 +- packages/api/src/router/home.ts | 2 +- packages/api/src/router/integration/integration-router.ts | 6 ++++-- packages/spotlight/src/modes/command/global-group.tsx | 4 +++- packages/spotlight/src/modes/page/pages-search-group.tsx | 2 +- 8 files changed, 25 insertions(+), 8 deletions(-) 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 8cd8bf01b..ed2da1aab 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 @@ -1,5 +1,7 @@ +import { notFound } from "next/navigation"; import { Container, Fieldset, Group, Stack, Title } from "@mantine/core"; +import { auth } from "@homarr/auth/next"; import { api } from "@homarr/api/server"; import { getIntegrationName } from "@homarr/definitions"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; @@ -16,6 +18,11 @@ interface EditIntegrationPageProps { export default async function EditIntegrationPage(props: EditIntegrationPageProps) { const params = await props.params; + const session = await auth(); + + if (!session?.user.permissions.includes("integration-full-all")) { + notFound(); + } const editT = await getScopedI18n("integration.page.edit"); const t = await getI18n(); const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound); 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 3194134c3..d6f1cce98 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -23,7 +23,10 @@ interface NewIntegrationPageProps { export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { const searchParams = await props.searchParams; const session = await auth(); - if (!session?.user.permissions.includes("integration-create")) { + if ( + !session?.user.permissions.includes("integration-full-all") || + !session?.user.permissions.includes("integration-create") + ) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 0eeb25c1a..692329896 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { redirect } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { AccordionControl, AccordionItem, @@ -50,6 +50,9 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { if (!session) { redirect("/auth/login"); } + if (!session.user.permissions.includes("integration-full-all")) { + notFound(); + } const integrations = await api.integration.all(); const t = await getScopedI18n("integration"); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 1cd0d3d2e..ba2440c25 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -71,7 +71,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconAffiliateFilled, href: "/manage/integrations", label: t("items.integrations"), - hidden: !session?.user.permissions.includes("integration-create"), + hidden: !session?.user.permissions.includes("integration-full-all"), "data-onboarding-tour-id": "manage-integrations", }, { diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index 4b63f9326..a9cee2c21 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -92,7 +92,7 @@ export const homeRouter = createTRPCRouter({ }); } - if (ctx.session?.user.permissions.includes("integration-create")) { + if (ctx.session?.user.permissions.includes("integration-full-all")) { statistics.push({ titleKey: "integration", subtitleKey: "resources", diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index af237bb31..2b40dc2a8 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -62,12 +62,13 @@ export const integrationRouter = createTRPCRouter({ requiredSecrets: def.secretKinds, })); }), - all: protectedProcedure + all: permissionRequiredProcedure + .requiresPermission("integration-full-all") .meta({ mcp: { enabled: true, description: - "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", + "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Requires integration-full-all permission. Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", }, }) .query(async ({ ctx }) => { @@ -296,6 +297,7 @@ export const integrationRouter = createTRPCRouter({ }; }), create: permissionRequiredProcedure + .requiresPermission("integration-full-all") .requiresPermission("integration-create") .meta({ mcp: { diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index fd277b55e..fa8f53c5f 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -95,7 +95,9 @@ export const globalCommandGroup = createGroup({ icon: IconPlug, name: tOption("newIntegration.label"), useInteraction: interaction.children(newIntegrationChildrenOptions), - hidden: !session?.user.permissions.includes("integration-create"), + hidden: + !session?.user.permissions.includes("integration-full-all") || + !session?.user.permissions.includes("integration-create"), }, { commandKey: "newUser", diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx index 66fe1ce4f..1b3b0ee68 100644 --- a/packages/spotlight/src/modes/page/pages-search-group.tsx +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -82,7 +82,7 @@ export const pagesSearchGroup = createGroup<{ icon: IconPlug, path: "/manage/integrations", name: t("manageIntegration.label"), - hidden: !session, + hidden: !session?.user.permissions.includes("integration-full-all"), }, { icon: IconSearch,