diff --git a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts index 2231cab2c9..90cb48eb5b 100644 --- a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts +++ b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts @@ -132,35 +132,29 @@ export class PermissionRepository implements IPermissionRepository { const { resource, action } = parsePermissionString(p); return { resource, action }; }); - const resourceActions = permissionPairs.map((p) => [p.resource, p.action]); - const resources = permissionPairs.map((p) => p.resource); - const actions = permissionPairs.map((p) => p.action); + // Convert permission pairs to JSONB for proper serialization + const permissionPairsJson = JSON.stringify(permissionPairs); + + // Check if each requested permission is satisfied by at least one role permission const matchingPermissions = await this.client.$queryRaw<[{ count: bigint }]>` - WITH permission_checks AS ( - -- Universal permission (*,*) - SELECT 1 as match FROM "RolePermission" - WHERE "roleId" = ${roleId} AND "resource" = '*' AND "action" = '*' - - UNION ALL - - -- Wildcard resource with specific actions - SELECT 1 as match FROM "RolePermission" - WHERE "roleId" = ${roleId} AND "resource" = '*' AND "action" = ANY(${actions}) - - UNION ALL - - -- Specific resources with wildcard action - SELECT 1 as match FROM "RolePermission" - WHERE "roleId" = ${roleId} AND "action" = '*' AND "resource" = ANY(${resources}) - - UNION ALL - - -- Exact resource-action pairs - SELECT 1 as match FROM "RolePermission" - WHERE "roleId" = ${roleId} AND ("resource", "action") = ANY(${resourceActions}) + SELECT COUNT(*) as count + FROM jsonb_array_elements(${permissionPairsJson}::jsonb) AS required_perm + WHERE EXISTS ( + SELECT 1 + FROM "RolePermission" rp + WHERE rp."roleId" = ${roleId} + AND ( + -- Universal permission (*,*) + (rp."resource" = '*' AND rp."action" = '*') OR + -- Wildcard resource with specific action + (rp."resource" = '*' AND rp."action" = required_perm->>'action') OR + -- Specific resource with wildcard action + (rp."resource" = required_perm->>'resource' AND rp."action" = '*') OR + -- Exact resource-action pair + (rp."resource" = required_perm->>'resource' AND rp."action" = required_perm->>'action') + ) ) - SELECT COUNT(*) as count FROM permission_checks `; return Number(matchingPermissions[0].count) >= permissions.length; diff --git a/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts new file mode 100644 index 0000000000..503b4dafed --- /dev/null +++ b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; + +import { prisma } from "@calcom/prisma"; + +import type { PermissionString } from "../../../domain/types/permission-registry"; +import { PermissionRepository } from "../PermissionRepository"; + +describe("PermissionRepository - Integration Tests", () => { + let repository: PermissionRepository; + let testRoleId: string; + let testUserId: number; + let testTeamId: number; + + beforeAll(async () => { + repository = new PermissionRepository(prisma); + }); + + beforeEach(async () => { + // Create test user + const testUser = await prisma.user.create({ + data: { + email: `test-${Date.now()}@example.com`, + username: `testuser-${Date.now()}`, + }, + }); + testUserId = testUser.id; + + // Create test team + const testTeam = await prisma.team.create({ + data: { + name: `Test Team ${Date.now()}`, + slug: `test-team-${Date.now()}`, + }, + }); + testTeamId = testTeam.id; + + // Create test role + const testRole = await prisma.role.create({ + data: { + name: `Test Role ${Date.now()}`, + teamId: testTeamId, + }, + }); + testRoleId = testRole.id; + }); + + afterEach(async () => { + // Clean up test data + await prisma.rolePermission.deleteMany({ + where: { roleId: testRoleId }, + }); + await prisma.membership.deleteMany({ + where: { userId: testUserId }, + }); + await prisma.role.deleteMany({ + where: { id: testRoleId }, + }); + await prisma.team.deleteMany({ + where: { id: testTeamId }, + }); + await prisma.user.deleteMany({ + where: { id: testUserId }, + }); + }); + + + describe("checkRolePermissions", () => { + it("should successfully check single permission without serialization error", async () => { + // Create a role permission + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "role", + action: "create", + }, + }); + + const permissions: PermissionString[] = ["role.create"]; + const result = await repository.checkRolePermissions(testRoleId, permissions); + + expect(result).toBe(true); + }); + + it("should successfully check multiple permissions without serialization error", async () => { + // Create multiple role permissions + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "role", action: "create" }, + { roleId: testRoleId, resource: "role", action: "read" }, + { roleId: testRoleId, resource: "eventType", action: "update" }, + ], + }); + + const permissions: PermissionString[] = ["role.create", "role.read", "eventType.update"]; + const result = await repository.checkRolePermissions(testRoleId, permissions); + + expect(result).toBe(true); + }); + + it("should handle exact resource-action pair matching", async () => { + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "team", + action: "invite", + }, + }); + + const result = await repository.checkRolePermissions(testRoleId, ["team.invite"]); + + expect(result).toBe(true); + }); + + it("should handle wildcard resource (*) with specific action", async () => { + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "*", + action: "read", + }, + }); + + // Should match any resource with read action + const result = await repository.checkRolePermissions(testRoleId, [ + "eventType.read", + "team.read", + "role.read", + ]); + + expect(result).toBe(true); + }); + + it("should handle specific resource with wildcard action (*)", async () => { + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "eventType", + action: "*", + }, + }); + + // Should match eventType with any action + const result = await repository.checkRolePermissions(testRoleId, [ + "eventType.create", + "eventType.read", + "eventType.update", + "eventType.delete", + ]); + + expect(result).toBe(true); + }); + + it("should handle universal wildcard (*.*)", async () => { + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "*", + action: "*", + }, + }); + + // Should match any permission + const result = await repository.checkRolePermissions(testRoleId, [ + "eventType.create", + "team.delete", + "role.update", + ]); + + expect(result).toBe(true); + }); + + it("should return false when not all permissions are matched", async () => { + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "role", action: "create" }, + { roleId: testRoleId, resource: "role", action: "read" }, + ], + }); + + // Missing eventType.update permission + const result = await repository.checkRolePermissions(testRoleId, [ + "role.create", + "role.read", + "eventType.update", + ]); + + expect(result).toBe(false); + }); + + it("should return false for empty permissions array", async () => { + const result = await repository.checkRolePermissions(testRoleId, []); + + expect(result).toBe(false); + }); + + it("should handle complex permission combinations", async () => { + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "*", action: "read" }, // wildcard resource + { roleId: testRoleId, resource: "eventType", action: "*" }, // wildcard action + { roleId: testRoleId, resource: "team", action: "invite" }, // exact match + ], + }); + + // All should match: + // - role.read matches wildcard resource + // - eventType.create matches wildcard action for eventType + // - team.invite matches exact pair + const result = await repository.checkRolePermissions(testRoleId, [ + "role.read", + "eventType.create", + "team.invite", + ]); + + expect(result).toBe(true); + }); + + it("should handle permission pairs with same resource but different actions", async () => { + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "eventType", action: "create" }, + { roleId: testRoleId, resource: "eventType", action: "update" }, + { roleId: testRoleId, resource: "eventType", action: "delete" }, + ], + }); + + const result = await repository.checkRolePermissions(testRoleId, [ + "eventType.create", + "eventType.update", + "eventType.delete", + ]); + + expect(result).toBe(true); + }); + + it("should handle permission pairs with same action but different resources", async () => { + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "eventType", action: "create" }, + { roleId: testRoleId, resource: "team", action: "create" }, + { roleId: testRoleId, resource: "role", action: "create" }, + ], + }); + + const result = await repository.checkRolePermissions(testRoleId, [ + "eventType.create", + "team.create", + "role.create", + ]); + + expect(result).toBe(true); + }); + + it("should correctly count matching permissions", async () => { + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "eventType", action: "create" }, + { roleId: testRoleId, resource: "eventType", action: "read" }, + ], + }); + + // Only 2 out of 3 permissions match + const result = await repository.checkRolePermissions(testRoleId, [ + "eventType.create", + "eventType.read", + "eventType.update", // This one doesn't match + ]); + + expect(result).toBe(false); + }); + + it("should handle role with no permissions", async () => { + const result = await repository.checkRolePermissions(testRoleId, ["eventType.create"]); + + expect(result).toBe(false); + }); + + it("should handle non-existent role", async () => { + const result = await repository.checkRolePermissions("non-existent-role-id", ["eventType.create"]); + + expect(result).toBe(false); + }); + + it("should reproduce the exact error scenario from the bug report", async () => { + // Create the exact permission from the error report + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "role", + action: "create", + }, + }); + + // This is the exact call that was failing + const permissions: PermissionString[] = ["role.create"]; + const result = await repository.checkRolePermissions(testRoleId, permissions); + + // Should not throw serialization error and should return true + expect(result).toBe(true); + }); + }); + + describe("checkRolePermission (single)", () => { + it("should check single permission correctly", async () => { + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "role", + action: "create", + }, + }); + + const result = await repository.checkRolePermission(testRoleId, "role.create"); + + expect(result).toBe(true); + }); + + it("should return false for non-existent permission", async () => { + const result = await repository.checkRolePermission(testRoleId, "role.create"); + + expect(result).toBe(false); + }); + }); +});