diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index 64ef81699..0ec2ac11e 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -1,6 +1,10 @@ import { TRPCError } from "@trpc/server"; +import type { Session } from "@homarr/auth"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { decryptSecret } from "@homarr/common"; +import type { Database } from "@homarr/db"; import { and, eq, inArray } from "@homarr/db"; import { integrations } from "@homarr/db/schema/sqlite"; import type { IntegrationKind } from "@homarr/definitions"; @@ -8,12 +12,41 @@ import { z } from "@homarr/validation"; import { publicProcedure } from "../trpc"; -export const createOneIntegrationMiddleware = (...kinds: TKind[]) => { +type IntegrationAction = "query" | "interact"; + +/** + * Creates a middleware that provides the integration in the context that is of the specified kinds + * @param action query for showing data or interact for mutating data + * @param kinds kinds of integrations that are supported + * @returns middleware that can be used with trpc + * @example publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...) + * @throws TRPCError NOT_FOUND if the integration was not found + * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration + */ +export const createOneIntegrationMiddleware = ( + action: IntegrationAction, + ...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided +) => { return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { const integration = await ctx.db.query.integrations.findFirst({ where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)), with: { secrets: true, + groupPermissions: true, + userPermissions: true, + items: { + with: { + item: { + with: { + section: { + columns: { + boardId: true, + }, + }, + }, + }, + }, + }, }, }); @@ -24,7 +57,16 @@ export const createOneIntegrationMiddleware = (.. }); } - const { secrets, kind, ...rest } = integration; + await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session); + + const { + secrets, + kind, + items: _ignore1, + groupPermissions: _ignore2, + userPermissions: _ignore3, + ...rest + } = integration; return next({ ctx: { @@ -41,7 +83,20 @@ export const createOneIntegrationMiddleware = (.. }); }; -export const createManyIntegrationMiddleware = (...kinds: TKind[]) => { +/** + * Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item + * It also ensures that the user has permission to perform the specified action on the integrations + * @param action query for showing data or interact for mutating data + * @param kinds kinds of integrations that are supported + * @returns middleware that can be used with trpc + * @example publicProcedure.unstable_concat(createManyIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...) + * @throws TRPCError NOT_FOUND if the integration was not found + * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations + */ +export const createManyIntegrationMiddleware = ( + action: IntegrationAction, + ...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided +) => { return publicProcedure .input(z.object({ integrationIds: z.array(z.string()).min(1) })) .use(async ({ ctx, input, next }) => { @@ -49,7 +104,21 @@ export const createManyIntegrationMiddleware = (. where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)), with: { secrets: true, - items: true, + items: { + with: { + item: { + with: { + section: { + columns: { + boardId: true, + }, + }, + }, + }, + }, + }, + userPermissions: true, + groupPermissions: true, }, }); @@ -61,22 +130,39 @@ export const createManyIntegrationMiddleware = (. }); } + await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session); + return next({ ctx: { - integrations: dbIntegrations.map(({ secrets, kind, ...rest }) => ({ - ...rest, - kind: kind as TKind, - decryptedSecrets: secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - })), + integrations: dbIntegrations.map( + ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({ + ...rest, + kind: kind as TKind, + decryptedSecrets: secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }), + ), }, }); }); }; -export const createManyIntegrationOfOneItemMiddleware = (...kinds: TKind[]) => { +/** + * Creates a middleware that provides the integrations and their items in the context that are of the specified kinds and have the specified item + * It also ensures that the user has permission to perform the specified action on the integrations + * @param action query for showing data or interact for mutating data + * @param kinds kinds of integrations that are supported + * @returns middleware that can be used with trpc + * @example publicProcedure.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "piHole", "homeAssistant")).query(...) + * @throws TRPCError NOT_FOUND if the integration for the item was not found + * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations + */ +export const createManyIntegrationOfOneItemMiddleware = ( + action: IntegrationAction, + ...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided +) => { return publicProcedure .input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() })) .use(async ({ ctx, input, next }) => { @@ -84,7 +170,21 @@ export const createManyIntegrationOfOneItemMiddleware = integration.items.some((item) => item.itemId === input.itemId), ); @@ -109,15 +211,53 @@ export const createManyIntegrationOfOneItemMiddleware = ({ - ...rest, - kind: kind as TKind, - decryptedSecrets: secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - })), + integrations: dbIntegrationWithItem.map( + ({ secrets, kind, groupPermissions: _ignore1, userPermissions: _ignore2, ...rest }) => ({ + ...rest, + kind: kind as TKind, + decryptedSecrets: secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }), + ), }, }); }); }; + +/** + * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations + * @param action action to perform + * @param db db instance + * @param integrations integrations to check permissions for + * @param session session of the user + * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations + */ +const throwIfActionIsNotAllowedAsync = async ( + action: IntegrationAction, + db: Database, + integrations: Parameters[1], + session: Session | null, +) => { + if (action === "interact") { + const haveAllInteractAccess = integrations + .map((integration) => constructIntegrationPermissions(integration, session)) + .every(({ hasInteractAccess }) => hasInteractAccess); + if (haveAllInteractAccess) return; + + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to interact with at least one of the specified integrations", + }); + } + + const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + if (hasQueryAccess) return; + + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query at least one of the specified integration", + }); +}; diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 0b2bec26d..1dba2888f 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure - .unstable_concat(createManyIntegrationOfOneItemMiddleware("sonarr", "radarr", "readarr", "lidarr")) + .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "sonarr", "radarr", "readarr", "lidarr")) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.flatMap(async (integration) => { diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index a163713c3..51207d148 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -9,7 +9,7 @@ import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const dnsHoleRouter = createTRPCRouter({ - summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("piHole")).query(async ({ ctx }) => { + summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole")).query(async ({ ctx }) => { const cache = createCacheChannel(`dns-hole-summary:${ctx.integration.id}`); const { data } = await cache.consumeAsync(async () => { diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 18fcff10d..00f4cf68c 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -8,7 +8,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaServerRouter = createTRPCRouter({ getCurrentStreams: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("jellyfin", "plex")) + .unstable_concat(createManyIntegrationMiddleware("query", "jellyfin", "plex")) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { @@ -22,7 +22,7 @@ export const mediaServerRouter = createTRPCRouter({ ); }), subscribeToCurrentStreams: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("jellyfin", "plex")) + .unstable_concat(createManyIntegrationMiddleware("query", "jellyfin", "plex")) .subscription(({ ctx }) => { return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { const unsubscribes: (() => void)[] = []; diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index 7b0b177fd..b77a5d051 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -26,14 +26,14 @@ export const smartHomeRouter = createTRPCRouter({ }); }), switchEntity: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("homeAssistant")) + .unstable_concat(createOneIntegrationMiddleware("interact", "homeAssistant")) .input(z.object({ entityId: z.string() })) .mutation(async ({ ctx, input }) => { const client = new HomeAssistantIntegration(ctx.integration); return await client.triggerToggleAsync(input.entityId); }), executeAutomation: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("homeAssistant")) + .unstable_concat(createOneIntegrationMiddleware("interact", "homeAssistant")) .input(z.object({ automationId: z.string() })) .mutation(async ({ input, ctx }) => { const client = new HomeAssistantIntegration(ctx.integration); diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts index be34dcba7..b082e896a 100644 --- a/packages/auth/callbacks.ts +++ b/packages/auth/callbacks.ts @@ -14,12 +14,15 @@ export const getCurrentUserPermissionsAsync = async (db: Database, userId: strin where: eq(groupMembers.userId, userId), }); const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId); + + if (groupIds.length === 0) return []; + const dbGroupPermissions = await db .selectDistinct({ permission: groupPermissions.permission, }) .from(groupPermissions) - .where(groupIds.length > 0 ? inArray(groupPermissions.groupId, groupIds) : undefined); + .where(inArray(groupPermissions.groupId, groupIds)); const permissionKeys = dbGroupPermissions.map(({ permission }) => permission); return getPermissionsWithChildren(permissionKeys); diff --git a/packages/auth/package.json b/packages/auth/package.json index 506b7db33..43e2715b8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,6 +7,7 @@ "./next": "./next.ts", "./security": "./security.ts", "./client": "./client.ts", + "./server": "./server.ts", "./shared": "./shared.ts", "./env.mjs": "./env.mjs" }, diff --git a/packages/auth/permissions/integration-query-permissions.ts b/packages/auth/permissions/integration-query-permissions.ts new file mode 100644 index 000000000..4ee7a31c2 --- /dev/null +++ b/packages/auth/permissions/integration-query-permissions.ts @@ -0,0 +1,124 @@ +import type { Session } from "next-auth"; + +import type { Database } from "@homarr/db"; +import { and, eq, inArray, or } from "@homarr/db"; +import { boards, boardUserPermissions, groupMembers } from "@homarr/db/schema/sqlite"; +import type { IntegrationPermission } from "@homarr/definitions"; + +import { constructIntegrationPermissions } from "./integration-permissions"; + +interface Integration { + id: string; + items: { + item: { + section: { + boardId: string; + }; + }; + }[]; + userPermissions: { + permission: IntegrationPermission; + }[]; + groupPermissions: { + permission: IntegrationPermission; + }[]; +} + +export const hasQueryAccessToIntegrationsAsync = async ( + db: Database, + integrations: Integration[], + session: Session | null, +) => { + // If the user has board-view-all and every integration has at least one item that is placed on a board he has access. + if ( + session?.user.permissions.includes("board-view-all") && + integrations.every((integration) => integration.items.length >= 1) + ) { + return true; + } + + const integrationsWithUseAccess = integrations.filter( + (integration) => constructIntegrationPermissions(integration, session).hasUseAccess, + ); + + // If the user has use access to all integrations, he has access. + if (integrationsWithUseAccess.length === integrations.length) { + return true; + } + + const integrationsWithoutUseAccessAndWithoutBoardViewAllAccess = integrations + .filter((integration) => !integrationsWithUseAccess.includes(integration)) + .filter((integration) => !(session?.user.permissions.includes("board-view-all") && integration.items.length >= 1)); + + if (integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.length === 0) { + return true; + } + + const integrationsWithBoardIds = integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.map((integration) => ({ + id: integration.id, + anyOfBoardIds: integration.items.map(({ item }) => item.section.boardId), + })); + + const permissionsOfCurrentUserWhenPresent = await db.query.boardUserPermissions.findMany({ + where: eq(boardUserPermissions.userId, session?.user.id ?? ""), + }); + + // If for each integration the user has access to at least of of it's present boards, he has access. + if ( + checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess( + integrationsWithBoardIds, + permissionsOfCurrentUserWhenPresent.map(({ boardId }) => boardId), + ) + ) { + return true; + } + + const permissionsOfCurrentUserGroupsWhenPresent = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, session?.user.id ?? ""), + with: { + group: { + with: { + boardPermissions: {}, + }, + }, + }, + }); + const boardIdsWithPermission = permissionsOfCurrentUserWhenPresent + .map((permission) => permission.boardId) + .concat( + permissionsOfCurrentUserGroupsWhenPresent + .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId)) + .flat(), + ); + + // If for each integration the user has access to at least of of it's present boards, he has access. + if ( + checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess(integrationsWithBoardIds, boardIdsWithPermission) + ) { + return true; + } + + const relevantBoardIds = [...new Set(integrationsWithBoardIds.map(({ anyOfBoardIds }) => anyOfBoardIds).flat())]; + const publicBoardsOrBoardsWhereCurrentUserIsOwner = await db.query.boards.findMany({ + where: and( + or(eq(boards.isPublic, true), eq(boards.creatorId, session?.user.id ?? "")), + inArray(boards.id, relevantBoardIds), + ), + }); + + const boardsWithAccess = boardIdsWithPermission.concat( + publicBoardsOrBoardsWhereCurrentUserIsOwner.map(({ id }) => id), + ); + + // If for each integration the user has access to at least of of it's present boards, he has access. + return checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess(integrationsWithBoardIds, boardsWithAccess); +}; + +const checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess = ( + integration: { id: string; anyOfBoardIds: string[] }[], + boardIdsWithAccess: string[], +) => { + return integration.every(({ anyOfBoardIds }) => + anyOfBoardIds.some((boardId) => boardIdsWithAccess.includes(boardId)), + ); +}; diff --git a/packages/auth/permissions/test/integration-query-permissions.spec.ts b/packages/auth/permissions/test/integration-query-permissions.spec.ts new file mode 100644 index 000000000..c0185c177 --- /dev/null +++ b/packages/auth/permissions/test/integration-query-permissions.spec.ts @@ -0,0 +1,639 @@ +import type { Session } from "next-auth"; +import { describe, expect, test, vi } from "vitest"; + +import type { InferInsertModel } from "@homarr/db"; +import { createId } from "@homarr/db"; +import { + boardGroupPermissions, + boards, + boardUserPermissions, + groupMembers, + groups, + users, +} from "@homarr/db/schema/sqlite"; +import { createDb } from "@homarr/db/test"; + +import * as integrationPermissions from "../integration-permissions"; +import { hasQueryAccessToIntegrationsAsync } from "../integration-query-permissions"; + +const createSession = (user: Partial): Session => ({ + user: { + id: "1", + permissions: [], + ...user, + }, + expires: new Date().toISOString(), +}); + +describe("hasQueryAccessToIntegrationsAsync should check if the user has query access to the specified integrations", () => { + test("should return true if the user has the board-view-all permission and the integrations are used anywhere", async () => { + // Arrange + const db = createDb(); + const session = createSession({ + permissions: ["board-view-all"], + }); + const integrations = [ + { + id: "1", + items: [{ item: { section: { boardId: "1" } } }], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [{ item: { section: { boardId: "2" } } }], + userPermissions: [], + groupPermissions: [], + }, + ]; + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return true if the user has the board-view-all permission, the first integration is used and the second one he has use access", async () => { + // Arrange + const db = createDb(); + const session = createSession({ + permissions: ["board-view-all"], + }); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: true, + }); + const integrations = [ + { + id: "1", + items: [{ item: { section: { boardId: "1" } } }], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [], + userPermissions: [], + groupPermissions: [], + }, + ]; + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return true if the user has use access to all integrations", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: true, + }); + const integrations = [ + { + id: "1", + items: [], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [], + userPermissions: [], + groupPermissions: [], + }, + ]; + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return true if the user has user permission to access to at least one board of each integration", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + await db.insert(users).values({ id: session.user.id }); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: false, + }); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1" })); + await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" }); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return false if the user has user permission to access board of first integration but not of second one", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + await db.insert(users).values({ id: session.user.id }); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: false, + }); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1" })); + await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" }); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(false); + }); + + test("should return true if the user has group permission to access to at least one board of each integration", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + await db.insert(users).values({ id: session.user.id }); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: false, + }); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1" })); + await db.insert(groups).values({ id: "1", name: "" }); + await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); + await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" }); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return false if the user has group permission to access board of first integration but not of second one", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + await db.insert(users).values({ id: session.user.id }); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: false, + }); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1" })); + await db.insert(groups).values({ id: "1", name: "" }); + await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); + await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" }); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(false); + }); + + test("should return true if the user has user permission to access first board and group permission to access second one", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + await db.insert(users).values({ id: session.user.id }); + const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions"); + spy.mockReturnValue({ + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: false, + }); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1" })); + await db.insert(boards).values(createMockBoard({ id: "2" })); + await db.insert(groups).values({ id: "1", name: "" }); + await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); + await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" }); + await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" }); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return true if one of the boards the integration is used is public", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1", isPublic: true })); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return true if the user is creator of the board the integration is used", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + await db.insert(users).values({ id: session.user.id }); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1", creatorId: session.user.id })); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(true); + }); + + test("should return false if the user has no access to any of the integrations", async () => { + // Arrange + const db = createDb(); + const session = createSession({}); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + + // Assert + expect(result).toBe(false); + }); + + test("should return false if the user is anonymous and the board is not public", async () => { + // Arrange + const db = createDb(); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1" })); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, null); + + // Assert + expect(result).toBe(false); + }); + + test("should return true if the user is anonymous and the board is public", async () => { + // Arrange + const db = createDb(); + const integrations = [ + { + id: "1", + items: [ + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + { + id: "2", + items: [ + { + item: { + section: { + boardId: "2", + }, + }, + }, + { + item: { + section: { + boardId: "1", + }, + }, + }, + ], + userPermissions: [], + groupPermissions: [], + }, + ]; + await db.insert(boards).values(createMockBoard({ id: "1", isPublic: true })); + + // Act + const result = await hasQueryAccessToIntegrationsAsync(db, integrations, null); + + // Assert + expect(result).toBe(true); + }); +}); + +const createMockBoard = (board: Partial>): InferInsertModel => ({ + id: createId(), + name: board.id ?? createId(), + ...board, +}); diff --git a/packages/auth/server.ts b/packages/auth/server.ts new file mode 100644 index 000000000..89166ddb1 --- /dev/null +++ b/packages/auth/server.ts @@ -0,0 +1 @@ +export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions"; diff --git a/packages/auth/test/callbacks.spec.ts b/packages/auth/test/callbacks.spec.ts index ed66f9af4..db439fbc4 100644 --- a/packages/auth/test/callbacks.spec.ts +++ b/packages/auth/test/callbacks.spec.ts @@ -17,6 +17,14 @@ describe("getCurrentUserPermissions", () => { test("should return empty permissions when non existing user requested", async () => { const db = createDb(); + await db.insert(groups).values({ + id: "2", + name: "test", + }); + await db.insert(groupPermissions).values({ + groupId: "2", + permission: "admin", + }); await db.insert(users).values({ id: "2", }); @@ -25,6 +33,27 @@ describe("getCurrentUserPermissions", () => { const result = await getCurrentUserPermissionsAsync(db, userId); expect(result).toEqual([]); }); + + test("should return empty permissions when user has no groups", async () => { + const db = createDb(); + const userId = "1"; + + await db.insert(groups).values({ + id: "2", + name: "test", + }); + await db.insert(groupPermissions).values({ + groupId: "2", + permission: "admin", + }); + await db.insert(users).values({ + id: userId, + }); + + const result = await getCurrentUserPermissionsAsync(db, userId); + expect(result).toEqual([]); + }); + test("should return permissions for user", async () => { const db = createDb(); const getPermissionsWithChildrenMock = vi