diff --git a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts index 90cb48eb5b19b1..2e1a6abc327535 100644 --- a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts +++ b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts @@ -1,3 +1,4 @@ +import logger from "@calcom/lib/logger"; import db from "@calcom/prisma"; import type { PrismaClient as PrismaClientWithExtensions } from "@calcom/prisma"; import type { MembershipRole } from "@calcom/prisma/enums"; @@ -15,6 +16,7 @@ import { export class PermissionRepository implements IPermissionRepository { private readonly PBAC_FEATURE_FLAG = "pbac" as const; private client: PrismaClientWithExtensions; + private readonly logger = logger.getSubLogger({ prefix: ["PermissionRepository"] }); constructor(client: PrismaClientWithExtensions = db) { this.client = client; @@ -237,7 +239,16 @@ export class PermissionRepository implements IPermissionRepository { return { resource, action }; }); + const permissionPairsJson = JSON.stringify(permissionPairs); + + // Teams with PBAC permissions (direct memberships + child teams via org membership) const teamsWithPermissionPromise = this.client.$queryRaw<{ teamId: number }[]>` + WITH required_permissions AS ( + SELECT + required_perm->>'resource' as resource, + required_perm->>'action' as action + FROM jsonb_array_elements(${permissionPairsJson}::jsonb) AS required_perm + ) SELECT DISTINCT m."teamId" FROM "Membership" m INNER JOIN "Role" r ON m."customRoleId" = r.id @@ -246,26 +257,61 @@ export class PermissionRepository implements IPermissionRepository { AND m."customRoleId" IS NOT NULL AND ( SELECT COUNT(*) - FROM jsonb_array_elements(${JSON.stringify(permissionPairs)}::jsonb) AS required_perm(perm) + FROM required_permissions rp_req + WHERE EXISTS ( + SELECT 1 + FROM "RolePermission" rp + WHERE rp."roleId" = r.id + AND ( + (rp."resource" = '*' AND rp."action" = '*') OR + (rp."resource" = '*' AND rp."action" = rp_req.action) OR + (rp."resource" = rp_req.resource AND rp."action" = '*') OR + (rp."resource" = rp_req.resource AND rp."action" = rp_req.action) + ) + ) + ) = ${permissions.length} + UNION + SELECT DISTINCT child."id" + FROM "Membership" m + INNER JOIN "Role" r ON m."customRoleId" = r.id + INNER JOIN "Team" org ON m."teamId" = org.id + INNER JOIN "Team" child ON child."parentId" = org.id + WHERE m."userId" = ${userId} + AND m."accepted" = true + AND m."customRoleId" IS NOT NULL + AND ( + SELECT COUNT(*) + FROM required_permissions rp_req WHERE EXISTS ( SELECT 1 FROM "RolePermission" rp WHERE rp."roleId" = r.id AND ( (rp."resource" = '*' AND rp."action" = '*') OR - (rp."resource" = '*' AND rp."action" = required_perm.perm->>'action') OR - (rp."resource" = required_perm.perm->>'resource' AND rp."action" = '*') OR - (rp."resource" = required_perm.perm->>'resource' AND rp."action" = required_perm.perm->>'action') + (rp."resource" = '*' AND rp."action" = rp_req.action) OR + (rp."resource" = rp_req.resource AND rp."action" = '*') OR + (rp."resource" = rp_req.resource AND rp."action" = rp_req.action) ) ) ) = ${permissions.length} `; + // Teams with fallback roles (direct memberships + child teams via org membership, PBAC disabled) const teamsWithFallbackRolesPromise = this.client.$queryRaw<{ teamId: number }[]>` SELECT DISTINCT m."teamId" FROM "Membership" m INNER JOIN "Team" t ON m."teamId" = t.id - LEFT JOIN "TeamFeatures" f ON t.id = f."teamId" AND f."featureId" = ${this.PBAC_FEATURE_FLAG} + LEFT JOIN "TeamFeatures" f ON f."teamId" = t.id AND f."featureId" = ${this.PBAC_FEATURE_FLAG} + WHERE m."userId" = ${userId} + AND m."accepted" = true + AND m."role"::text = ANY(${fallbackRoles}) + AND f."teamId" IS NULL + UNION + SELECT DISTINCT child."id" + FROM "Membership" m + INNER JOIN "Team" org ON m."teamId" = org.id + INNER JOIN "Team" child ON child."parentId" = org.id + LEFT JOIN "TeamFeatures" f ON f."teamId" = org.id AND f."featureId" = ${this.PBAC_FEATURE_FLAG} WHERE m."userId" = ${userId} AND m."accepted" = true AND m."role"::text = ANY(${fallbackRoles}) diff --git a/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts index 503b4dafed1ab9..3672f1a6e14e8a 100644 --- a/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts +++ b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest"; import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { PermissionString } from "../../../domain/types/permission-registry"; import { PermissionRepository } from "../PermissionRepository"; @@ -55,6 +56,9 @@ describe("PermissionRepository - Integration Tests", () => { await prisma.role.deleteMany({ where: { id: testRoleId }, }); + await prisma.teamFeatures.deleteMany({ + where: { teamId: testTeamId }, + }); await prisma.team.deleteMany({ where: { id: testTeamId }, }); @@ -63,7 +67,6 @@ describe("PermissionRepository - Integration Tests", () => { }); }); - describe("checkRolePermissions", () => { it("should successfully check single permission without serialization error", async () => { // Create a role permission @@ -321,4 +324,618 @@ describe("PermissionRepository - Integration Tests", () => { expect(result).toBe(false); }); }); + + describe("getTeamIdsWithPermissions", () => { + it("should return empty array for empty permissions", async () => { + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: [], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toEqual([]); + }); + + it("should return team IDs for PBAC-enabled team with matching permissions", async () => { + // Enable PBAC for the team + await prisma.teamFeatures.create({ + data: { + teamId: testTeamId, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create membership with custom role + const membership = await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: testRoleId, + }, + }); + + // Update membership to ensure customRoleId is set (in case of trigger override) + await prisma.membership.update({ + where: { id: membership.id }, + data: { customRoleId: testRoleId }, + }); + + // Create role permissions + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "eventType", action: "create" }, + { roleId: testRoleId, resource: "eventType", action: "read" }, + ], + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create", "eventType.read"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toContain(testTeamId); + expect(result.length).toBe(1); + }); + + it("should not return team IDs when permissions do not match", async () => { + // Enable PBAC for the team + await prisma.teamFeatures.create({ + data: { + teamId: testTeamId, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create membership with custom role + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: testRoleId, + }, + }); + + // Create role permissions (different from requested) + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "eventType", + action: "create", + }, + }); + + // Request permissions that don't match + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.delete"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).not.toContain(testTeamId); + }); + + it("should return team IDs for fallback roles when PBAC is disabled", async () => { + // Do NOT enable PBAC for the team (fallback mode) + + // Create membership with fallback role + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.ADMIN, + accepted: true, + customRoleId: null, + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toContain(testTeamId); + expect(result.length).toBe(1); + }); + + it("should not return team IDs for fallback roles that are not in the list", async () => { + // Do NOT enable PBAC for the team + + // Create membership with MEMBER role (not in fallbackRoles) + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: null, + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + expect(result).not.toContain(testTeamId); + }); + + it("should return child team IDs when user has PBAC permissions via org", async () => { + // Create organization + const org = await prisma.team.create({ + data: { + name: `Test Org ${Date.now()}`, + slug: `test-org-${Date.now()}`, + isOrganization: true, + }, + }); + + // Enable PBAC for org + await prisma.teamFeatures.create({ + data: { + teamId: org.id, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create org role + const orgRole = await prisma.role.create({ + data: { + name: `Org Role ${Date.now()}`, + teamId: org.id, + }, + }); + + // Create org membership with custom role + const orgMembership = await prisma.membership.create({ + data: { + userId: testUserId, + teamId: org.id, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: orgRole.id, + }, + }); + + // Update membership to ensure customRoleId is set (in case of trigger override) + await prisma.membership.update({ + where: { id: orgMembership.id }, + data: { customRoleId: orgRole.id }, + }); + + // Create org role permissions + await prisma.rolePermission.createMany({ + data: [ + { roleId: orgRole.id, resource: "eventType", action: "create" }, + { roleId: orgRole.id, resource: "eventType", action: "read" }, + ], + }); + + // Create child team + const childTeam = await prisma.team.create({ + data: { + name: `Child Team ${Date.now()}`, + slug: `child-team-${Date.now()}`, + parentId: org.id, + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create", "eventType.read"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toContain(childTeam.id); + + // Cleanup + await prisma.rolePermission.deleteMany({ where: { roleId: orgRole.id } }); + await prisma.membership.deleteMany({ where: { userId: testUserId } }); + await prisma.role.deleteMany({ where: { id: orgRole.id } }); + await prisma.teamFeatures.deleteMany({ where: { teamId: org.id } }); + await prisma.team.deleteMany({ where: { id: { in: [org.id, childTeam.id] } } }); + }); + + it("should return child team IDs when user has fallback roles via org", async () => { + // Create organization + const org = await prisma.team.create({ + data: { + name: `Test Org ${Date.now()}`, + slug: `test-org-${Date.now()}`, + isOrganization: true, + }, + }); + + // Do NOT enable PBAC for org + + // Create org membership with fallback role + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: org.id, + role: MembershipRole.OWNER, + accepted: true, + customRoleId: null, + }, + }); + + // Create child team + const childTeam = await prisma.team.create({ + data: { + name: `Child Team ${Date.now()}`, + slug: `child-team-${Date.now()}`, + parentId: org.id, + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.OWNER], + }); + + expect(result).toContain(childTeam.id); + + // Cleanup + await prisma.membership.deleteMany({ where: { userId: testUserId } }); + await prisma.team.deleteMany({ where: { id: { in: [org.id, childTeam.id] } } }); + }); + + it("should handle wildcard permissions for PBAC teams", async () => { + // Enable PBAC for the team + await prisma.teamFeatures.create({ + data: { + teamId: testTeamId, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create membership with custom role + const membership = await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: testRoleId, + }, + }); + + // Update membership to ensure customRoleId is set (in case of trigger override) + await prisma.membership.update({ + where: { id: membership.id }, + data: { customRoleId: testRoleId }, + }); + + // Create wildcard permission + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "*", + action: "*", + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create", "team.delete", "role.update"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toContain(testTeamId); + }); + + it("should require all permissions to match (not just some)", async () => { + // Enable PBAC for the team + await prisma.teamFeatures.create({ + data: { + teamId: testTeamId, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create membership with custom role + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: testRoleId, + }, + }); + + // Create only 2 out of 3 required permissions + await prisma.rolePermission.createMany({ + data: [ + { roleId: testRoleId, resource: "eventType", action: "create" }, + { roleId: testRoleId, resource: "eventType", action: "read" }, + ], + }); + + // Request 3 permissions + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create", "eventType.read", "eventType.delete"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).not.toContain(testTeamId); + }); + + it("should not return teams for non-accepted memberships", async () => { + // Enable PBAC for the team + await prisma.teamFeatures.create({ + data: { + teamId: testTeamId, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create membership with accepted: false + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: false, // Not accepted + customRoleId: testRoleId, + }, + }); + + await prisma.rolePermission.create({ + data: { + roleId: testRoleId, + resource: "eventType", + action: "create", + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).not.toContain(testTeamId); + }); + + it("should return multiple teams when user has permissions on multiple teams", async () => { + // Create first team with its own role + const team1 = await prisma.team.create({ + data: { + name: `Test Team 1 ${Date.now()}`, + slug: `test-team-1-${Date.now()}`, + }, + }); + + const role1 = await prisma.role.create({ + data: { + name: `Test Role 1 ${Date.now()}`, + teamId: team1.id, + }, + }); + + // Create second team with its own role + const team2 = await prisma.team.create({ + data: { + name: `Test Team 2 ${Date.now()}`, + slug: `test-team-2-${Date.now()}`, + }, + }); + + const role2 = await prisma.role.create({ + data: { + name: `Test Role 2 ${Date.now()}`, + teamId: team2.id, + }, + }); + + // Enable PBAC for both teams + await prisma.teamFeatures.createMany({ + data: [ + { teamId: team1.id, featureId: "pbac", assignedBy: "test" }, + { teamId: team2.id, featureId: "pbac", assignedBy: "test" }, + ], + }); + + // Create memberships for both teams + const membership1 = await prisma.membership.create({ + data: { + userId: testUserId, + teamId: team1.id, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: role1.id, + }, + }); + + const membership2 = await prisma.membership.create({ + data: { + userId: testUserId, + teamId: team2.id, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: role2.id, + }, + }); + + // Update memberships to ensure customRoleId is set (in case of trigger override) + await prisma.membership.updateMany({ + where: { + id: { in: [membership1.id, membership2.id] }, + }, + data: { + customRoleId: undefined, // This will be set by the individual updates below + }, + }); + + await prisma.membership.update({ + where: { id: membership1.id }, + data: { customRoleId: role1.id }, + }); + + await prisma.membership.update({ + where: { id: membership2.id }, + data: { customRoleId: role2.id }, + }); + + // Create permissions for both roles + await prisma.rolePermission.createMany({ + data: [ + { roleId: role1.id, resource: "eventType", action: "create" }, + { roleId: role2.id, resource: "eventType", action: "create" }, + ], + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toContain(team1.id); + expect(result).toContain(team2.id); + expect(result.length).toBe(2); + + // Cleanup + await prisma.rolePermission.deleteMany({ where: { roleId: { in: [role1.id, role2.id] } } }); + await prisma.membership.deleteMany({ where: { userId: testUserId } }); + await prisma.role.deleteMany({ where: { id: { in: [role1.id, role2.id] } } }); + await prisma.teamFeatures.deleteMany({ where: { teamId: { in: [team1.id, team2.id] } } }); + await prisma.team.deleteMany({ where: { id: { in: [team1.id, team2.id] } } }); + }); + + it("should combine PBAC and fallback teams in results", async () => { + // Create first team for PBAC + const team1 = await prisma.team.create({ + data: { + name: `Test Team 1 ${Date.now()}`, + slug: `test-team-1-${Date.now()}`, + }, + }); + + const role1 = await prisma.role.create({ + data: { + name: `Test Role 1 ${Date.now()}`, + teamId: team1.id, + }, + }); + + // Create second team for fallback + const team2 = await prisma.team.create({ + data: { + name: `Test Team 2 ${Date.now()}`, + slug: `test-team-2-${Date.now()}`, + }, + }); + + // Enable PBAC for first team only + await prisma.teamFeatures.create({ + data: { + teamId: team1.id, + featureId: "pbac", + assignedBy: "test", + }, + }); + + // Create PBAC membership + const pbacMembership = await prisma.membership.create({ + data: { + userId: testUserId, + teamId: team1.id, + role: MembershipRole.MEMBER, + accepted: true, + customRoleId: role1.id, + }, + }); + + // Update membership to ensure customRoleId is set (in case of trigger override) + await prisma.membership.update({ + where: { id: pbacMembership.id }, + data: { customRoleId: role1.id }, + }); + + await prisma.rolePermission.create({ + data: { + roleId: role1.id, + resource: "eventType", + action: "create", + }, + }); + + // Create fallback membership + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: team2.id, + role: MembershipRole.ADMIN, + accepted: true, + customRoleId: null, + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toContain(team1.id); + expect(result).toContain(team2.id); + expect(result.length).toBe(2); + + // Cleanup + await prisma.rolePermission.deleteMany({ where: { roleId: role1.id } }); + await prisma.membership.deleteMany({ where: { userId: testUserId } }); + await prisma.role.deleteMany({ where: { id: role1.id } }); + await prisma.teamFeatures.deleteMany({ where: { teamId: team1.id } }); + await prisma.team.deleteMany({ where: { id: { in: [team1.id, team2.id] } } }); + }); + + it("should return empty array when user has no memberships", async () => { + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN], + }); + + expect(result).toEqual([]); + }); + + it("should handle multiple fallback roles", async () => { + // Do NOT enable PBAC + + // Create membership with ADMIN role + await prisma.membership.create({ + data: { + userId: testUserId, + teamId: testTeamId, + role: MembershipRole.ADMIN, + accepted: true, + customRoleId: null, + }, + }); + + const result = await repository.getTeamIdsWithPermissions({ + userId: testUserId, + permissions: ["eventType.create"], + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + expect(result).toContain(testTeamId); + }); + }); }); diff --git a/packages/features/pbac/services/__tests__/permission-check.service.test.ts b/packages/features/pbac/services/__tests__/permission-check.service.test.ts index b16083ce37ee6e..4989fa24c0c4dc 100644 --- a/packages/features/pbac/services/__tests__/permission-check.service.test.ts +++ b/packages/features/pbac/services/__tests__/permission-check.service.test.ts @@ -256,8 +256,16 @@ describe("PermissionCheckService", () => { expect(result).toBe(true); expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1); expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 100); - expect(mockRepository.checkRolePermission).toHaveBeenNthCalledWith(1, "team_member_role", "eventType.update"); - expect(mockRepository.checkRolePermission).toHaveBeenNthCalledWith(2, "org_admin_role", "eventType.update"); + expect(mockRepository.checkRolePermission).toHaveBeenNthCalledWith( + 1, + "team_member_role", + "eventType.update" + ); + expect(mockRepository.checkRolePermission).toHaveBeenNthCalledWith( + 2, + "org_admin_role", + "eventType.update" + ); }); it("should check org-level permissions when user has no team membership but PBAC is enabled", async () => { @@ -534,6 +542,47 @@ describe("PermissionCheckService", () => { fallbackRoles: [], }); }); + + it("should include child teams where user has org-level fallback roles", async () => { + // User is ADMIN in org (teamId: 100) but MEMBER in child team (teamId: 1) + // Should get access to child team via org-level ADMIN role + const expectedTeamIds = [1, 100]; // Child team + org team + mockRepository.getTeamIdsWithPermission.mockResolvedValueOnce(expectedTeamIds); + + const result = await service.getTeamIdsWithPermission({ + userId: 1, + permission: "insights.read", + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toEqual(expectedTeamIds); + expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith({ + userId: 1, + permission: "insights.read", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should include child teams where user has org-level PBAC permissions", async () => { + // User has PBAC permission via custom role in org (teamId: 100) but not in child team (teamId: 1) + // When PBAC is enabled, fallback roles are NOT used - only PBAC permissions matter + // Should get access to child team via org-level PBAC permission + const expectedTeamIds = [1, 100]; // Child team + org team + mockRepository.getTeamIdsWithPermission.mockResolvedValueOnce(expectedTeamIds); + + const result = await service.getTeamIdsWithPermission({ + userId: 1, + permission: "insights.read", + fallbackRoles: [], // Empty fallback roles - PBAC permissions work independently when PBAC is enabled + }); + + expect(result).toEqual(expectedTeamIds); + expect(mockRepository.getTeamIdsWithPermission).toHaveBeenCalledWith({ + userId: 1, + permission: "insights.read", + fallbackRoles: [], // Verify PBAC permissions work without fallback roles + }); + }); }); describe("getTeamIdsWithPermissions", () => { @@ -598,6 +647,48 @@ describe("PermissionCheckService", () => { fallbackRoles: [], }); }); + + it("should include child teams where user has org-level fallback roles", async () => { + // User is ADMIN in org (teamId: 100) but MEMBER in child team (teamId: 1) + // Should get access to child team via org-level ADMIN role + const permissions: PermissionString[] = ["insights.read", "insights.create"]; + const expectedTeamIds = [1, 2, 100]; // Child teams + org team + mockRepository.getTeamIdsWithPermissions.mockResolvedValueOnce(expectedTeamIds); + + const result = await service.getTeamIdsWithPermissions({ + userId: 1, + permissions, + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toEqual(expectedTeamIds); + expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith({ + userId: 1, + permissions, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should include child teams where user has org-level PBAC permissions", async () => { + // User has PBAC permissions in org (teamId: 100) but not in child team (teamId: 1) + // Should get access to child team via org-level PBAC permissions + const permissions: PermissionString[] = ["insights.read", "insights.create"]; + const expectedTeamIds = [1, 2, 100]; // Child teams + org team + mockRepository.getTeamIdsWithPermissions.mockResolvedValueOnce(expectedTeamIds); + + const result = await service.getTeamIdsWithPermissions({ + userId: 1, + permissions, + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toEqual(expectedTeamIds); + expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith({ + userId: 1, + permissions, + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); }); describe("getResourcePermissions", () => {