Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c9086d4
fix: getTeamIdsWithPermission
sean-brydon Nov 25, 2025
7bedee6
Merge branch 'main' into fix/getTeamIdsWithPermission-org
anikdhabal Nov 25, 2025
31616c5
Merge branch 'main' into fix/getTeamIdsWithPermission-org
Udit-takkar Nov 26, 2025
38550aa
fix get bookings handler for pbac and fallback roles
sean-brydon Nov 27, 2025
03fd4a3
add tests
sean-brydon Nov 27, 2025
cd6b781
fix fallback roll query
sean-brydon Dec 1, 2025
3edf2a4
push integration test fixes
sean-brydon Dec 1, 2025
a86cc0d
Check for pbac features
sean-brydon Dec 1, 2025
5743bb0
fix
sean-brydon Dec 1, 2025
2c6f4a0
restore role behaviour
sean-brydon Dec 1, 2025
381c1b8
fix fallabck roles no pbac enabled
sean-brydon Dec 1, 2025
2005011
debug: add logging to investigate PBAC query failures
devin-ai-integration[bot] Dec 1, 2025
ffa2f74
re-write integration tests
sean-brydon Dec 1, 2025
658fb38
Merge remote-tracking branch 'refs/remotes/origin/fix/getTeamIdsWithP…
sean-brydon Dec 1, 2025
5f42c1a
fix: use >= comparison with bigint cast for PBAC permission count
devin-ai-integration[bot] Dec 1, 2025
13e080a
Restore permission repo
sean-brydon Dec 1, 2025
30cf04d
re-write from scratch
sean-brydon Dec 1, 2025
c26dcd8
fix tests + file
sean-brydon Dec 1, 2025
a775799
imrpove perf by makign CTE and moving to left join
sean-brydon Dec 1, 2025
420908c
Merge branch 'fix/getTeamIdsWithPermission-org' into fix/get-bookings…
sean-brydon Dec 2, 2025
3dba1c7
Merge branch 'main' into fix/get-bookings-pbac
sean-brydon Dec 2, 2025
a4ae898
fix org scoped taems
sean-brydon Dec 2, 2025
2bcbfec
clean up
sean-brydon Dec 2, 2025
2b7680e
Merge branch 'main' into fix/get-bookings-pbac
eunjae-lee Jan 7, 2026
366489a
Merge branch 'main' into fix/get-bookings-pbac
hbjORbj Jan 7, 2026
23c9492
Merge remote-tracking branch 'origin/main' into fix/get-bookings-pbac
devin-ai-integration[bot] Jan 16, 2026
1135b89
fix: update PermissionCheckService mock to use function instead of ar…
devin-ai-integration[bot] Jan 16, 2026
eb9743d
refactor: rename scopedOrgId to orgId
devin-ai-integration[bot] Jan 16, 2026
e0bb6c4
refactor: use MembershipRole enum instead of hardcoded strings
devin-ai-integration[bot] Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,23 @@ export interface IPermissionRepository {

/**
* Gets all team IDs where the user has a specific permission
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
getTeamIdsWithPermission(params: {
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]>;

/**
* Gets all team IDs where the user has all of the specified permissions
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
getTeamIdsWithPermissions(params: {
userId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,22 +212,26 @@ export class PermissionRepository implements IPermissionRepository {
userId,
permission,
fallbackRoles,
orgId,
}: {
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
return this.getTeamIdsWithPermissions({ userId, permissions: [permission], fallbackRoles });
return this.getTeamIdsWithPermissions({ userId, permissions: [permission], fallbackRoles, orgId });
}

async getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
}: {
userId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
// Validate that permissions array is not empty to prevent privilege escalation
if (permissions.length === 0) {
Expand All @@ -241,8 +245,29 @@ export class PermissionRepository implements IPermissionRepository {

const permissionPairsJson = JSON.stringify(permissionPairs);

// Teams with PBAC permissions (direct memberships + child teams via org membership)
const teamsWithPermissionPromise = this.client.$queryRaw<{ teamId: number }[]>`
const [teamsWithPermission, teamsWithFallbackRoles] = await Promise.all([
this.getTeamsWithPBACPermissions(userId, permissionPairsJson, permissions.length, orgId),
this.getTeamsWithFallbackRoles(userId, fallbackRoles, orgId),
]);

const pbacTeamIds = teamsWithPermission.map((team) => team.teamId);
const fallbackTeamIds = teamsWithFallbackRoles.map((team) => team.teamId);

const allTeamIds = Array.from(new Set([...pbacTeamIds, ...fallbackTeamIds]));
return allTeamIds;
}

/**
* Gets teams where user has PBAC permissions (direct memberships + child teams via org membership)
* @param orgId Optional organization ID to scope results. When null/undefined, returns all teams.
*/
private async getTeamsWithPBACPermissions(
userId: number,
permissionPairsJson: string,
permissionsCount: number,
orgId?: number | null
): Promise<{ teamId: number }[]> {
return this.client.$queryRaw<{ teamId: number }[]>`
WITH required_permissions AS (
SELECT
required_perm->>'resource' as resource,
Expand All @@ -252,9 +277,11 @@ export class PermissionRepository implements IPermissionRepository {
SELECT DISTINCT m."teamId"
FROM "Membership" m
INNER JOIN "Role" r ON m."customRoleId" = r.id
INNER JOIN "Team" t ON m."teamId" = t.id
WHERE m."userId" = ${userId}
AND m."accepted" = true
AND m."customRoleId" IS NOT NULL
AND (${orgId}::bigint IS NULL OR t."id" = ${orgId} OR t."parentId" = ${orgId})
AND (
SELECT COUNT(*)
FROM required_permissions rp_req
Expand All @@ -269,7 +296,7 @@ export class PermissionRepository implements IPermissionRepository {
(rp."resource" = rp_req.resource AND rp."action" = rp_req.action)
)
)
) = ${permissions.length}
) = ${permissionsCount}
UNION
SELECT DISTINCT child."id"
FROM "Membership" m
Expand All @@ -279,6 +306,7 @@ export class PermissionRepository implements IPermissionRepository {
WHERE m."userId" = ${userId}
AND m."accepted" = true
AND m."customRoleId" IS NOT NULL
AND (${orgId}::bigint IS NULL OR org."id" = ${orgId} OR child."id" = ${orgId} OR child."parentId" = ${orgId})
AND (
SELECT COUNT(*)
FROM required_permissions rp_req
Expand All @@ -293,11 +321,20 @@ export class PermissionRepository implements IPermissionRepository {
(rp."resource" = rp_req.resource AND rp."action" = rp_req.action)
)
)
) = ${permissions.length}
) = ${permissionsCount}
`;
}

// Teams with fallback roles (direct memberships + child teams via org membership, PBAC disabled)
const teamsWithFallbackRolesPromise = this.client.$queryRaw<{ teamId: number }[]>`
/**
* Gets teams where user has fallback roles (direct memberships + child teams via org membership, PBAC disabled)
* @param orgId Optional organization ID to scope results. When null/undefined, returns all teams.
*/
private async getTeamsWithFallbackRoles(
userId: number,
fallbackRoles: MembershipRole[],
orgId?: number | null
): Promise<{ teamId: number }[]> {
return this.client.$queryRaw<{ teamId: number }[]>`
SELECT DISTINCT m."teamId"
FROM "Membership" m
INNER JOIN "Team" t ON m."teamId" = t.id
Expand All @@ -306,6 +343,7 @@ export class PermissionRepository implements IPermissionRepository {
AND m."accepted" = true
AND m."role"::text = ANY(${fallbackRoles})
AND f."teamId" IS NULL
AND (${orgId}::bigint IS NULL OR t."id" = ${orgId} OR t."parentId" = ${orgId})
UNION
SELECT DISTINCT child."id"
FROM "Membership" m
Expand All @@ -316,17 +354,7 @@ export class PermissionRepository implements IPermissionRepository {
AND m."accepted" = true
AND m."role"::text = ANY(${fallbackRoles})
AND f."teamId" IS NULL
AND (${orgId}::bigint IS NULL OR org."id" = ${orgId} OR child."id" = ${orgId} OR child."parentId" = ${orgId})
`;

const [teamsWithPermission, teamsWithFallbackRoles] = await Promise.all([
teamsWithPermissionPromise,
teamsWithFallbackRolesPromise,
]);

const pbacTeamIds = teamsWithPermission.map((team) => team.teamId);
const fallbackTeamIds = teamsWithFallbackRoles.map((team) => team.teamId);

const allTeamIds = Array.from(new Set([...pbacTeamIds, ...fallbackTeamIds]));
return allTeamIds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -969,5 +969,132 @@ describe("PermissionRepository - Integration Tests", () => {

expect(result).toContain(testTeamId);
});

it("should filter teams by orgId when provided", async () => {
// Create two organizations
const org1 = await prisma.team.create({
data: {
name: `Org 1 ${Date.now()}`,
slug: `org1-${Date.now()}`,
isOrganization: true,
},
});

const org2 = await prisma.team.create({
data: {
name: `Org 2 ${Date.now()}`,
slug: `org2-${Date.now()}`,
isOrganization: true,
},
});

// Create teams within each organization
const team1 = await prisma.team.create({
data: {
name: `Team 1 ${Date.now()}`,
slug: `team1-${Date.now()}`,
parentId: org1.id,
},
});

const team2 = await prisma.team.create({
data: {
name: `Team 2 ${Date.now()}`,
slug: `team2-${Date.now()}`,
parentId: org2.id,
},
});

// Create memberships with ADMIN role in both organizations
await prisma.membership.create({
data: {
userId: testUserId,
teamId: org1.id,
role: MembershipRole.ADMIN,
accepted: true,
},
});

await prisma.membership.create({
data: {
userId: testUserId,
teamId: org2.id,
role: MembershipRole.ADMIN,
accepted: true,
},
});

// Without orgId, should return both organizations
const resultWithoutScope = await repository.getTeamIdsWithPermissions({
userId: testUserId,
permissions: ["eventType.create"],
fallbackRoles: [MembershipRole.ADMIN],
});

expect(resultWithoutScope).toContain(org1.id);
expect(resultWithoutScope).toContain(org2.id);

// With orgId = org1, should only return org1 and its child teams
const resultWithScope = await repository.getTeamIdsWithPermissions({
userId: testUserId,
permissions: ["eventType.create"],
fallbackRoles: [MembershipRole.ADMIN],
orgId: org1.id,
});

expect(resultWithScope).toContain(org1.id);
expect(resultWithScope).toContain(team1.id);
expect(resultWithScope).not.toContain(org2.id);
expect(resultWithScope).not.toContain(team2.id);

// Cleanup
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.team.deleteMany({ where: { id: { in: [org1.id, org2.id, team1.id, team2.id] } } });
});

it("should include child teams when orgId is provided", async () => {
// Create organization
const org = await prisma.team.create({
data: {
name: `Org ${Date.now()}`,
slug: `org-${Date.now()}`,
isOrganization: true,
},
});

// Create child team
const childTeam = await prisma.team.create({
data: {
name: `Child Team ${Date.now()}`,
slug: `child-team-${Date.now()}`,
parentId: org.id,
},
});

// Create membership with ADMIN role in organization
await prisma.membership.create({
data: {
userId: testUserId,
teamId: org.id,
role: MembershipRole.ADMIN,
accepted: true,
},
});

// With orgId, should return both org and child team
const result = await repository.getTeamIdsWithPermissions({
userId: testUserId,
permissions: ["eventType.create"],
fallbackRoles: [MembershipRole.ADMIN],
orgId: org.id,
});

expect(result).toContain(org.id);
expect(result).toContain(childTeam.id);

// Cleanup
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.team.deleteMany({ where: { id: { in: [org.id, childTeam.id] } } });
});
});
});
29 changes: 21 additions & 8 deletions packages/features/pbac/services/permission-check.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class PermissionCheckService {
userId,
teamId: team.parentId,
});

// Use the highest role between team and org
if (orgMembership) {
effectiveRole = this.getHighestRole(effectiveRole, orgMembership.role);
Expand Down Expand Up @@ -200,7 +200,7 @@ export class PermissionCheckService {
userId,
teamId: team.parentId,
});

// Use the highest role between team and org
if (orgMembership) {
effectiveRole = this.getHighestRole(effectiveRole, orgMembership.role);
Expand Down Expand Up @@ -293,10 +293,7 @@ export class PermissionCheckService {
return allowedRoles.includes(userRole);
}

private getHighestRole(
role1: MembershipRole | null,
role2: MembershipRole | null
): MembershipRole | null {
private getHighestRole(role1: MembershipRole | null, role2: MembershipRole | null): MembershipRole | null {
if (!role1) return role2;
if (!role2) return role1;

Expand All @@ -311,15 +308,18 @@ export class PermissionCheckService {

/**
* Gets all team IDs where the user has a specific permission
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
async getTeamIdsWithPermission({
userId,
permission,
fallbackRoles,
orgId,
}: {
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
try {
const validationResult = this.permissionService.validatePermission(permission);
Expand All @@ -328,7 +328,12 @@ export class PermissionCheckService {
return [];
}

return await this.repository.getTeamIdsWithPermission({ userId, permission, fallbackRoles });
return await this.repository.getTeamIdsWithPermission({
userId,
permission,
fallbackRoles,
orgId,
});
} catch (error) {
this.logger.error(error);
return [];
Expand All @@ -337,15 +342,18 @@ export class PermissionCheckService {

/**
* Gets all team IDs where the user has all of the specified permissions
* @param orgId Optional organization ID to scope results to. When provided, only returns teams within this organization.
*/
async getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
}: {
userId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
orgId?: number;
}): Promise<number[]> {
try {
const validationResult = this.permissionService.validatePermissions(permissions);
Expand All @@ -354,7 +362,12 @@ export class PermissionCheckService {
return [];
}

return await this.repository.getTeamIdsWithPermissions({ userId, permissions, fallbackRoles });
return await this.repository.getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
});
} catch (error) {
this.logger.error(error);
return [];
Expand Down
Loading
Loading