feat: board access group permissions (#422)

* fix: cache is not exportet from react

* fix: format issue

* wip: add usage of group permissions

* feat: show inherited groups and add manage group

* refactor: improve board access management

* chore: address pull request feedback

* fix: type issues

* fix: migrations

* test: add unit tests for board permissions, permissions and board router

* test: add unit tests for board router and get current user permissions method

* fix: format issues

* fix: deepsource issue
This commit is contained in:
Meier Lukas
2024-05-04 18:34:41 +02:00
committed by GitHub
parent ca49a01352
commit b1e065f1da
42 changed files with 2375 additions and 423 deletions

View File

@@ -4,14 +4,17 @@ import superjson from "superjson";
import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray, or } from "@homarr/db";
import {
boardPermissions,
boardGroupPermissions,
boards,
boardUserPermissions,
groupMembers,
groupPermissions,
integrationItems,
items,
sections,
} from "@homarr/db/schema/sqlite";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import {
createSectionSchema,
sharedItemSchema,
@@ -20,42 +23,43 @@ import {
} from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
createTRPCRouter,
permissionRequiredProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
export const boardRouter = createTRPCRouter({
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const permissionsOfCurrentUserWhenPresent =
await ctx.db.query.boardPermissions.findMany({
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
});
const boardIds = permissionsOfCurrentUserWhenPresent.map(
(permission) => permission.boardId,
);
const permissionsOfCurrentUserGroupsWhenPresent =
await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) =>
groupMember.group.boardPermissions.map(
(permission) => permission.boardId,
),
)
.flat(),
);
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
@@ -70,19 +74,34 @@ export const boardRouter = createTRPCRouter({
image: true,
},
},
permissions: {
where: eq(boardPermissions.userId, ctx.session?.user.id ?? ""),
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map(
(groupMember) => groupMember.groupId,
),
)
: undefined,
},
},
where: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
// Allow viewing all boards if the user has the permission
where: ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards;
}),
createBoard: protectedProcedure
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.create)
.mutation(async ({ ctx, input }) => {
const boardId = createId();
@@ -377,10 +396,20 @@ export const boardRouter = createTRPCRouter({
"full-access",
);
const permissions = await ctx.db.query.boardPermissions.findMany({
where: eq(boardPermissions.boardId, input.id),
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents([
"board-view-all",
"board-modify-all",
"board-full-access",
]),
),
columns: {
groupId: false,
},
with: {
user: {
group: {
columns: {
id: true,
name: true,
@@ -388,19 +417,61 @@ export const boardRouter = createTRPCRouter({
},
},
});
return permissions
.map((permission) => ({
const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.boardId, input.id),
with: {
user: {
id: permission.userId,
name: permission.user.name ?? "",
columns: {
id: true,
name: true,
image: true,
},
},
},
});
const dbGroupBoardPermission =
await ctx.db.query.boardGroupPermissions.findMany({
where: eq(boardGroupPermissions.boardId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
permission: permission.permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.user.name.localeCompare(permissionB.user.name);
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
userPermissions: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(
permissionB.user.name ?? "",
);
}),
groupPermissions: dbGroupBoardPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
name: group.name,
},
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
}),
saveBoardPermissions: protectedProcedure
saveUserBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
@@ -411,14 +482,39 @@ export const boardRouter = createTRPCRouter({
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(boardPermissions)
.where(eq(boardPermissions.boardId, input.id));
.delete(boardUserPermissions)
.where(eq(boardUserPermissions.boardId, input.id));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardPermissions).values(
await transaction.insert(boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.user.id,
userId: permission.itemId,
permission: permission.permission,
boardId: input.id,
})),
);
});
}),
saveGroupBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(boardGroupPermissions)
.where(eq(boardGroupPermissions.boardId, input.id));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.itemId,
permission: permission.permission,
boardId: input.id,
})),
@@ -458,6 +554,9 @@ const getFullBoardWithWhere = async (
where: SQL<unknown>,
userId: string | null,
) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
});
const board = await db.query.boards.findFirst({
where,
with: {
@@ -465,6 +564,7 @@ const getFullBoardWithWhere = async (
columns: {
id: true,
name: true,
image: true,
},
},
sections: {
@@ -480,12 +580,18 @@ const getFullBoardWithWhere = async (
},
},
},
permissions: {
where: eq(boardPermissions.userId, userId ?? ""),
userPermissions: {
where: eq(boardUserPermissions.userId, userId ?? ""),
columns: {
permission: true,
},
},
groupPermissions: {
where: inArray(
boardGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
@@ -530,3 +636,27 @@ const parseSection = (section: unknown) => {
}
return result.data;
};
const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);

View File

@@ -3,8 +3,12 @@ import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq } from "@homarr/db";
import { boardPermissions } from "@homarr/db/schema/sqlite";
import { eq, inArray } from "@homarr/db";
import {
boardGroupPermissions,
boardUserPermissions,
groupMembers,
} from "@homarr/db/schema/sqlite";
import type { BoardPermission } from "@homarr/definitions";
/**
@@ -19,6 +23,9 @@ export const throwIfActionForbiddenAsync = async (
permission: "full-access" | BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
@@ -27,8 +34,14 @@ export const throwIfActionForbiddenAsync = async (
isPublic: true,
},
with: {
permissions: {
where: eq(boardPermissions.userId, session?.user.id ?? ""),
userPermissions: {
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
boardGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});

View File

@@ -94,6 +94,14 @@ export const groupRouter = createTRPCRouter({
),
};
}),
selectable: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.groups.findMany({
columns: {
id: true,
name: true,
},
});
}),
createGroup: protectedProcedure
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {

View File

@@ -1,11 +1,16 @@
import SuperJSON from "superjson";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { createId, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
boardUserPermissions,
groupMembers,
groupPermissions,
groups,
integrationItems,
integrations,
items,
@@ -13,6 +18,7 @@ import {
users,
} from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
import type { RouterOutputs } from "../..";
import { boardRouter } from "../board";
@@ -23,6 +29,7 @@ const defaultCreatorId = createId();
const defaultSession = {
user: {
id: defaultCreatorId,
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
@@ -30,6 +37,462 @@ const defaultSession = {
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const createRandomUser = async (db: Database) => {
const userId = createId();
await db.insert(users).values({
id: userId,
});
return userId;
};
describe("getAllBoards should return all boards accessable to the current user", () => {
test("without session it should return only public boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: null });
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: createId(),
name: "private",
creatorId: user2,
isPublic: false,
},
]);
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(1);
expect(result[0]?.name).toBe("public");
});
test("with session containing board-view-all permission it should return all boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({
db,
session: {
user: {
id: defaultCreatorId,
permissions: ["board-view-all"],
},
expires: new Date().toISOString(),
},
});
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: createId(),
name: "private",
creatorId: user2,
isPublic: false,
},
]);
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map((board) => board.name)).toEqual(["public", "private"]);
});
test("with session user beeing creator it should return all private boards of them", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: createId(),
name: "private",
creatorId: user2,
isPublic: false,
},
{
id: createId(),
name: "private2",
creatorId: defaultCreatorId,
isPublic: false,
},
]);
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private2",
]);
});
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"with %s group board permission it should show board",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({
db,
session: defaultSession,
});
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: boardId,
name: "private1",
creatorId: user2,
isPublic: false,
},
{
id: createId(),
name: "private2",
creatorId: user2,
isPublic: false,
},
]);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "group1",
});
await db.insert(groupMembers).values({
userId: defaultSession.user.id,
groupId,
});
await db.insert(boardGroupPermissions).values({
groupId,
permission,
boardId,
});
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private1",
]);
},
);
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"with %s user board permission it should show board",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({
db,
session: defaultSession,
});
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values([
{
id: createId(),
name: "public",
creatorId: user1,
isPublic: true,
},
{
id: boardId,
name: "private1",
creatorId: user2,
isPublic: false,
},
{
id: createId(),
name: "private2",
creatorId: user2,
isPublic: false,
},
]);
await db.insert(boardUserPermissions).values({
userId: defaultSession.user.id,
permission,
boardId,
});
// Act
const result = await caller.getAllBoards();
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private1",
]);
},
);
});
describe("createBoard should create a new board", () => {
test("should create a new board with permission board-create", async () => {
// Arrange
const db = createDb();
const session = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, session });
await db.insert(users).values({
id: defaultCreatorId,
});
// Act
await caller.createBoard({ name: "newBoard" });
// Assert
const dbBoard = await db.query.boards.findFirst();
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newBoard");
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
const dbSection = await db.query.sections.findFirst();
expect(dbSection).toBeDefined();
expect(dbSection?.boardId).toBe(dbBoard?.id);
expect(dbSection?.kind).toBe("empty");
});
test("should throw error when user has no board-create permission", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const act = async () => await caller.createBoard({ name: "newBoard" });
// Assert
await expect(act()).rejects.toThrowError("Permission denied");
});
});
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "oldName",
creatorId: defaultCreatorId,
});
// Act
await caller.renameBoard({ id: boardId, name: "newName" });
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newName");
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
});
test("should throw error when similar board name exists", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "oldName",
creatorId: defaultCreatorId,
});
await db.insert(boards).values({
id: createId(),
name: "newName",
creatorId: defaultCreatorId,
});
// Act
const act = async () =>
await caller.renameBoard({ id: boardId, name: "Newname" });
// Assert
await expect(act()).rejects.toThrowError(
"Board with similar name already exists",
);
});
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const act = async () =>
await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
// Assert
await expect(act()).rejects.toThrowError("Board not found");
});
});
describe("changeBoardVisibility should change board visibility", () => {
test.each([["public"], ["private"]] satisfies ["private" | "public"][])(
"should change board visibility to %s",
async (visibility) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
isPublic: visibility === "public",
});
// Act
await caller.changeBoardVisibility({
id: boardId,
visibility,
});
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.isPublic).toBe(visibility === "public");
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
},
);
});
describe("deleteBoard should delete board", () => {
test("should delete board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
// Act
await caller.deleteBoard({ id: boardId });
// Assert
const dbBoard = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
});
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const act = async () =>
await caller.deleteBoard({ id: "nonExistentBoardId" });
// Assert
await expect(act()).rejects.toThrowError("Board not found");
});
});
describe("getDefaultBoard should return default board", () => {
it("should return default board", async () => {
// Arrange
@@ -698,6 +1161,183 @@ describe("saveBoard should save full board", () => {
});
});
describe("getBoardPermissions should return board permissions", () => {
test("should return board permissions", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUser(db);
const user2 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
await db.insert(boardUserPermissions).values([
{
userId: user1,
permission: "board-view",
boardId,
},
{
userId: user2,
permission: "board-change",
boardId,
},
]);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "group1",
});
await db.insert(boardGroupPermissions).values({
groupId,
permission: "board-view",
boardId,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
const result = await caller.getBoardPermissions({ id: boardId });
// Assert
expect(result.groupPermissions).toEqual([
{ group: { id: groupId, name: "group1" }, permission: "board-view" },
]);
expect(result.userPermissions).toEqual(
expect.arrayContaining([
{
user: { id: user1, name: null, image: null },
permission: "board-view",
},
{
user: { id: user2, name: null, image: null },
permission: "board-change",
},
]),
);
expect(result.inherited).toEqual([
{ group: { id: groupId, name: "group1" }, permission: "admin" },
]);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
});
});
describe("saveUserBoardPermissions should save user board permissions", () => {
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"should save user board permissions",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUser(db);
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
// Act
await caller.saveUserBoardPermissions({
id: boardId,
permissions: [
{
itemId: user1,
permission,
},
],
});
// Assert
const dbUserPermission = await db.query.boardUserPermissions.findFirst({
where: eq(boardUserPermissions.userId, user1),
});
expect(dbUserPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
},
);
});
describe("saveGroupBoardPermissions should save group board permissions", () => {
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
"should save group board permissions",
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
id: defaultCreatorId,
});
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "group1",
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "board",
creatorId: defaultCreatorId,
});
// Act
await caller.saveGroupBoardPermissions({
id: boardId,
permissions: [
{
itemId: groupId,
permission,
},
],
});
// Assert
const dbGroupPermission = await db.query.boardGroupPermissions.findFirst({
where: eq(boardGroupPermissions.groupId, groupId),
});
expect(dbGroupPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
},
);
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getDefaultBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,

View File

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

View File

@@ -10,9 +10,10 @@ import { inviteRouter } from "../invite";
const defaultSession = {
user: {
id: createId(),
permissions: [],
},
expires: new Date().toISOString(),
};
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {

View File

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

View File

@@ -11,6 +11,7 @@ import superjson from "superjson";
import type { Session } from "@homarr/auth";
import { db } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { ZodError } from "@homarr/validation";
@@ -115,3 +116,25 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
*
* If you want a query or mutation to ONLY be accessible to users with a specific permission, use
* this. It verifies that the user has the required permission
*
* @see https://trpc.io/docs/procedures
*/
export const permissionRequiredProcedure = {
requiresPermission: (permission: GroupPermissionKey) => {
return protectedProcedure.use(({ ctx, input, next }) => {
if (!ctx.session?.user.permissions.includes(permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Permission denied",
});
}
return next({ input, ctx });
});
},
};

View File

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

View File

@@ -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

View File

@@ -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"];
}
}

View File

@@ -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",

View File

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

View File

@@ -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;

View File

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

View File

@@ -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",

View File

@@ -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

View File

@@ -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"]
}
},

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1714414260766,
"tag": "0000_chubby_darkhawk",
"when": 1714817536714,
"tag": "0000_hot_mandrill",
"breakpoints": true
}
]

View File

@@ -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,

View File

@@ -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": {}

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1714414359385,
"tag": "0000_abnormal_kree",
"when": 1714817544524,
"tag": "0000_premium_forgotten_one",
"breakpoints": true
}
]

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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[],

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

View File

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

View File

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