Files
calendar/packages/features/pbac/services/permission-check.service.ts
sean-brydonGitHubAnik Dhabal BabuUdit TakkarDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Eunjae LeeBenny Joo
c0302bfc51 fix: get bookings handler for pbac and fallback roles (#25434)
* fix: getTeamIdsWithPermission

* fix get bookings handler for pbac and fallback roles

* add tests

* fix fallback roll query

* push integration test fixes

* Check for pbac features

* fix

* restore role behaviour

* fix fallabck roles no pbac enabled

* debug: add logging to investigate PBAC query failures

Co-Authored-By: [email protected] <[email protected]>

* re-write integration tests

* fix: use >= comparison with bigint cast for PBAC permission count

Co-Authored-By: [email protected] <[email protected]>

* Restore permission repo

* re-write from scratch

* fix tests + file

* imrpove perf by makign CTE and moving to left join

* fix org scoped taems

* clean up

* fix: update PermissionCheckService mock to use function instead of arrow function

Co-Authored-By: [email protected] <[email protected]>

* refactor: rename scopedOrgId to orgId

Co-Authored-By: [email protected] <[email protected]>

* refactor: use MembershipRole enum instead of hardcoded strings

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Anik Dhabal Babu <[email protected]>
Co-authored-by: Udit Takkar <[email protected]>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <[email protected]>
Co-authored-by: Benny Joo <[email protected]>
2026-01-16 15:03:15 +00:00

377 lines
12 KiB
TypeScript

import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/enums";
import { PermissionMapper } from "../domain/mappers/PermissionMapper";
import type { PermissionCheck, TeamPermissions } from "../domain/models/Permission";
import type { IPermissionRepository } from "../domain/repositories/IPermissionRepository";
import type {
PermissionString,
Resource,
CrudAction,
CustomAction,
} from "../domain/types/permission-registry";
import { PermissionRepository } from "../infrastructure/repositories/PermissionRepository";
import { PermissionService } from "./permission.service";
export class PermissionCheckService {
private readonly PBAC_FEATURE_FLAG = "pbac" as const;
private readonly logger = logger.getSubLogger({ prefix: ["PermissionCheckService"] });
private readonly featuresRepository: FeaturesRepository;
private readonly permissionService: PermissionService;
private readonly membershipRepository: MembershipRepository;
constructor(
private readonly repository: IPermissionRepository = new PermissionRepository(),
featuresRepository: FeaturesRepository = new FeaturesRepository(prisma),
permissionService: PermissionService = new PermissionService(),
membershipRepository: MembershipRepository = new MembershipRepository()
) {
this.featuresRepository = featuresRepository;
this.permissionService = permissionService;
this.membershipRepository = membershipRepository;
}
async getUserPermissions(userId: number): Promise<TeamPermissions[]> {
const memberships = await this.repository.getUserMemberships(userId);
return memberships;
}
/**
* Gets all permissions for a specific resource in a team context
* @warning: this approach of fetching permssions per resource does not account for fall back roles. This should be used in places where you know PBAC is enabled and has been rolled out.
*/
async getResourcePermissions({
userId,
teamId,
resource,
}: {
userId: number;
teamId: number;
resource: Resource;
}): Promise<PermissionString[]> {
try {
const isPBACEnabled = await this.featuresRepository.checkIfTeamHasFeature(
teamId,
this.PBAC_FEATURE_FLAG
);
if (!isPBACEnabled) {
return [];
}
const { membership, orgMembership } = await this.getMembership({ userId, teamId });
const actions = new Set<CrudAction | CustomAction>();
// Get team-level permissions
if (membership?.customRoleId) {
const teamActions = await this.repository.getResourcePermissionsByRoleId(
membership.customRoleId,
resource
);
teamActions.forEach((action) => actions.add(action));
}
// Get org-level permissions (works even without team membership)
if (orgMembership?.customRoleId) {
const orgActions = await this.repository.getResourcePermissionsByRoleId(
orgMembership.customRoleId,
resource
);
orgActions.forEach((action) => actions.add(action));
}
return Array.from(actions).map((action) => PermissionMapper.toPermissionString({ resource, action }));
} catch (error) {
this.logger.error(error);
return [];
}
}
/**
* Checks if a user has a specific permission in a team context
*/
async checkPermission({
userId,
teamId,
permission,
fallbackRoles,
}: {
userId: number;
teamId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
}): Promise<boolean> {
try {
const validationResult = this.permissionService.validatePermission(permission);
if (!validationResult.isValid) {
this.logger.error(validationResult.error);
return false;
}
const isPBACEnabled = await this.featuresRepository.checkIfTeamHasFeature(
teamId,
this.PBAC_FEATURE_FLAG
);
if (isPBACEnabled) {
// Check if user has permission through team or org membership
return this.hasPermission({ userId, teamId }, permission);
}
// Fallback to role-based check - use highest role between team and org membership
const membership = await this.membershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId,
});
let effectiveRole: MembershipRole | null = membership?.role ?? null;
// Check if team has parent org and get org membership
const team = await this.repository.getTeamById(teamId);
if (team?.parentId) {
const orgMembership = await this.membershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId: team.parentId,
});
// Use the highest role between team and org
if (orgMembership) {
effectiveRole = this.getHighestRole(effectiveRole, orgMembership.role);
}
}
if (!effectiveRole) {
return false;
}
return this.checkFallbackRoles(effectiveRole, fallbackRoles);
} catch (error) {
this.logger.error(error);
return false;
}
}
/**
* Checks if a user has all specified permissions in a team context
*/
async checkPermissions({
userId,
teamId,
permissions,
fallbackRoles,
}: {
userId: number;
teamId: number;
permissions: PermissionString[];
fallbackRoles: MembershipRole[];
}): Promise<boolean> {
try {
const validationResult = this.permissionService.validatePermissions(permissions);
if (!validationResult.isValid) {
this.logger.error(validationResult.error);
return false;
}
const isPBACEnabled = await this.featuresRepository.checkIfTeamHasFeature(
teamId,
this.PBAC_FEATURE_FLAG
);
if (isPBACEnabled) {
// Check if user has permissions through team or org membership
return this.hasPermissions({ userId, teamId }, permissions);
}
// Fallback to role-based check - use highest role between team and org membership
const membership = await this.membershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId,
});
let effectiveRole: MembershipRole | null = membership?.role ?? null;
// Check if team has parent org and get org membership
const team = await this.repository.getTeamById(teamId);
if (team?.parentId) {
const orgMembership = await this.membershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId: team.parentId,
});
// Use the highest role between team and org
if (orgMembership) {
effectiveRole = this.getHighestRole(effectiveRole, orgMembership.role);
}
}
if (!effectiveRole) {
return false;
}
return this.checkFallbackRoles(effectiveRole, fallbackRoles);
} catch (error) {
this.logger.error(error);
return false;
}
}
/**
* Internal method to check permissions for a specific role
*/
private async hasPermission(query: PermissionCheck, permission: PermissionString): Promise<boolean> {
const { membership, orgMembership } = await this.getMembership(query);
// First check team-level permissions
if (membership?.customRoleId) {
const hasTeamPermission = await this.repository.checkRolePermission(
membership.customRoleId,
permission
);
if (hasTeamPermission) return true;
}
// If no team permission, check org-level permissions
if (orgMembership?.customRoleId) {
return this.repository.checkRolePermission(orgMembership.customRoleId, permission);
}
return false;
}
/**
* Internal method to check multiple permissions for a specific role
*/
private async hasPermissions(query: PermissionCheck, permissions: PermissionString[]): Promise<boolean> {
const { membership, orgMembership } = await this.getMembership(query);
// First check team-level permissions
if (membership?.customRoleId) {
const hasTeamPermissions = await this.repository.checkRolePermissions(
membership.customRoleId,
permissions
);
if (hasTeamPermissions) return true;
}
// If no team permissions, check org-level permissions
if (orgMembership?.customRoleId) {
return this.repository.checkRolePermissions(orgMembership.customRoleId, permissions);
}
return false;
}
private async getMembership(query: PermissionCheck) {
let membership = null;
let orgMembership = null;
if (query.membershipId) {
membership = await this.repository.getMembershipByMembershipId(query.membershipId);
} else if (query.userId && query.teamId) {
membership = await this.repository.getMembershipByUserAndTeam(query.userId, query.teamId);
}
// Get org membership either through the team membership or directly from teamId
if (membership?.team.parentId) {
// User has team membership, check org through that
orgMembership = await this.repository.getOrgMembership(membership.userId, membership.team.parentId);
} else if (query.userId && query.teamId) {
// No team membership, but check if team belongs to an org
const team = await this.repository.getTeamById(query.teamId);
if (team?.parentId) {
orgMembership = await this.repository.getOrgMembership(query.userId, team.parentId);
}
}
return { membership, orgMembership };
}
private checkFallbackRoles(userRole: MembershipRole, allowedRoles: MembershipRole[]): boolean {
return allowedRoles.includes(userRole);
}
private getHighestRole(role1: MembershipRole | null, role2: MembershipRole | null): MembershipRole | null {
if (!role1) return role2;
if (!role2) return role1;
const roleHierarchy: Record<MembershipRole, number> = {
OWNER: 3,
ADMIN: 2,
MEMBER: 1,
};
return roleHierarchy[role1] >= roleHierarchy[role2] ? role1 : role2;
}
/**
* 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);
if (!validationResult.isValid) {
this.logger.error(validationResult.error);
return [];
}
return await this.repository.getTeamIdsWithPermission({
userId,
permission,
fallbackRoles,
orgId,
});
} catch (error) {
this.logger.error(error);
return [];
}
}
/**
* 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);
if (!validationResult.isValid) {
this.logger.error(validationResult.error);
return [];
}
return await this.repository.getTeamIdsWithPermissions({
userId,
permissions,
fallbackRoles,
orgId,
});
} catch (error) {
this.logger.error(error);
return [];
}
}
}