Skip to content

Commit

Permalink
feat: restrict access with app permissions
Browse files Browse the repository at this point in the history
  • Loading branch information
Meierschlumpf committed Nov 8, 2024
1 parent 924de96 commit 9d85642
Show file tree
Hide file tree
Showing 17 changed files with 211 additions and 68 deletions.
7 changes: 7 additions & 0 deletions apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";

import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";

import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
Expand All @@ -11,6 +13,11 @@ interface AppEditPageProps {
}

export default async function AppEditPage({ params }: AppEditPageProps) {
const session = await auth();

if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id });
const t = await getI18n();

Expand Down
8 changes: 8 additions & 0 deletions apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";

import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";

import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form";

export default async function AppNewPage() {
const session = await auth();

if (!session?.user.permissions.includes("app-create")) {
notFound();
}

const t = await getI18n();

return (
Expand Down
39 changes: 26 additions & 13 deletions apps/nextjs/src/app/[locale]/manage/apps/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react";

import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";

Expand All @@ -13,6 +15,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppDeleteButton } from "./_app-delete-button";

export default async function AppsPage() {
const session = await auth();

if (!session) {
redirect("/auth/login");
}

const apps = await api.app.all();
const t = await getScopedI18n("app");

Expand All @@ -22,9 +30,11 @@ export default async function AppsPage() {
<Stack>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
)}
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
Expand All @@ -45,6 +55,7 @@ interface AppCardProps {

const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const session = await auth();

return (
<Card>
Expand Down Expand Up @@ -78,16 +89,18 @@ const AppCard = async ({ app }: AppCardProps) => {
</Group>
<Group>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<AppDeleteButton app={app} />
{session?.user.permissions.includes("app-modify-all") && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
</ActionIconGroup>
</Group>
</Group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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 { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form";

Expand Down
1 change: 1 addition & 0 deletions apps/nextjs/src/app/[locale]/manage/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
},
{
icon: IconPlug,
Expand Down
1 change: 1 addition & 0 deletions apps/nextjs/src/app/[locale]/manage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default async function ManagementPage() {
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.app"),
hidden: !session?.user,
},
{
count: statistics.countGroups,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";

import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";

import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";

import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";

Expand Down
20 changes: 20 additions & 0 deletions apps/nextjs/src/errors/trpc-catch-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import "server-only";

import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";

export const catchTrpcNotFound = (err: unknown) => {
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
notFound();
}

throw err;
};

export const catchTrpcUnauthorized = (err: unknown) => {
if (err instanceof TRPCError && err.code === "UNAUTHORIZED") {
redirect("/auth/login");
}

throw err;
};
12 changes: 0 additions & 12 deletions apps/nextjs/src/errors/trpc-not-found.ts

This file was deleted.

68 changes: 41 additions & 27 deletions packages/api/src/router/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation";

import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { canUserSeeAppAsync } from "./app/app-access-control";

export const appRouter = createTRPCRouter({
all: publicProcedure
all: protectedProcedure
.input(z.void())
.output(
z.array(
Expand All @@ -26,7 +27,7 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name),
});
}),
search: publicProcedure
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.output(
z.array(
Expand All @@ -47,7 +48,7 @@ export const appRouter = createTRPCRouter({
limit: input.limit,
});
}),
selectable: publicProcedure
selectable: protectedProcedure
.input(z.void())
.output(
z.array(
Expand Down Expand Up @@ -104,14 +105,23 @@ export const appRouter = createTRPCRouter({
});
}

const canUserSeeApp = await canUserSeeAppAsync(ctx.session?.user ?? null, app.id);
if (!canUserSeeApp) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}

return app;
}),
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
byIds: protectedProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
where: inArray(apps.id, input),
});
}),
create: protectedProcedure
create: permissionRequiredProcedure
.requiresPermission("app-create")
.input(validation.app.manage)
.output(z.void())
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
Expand All @@ -124,29 +134,33 @@ export const appRouter = createTRPCRouter({
href: input.href,
});
}),
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});

if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
update: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(validation.app.edit)
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
}

await ctx.db
.update(apps)
.set({
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
})
.where(eq(apps.id, input.id));
}),
delete: protectedProcedure
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}

await ctx.db
.update(apps)
.set({
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
})
.where(eq(apps.id, input.id));
}),
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.input(validation.common.byId)
Expand Down
50 changes: 50 additions & 0 deletions packages/api/src/router/app/app-access-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import SuperJSON from "superjson";

import type { Session } from "@homarr/auth";
import { db, eq, or } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";

import type { WidgetComponentProps } from "../../../../widgets/src";

export const canUserSeeAppAsync = async (user: Session["user"] | null, appId: string) => {
return await canUserSeeAppsAsync(user, [appId]);
};

export const canUserSeeAppsAsync = async (user: Session["user"] | null, appIds: string[]) => {
if (user) return true;

const appIdsOnPublicBoards = await getAllAppIdsOnPublicBoardsAsync();
return appIds.every((appId) => appIdsOnPublicBoards.includes(appId));
};

const getAllAppIdsOnPublicBoardsAsync = async () => {
const itemsWithApps = await db.query.items.findMany({
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
with: {
section: {
columns: {}, // Nothing
with: {
board: {
columns: {
isPublic: true,
},
},
},
},
},
});

return itemsWithApps
.filter((item) => item.section.board.isPublic)
.flatMap((item) => {
if (item.kind === "app") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
return [parsedOptions.appId];
} else if (item.kind === "bookmarks") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"bookmarks">["options"]>(item.options);
return parsedOptions.items;
}

throw new Error("Invalid item kind");
});
};
Loading

0 comments on commit 9d85642

Please sign in to comment.