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:
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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(""),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -16,6 +16,7 @@ const defaultOwnerId = createId();
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: defaultOwnerId,
|
||||
permissions: [],
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export const userRouter = createTRPCRouter({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user