From b1e065f1da0fc0c4992d0de8f6ed87a9adef1d24 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 4 May 2024 18:34:41 +0200 Subject: [PATCH] feat: board access group permissions (#422) * fix: cache is not exportet from react * fix: format issue * wip: add usage of group permissions * feat: show inherited groups and add manage group * refactor: improve board access management * chore: address pull request feedback * fix: type issues * fix: migrations * test: add unit tests for board permissions, permissions and board router * test: add unit tests for board router and get current user permissions method * fix: format issues * fix: deepsource issue --- .../boards/[name]/settings/_access.tsx | 347 ++-------- .../_access/board-access-table-rows.tsx | 139 ++++ .../boards/[name]/settings/_access/form.ts | 14 + .../[name]/settings/_access/group-access.tsx | 148 ++++ .../settings/_access/group-select-modal.tsx | 72 ++ .../settings/_access/inherit-access.tsx | 66 ++ .../[name]/settings/_access/user-access.tsx | 173 +++++ .../settings/_access/user-select-modal.tsx | 112 +++ .../[locale]/boards/[name]/settings/page.tsx | 9 +- .../_components/board-card-menu-dropdown.tsx | 7 +- .../groups/[id]/_transfer-group-ownership.tsx | 2 +- .../groups/[id]/members/_add-group-member.tsx | 2 +- packages/api/src/router/board.ts | 246 +++++-- packages/api/src/router/board/board-access.ts | 21 +- packages/api/src/router/group.ts | 8 + packages/api/src/router/test/board.spec.ts | 642 +++++++++++++++++- packages/api/src/router/test/group.spec.ts | 1 + packages/api/src/router/test/invite.spec.ts | 3 +- packages/api/src/router/user.ts | 1 + packages/api/src/trpc.ts | 23 + packages/auth/callbacks.ts | 54 +- packages/auth/configuration.ts | 4 +- packages/auth/index.ts | 3 + packages/auth/package.json | 1 + .../auth/permissions/board-permissions.ts | 23 +- .../test/board-permissions.spec.ts | 185 ++++- packages/auth/session.ts | 7 +- packages/auth/test/callbacks.spec.ts | 63 +- ...bby_darkhawk.sql => 0000_hot_mandrill.sql} | 17 +- .../migrations/mysql/meta/0000_snapshot.json | 76 ++- .../db/migrations/mysql/meta/_journal.json | 4 +- ...ree.sql => 0000_premium_forgotten_one.sql} | 11 +- .../migrations/sqlite/meta/0000_snapshot.json | 76 ++- .../db/migrations/sqlite/meta/_journal.json | 4 +- packages/db/package.json | 4 +- packages/db/schema/mysql.ts | 50 +- packages/db/schema/sqlite.ts | 50 +- packages/definitions/src/permissions.ts | 17 +- .../definitions/src/test/permissions.spec.ts | 90 +++ packages/translation/src/lang/en.ts | 15 + packages/validation/src/board.ts | 5 +- pnpm-lock.yaml | 3 + 42 files changed, 2375 insertions(+), 423 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx rename packages/db/migrations/mysql/{0000_chubby_darkhawk.sql => 0000_hot_mandrill.sql} (85%) rename packages/db/migrations/sqlite/{0000_abnormal_kree.sql => 0000_premium_forgotten_one.sql} (92%) create mode 100644 packages/definitions/src/test/permissions.spec.ts diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx index 2cefb3dbf..c90192bfa 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx @@ -1,40 +1,19 @@ "use client"; -import { useCallback, useState } from "react"; -import type { SelectProps } from "@mantine/core"; -import { - Button, - Flex, - Group, - Loader, - Select, - Stack, - Table, - TableTbody, - TableTd, - TableTh, - TableThead, - TableTr, - Text, -} from "@mantine/core"; -import { - IconCheck, - IconEye, - IconPencil, - IconPlus, - IconSettings, -} from "@tabler/icons-react"; +import { useState } from "react"; +import { Group, Stack, Tabs } from "@mantine/core"; +import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import type { BoardPermission } from "@homarr/definitions"; -import { boardPermissions } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal, useModalAction } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; +import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; +import { CountBadge } from "@homarr/ui"; import type { Board } from "../../_types"; +import { GroupsForm } from "./_access/group-access"; +import { InheritTable } from "./_access/inherit-access"; +import { UsersForm } from "./_access/user-access"; interface Props { board: Board; @@ -54,251 +33,73 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => { }, ); - const t = useI18n(); - const form = useForm({ - initialValues: { - permissions: permissions.sort((permissionA, permissionB) => { - if (permissionA.user.id === board.creatorId) return -1; - if (permissionB.user.id === board.creatorId) return 1; - return permissionA.user.name.localeCompare(permissionB.user.name); - }), - }, + const [counts, setCounts] = useState({ + user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0), + group: initialPermissions.groupPermissions.length, }); - const { mutate, isPending } = - clientApi.board.saveBoardPermissions.useMutation(); - const utils = clientApi.useUtils(); - const { openModal } = useModalAction(UserSelectModal); - - const handleSubmit = useCallback( - (values: FormType) => { - mutate( - { - id: board.id, - permissions: values.permissions, - }, - { - onSuccess: () => { - void utils.board.getBoardPermissions.invalidate(); - }, - }, - ); - }, - [board.id, mutate, utils.board.getBoardPermissions], - ); - - const handleAddUser = useCallback(() => { - const presentUserIds = form.values.permissions.map( - (permission) => permission.user.id, - ); - - openModal({ - presentUserIds: board.creatorId - ? presentUserIds.concat(board.creatorId) - : presentUserIds, - onSelect: (user) => { - form.setFieldValue("permissions", [ - ...form.values.permissions, - { - user, - permission: "board-view", - }, - ]); - }, - }); - }, [form, openModal, board.creatorId]); return ( -
- - - - - - {t("board.setting.section.access.permission.field.user.label")} - - - {t( - "board.setting.section.access.permission.field.permission.label", - )} - - - - - {board.creator && } - {form.values.permissions.map((row, index) => { - const Icon = icons[row.permission]; - return ( - - {row.user.name} - - -
- - - - - -
-
- ); -}; - -interface CreatorRowProps { - user: Exclude; -} - -const CreatorRow = ({ user }: CreatorRowProps) => { - const t = useI18n(); - return ( - - {user.name} - - - - - - - {t("board.setting.section.access.permission.item.board-full.label")} - - - - - ); -}; - -const icons = { - "board-change": IconPencil, - "board-view": IconEye, -} satisfies Record; - -const iconProps = { - stroke: 1.5, - color: "currentColor", - opacity: 0.6, - size: "1rem", -}; - -const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { - const Icon = icons[option.value as BoardPermission]; - return ( - - - {option.label} - {checked && ( - - )} - - ); -}; - -interface FormType { - permissions: RouterOutputs["board"]["getBoardPermissions"]; -} - -interface InnerProps { - presentUserIds: string[]; - onSelect: (props: { id: string; name: string }) => void | Promise; - confirmLabel?: string; -} - -interface UserSelectFormType { - userId: string; -} - -export const UserSelectModal = createModal( - ({ actions, innerProps }) => { - const t = useI18n(); - const { data: users, isPending } = clientApi.user.selectable.useQuery(); - const [loading, setLoading] = useState(false); - const form = useForm(); - const handleSubmit = async (values: UserSelectFormType) => { - const currentUser = users?.find((user) => user.id === values.userId); - if (!currentUser) return; - setLoading(true); - await innerProps.onSelect({ - id: currentUser.id, - name: currentUser.name ?? "", - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); - - return ( -
void handleSubmit(values))}> - - } + renderOption={RenderOption} + variant="unstyled" + data={boardPermissions.map((permission) => ({ + value: permission, + label: tPermissions(`item.${permission}.label`), + }))} + {...form.getInputProps(`items.${index}.permission`)} + /> + + + + + + ); +}; + +interface BoardAccessDisplayRowProps { + itemContent: ReactNode; + permission: BoardPermission | "board-full"; +} + +export const BoardAccessDisplayRow = ({ + itemContent, + permission, +}: BoardAccessDisplayRowProps) => { + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const Icon = icons[permission]; + + return ( + + {itemContent} + + + + + + {tPermissions(`item.${permission}.label`)} + + + + ); +}; + +const iconProps = { + stroke: 1.5, + color: "currentColor", + opacity: 0.6, + size: "1rem", +}; + +const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { + const Icon = icons[option.value as BoardPermission]; + return ( + + + {option.label} + {checked && ( + + )} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts new file mode 100644 index 000000000..874376fc0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts @@ -0,0 +1,14 @@ +import type { BoardPermission } from "@homarr/definitions"; +import { createFormContext } from "@homarr/form"; + +export interface BoardAccessFormType { + items: { + itemId: string; + permission: BoardPermission; + }[]; +} + +export const [FormProvider, useFormContext, useForm] = + createFormContext(); + +export type OnCountChange = (callback: (prev: number) => number) => void; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx new file mode 100644 index 000000000..4ea047eca --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx @@ -0,0 +1,148 @@ +import { useCallback, useState } from "react"; +import Link from "next/link"; +import { + Anchor, + Button, + Group, + Stack, + Table, + TableTbody, + TableTh, + TableThead, + TableTr, +} from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useModalAction } from "@homarr/modals"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import { BoardAccessSelectRow } from "./board-access-table-rows"; +import type { BoardAccessFormType } from "./form"; +import { FormProvider, useForm } from "./form"; +import { GroupSelectModal } from "./group-select-modal"; +import type { FormProps } from "./user-access"; + +export const GroupsForm = ({ + board, + initialPermissions, + onCountChange, +}: FormProps) => { + const { mutate, isPending } = + clientApi.board.saveGroupBoardPermissions.useMutation(); + const utils = clientApi.useUtils(); + const [groups, setGroups] = useState>( + new Map( + initialPermissions.groupPermissions.map(({ group }) => [group.id, group]), + ), + ); + const { openModal } = useModalAction(GroupSelectModal); + const t = useI18n(); + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const form = useForm({ + initialValues: { + items: initialPermissions.groupPermissions.map( + ({ group, permission }) => ({ + itemId: group.id, + permission, + }), + ), + }, + }); + + const handleSubmit = useCallback( + (values: BoardAccessFormType) => { + mutate( + { + id: board.id, + permissions: values.items, + }, + { + onSuccess: () => { + void utils.board.getBoardPermissions.invalidate(); + }, + }, + ); + }, + [board.id, mutate, utils.board.getBoardPermissions], + ); + + const handleAddUser = useCallback(() => { + openModal({ + presentGroupIds: form.values.items.map(({ itemId: id }) => id), + onSelect: (group) => { + setGroups((prev) => new Map(prev).set(group.id, group)); + form.setFieldValue("items", [ + { + itemId: group.id, + permission: "board-view", + }, + ...form.values.items, + ]); + onCountChange((prev) => prev + 1); + }, + }); + }, [form, openModal, onCountChange]); + + return ( + + + + + + + + {tPermissions("field.group.label")} + + {tPermissions("field.permission.label")} + + + + {form.values.items.map((row, index) => ( + + } + permission={row.permission} + index={index} + onCountChange={onCountChange} + /> + ))} + +
+ + + + + +
+
+ + ); +}; + +export const GroupItemContent = ({ group }: { group: Group }) => { + return ( + + {group.name} + + ); +}; + +type Group = + RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"]; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx new file mode 100644 index 000000000..1e9a60b31 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { Button, Group, Loader, Select, Stack } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useForm } from "@homarr/form"; +import { createModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; + +interface InnerProps { + presentGroupIds: string[]; + onSelect: (props: { id: string; name: string }) => void | Promise; + confirmLabel?: string; +} + +interface GroupSelectFormType { + groupId: string; +} + +export const GroupSelectModal = createModal( + ({ actions, innerProps }) => { + const t = useI18n(); + const { data: groups, isPending } = clientApi.group.selectable.useQuery(); + const [loading, setLoading] = useState(false); + const form = useForm(); + const handleSubmit = async (values: GroupSelectFormType) => { + const currentGroup = groups?.find((group) => group.id === values.groupId); + if (!currentGroup) return; + setLoading(true); + await innerProps.onSelect({ + id: currentGroup.id, + name: currentGroup.name, + }); + + setLoading(false); + actions.closeModal(); + }; + + const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); + + return ( +
void handleSubmit(values))}> + + + ) : currentUser ? ( + + ) : undefined + } + nothingFoundMessage={t("user.action.select.notFound")} + renderOption={createRenderOption(users ?? [])} + limit={5} + data={users + ?.filter((user) => !innerProps.presentUserIds.includes(user.id)) + .map((user) => ({ value: user.id, label: user.name ?? "" }))} + /> + + + + + +
+ ); + }, +).withOptions({ + defaultTitle: (t) => + t("board.setting.section.access.permission.userSelect.title"), +}); + +const iconProps = { + stroke: 1.5, + color: "currentColor", + opacity: 0.6, + size: "1rem", +}; + +const createRenderOption = ( + users: RouterOutputs["user"]["selectable"], +): SelectProps["renderOption"] => + function InnerRenderRoot({ option, checked }) { + const user = users.find((user) => user.id === option.value); + if (!user) return null; + + return ( + + + {option.label} + {checked && ( + + )} + + ); + }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index 90f070106..411e8c9c2 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -48,7 +48,14 @@ interface Props { const getBoardAndPermissions = async (params: Props["params"]) => { try { const board = await api.board.getBoardByName({ name: params.name }); - const permissions = await api.board.getBoardPermissions({ id: board.id }); + const { hasFullAccess } = await getBoardPermissions(board); + const permissions = hasFullAccess + ? await api.board.getBoardPermissions({ id: board.id }) + : { + userPermissions: [], + groupPermissions: [], + inherited: [], + }; return { board, permissions }; } catch (error) { diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx index 68eeaf3a0..46f0a3c82 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx @@ -21,7 +21,12 @@ const iconProps = { interface BoardCardMenuDropdownProps { board: Pick< RouterOutputs["board"]["getAllBoards"][number], - "id" | "name" | "creator" | "permissions" | "isPublic" + | "id" + | "name" + | "creator" + | "userPermissions" + | "groupPermissions" + | "isPublic" >; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx index cd91ef109..4d7781abb 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx @@ -11,7 +11,7 @@ import { } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access"; +import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal"; interface TransferGroupOwnershipProps { group: { diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index 6406b2745..af34e25f7 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client"; import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access"; +import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal"; import { revalidatePathAction } from "~/app/revalidatePathAction"; interface AddGroupMemberProps { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index fe84061de..00042ef8f 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -4,14 +4,17 @@ import superjson from "superjson"; import type { Database, SQL } from "@homarr/db"; import { and, createId, eq, inArray, or } from "@homarr/db"; import { - boardPermissions, + boardGroupPermissions, boards, + boardUserPermissions, + groupMembers, + groupPermissions, integrationItems, items, sections, } from "@homarr/db/schema/sqlite"; import type { WidgetKind } from "@homarr/definitions"; -import { widgetKinds } from "@homarr/definitions"; +import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions"; import { createSectionSchema, sharedItemSchema, @@ -20,42 +23,43 @@ import { } from "@homarr/validation"; import { zodUnionFromArray } from "../../../validation/src/enums"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { + createTRPCRouter, + permissionRequiredProcedure, + protectedProcedure, + publicProcedure, +} from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; -const filterAddedItems = ( - inputArray: TInput[], - dbArray: TInput[], -) => - inputArray.filter( - (inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id), - ); - -const filterRemovedItems = ( - inputArray: TInput[], - dbArray: TInput[], -) => - dbArray.filter( - (dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id), - ); - -const filterUpdatedItems = ( - inputArray: TInput[], - dbArray: TInput[], -) => - inputArray.filter((inputItem) => - dbArray.some((dbItem) => dbItem.id === inputItem.id), - ); - export const boardRouter = createTRPCRouter({ getAllBoards: publicProcedure.query(async ({ ctx }) => { const permissionsOfCurrentUserWhenPresent = - await ctx.db.query.boardPermissions.findMany({ - where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""), + await ctx.db.query.boardUserPermissions.findMany({ + where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""), }); - const boardIds = permissionsOfCurrentUserWhenPresent.map( - (permission) => permission.boardId, - ); + + const permissionsOfCurrentUserGroupsWhenPresent = + await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + with: { + group: { + with: { + boardPermissions: {}, + }, + }, + }, + }); + const boardIds = permissionsOfCurrentUserWhenPresent + .map((permission) => permission.boardId) + .concat( + permissionsOfCurrentUserGroupsWhenPresent + .map((groupMember) => + groupMember.group.boardPermissions.map( + (permission) => permission.boardId, + ), + ) + .flat(), + ); const dbBoards = await ctx.db.query.boards.findMany({ columns: { id: true, @@ -70,19 +74,34 @@ export const boardRouter = createTRPCRouter({ image: true, }, }, - permissions: { - where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""), + userPermissions: { + where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: + permissionsOfCurrentUserGroupsWhenPresent.length >= 1 + ? inArray( + boardGroupPermissions.groupId, + permissionsOfCurrentUserGroupsWhenPresent.map( + (groupMember) => groupMember.groupId, + ), + ) + : undefined, }, }, - where: or( - eq(boards.isPublic, true), - eq(boards.creatorId, ctx.session?.user.id ?? ""), - boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, - ), + // Allow viewing all boards if the user has the permission + where: ctx.session?.user.permissions.includes("board-view-all") + ? undefined + : or( + eq(boards.isPublic, true), + eq(boards.creatorId, ctx.session?.user.id ?? ""), + boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, + ), }); return dbBoards; }), - createBoard: protectedProcedure + createBoard: permissionRequiredProcedure + .requiresPermission("board-create") .input(validation.board.create) .mutation(async ({ ctx, input }) => { const boardId = createId(); @@ -377,10 +396,20 @@ export const boardRouter = createTRPCRouter({ "full-access", ); - const permissions = await ctx.db.query.boardPermissions.findMany({ - where: eq(boardPermissions.boardId, input.id), + const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({ + where: inArray( + groupPermissions.permission, + getPermissionsWithParents([ + "board-view-all", + "board-modify-all", + "board-full-access", + ]), + ), + columns: { + groupId: false, + }, with: { - user: { + group: { columns: { id: true, name: true, @@ -388,19 +417,61 @@ export const boardRouter = createTRPCRouter({ }, }, }); - return permissions - .map((permission) => ({ + + const userPermissions = await ctx.db.query.boardUserPermissions.findMany({ + where: eq(boardUserPermissions.boardId, input.id), + with: { user: { - id: permission.userId, - name: permission.user.name ?? "", + columns: { + id: true, + name: true, + image: true, + }, + }, + }, + }); + + const dbGroupBoardPermission = + await ctx.db.query.boardGroupPermissions.findMany({ + where: eq(boardGroupPermissions.boardId, input.id), + with: { + group: { + columns: { + id: true, + name: true, + }, + }, }, - permission: permission.permission, - })) - .sort((permissionA, permissionB) => { - return permissionA.user.name.localeCompare(permissionB.user.name); }); + + return { + inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + return permissionA.group.name.localeCompare(permissionB.group.name); + }), + userPermissions: userPermissions + .map(({ user, permission }) => ({ + user, + permission, + })) + .sort((permissionA, permissionB) => { + return (permissionA.user.name ?? "").localeCompare( + permissionB.user.name ?? "", + ); + }), + groupPermissions: dbGroupBoardPermission + .map(({ group, permission }) => ({ + group: { + id: group.id, + name: group.name, + }, + permission, + })) + .sort((permissionA, permissionB) => { + return permissionA.group.name.localeCompare(permissionB.group.name); + }), + }; }), - saveBoardPermissions: protectedProcedure + saveUserBoardPermissions: protectedProcedure .input(validation.board.savePermissions) .mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync( @@ -411,14 +482,39 @@ export const boardRouter = createTRPCRouter({ await ctx.db.transaction(async (transaction) => { await transaction - .delete(boardPermissions) - .where(eq(boardPermissions.boardId, input.id)); + .delete(boardUserPermissions) + .where(eq(boardUserPermissions.boardId, input.id)); if (input.permissions.length === 0) { return; } - await transaction.insert(boardPermissions).values( + await transaction.insert(boardUserPermissions).values( input.permissions.map((permission) => ({ - userId: permission.user.id, + userId: permission.itemId, + permission: permission.permission, + boardId: input.id, + })), + ); + }); + }), + saveGroupBoardPermissions: protectedProcedure + .input(validation.board.savePermissions) + .mutation(async ({ input, ctx }) => { + await throwIfActionForbiddenAsync( + ctx, + eq(boards.id, input.id), + "full-access", + ); + + await ctx.db.transaction(async (transaction) => { + await transaction + .delete(boardGroupPermissions) + .where(eq(boardGroupPermissions.boardId, input.id)); + if (input.permissions.length === 0) { + return; + } + await transaction.insert(boardGroupPermissions).values( + input.permissions.map((permission) => ({ + groupId: permission.itemId, permission: permission.permission, boardId: input.id, })), @@ -458,6 +554,9 @@ const getFullBoardWithWhere = async ( where: SQL, userId: string | null, ) => { + const groupsOfCurrentUser = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, userId ?? ""), + }); const board = await db.query.boards.findFirst({ where, with: { @@ -465,6 +564,7 @@ const getFullBoardWithWhere = async ( columns: { id: true, name: true, + image: true, }, }, sections: { @@ -480,12 +580,18 @@ const getFullBoardWithWhere = async ( }, }, }, - permissions: { - where: eq(boardPermissions.userId, userId ?? ""), + userPermissions: { + where: eq(boardUserPermissions.userId, userId ?? ""), columns: { permission: true, }, }, + groupPermissions: { + where: inArray( + boardGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId).concat(""), + ), + }, }, }); @@ -530,3 +636,27 @@ const parseSection = (section: unknown) => { } return result.data; }; + +const filterAddedItems = ( + inputArray: TInput[], + dbArray: TInput[], +) => + inputArray.filter( + (inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id), + ); + +const filterRemovedItems = ( + inputArray: TInput[], + dbArray: TInput[], +) => + dbArray.filter( + (dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id), + ); + +const filterUpdatedItems = ( + inputArray: TInput[], + dbArray: TInput[], +) => + inputArray.filter((inputItem) => + dbArray.some((dbItem) => dbItem.id === inputItem.id), + ); diff --git a/packages/api/src/router/board/board-access.ts b/packages/api/src/router/board/board-access.ts index b3b7c4f1e..b66866b98 100644 --- a/packages/api/src/router/board/board-access.ts +++ b/packages/api/src/router/board/board-access.ts @@ -3,8 +3,12 @@ import { TRPCError } from "@trpc/server"; import type { Session } from "@homarr/auth"; import { constructBoardPermissions } from "@homarr/auth/shared"; import type { Database, SQL } from "@homarr/db"; -import { eq } from "@homarr/db"; -import { boardPermissions } from "@homarr/db/schema/sqlite"; +import { eq, inArray } from "@homarr/db"; +import { + boardGroupPermissions, + boardUserPermissions, + groupMembers, +} from "@homarr/db/schema/sqlite"; import type { BoardPermission } from "@homarr/definitions"; /** @@ -19,6 +23,9 @@ export const throwIfActionForbiddenAsync = async ( permission: "full-access" | BoardPermission, ) => { const { db, session } = ctx; + const groupsOfCurrentUser = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, session?.user.id ?? ""), + }); const board = await db.query.boards.findFirst({ where: boardWhere, columns: { @@ -27,8 +34,14 @@ export const throwIfActionForbiddenAsync = async ( isPublic: true, }, with: { - permissions: { - where: eq(boardPermissions.userId, session?.user.id ?? ""), + userPermissions: { + where: eq(boardUserPermissions.userId, session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + boardGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId).concat(""), + ), }, }, }); diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 2985674ed..8b1451946 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -94,6 +94,14 @@ export const groupRouter = createTRPCRouter({ ), }; }), + selectable: protectedProcedure.query(async ({ ctx }) => { + return await ctx.db.query.groups.findMany({ + columns: { + id: true, + name: true, + }, + }); + }), createGroup: protectedProcedure .input(validation.group.create) .mutation(async ({ input, ctx }) => { diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index d2c084f76..a91ec395b 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -1,11 +1,16 @@ import SuperJSON from "superjson"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import type { Database } from "@homarr/db"; import { createId, eq } from "@homarr/db"; import { + boardGroupPermissions, boards, + boardUserPermissions, + groupMembers, + groupPermissions, + groups, integrationItems, integrations, items, @@ -13,6 +18,7 @@ import { users, } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; +import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions"; import type { RouterOutputs } from "../.."; import { boardRouter } from "../board"; @@ -23,6 +29,7 @@ const defaultCreatorId = createId(); const defaultSession = { user: { id: defaultCreatorId, + permissions: [], }, expires: new Date().toISOString(), } satisfies Session; @@ -30,6 +37,462 @@ const defaultSession = { // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); +const createRandomUser = async (db: Database) => { + const userId = createId(); + await db.insert(users).values({ + id: userId, + }); + return userId; +}; + +describe("getAllBoards should return all boards accessable to the current user", () => { + test("without session it should return only public boards", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const user1 = await createRandomUser(db); + const user2 = await createRandomUser(db); + + await db.insert(boards).values([ + { + id: createId(), + name: "public", + creatorId: user1, + isPublic: true, + }, + { + id: createId(), + name: "private", + creatorId: user2, + isPublic: false, + }, + ]); + + // Act + const result = await caller.getAllBoards(); + + // Assert + expect(result.length).toBe(1); + expect(result[0]?.name).toBe("public"); + }); + + test("with session containing board-view-all permission it should return all boards", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ + db, + session: { + user: { + id: defaultCreatorId, + permissions: ["board-view-all"], + }, + expires: new Date().toISOString(), + }, + }); + + const user1 = await createRandomUser(db); + const user2 = await createRandomUser(db); + + await db.insert(boards).values([ + { + id: createId(), + name: "public", + creatorId: user1, + isPublic: true, + }, + { + id: createId(), + name: "private", + creatorId: user2, + isPublic: false, + }, + ]); + + // Act + const result = await caller.getAllBoards(); + + // Assert + expect(result.length).toBe(2); + expect(result.map((board) => board.name)).toEqual(["public", "private"]); + }); + + test("with session user beeing creator it should return all private boards of them", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + + const user1 = await createRandomUser(db); + const user2 = await createRandomUser(db); + await db.insert(users).values({ + id: defaultCreatorId, + }); + + await db.insert(boards).values([ + { + id: createId(), + name: "public", + creatorId: user1, + isPublic: true, + }, + { + id: createId(), + name: "private", + creatorId: user2, + isPublic: false, + }, + { + id: createId(), + name: "private2", + creatorId: defaultCreatorId, + isPublic: false, + }, + ]); + + // Act + const result = await caller.getAllBoards(); + + // Assert + expect(result.length).toBe(2); + expect(result.map(({ name }) => name)).toStrictEqual([ + "public", + "private2", + ]); + }); + + test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])( + "with %s group board permission it should show board", + async (permission) => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ + db, + session: defaultSession, + }); + + const user1 = await createRandomUser(db); + const user2 = await createRandomUser(db); + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); + + await db.insert(boards).values([ + { + id: createId(), + name: "public", + creatorId: user1, + isPublic: true, + }, + { + id: boardId, + name: "private1", + creatorId: user2, + isPublic: false, + }, + { + id: createId(), + name: "private2", + creatorId: user2, + isPublic: false, + }, + ]); + + const groupId = createId(); + await db.insert(groups).values({ + id: groupId, + name: "group1", + }); + + await db.insert(groupMembers).values({ + userId: defaultSession.user.id, + groupId, + }); + + await db.insert(boardGroupPermissions).values({ + groupId, + permission, + boardId, + }); + + // Act + const result = await caller.getAllBoards(); + + // Assert + expect(result.length).toBe(2); + expect(result.map(({ name }) => name)).toStrictEqual([ + "public", + "private1", + ]); + }, + ); + + test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])( + "with %s user board permission it should show board", + async (permission) => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ + db, + session: defaultSession, + }); + + const user1 = await createRandomUser(db); + const user2 = await createRandomUser(db); + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); + + await db.insert(boards).values([ + { + id: createId(), + name: "public", + creatorId: user1, + isPublic: true, + }, + { + id: boardId, + name: "private1", + creatorId: user2, + isPublic: false, + }, + { + id: createId(), + name: "private2", + creatorId: user2, + isPublic: false, + }, + ]); + + await db.insert(boardUserPermissions).values({ + userId: defaultSession.user.id, + permission, + boardId, + }); + + // Act + const result = await caller.getAllBoards(); + + // Assert + expect(result.length).toBe(2); + expect(result.map(({ name }) => name)).toStrictEqual([ + "public", + "private1", + ]); + }, + ); +}); + +describe("createBoard should create a new board", () => { + test("should create a new board with permission board-create", async () => { + // Arrange + const db = createDb(); + const session = { + ...defaultSession, + user: { + ...defaultSession.user, + permissions: ["board-create"] satisfies GroupPermissionKey[], + }, + }; + const caller = boardRouter.createCaller({ db, session }); + + await db.insert(users).values({ + id: defaultCreatorId, + }); + + // Act + await caller.createBoard({ name: "newBoard" }); + + // Assert + const dbBoard = await db.query.boards.findFirst(); + expect(dbBoard).toBeDefined(); + expect(dbBoard?.name).toBe("newBoard"); + expect(dbBoard?.creatorId).toBe(defaultCreatorId); + + const dbSection = await db.query.sections.findFirst(); + expect(dbSection).toBeDefined(); + expect(dbSection?.boardId).toBe(dbBoard?.id); + expect(dbSection?.kind).toBe("empty"); + }); + + test("should throw error when user has no board-create permission", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + + // Act + const act = async () => await caller.createBoard({ name: "newBoard" }); + + // Assert + await expect(act()).rejects.toThrowError("Permission denied"); + }); +}); + +describe("rename board should rename board", () => { + test("should rename board", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); + + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "oldName", + creatorId: defaultCreatorId, + }); + + // Act + await caller.renameBoard({ id: boardId, name: "newName" }); + + // Assert + const dbBoard = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + }); + expect(dbBoard).toBeDefined(); + expect(dbBoard?.name).toBe("newName"); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "full-access", + ); + }); + + test("should throw error when similar board name exists", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "oldName", + creatorId: defaultCreatorId, + }); + await db.insert(boards).values({ + id: createId(), + name: "newName", + creatorId: defaultCreatorId, + }); + + // Act + const act = async () => + await caller.renameBoard({ id: boardId, name: "Newname" }); + + // Assert + await expect(act()).rejects.toThrowError( + "Board with similar name already exists", + ); + }); + + test("should throw error when board not found", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + + // Act + const act = async () => + await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" }); + + // Assert + await expect(act()).rejects.toThrowError("Board not found"); + }); +}); + +describe("changeBoardVisibility should change board visibility", () => { + test.each([["public"], ["private"]] satisfies ["private" | "public"][])( + "should change board visibility to %s", + async (visibility) => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); + + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "board", + creatorId: defaultCreatorId, + isPublic: visibility === "public", + }); + + // Act + await caller.changeBoardVisibility({ + id: boardId, + visibility, + }); + + // Assert + const dbBoard = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + }); + expect(dbBoard).toBeDefined(); + expect(dbBoard?.isPublic).toBe(visibility === "public"); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "full-access", + ); + }, + ); +}); + +describe("deleteBoard should delete board", () => { + test("should delete board", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); + + await db.insert(users).values({ + id: defaultCreatorId, + }); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "board", + creatorId: defaultCreatorId, + }); + + // Act + await caller.deleteBoard({ id: boardId }); + + // Assert + const dbBoard = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + }); + expect(dbBoard).toBeUndefined(); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "full-access", + ); + }); + + test("should throw error when board not found", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + + // Act + const act = async () => + await caller.deleteBoard({ id: "nonExistentBoardId" }); + + // Assert + await expect(act()).rejects.toThrowError("Board not found"); + }); +}); + describe("getDefaultBoard should return default board", () => { it("should return default board", async () => { // Arrange @@ -698,6 +1161,183 @@ describe("saveBoard should save full board", () => { }); }); +describe("getBoardPermissions should return board permissions", () => { + test("should return board permissions", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); + + const user1 = await createRandomUser(db); + const user2 = await createRandomUser(db); + await db.insert(users).values({ + id: defaultCreatorId, + }); + + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "board", + creatorId: defaultCreatorId, + }); + + await db.insert(boardUserPermissions).values([ + { + userId: user1, + permission: "board-view", + boardId, + }, + { + userId: user2, + permission: "board-change", + boardId, + }, + ]); + + const groupId = createId(); + await db.insert(groups).values({ + id: groupId, + name: "group1", + }); + + await db.insert(boardGroupPermissions).values({ + groupId, + permission: "board-view", + boardId, + }); + + await db.insert(groupPermissions).values({ + groupId, + permission: "admin", + }); + + // Act + const result = await caller.getBoardPermissions({ id: boardId }); + + // Assert + expect(result.groupPermissions).toEqual([ + { group: { id: groupId, name: "group1" }, permission: "board-view" }, + ]); + expect(result.userPermissions).toEqual( + expect.arrayContaining([ + { + user: { id: user1, name: null, image: null }, + permission: "board-view", + }, + { + user: { id: user2, name: null, image: null }, + permission: "board-change", + }, + ]), + ); + expect(result.inherited).toEqual([ + { group: { id: groupId, name: "group1" }, permission: "admin" }, + ]); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "full-access", + ); + }); +}); + +describe("saveUserBoardPermissions should save user board permissions", () => { + test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])( + "should save user board permissions", + async (permission) => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); + + const user1 = await createRandomUser(db); + await db.insert(users).values({ + id: defaultCreatorId, + }); + + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "board", + creatorId: defaultCreatorId, + }); + + // Act + await caller.saveUserBoardPermissions({ + id: boardId, + permissions: [ + { + itemId: user1, + permission, + }, + ], + }); + + // Assert + const dbUserPermission = await db.query.boardUserPermissions.findFirst({ + where: eq(boardUserPermissions.userId, user1), + }); + expect(dbUserPermission).toBeDefined(); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "full-access", + ); + }, + ); +}); + +describe("saveGroupBoardPermissions should save group board permissions", () => { + test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])( + "should save group board permissions", + async (permission) => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: defaultSession }); + const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); + + await db.insert(users).values({ + id: defaultCreatorId, + }); + + const groupId = createId(); + await db.insert(groups).values({ + id: groupId, + name: "group1", + }); + + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "board", + creatorId: defaultCreatorId, + }); + + // Act + await caller.saveGroupBoardPermissions({ + id: boardId, + permissions: [ + { + itemId: groupId, + permission, + }, + ], + }); + + // Assert + const dbGroupPermission = await db.query.boardGroupPermissions.findFirst({ + where: eq(boardGroupPermissions.groupId, groupId), + }); + expect(dbGroupPermission).toBeDefined(); + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "full-access", + ); + }, + ); +}); + const expectInputToBeFullBoardWithName = ( input: RouterOutputs["board"]["getDefaultBoard"], props: { name: string } & Awaited>, diff --git a/packages/api/src/router/test/group.spec.ts b/packages/api/src/router/test/group.spec.ts index d3c0d2429..b52aa7630 100644 --- a/packages/api/src/router/test/group.spec.ts +++ b/packages/api/src/router/test/group.spec.ts @@ -16,6 +16,7 @@ const defaultOwnerId = createId(); const defaultSession = { user: { id: defaultOwnerId, + permissions: [], }, expires: new Date().toISOString(), } satisfies Session; diff --git a/packages/api/src/router/test/invite.spec.ts b/packages/api/src/router/test/invite.spec.ts index 6a4e5668e..8c3f7be69 100644 --- a/packages/api/src/router/test/invite.spec.ts +++ b/packages/api/src/router/test/invite.spec.ts @@ -10,9 +10,10 @@ import { inviteRouter } from "../invite"; const defaultSession = { user: { id: createId(), + permissions: [], }, expires: new Date().toISOString(), -}; +} satisfies Session; // Mock the auth module to return an empty session vi.mock("@homarr/auth", async () => { diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index cc199235c..364a3ff82 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -50,6 +50,7 @@ export const userRouter = createTRPCRouter({ columns: { id: true, name: true, + image: true, }, }); }), diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index f4cd583c7..0f64c9ff8 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -11,6 +11,7 @@ import superjson from "superjson"; import type { Session } from "@homarr/auth"; import { db } from "@homarr/db"; +import type { GroupPermissionKey } from "@homarr/definitions"; import { logger } from "@homarr/log"; import { ZodError } from "@homarr/validation"; @@ -115,3 +116,25 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { * @see https://trpc.io/docs/procedures */ export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); + +/** + * Procedure that requires a specific permission + * + * If you want a query or mutation to ONLY be accessible to users with a specific permission, use + * this. It verifies that the user has the required permission + * + * @see https://trpc.io/docs/procedures + */ +export const permissionRequiredProcedure = { + requiresPermission: (permission: GroupPermissionKey) => { + return protectedProcedure.use(({ ctx, input, next }) => { + if (!ctx.session?.user.permissions.includes(permission)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Permission denied", + }); + } + return next({ input, ctx }); + }); + }, +}; diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts index 05a298290..eb90555f1 100644 --- a/packages/auth/callbacks.ts +++ b/packages/auth/callbacks.ts @@ -2,6 +2,11 @@ import { cookies } from "next/headers"; import type { Adapter } from "@auth/core/adapters"; import type { NextAuthConfig } from "next-auth"; +import type { Database } from "@homarr/db"; +import { eq, inArray } from "@homarr/db"; +import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite"; +import { getPermissionsWithChildren } from "@homarr/definitions"; + import { expireDateAfter, generateSessionToken, @@ -9,17 +14,44 @@ import { sessionTokenCookieName, } from "./session"; -export const sessionCallback: NextAuthCallbackOf<"session"> = ({ - session, - user, -}) => ({ - ...session, - user: { - ...session.user, - id: user.id, - name: user.name, - }, -}); +export const getCurrentUserPermissions = async ( + db: Database, + userId: string, +) => { + const dbGroupMembers = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, userId), + }); + const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId); + const dbGroupPermissions = await db + .selectDistinct({ + permission: groupPermissions.permission, + }) + .from(groupPermissions) + .where( + groupIds.length > 0 + ? inArray(groupPermissions.groupId, groupIds) + : undefined, + ); + const permissionKeys = dbGroupPermissions.map(({ permission }) => permission); + + return getPermissionsWithChildren(permissionKeys); +}; + +export const createSessionCallback = ( + db: Database, +): NextAuthCallbackOf<"session"> => { + return async ({ session, user }) => { + return { + ...session, + user: { + ...session.user, + id: user.id, + name: user.name, + permissions: await getCurrentUserPermissions(db, user.id), + }, + }; + }; +}; export const createSignInCallback = ( diff --git a/packages/auth/configuration.ts b/packages/auth/configuration.ts index a422008fd..2c8c4459f 100644 --- a/packages/auth/configuration.ts +++ b/packages/auth/configuration.ts @@ -5,7 +5,7 @@ import Credentials from "next-auth/providers/credentials"; import { db } from "@homarr/db"; -import { createSignInCallback, sessionCallback } from "./callbacks"; +import { createSessionCallback, createSignInCallback } from "./callbacks"; import { createCredentialsConfiguration } from "./providers/credentials"; import { EmptyNextAuthProvider } from "./providers/empty"; import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session"; @@ -33,7 +33,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) => EmptyNextAuthProvider(), ], callbacks: { - session: sessionCallback, + session: createSessionCallback(db), signIn: createSignInCallback(adapter, isCredentialsRequest), }, secret: "secret-is-not-defined-yet", // TODO: This should be added later diff --git a/packages/auth/index.ts b/packages/auth/index.ts index 687689695..a395302c3 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -1,5 +1,7 @@ import type { DefaultSession } from "@auth/core/types"; +import type { GroupPermissionKey } from "@homarr/definitions"; + import { createConfiguration } from "./configuration"; export type { Session } from "next-auth"; @@ -8,6 +10,7 @@ declare module "next-auth" { interface Session { user: { id: string; + permissions: GroupPermissionKey[]; } & DefaultSession["user"]; } } diff --git a/packages/auth/package.json b/packages/auth/package.json index de77e8516..53f3c0acc 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -37,6 +37,7 @@ "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", "@types/bcrypt": "5.0.2", "@types/cookies": "0.9.0", "eslint": "^8.57.0", diff --git a/packages/auth/permissions/board-permissions.ts b/packages/auth/permissions/board-permissions.ts index 6921d8f86..a67b988b5 100644 --- a/packages/auth/permissions/board-permissions.ts +++ b/packages/auth/permissions/board-permissions.ts @@ -10,7 +10,10 @@ export type BoardPermissionsProps = ( creatorId: string | null; } ) & { - permissions: { + userPermissions: { + permission: string; + }[]; + groupPermissions: { permission: string; }[]; isPublic: boolean; @@ -23,13 +26,23 @@ export const constructBoardPermissions = ( const creatorId = "creator" in board ? board.creator?.id : board.creatorId; return { - hasFullAccess: session?.user?.id === creatorId, + hasFullAccess: + session?.user?.id === creatorId || + session?.user.permissions.includes("board-full-access"), hasChangeAccess: session?.user?.id === creatorId || - board.permissions.some(({ permission }) => permission === "board-change"), + board.userPermissions.some( + ({ permission }) => permission === "board-change", + ) || + board.groupPermissions.some( + ({ permission }) => permission === "board-change", + ) || + session?.user.permissions.includes("board-modify-all"), hasViewAccess: session?.user?.id === creatorId || - board.permissions.length >= 1 || - board.isPublic, + board.userPermissions.length >= 1 || + board.groupPermissions.length >= 1 || + board.isPublic || + session?.user.permissions.includes("board-view-all"), }; }; diff --git a/packages/auth/permissions/test/board-permissions.spec.ts b/packages/auth/permissions/test/board-permissions.spec.ts index 91a348f6f..24b180836 100644 --- a/packages/auth/permissions/test/board-permissions.spec.ts +++ b/packages/auth/permissions/test/board-permissions.spec.ts @@ -1,6 +1,8 @@ import type { Session } from "@auth/core/types"; import { describe, expect, test } from "vitest"; +import { getPermissionsWithChildren } from "@homarr/definitions"; + import { constructBoardPermissions } from "../board-permissions"; describe("constructBoardPermissions", () => { @@ -10,12 +12,14 @@ describe("constructBoardPermissions", () => { creator: { id: "1", }, - permissions: [], + userPermissions: [], + groupPermissions: [], isPublic: false, }; const session = { user: { id: "1", + permissions: [], }, expires: new Date().toISOString(), } satisfies Session; @@ -29,18 +33,47 @@ describe("constructBoardPermissions", () => { expect(result.hasViewAccess).toBe(true); }); - test('should return hasChangeAccess as true when board permissions include "board-change"', () => { + test("should return hasFullAccess as true when session permissions include board-full-access", () => { // Arrange const board = { creator: { id: "1", }, - permissions: [{ permission: "board-change" }], + userPermissions: [], + groupPermissions: [], isPublic: false, }; const session = { user: { id: "2", + permissions: getPermissionsWithChildren(["board-full-access"]), + }, + expires: new Date().toISOString(), + } satisfies Session; + + // Act + const result = constructBoardPermissions(board, session); + + // Assert + expect(result.hasFullAccess).toBe(true); + expect(result.hasChangeAccess).toBe(true); + expect(result.hasViewAccess).toBe(true); + }); + + test("should return hasChangeAccess as true when session permissions include board-modify-all", () => { + // Arrange + const board = { + creator: { + id: "1", + }, + userPermissions: [], + groupPermissions: [], + isPublic: false, + }; + const session = { + user: { + id: "2", + permissions: getPermissionsWithChildren(["board-modify-all"]), }, expires: new Date().toISOString(), } satisfies Session; @@ -54,18 +87,75 @@ describe("constructBoardPermissions", () => { expect(result.hasViewAccess).toBe(true); }); - test("should return hasViewAccess as true when board permissions length is greater than or equal to 1", () => { + test('should return hasChangeAccess as true when board user permissions include "board-change"', () => { // Arrange const board = { creator: { id: "1", }, - permissions: [{ permission: "board-view" }], + + userPermissions: [{ permission: "board-change" }], + groupPermissions: [], isPublic: false, }; const session = { user: { id: "2", + permissions: [], + }, + expires: new Date().toISOString(), + } satisfies Session; + + // Act + const result = constructBoardPermissions(board, session); + + // Assert + expect(result.hasFullAccess).toBe(false); + expect(result.hasChangeAccess).toBe(true); + expect(result.hasViewAccess).toBe(true); + }); + + test("should return hasChangeAccess as true when board group permissions include board-change", () => { + // Arrange + const board = { + creator: { + id: "1", + }, + userPermissions: [], + groupPermissions: [{ permission: "board-change" }], + isPublic: false, + }; + const session = { + user: { + id: "2", + permissions: [], + }, + expires: new Date().toISOString(), + } satisfies Session; + + // Act + const result = constructBoardPermissions(board, session); + + // Assert + expect(result.hasFullAccess).toBe(false); + expect(result.hasChangeAccess).toBe(true); + expect(result.hasViewAccess).toBe(true); + }); + + test("should return hasViewAccess as true when session permissions include board-view-all", () => { + // Arrange + const board = { + creator: { + id: "1", + }, + userPermissions: [], + groupPermissions: [], + isPublic: false, + }; + const session = { + user: { + id: "2", + permissions: getPermissionsWithChildren(["board-view-all"]), }, expires: new Date().toISOString(), } satisfies Session; @@ -79,18 +169,101 @@ describe("constructBoardPermissions", () => { expect(result.hasViewAccess).toBe(true); }); + test("should return hasViewAccess as true when board user permissions length is greater than or equal to 1", () => { + // Arrange + const board = { + creator: { + id: "1", + }, + userPermissions: [{ permission: "board-view" }], + groupPermissions: [], + isPublic: false, + }; + const session = { + user: { + id: "2", + permissions: [], + }, + expires: new Date().toISOString(), + } satisfies Session; + + // Act + const result = constructBoardPermissions(board, session); + + // Assert + expect(result.hasFullAccess).toBe(false); + expect(result.hasChangeAccess).toBe(false); + expect(result.hasViewAccess).toBe(true); + }); + + test("should return hasViewAccess as true when board group permissions length is greater than or equal to 1", () => { + // Arrange + const board = { + creator: { + id: "1", + }, + userPermissions: [], + groupPermissions: [{ permission: "board-view" }], + isPublic: false, + }; + const session = { + user: { + id: "2", + permissions: [], + }, + expires: new Date().toISOString(), + } satisfies Session; + + // Act + const result = constructBoardPermissions(board, session); + + // Assert + expect(result.hasFullAccess).toBe(false); + expect(result.hasChangeAccess).toBe(false); + expect(result.hasViewAccess).toBe(true); + }); + + test("should return all false when board is not public and session user id is not equal to creator id and no permissions", () => { + // Arrange + const board = { + creator: { + id: "1", + }, + userPermissions: [], + groupPermissions: [], + isPublic: false, + }; + const session = { + user: { + id: "2", + permissions: [], + }, + expires: new Date().toISOString(), + } satisfies Session; + + // Act + const result = constructBoardPermissions(board, session); + + // Assert + expect(result.hasFullAccess).toBe(false); + expect(result.hasChangeAccess).toBe(false); + expect(result.hasViewAccess).toBe(false); + }); + test("should return hasViewAccess as true when board is public", () => { // Arrange const board = { creator: { id: "1", }, - permissions: [], + userPermissions: [], + groupPermissions: [], isPublic: true, }; const session = { user: { id: "2", + permissions: [], }, expires: new Date().toISOString(), } satisfies Session; diff --git a/packages/auth/session.ts b/packages/auth/session.ts index 6c04ddf1c..d7ab5b238 100644 --- a/packages/auth/session.ts +++ b/packages/auth/session.ts @@ -3,6 +3,8 @@ import type { Session } from "next-auth"; import type { Database } from "@homarr/db"; +import { getCurrentUserPermissions } from "./callbacks"; + export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days export const sessionTokenCookieName = "next-auth.session-token"; @@ -44,7 +46,10 @@ export const getSessionFromToken = async ( } return { - user: session.user, + user: { + ...session.user, + permissions: await getCurrentUserPermissions(db, session.user.id), + }, expires: session.expires.toISOString(), }; }; diff --git a/packages/auth/test/callbacks.spec.ts b/packages/auth/test/callbacks.spec.ts index c175a7aa8..ffb8fb5d4 100644 --- a/packages/auth/test/callbacks.spec.ts +++ b/packages/auth/test/callbacks.spec.ts @@ -4,9 +4,63 @@ import { cookies } from "next/headers"; import type { Adapter, AdapterUser } from "@auth/core/adapters"; import type { Account, User } from "next-auth"; import type { JWT } from "next-auth/jwt"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, test, vi } from "vitest"; -import { createSignInCallback, sessionCallback } from "../callbacks"; +import { + groupMembers, + groupPermissions, + groups, + users, +} from "@homarr/db/schema/sqlite"; +import { createDb } from "@homarr/db/test"; +import * as definitions from "@homarr/definitions"; + +import { + createSessionCallback, + createSignInCallback, + getCurrentUserPermissions, +} from "../callbacks"; + +describe("getCurrentUserPermissions", () => { + test("should return empty permissions when non existing user requested", async () => { + const db = createDb(); + + await db.insert(users).values({ + id: "2", + }); + + const userId = "1"; + const result = await getCurrentUserPermissions(db, userId); + expect(result).toEqual([]); + }); + test("should return permissions for user", async () => { + const db = createDb(); + const getPermissionsWithChildrenMock = vi + .spyOn(definitions, "getPermissionsWithChildren") + .mockReturnValue(["board-create"]); + const mockId = "1"; + + await db.insert(users).values({ + id: mockId, + }); + await db.insert(groups).values({ + id: mockId, + name: "test", + }); + await db.insert(groupMembers).values({ + userId: mockId, + groupId: mockId, + }); + await db.insert(groupPermissions).values({ + groupId: mockId, + permission: "admin", + }); + + const result = await getCurrentUserPermissions(db, mockId); + expect(result).toEqual(["board-create"]); + expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]); + }); +}); describe("session callback", () => { it("should add id and name to session user", async () => { @@ -17,12 +71,15 @@ describe("session callback", () => { emailVerified: new Date("2023-01-13"), }; const token: JWT = {}; - const result = await sessionCallback({ + const db = createDb(); + const callback = createSessionCallback(db); + const result = await callback({ session: { user: { id: "no-id", email: "no-email", emailVerified: new Date("2023-01-13"), + permissions: [], }, expires: "2023-01-13" as Date & string, sessionToken: "token", diff --git a/packages/db/migrations/mysql/0000_chubby_darkhawk.sql b/packages/db/migrations/mysql/0000_hot_mandrill.sql similarity index 85% rename from packages/db/migrations/mysql/0000_chubby_darkhawk.sql rename to packages/db/migrations/mysql/0000_hot_mandrill.sql index 7b6ba59c2..699c102e5 100644 --- a/packages/db/migrations/mysql/0000_chubby_darkhawk.sql +++ b/packages/db/migrations/mysql/0000_hot_mandrill.sql @@ -22,11 +22,18 @@ CREATE TABLE `app` ( CONSTRAINT `app_id` PRIMARY KEY(`id`) ); --> statement-breakpoint -CREATE TABLE `boardPermission` ( +CREATE TABLE `boardGroupPermission` ( + `board_id` text NOT NULL, + `group_id` text NOT NULL, + `permission` text NOT NULL, + CONSTRAINT `boardGroupPermission_board_id_group_id_permission_pk` PRIMARY KEY(`board_id`,`group_id`,`permission`) +); +--> statement-breakpoint +CREATE TABLE `boardUserPermission` ( `board_id` text NOT NULL, `user_id` text NOT NULL, `permission` text NOT NULL, - CONSTRAINT `boardPermission_board_id_user_id_permission_pk` PRIMARY KEY(`board_id`,`user_id`,`permission`) + CONSTRAINT `boardUserPermission_board_id_user_id_permission_pk` PRIMARY KEY(`board_id`,`user_id`,`permission`) ); --> statement-breakpoint CREATE TABLE `board` ( @@ -152,8 +159,10 @@ CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updat CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint CREATE INDEX `user_id_idx` ON `session` (`userId`);--> statement-breakpoint ALTER TABLE `account` ADD CONSTRAINT `account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `boardGroupPermission` ADD CONSTRAINT `boardGroupPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `boardGroupPermission` ADD CONSTRAINT `boardGroupPermission_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `boardUserPermission` ADD CONSTRAINT `boardUserPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `boardUserPermission` ADD CONSTRAINT `boardUserPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE `board` ADD CONSTRAINT `board_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_groupId_group_id_fk` FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint diff --git a/packages/db/migrations/mysql/meta/0000_snapshot.json b/packages/db/migrations/mysql/meta/0000_snapshot.json index 9ff2ed7f7..293d2f12a 100644 --- a/packages/db/migrations/mysql/meta/0000_snapshot.json +++ b/packages/db/migrations/mysql/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "mysql", - "id": "d0a05e9e-107f-4bed-ac54-a4a41369f0da", + "id": "47dc6887-a308-480d-8125-183412fe7fa7", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -160,8 +160,62 @@ }, "uniqueConstraints": {} }, - "boardPermission": { - "name": "boardPermission", + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", "columns": { "board_id": { "name": "board_id", @@ -187,18 +241,18 @@ }, "indexes": {}, "foreignKeys": { - "boardPermission_board_id_board_id_fk": { - "name": "boardPermission_board_id_board_id_fk", - "tableFrom": "boardPermission", + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", "tableTo": "board", "columnsFrom": ["board_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "boardPermission_user_id_user_id_fk": { - "name": "boardPermission_user_id_user_id_fk", - "tableFrom": "boardPermission", + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], @@ -207,8 +261,8 @@ } }, "compositePrimaryKeys": { - "boardPermission_board_id_user_id_permission_pk": { - "name": "boardPermission_board_id_user_id_permission_pk", + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", "columns": ["board_id", "user_id", "permission"] } }, diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 15f7134b4..739d1c670 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1714414260766, - "tag": "0000_chubby_darkhawk", + "when": 1714817536714, + "tag": "0000_hot_mandrill", "breakpoints": true } ] diff --git a/packages/db/migrations/sqlite/0000_abnormal_kree.sql b/packages/db/migrations/sqlite/0000_premium_forgotten_one.sql similarity index 92% rename from packages/db/migrations/sqlite/0000_abnormal_kree.sql rename to packages/db/migrations/sqlite/0000_premium_forgotten_one.sql index 1275fe156..9f9c3f451 100644 --- a/packages/db/migrations/sqlite/0000_abnormal_kree.sql +++ b/packages/db/migrations/sqlite/0000_premium_forgotten_one.sql @@ -22,7 +22,16 @@ CREATE TABLE `app` ( `href` text ); --> statement-breakpoint -CREATE TABLE `boardPermission` ( +CREATE TABLE `boardGroupPermission` ( + `board_id` text NOT NULL, + `group_id` text NOT NULL, + `permission` text NOT NULL, + PRIMARY KEY(`board_id`, `group_id`, `permission`), + FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `boardUserPermission` ( `board_id` text NOT NULL, `user_id` text NOT NULL, `permission` text NOT NULL, diff --git a/packages/db/migrations/sqlite/meta/0000_snapshot.json b/packages/db/migrations/sqlite/meta/0000_snapshot.json index af10f53da..580bc6a9e 100644 --- a/packages/db/migrations/sqlite/meta/0000_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "e3ff4a97-d357-4a64-989b-78668b36c82d", + "id": "116fcd87-09c7-4c7c-b590-0ed5681ffdc5", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -155,8 +155,62 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "boardPermission": { - "name": "boardPermission", + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", "columns": { "board_id": { "name": "board_id", @@ -182,18 +236,18 @@ }, "indexes": {}, "foreignKeys": { - "boardPermission_board_id_board_id_fk": { - "name": "boardPermission_board_id_board_id_fk", - "tableFrom": "boardPermission", + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", "tableTo": "board", "columnsFrom": ["board_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "boardPermission_user_id_user_id_fk": { - "name": "boardPermission_user_id_user_id_fk", - "tableFrom": "boardPermission", + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], @@ -202,9 +256,9 @@ } }, "compositePrimaryKeys": { - "boardPermission_board_id_user_id_permission_pk": { + "boardUserPermission_board_id_user_id_permission_pk": { "columns": ["board_id", "permission", "user_id"], - "name": "boardPermission_board_id_user_id_permission_pk" + "name": "boardUserPermission_board_id_user_id_permission_pk" } }, "uniqueConstraints": {} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index d46426bd8..70a20525a 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1714414359385, - "tag": "0000_abnormal_kree", + "when": 1714817544524, + "tag": "0000_premium_forgotten_one", "breakpoints": true } ] diff --git a/packages/db/package.json b/packages/db/package.json index 4af8fd951..074b204fa 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -20,8 +20,8 @@ "migration:sqlite:generate": "drizzle-kit generate:sqlite --config ./sqlite.config.ts", "migration:run": "tsx ./migrate.ts", "migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts", - "push": "drizzle-kit push:sqlite", - "studio": "drizzle-kit studio", + "push": "drizzle-kit push:sqlite --config ./sqlite.config.ts", + "studio": "drizzle-kit studio --config ./sqlite.config.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 74fe219e3..adbf0eb22 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -201,8 +201,8 @@ export const boards = mysqlTable("board", { columnCount: int("column_count").default(10).notNull(), }); -export const boardPermissions = mysqlTable( - "boardPermission", +export const boardUserPermissions = mysqlTable( + "boardUserPermission", { boardId: text("board_id") .notNull() @@ -219,6 +219,24 @@ export const boardPermissions = mysqlTable( }), ); +export const boardGroupPermissions = mysqlTable( + "boardGroupPermission", + { + boardId: text("board_id") + .notNull() + .references(() => boards.id, { onDelete: "cascade" }), + groupId: text("group_id") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + permission: text("permission").$type().notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.boardId, table.groupId, table.permission], + }), + }), +); + export const sections = mysqlTable("section", { id: varchar("id", { length: 256 }).notNull().primaryKey(), boardId: varchar("board_id", { length: 256 }) @@ -277,7 +295,7 @@ export const accountRelations = relations(accounts, ({ one }) => ({ export const userRelations = relations(users, ({ many }) => ({ accounts: many(accounts), boards: many(boards), - boardPermissions: many(boardPermissions), + boardPermissions: many(boardUserPermissions), groups: many(groupMembers), ownedGroups: many(groups), invites: many(invites), @@ -310,6 +328,7 @@ export const groupMemberRelations = relations(groupMembers, ({ one }) => ({ export const groupRelations = relations(groups, ({ one, many }) => ({ permissions: many(groupPermissions), + boardPermissions: many(boardGroupPermissions), members: many(groupMembers), owner: one(users, { fields: [groups.ownerId], @@ -327,15 +346,29 @@ export const groupPermissionRelations = relations( }), ); -export const boardPermissionRelations = relations( - boardPermissions, +export const boardUserPermissionRelations = relations( + boardUserPermissions, ({ one }) => ({ user: one(users, { - fields: [boardPermissions.userId], + fields: [boardUserPermissions.userId], references: [users.id], }), board: one(boards, { - fields: [boardPermissions.boardId], + fields: [boardUserPermissions.boardId], + references: [boards.id], + }), + }), +); + +export const boardGroupPermissionRelations = relations( + boardGroupPermissions, + ({ one }) => ({ + group: one(groups, { + fields: [boardGroupPermissions.groupId], + references: [groups.id], + }), + board: one(boards, { + fields: [boardGroupPermissions.boardId], references: [boards.id], }), }), @@ -362,7 +395,8 @@ export const boardRelations = relations(boards, ({ many, one }) => ({ fields: [boards.creatorId], references: [users.id], }), - permissions: many(boardPermissions), + userPermissions: many(boardUserPermissions), + groupPermissions: many(boardGroupPermissions), })); export const sectionRelations = relations(sections, ({ many, one }) => ({ diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 5f770242e..7044a0815 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -198,8 +198,8 @@ export const boards = sqliteTable("board", { columnCount: int("column_count").default(10).notNull(), }); -export const boardPermissions = sqliteTable( - "boardPermission", +export const boardUserPermissions = sqliteTable( + "boardUserPermission", { boardId: text("board_id") .notNull() @@ -216,6 +216,24 @@ export const boardPermissions = sqliteTable( }), ); +export const boardGroupPermissions = sqliteTable( + "boardGroupPermission", + { + boardId: text("board_id") + .notNull() + .references(() => boards.id, { onDelete: "cascade" }), + groupId: text("group_id") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + permission: text("permission").$type().notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.boardId, table.groupId, table.permission], + }), + }), +); + export const sections = sqliteTable("section", { id: text("id").notNull().primaryKey(), boardId: text("board_id") @@ -274,7 +292,7 @@ export const accountRelations = relations(accounts, ({ one }) => ({ export const userRelations = relations(users, ({ many }) => ({ accounts: many(accounts), boards: many(boards), - boardPermissions: many(boardPermissions), + boardPermissions: many(boardUserPermissions), groups: many(groupMembers), ownedGroups: many(groups), invites: many(invites), @@ -307,6 +325,7 @@ export const groupMemberRelations = relations(groupMembers, ({ one }) => ({ export const groupRelations = relations(groups, ({ one, many }) => ({ permissions: many(groupPermissions), + boardPermissions: many(boardGroupPermissions), members: many(groupMembers), owner: one(users, { fields: [groups.ownerId], @@ -324,15 +343,29 @@ export const groupPermissionRelations = relations( }), ); -export const boardPermissionRelations = relations( - boardPermissions, +export const boardUserPermissionRelations = relations( + boardUserPermissions, ({ one }) => ({ user: one(users, { - fields: [boardPermissions.userId], + fields: [boardUserPermissions.userId], references: [users.id], }), board: one(boards, { - fields: [boardPermissions.boardId], + fields: [boardUserPermissions.boardId], + references: [boards.id], + }), + }), +); + +export const boardGroupPermissionRelations = relations( + boardGroupPermissions, + ({ one }) => ({ + group: one(groups, { + fields: [boardGroupPermissions.groupId], + references: [groups.id], + }), + board: one(boards, { + fields: [boardGroupPermissions.boardId], references: [boards.id], }), }), @@ -359,7 +392,8 @@ export const boardRelations = relations(boards, ({ many, one }) => ({ fields: [boards.creatorId], references: [users.id], }), - permissions: many(boardPermissions), + userPermissions: many(boardUserPermissions), + groupPermissions: many(boardGroupPermissions), })); export const sectionRelations = relations(sections, ({ many, one }) => ({ diff --git a/packages/definitions/src/permissions.ts b/packages/definitions/src/permissions.ts index c124c90c6..a3a25f31d 100644 --- a/packages/definitions/src/permissions.ts +++ b/packages/definitions/src/permissions.ts @@ -1,4 +1,4 @@ -import { objectKeys } from "@homarr/common"; +import { objectEntries, objectKeys } from "@homarr/common"; export const boardPermissions = ["board-view", "board-change"] as const; export const groupPermissions = { @@ -20,6 +20,21 @@ const groupPermissionParents = { admin: ["board-full-access", "integration-full-access"], } satisfies Partial>; +export const getPermissionsWithParents = ( + permissions: GroupPermissionKey[], +): GroupPermissionKey[] => { + const res = permissions.map((permission) => { + return objectEntries(groupPermissionParents) + .filter(([_key, value]: [string, GroupPermissionKey[]]) => + value.includes(permission), + ) + .map(([key]) => getPermissionsWithParents([key])) + .flat(); + }); + + return permissions.concat(res.flat()); +}; + const getPermissionsInner = ( permissionSet: Set, permissions: GroupPermissionKey[], diff --git a/packages/definitions/src/test/permissions.spec.ts b/packages/definitions/src/test/permissions.spec.ts new file mode 100644 index 000000000..abc581476 --- /dev/null +++ b/packages/definitions/src/test/permissions.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "vitest"; + +import type { GroupPermissionKey } from "../permissions"; +import { + getPermissionsWithChildren, + getPermissionsWithParents, +} from "../permissions"; + +describe("getPermissionsWithParents should return the correct permissions", () => { + test.each([ + [ + ["board-view-all"], + ["board-view-all", "board-modify-all", "board-full-access", "admin"], + ], + [["board-modify-all"], ["board-modify-all", "board-full-access", "admin"]], + [["board-create"], ["board-create", "board-full-access", "admin"]], + [["board-full-access"], ["board-full-access", "admin"]], + [ + ["integration-use-all"], + [ + "integration-use-all", + "integration-interact-all", + "integration-full-access", + "admin", + ], + ], + [ + ["integration-create"], + ["integration-create", "integration-full-access", "admin"], + ], + [ + ["integration-interact-all"], + ["integration-interact-all", "integration-full-access", "admin"], + ], + [["integration-full-access"], ["integration-full-access", "admin"]], + [["admin"], ["admin"]], + ] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])( + "expect %s to return %s", + (input, expectedOutput) => { + expect(getPermissionsWithParents(input)).toEqual( + expect.arrayContaining(expectedOutput), + ); + }, + ); +}); + +describe("getPermissionsWithChildren should return the correct permissions", () => { + test.each([ + [["board-view-all"], ["board-view-all"]], + [["board-modify-all"], ["board-view-all", "board-modify-all"]], + [["board-create"], ["board-create"]], + [ + ["board-full-access"], + ["board-full-access", "board-modify-all", "board-view-all"], + ], + [["integration-use-all"], ["integration-use-all"]], + [["integration-create"], ["integration-create"]], + [ + ["integration-interact-all"], + ["integration-interact-all", "integration-use-all"], + ], + [ + ["integration-full-access"], + [ + "integration-full-access", + "integration-interact-all", + "integration-use-all", + ], + ], + [ + ["admin"], + [ + "admin", + "board-full-access", + "board-modify-all", + "board-view-all", + "integration-full-access", + "integration-interact-all", + "integration-use-all", + ], + ], + ] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])( + "expect %s to return %s", + (input, expectedOutput) => { + expect(getPermissionsWithChildren(input)).toEqual( + expect.arrayContaining(expectedOutput), + ); + }, + ); +}); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 338e07f8c..b16b91d3e 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -173,6 +173,10 @@ export default { }, }, }, + select: { + label: "Select group", + notFound: "No group found", + }, }, }, app: { @@ -874,10 +878,21 @@ export default { userSelect: { title: "Add user permission", }, + groupSelect: { + title: "Add group permission", + }, + tab: { + user: "Users", + group: "Groups", + inherited: "Inherited groups", + }, field: { user: { label: "User", }, + group: { + label: "Group", + }, permission: { label: "Permission", }, diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 437ceb7f2..b908ab591 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -75,10 +75,7 @@ const savePermissionsSchema = z.object({ id: z.string(), permissions: z.array( z.object({ - user: z.object({ - id: z.string(), - name: z.string(), - }), + itemId: z.string(), permission: zodEnumFromArray(boardPermissions), }), ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756a89437..ae319226d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions '@homarr/eslint-config': specifier: workspace:^0.2.0 version: link:../../tooling/eslint