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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1714414260766,
|
||||
"tag": "0000_chubby_darkhawk",
|
||||
"when": 1714817536714,
|
||||
"tag": "0000_hot_mandrill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
@@ -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": {}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1714414359385,
|
||||
"tag": "0000_abnormal_kree",
|
||||
"when": 1714817544524,
|
||||
"tag": "0000_premium_forgotten_one",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<BoardPermission>().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 }) => ({
|
||||
|
||||
@@ -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<BoardPermission>().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 }) => ({
|
||||
|
||||
@@ -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<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
||||
|
||||
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<GroupPermissionKey>,
|
||||
permissions: GroupPermissionKey[],
|
||||
|
||||
90
packages/definitions/src/test/permissions.spec.ts
Normal file
90
packages/definitions/src/test/permissions.spec.ts
Normal file
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user