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
This commit is contained in:
Meier Lukas
2024-05-04 18:34:41 +02:00
committed by GitHub
parent ca49a01352
commit b1e065f1da
42 changed files with 2375 additions and 423 deletions

View File

@@ -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 = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
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<unknown>,
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 = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);

View File

@@ -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(""),
),
},
},
});

View File

@@ -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 }) => {

View File

@@ -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<ReturnType<typeof createFullBoardAsync>>,

View File

@@ -16,6 +16,7 @@ const defaultOwnerId = createId();
const defaultSession = {
user: {
id: defaultOwnerId,
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;

View File

@@ -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 () => {

View File

@@ -50,6 +50,7 @@ export const userRouter = createTRPCRouter({
columns: {
id: true,
name: true,
image: true,
},
});
}),

View File

@@ -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 });
});
},
};