packages/features/pbac/PBAC_REFACTORING_GUIDE.md
Quick guide for refactoring Cal.com to use Permission-Based Access Control (PBAC) instead of role-based checks and membership queries.
Before (Membership-based):
const teamsToQuery = (
await prisma.membership.findMany({
where: {
userId: ctx.user.id,
accepted: true,
NOT: [
{
role: MembershipRole.MEMBER,
team: { isPrivate: true },
},
],
},
select: { teamId: true },
})
).map((membership) => membership.teamId);
After (PBAC-based):
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
const permissionCheckService = new PermissionCheckService();
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
userId: ctx.user.id,
permission: "team.listMembers",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
Before (Role-based):
// Client-side role check with role-based variable name
const isTeamAdminOrOwner = user?.isTeamAdminOrOwner ?? false;
const canSeeMembers = isTeamAdminOrOwner;
After (PBAC-based):
// Server-side permission check in page/layout
import { MembershipRole } from "@calcom/prisma/enums";
const permissionCheckService = new PermissionCheckService();
const teamIdsWithPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: session.user.id,
permission: "team.listMembers",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const permissions = {
canListMembers: teamIdsWithPermission.length > 0,
};
// Pass to component
<MyComponent permissions={permissions} />;
"team.listMembers" directly instead of PermissionMapper.toPermissionString()getTeamIdsWithPermission() handles errors internally[] when user has no permissions (this is legitimate)permissions object for extensibilitycanListMembers instead of raw permission stringscanListMembers instead of role-based names like isTeamAdminOrOwnercan[Action][Resource] (e.g., canListMembers, canUpdateTeam, canInviteUsers)Rule: Use teamId to determine if permission checks are needed:
teamId present → Team resource → Check permissionsteamId null/undefined → Personal resource → Check ownership onlyExample: booking.read permission
if (resource.teamId) {
// Team resource - check permissions
const hasPermission = await permissionService.hasPermission(userId, "resource.read", resource.teamId);
if (!hasPermission) throw new ForbiddenError();
} else {
// Personal resource - check ownership only
if (resource.userId !== currentUserId) throw new ForbiddenError();
}
"booking.read" - Read all team members' bookings (not just own)"team.listMembers" - List team members"team.read" - View team details"team.update" - Edit team settings"team.invite" - Invite members"team.remove" - Remove membersimport { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
const permissionCheckService = new PermissionCheckService();
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
userId: ctx.user.id,
permission: "team.listMembers",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
// In your page component
const permissionCheckService = new PermissionCheckService();
const teamIdsWithPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: session.user.id,
permission: "team.listMembers",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const permissions = {
canListMembers: teamIdsWithPermission.length > 0, // Permission-specific name
};
<BookingsContent permissions={permissions} />
interface BookingsProps {
permissions: {
canListMembers: boolean;
};
}
// Replace: const isTeamAdminOrOwner = user?.isTeamAdminOrOwner ?? false
// With: const canListMembers = permissions.canListMembers
// Use permission-specific variable names throughout your component
What changed:
"team.listMembers"Result: Cleaner, more maintainable code that respects fine-grained permissions.
What changed (from PR #24006):
user?.isTeamAdminOrOwner) to serverpermissions prop with canListMembers booleanteamIdsWithPermission.length > 0 pattern for UI controlBefore:
// In component - role-based variable name
const isTeamAdminOrOwner = user?.isTeamAdminOrOwner ?? false;
const canSeeMembers = isTeamAdminOrOwner;
After:
// In page component (server-side)
const permissions = {
canListMembers: teamIdsWithPermission.length > 0,
};
// In UI component - permission-specific variable name
const canListMembers = permissions.canListMembers;
const canSeeMembers = canListMembers; // Use permission-specific name
Result: Secure server-side permission checking with clean UI separation.
yarn type-check:ci --forcepermissions propsconst canListMembers = permissions.canListMembers;
const canUpdateTeam = permissions.canUpdateTeam;
const canInviteUsers = permissions.canInviteUsers;
const canDeleteBookings = permissions.canDeleteBookings;
const isTeamAdminOrOwner = user?.isTeamAdminOrOwner;
const isAdmin = user?.role === "ADMIN";
const hasPermission = user?.isTeamAdminOrOwner;
can[Action][Resource]canListMembers, canUpdateTeam, canInviteUsers