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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user