feat: add integration access settings (#725)

* feat: add integration access settings

* fix: typecheck and test issues

* fix: test timeout

* chore: address pull request feedback

* chore: add throw if action forbidden for integration permissions

* fix: unable to create new migrations because of duplicate prevId in sqlite snapshots

* chore: add sqlite migration for integration permissions

* test: add unit tests for integration access

* test: add permission checks to integration router tests

* test: add unit test for integration permissions

* chore: add mysql migration

* fix: format issues
This commit is contained in:
Meier Lukas
2024-07-08 00:00:37 +02:00
committed by GitHub
parent be711149f7
commit 408cdeb5c3
50 changed files with 4392 additions and 615 deletions

View File

@@ -113,7 +113,7 @@ export const boardRouter = createTRPCRouter({
});
}),
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
@@ -122,7 +122,7 @@ export const boardRouter = createTRPCRouter({
changeBoardVisibility: protectedProcedure
.input(validation.board.changeVisibility)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await ctx.db
.update(boards)
@@ -130,12 +130,12 @@ export const boardRouter = createTRPCRouter({
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-view");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
@@ -148,20 +148,20 @@ export const boardRouter = createTRPCRouter({
: null;
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
const boardWhere = eq(boards.name, input.name);
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
savePartialBoardSettings: protectedProcedure
.input(validation.board.savePartialSettings.and(z.object({ id: z.string() })))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
await ctx.db
.update(boards)
@@ -192,7 +192,7 @@ export const boardRouter = createTRPCRouter({
.where(eq(boards.id, input.id));
}),
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
@@ -332,12 +332,12 @@ export const boardRouter = createTRPCRouter({
}),
getBoardPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-access"]),
getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-all"]),
),
columns: {
groupId: false,
@@ -381,7 +381,7 @@ export const boardRouter = createTRPCRouter({
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
userPermissions: userPermissions
users: userPermissions
.map(({ user, permission }) => ({
user,
permission,
@@ -389,7 +389,7 @@ export const boardRouter = createTRPCRouter({
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groupPermissions: dbGroupBoardPermission
groups: dbGroupBoardPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
@@ -405,18 +405,18 @@ export const boardRouter = createTRPCRouter({
saveUserBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.id));
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.itemId,
userId: permission.principalId,
permission: permission.permission,
boardId: input.id,
boardId: input.entityId,
})),
);
});
@@ -424,18 +424,18 @@ export const boardRouter = createTRPCRouter({
saveGroupBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.id));
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.itemId,
groupId: permission.principalId,
permission: permission.permission,
boardId: input.id,
boardId: input.entityId,
})),
);
});

View File

@@ -16,7 +16,7 @@ import type { BoardPermission } from "@homarr/definitions";
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
boardWhere: SQL<unknown>,
permission: "full-access" | BoardPermission,
permission: BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
@@ -49,11 +49,11 @@ export const throwIfActionForbiddenAsync = async (
return; // As full access is required and user has full access, allow
}
if (["board-change", "board-view"].includes(permission) && hasChangeAccess) {
if (["modify", "view"].includes(permission) && hasChangeAccess) {
return; // As change access is required and user has change access, allow
}
if (permission === "board-view" && hasViewAccess) {
if (permission === "view" && hasViewAccess) {
return; // As view access is required and user has view access, allow
}

View File

@@ -0,0 +1,73 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";
import type { IntegrationPermission } from "@homarr/definitions";
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
* @param integrationWhere where clause for the integration
* @param permission permission required to perform action on integration
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
if (!integration) {
notAllowed();
}
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["interact", "use"].includes(permission) && hasInteractAccess) {
return; // As interact access is required and user has interact access, allow
}
if (permission === "use" && hasUseAccess) {
return; // As use access is required and user has use access, allow
}
notAllowed();
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
*/
function notAllowed(): never {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}

View File

@@ -2,17 +2,24 @@ import { TRPCError } from "@trpc/server";
import { decryptSecret, encryptSecret } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { and, createId, eq, inArray } from "@homarr/db";
import {
groupPermissions,
integrationGroupPermissions,
integrations,
integrationSecrets,
integrationUserPermissions,
} from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { testConnectionAsync } from "./integration-test-connection";
export const integrationRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
all: protectedProcedure.query(async ({ ctx }) => {
const integrations = await ctx.db.query.integrations.findMany();
return integrations
.map((integration) => ({
@@ -26,7 +33,8 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
byId: publicProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
@@ -60,34 +68,39 @@ export const integrationRouter = createTRPCRouter({
})),
};
}),
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
});
create: permissionRequiredProcedure
.requiresPermission("integration-create")
.input(validation.integration.create)
.mutation(async ({ ctx, input }) => {
await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
});
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
});
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
@@ -146,7 +159,9 @@ export const integrationRouter = createTRPCRouter({
}
}
}),
delete: publicProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
delete: protectedProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
@@ -160,6 +175,119 @@ export const integrationRouter = createTRPCRouter({
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
getIntegrationPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents(["integration-use-all", "integration-interact-all", "integration-full-all"]),
),
columns: {
groupId: false,
},
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
const userPermissions = await ctx.db.query.integrationUserPermissions.findMany({
where: eq(integrationUserPermissions.integrationId, input.id),
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
});
const dbGroupIntegrationPermission = await ctx.db.query.integrationGroupPermissions.findMany({
where: eq(integrationGroupPermissions.integrationId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupIntegrationPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
name: group.name,
},
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
}),
saveUserIntegrationPermissions: protectedProcedure
.input(validation.integration.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
}),
saveGroupIntegrationPermissions: protectedProcedure
.input(validation.integration.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
}),
});
interface UpdateSecretInput {

View File

@@ -158,7 +158,7 @@ describe("getAllBoards should return all boards accessable to the current user",
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private2"]);
});
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
"with %s group board permission it should show board",
async (permission) => {
// Arrange
@@ -222,7 +222,7 @@ describe("getAllBoards should return all boards accessable to the current user",
},
);
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
"with %s user board permission it should show board",
async (permission) => {
// Arrange
@@ -347,7 +347,7 @@ describe("rename board should rename board", () => {
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newName");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
});
test("should throw error when similar board name exists", async () => {
@@ -422,7 +422,7 @@ describe("changeBoardVisibility should change board visibility", () => {
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.isPublic).toBe(visibility === "public");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
},
);
});
@@ -452,7 +452,7 @@ describe("deleteBoard should delete board", () => {
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
});
test("should throw error when board not found", async () => {
@@ -485,7 +485,7 @@ describe("getHomeBoard should return home board", () => {
name: "home",
...fullBoardProps,
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
});
@@ -506,7 +506,7 @@ describe("getBoardByName should return board by name", () => {
name,
...fullBoardProps,
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
it("should throw error when not present", async () => {
@@ -583,7 +583,7 @@ describe("savePartialBoardSettings should save general settings", () => {
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should throw error when board not found", async () => {
@@ -638,7 +638,7 @@ describe("saveBoard should save full board", () => {
expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
expect(section).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should remove item when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -692,7 +692,7 @@ describe("saveBoard should save full board", () => {
expect(firstSection.items.length).toBe(1);
expect(firstSection.items[0]?.id).not.toBe(itemId);
expect(item).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -759,7 +759,7 @@ describe("saveBoard should save full board", () => {
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
expect(integration).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }]])(
"should add section when present in input",
@@ -811,7 +811,7 @@ describe("saveBoard should save full board", () => {
expect(addedSection.name).toBe(partialSection.name);
}
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
},
);
it("should add item when present in input", async () => {
@@ -875,7 +875,7 @@ describe("saveBoard should save full board", () => {
expect(addedItem.xOffset).toBe(3);
expect(addedItem.yOffset).toBe(2);
expect(item).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -942,7 +942,7 @@ describe("saveBoard should save full board", () => {
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
expect(integrationItem).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should update section when present in input", async () => {
const db = createDb();
@@ -1052,7 +1052,7 @@ describe("saveBoard should save full board", () => {
expect(firstItem.width).toBe(2);
expect(firstItem.xOffset).toBe(7);
expect(firstItem.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
const db = createDb();
@@ -1091,12 +1091,12 @@ describe("getBoardPermissions should return board permissions", () => {
await db.insert(boardUserPermissions).values([
{
userId: user1,
permission: "board-view",
permission: "view",
boardId,
},
{
userId: user2,
permission: "board-change",
permission: "modify",
boardId,
},
]);
@@ -1109,7 +1109,7 @@ describe("getBoardPermissions should return board permissions", () => {
await db.insert(boardGroupPermissions).values({
groupId,
permission: "board-view",
permission: "view",
boardId,
});
@@ -1122,26 +1122,26 @@ describe("getBoardPermissions should return board permissions", () => {
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(result.groups).toEqual([{ group: { id: groupId, name: "group1" }, permission: "view" }]);
expect(result.users).toEqual(
expect.arrayContaining([
{
user: { id: user1, name: null, image: null },
permission: "board-view",
permission: "view",
},
{
user: { id: user2, name: null, image: null },
permission: "board-change",
permission: "modify",
},
]),
);
expect(result.inherited).toEqual([{ group: { id: groupId, name: "group1" }, permission: "admin" }]);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
});
});
describe("saveUserBoardPermissions should save user board permissions", () => {
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
"should save user board permissions",
async (permission) => {
// Arrange
@@ -1163,10 +1163,10 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
// Act
await caller.saveUserBoardPermissions({
id: boardId,
entityId: boardId,
permissions: [
{
itemId: user1,
principalId: user1,
permission,
},
],
@@ -1177,13 +1177,13 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
where: eq(boardUserPermissions.userId, user1),
});
expect(dbUserPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
},
);
});
describe("saveGroupBoardPermissions should save group board permissions", () => {
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
"should save group board permissions",
async (permission) => {
// Arrange
@@ -1210,10 +1210,10 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
// Act
await caller.saveGroupBoardPermissions({
id: boardId,
entityId: boardId,
permissions: [
{
itemId: groupId,
principalId: groupId,
permission,
},
],
@@ -1224,7 +1224,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
where: eq(boardGroupPermissions.groupId, groupId),
});
expect(dbGroupPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
},
);
});

View File

@@ -18,14 +18,11 @@ const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) =>
await expect(act()).resolves.toBeUndefined();
};
// TODO: most of this test can be used for constructBoardPermissions
// TODO: the tests for the board-access can be reduced to about 4 tests (as the unit has shrunk)
describe("throwIfActionForbiddenAsync should check access to board and return boolean", () => {
test.each([
["full-access" as const, true],
["board-change" as const, true],
["board-view" as const, true],
["full" as const, true],
["modify" as const, true],
["view" as const, true],
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
@@ -52,9 +49,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
});
test.each([
["full-access" as const, false],
["board-change" as const, true],
["board-view" as const, true],
["full" as const, false],
["modify" as const, true],
["view" as const, true],
])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
@@ -81,9 +78,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
});
test.each([
["full-access" as const, false],
["board-change" as const, false],
["board-view" as const, true],
["full" as const, false],
["modify" as const, false],
["view" as const, true],
])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
@@ -110,9 +107,9 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
});
test.each([
["full-access" as const, false],
["board-change" as const, false],
["board-view" as const, false],
["full" as const, false],
["modify" as const, false],
["view" as const, false],
])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
// Arrange
const db = createDb();
@@ -143,7 +140,7 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
const db = createDb();
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full-access");
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full");
// Assert
await expect(act()).rejects.toThrow("Board not found");

View File

@@ -364,7 +364,7 @@ describe("savePermissions should save permissions for group", () => {
// Act
await caller.savePermissions({
groupId,
permissions: ["integration-use-all", "board-full-access"],
permissions: ["integration-use-all", "board-full-all"],
});
// Assert
@@ -373,7 +373,7 @@ describe("savePermissions should save permissions for group", () => {
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-access"]);
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
});
test("with non existing group it should throw not found error", async () => {
@@ -390,7 +390,7 @@ describe("savePermissions should save permissions for group", () => {
const actAsync = async () =>
await caller.savePermissions({
groupId: createId(),
permissions: ["integration-create", "board-full-access"],
permissions: ["integration-create", "board-full-all"],
});
// Assert

View File

@@ -0,0 +1,155 @@
import { describe, expect, test, vi } from "vitest";
import * as authShared from "@homarr/auth/shared";
import { createId, eq } from "@homarr/db";
import { integrations, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { throwIfActionForbiddenAsync } from "../../integration/integration-access";
const defaultCreatorId = createId();
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
if (!success) {
await expect(act()).rejects.toThrow("Integration not found");
return;
}
await expect(act()).resolves.toBeUndefined();
};
describe("throwIfActionForbiddenAsync should check access to integration and return boolean", () => {
test.each([
["full" as const, true],
["interact" as const, true],
["use" as const, true],
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, true],
["use" as const, true],
])("with permission %s should return %s when hasInteractAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: true,
hasUseAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, false],
["use" as const, true],
])("with permission %s should return %s when hasUseAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: true,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, false],
["use" as const, false],
])("with permission %s should return %s when hasUseAccess is false", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test("should throw when integration is not found", async () => {
// Arrange
const db = createDb();
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, createId()), "full");
// Assert
await expect(act()).rejects.toThrow("Integration not found");
});
});

View File

@@ -1,15 +1,26 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { encryptSecret } from "@homarr/common";
import { createId } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { integrationRouter } from "../../integration/integration-router";
import { expectToBeDefined } from "../helper";
const defaultUserId = createId();
const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =>
({
user: {
id: defaultUserId,
permissions,
},
expires: new Date().toISOString(),
}) satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("../../integration/integration-test-connection", () => ({
@@ -17,11 +28,11 @@ vi.mock("../../integration/integration-test-connection", () => ({
}));
describe("all should return all integrations", () => {
it("should return all integrations", async () => {
test("with any session should return all integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
@@ -47,11 +58,11 @@ describe("all should return all integrations", () => {
});
describe("byId should return an integration by id", () => {
it("should return an integration by id", async () => {
test("with full access should return an integration by id", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
@@ -73,22 +84,22 @@ describe("byId should return an integration by id", () => {
expect(result.kind).toBe("plex");
});
it("should throw an error if the integration does not exist", async () => {
test("with full access should throw an error if the integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const actAsync = async () => await caller.byId({ id: "2" });
await expect(actAsync()).rejects.toThrow("Integration not found");
});
it("should only return the public secret values", async () => {
test("with full access should only return the public secret values", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
@@ -129,14 +140,38 @@ describe("byId should return an integration by id", () => {
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
expect(apiKey.value).toBeNull();
});
});
describe("create should create a new integration", () => {
it("should create a new integration", async () => {
test("without full access should throw integration not found error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
]);
// Act
const actAsync = async () => await caller.byId({ id: "1" });
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("create should create a new integration", () => {
test("with create integration access should create a new integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
@@ -164,14 +199,35 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
});
describe("update should update an integration", () => {
it("should update an integration", async () => {
test("without create integration access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
};
// Act
const actAsync = async () => await caller.create(input);
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("update should update an integration", () => {
test("with full access should update an integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const lastWeek = new Date("2023-06-24T00:00:00Z");
@@ -241,11 +297,11 @@ describe("update should update an integration", () => {
expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
});
it("should throw an error if the integration does not exist", async () => {
test("with full access should throw an error if the integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const actAsync = async () =>
@@ -257,14 +313,35 @@ describe("update should update an integration", () => {
});
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("delete should delete an integration", () => {
it("should delete an integration", async () => {
test("without full access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
// Act
const actAsync = async () =>
await caller.update({
id: createId(),
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
});
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("delete should delete an integration", () => {
test("with full access should delete an integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const integrationId = createId();
@@ -291,4 +368,19 @@ describe("delete should delete an integration", () => {
const dbSecrets = await db.query.integrationSecrets.findMany();
expect(dbSecrets.length).toBe(0);
});
test("without full access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
// Act
const actAsync = async () => await caller.delete({ id: createId() });
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});