packages/features/pbac/README.md
The PBAC system provides fine-grained access control for Cal.com using a combination of CRUD-based permissions and custom actions. It's designed to be flexible, performant, and maintain backward compatibility with the existing role system through fallback mechanisms.
The system comes with three pre-configured default roles that form the foundation of the permission system:
owner_role)*.*)*.* grants access to all actions on all resourcesadmin_role)eventType.*)role.*)team.create, team.read, team.update, team.invite, team.remove, team.changeMemberRoleorganization.read, organization.update, organization.listMembers, organization.invite, organization.remove, organization.manageBilling, organization.changeMemberRolebooking.read, booking.update, booking.readTeamBookings, booking.readOrgBookings, booking.readRecordingsinsights.readworkflow.*)routingForm.*)webhook.*)availability.read, availability.updatemember_role)eventType.readteam.readrole.readorganization.read, organization.listMembersbooking.read, booking.update (for their own bookings)workflow.readroutingForm.readavailability.read, availability.update (for their own availability)PBAC is currently behind a feature flag, so we use utility functions that automatically handle the fallback logic for you.
The recommended approach is to check permissions in RSC before rendering the page:
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
// In a Server Component or API route
async function MyServerComponent({ teamId, userId }: { teamId: number; userId: number }) {
const permissionService = new PermissionCheckService();
// Check a single permission
const canUpdateTeam = await permissionService.checkPermission({
userId,
teamId,
permission: "team.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
// Check multiple permissions (user must have ALL)
const canManageMembers = await permissionService.checkPermissions({
userId,
teamId,
permissions: ["team.invite", "team.remove"],
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!canUpdateTeam) {
return <div>Not authorized</div>;
}
return <div>Team Settings</div>;
}
Fallback Roles: If PBAC is not enabled for the team, the system will check if the user has one of the specified fallbackRoles instead.
For client components or when you're deep in the component tree, use the TRPC router:
import { trpc } from "@calcom/trpc/react";
function TeamSettingsButton({ teamId }: { teamId: number }) {
// Check a single permission
const { data: hasPermission, isLoading } = trpc.viewer.pbac.checkPermission.useQuery({
teamId,
permission: "team.update",
});
// Check multiple permissions
const { data: hasPermissions } = trpc.viewer.pbac.checkPermissions.useQuery({
teamId,
permissions: ["team.update", "team.invite"],
});
if (isLoading) return <div>Loading...</div>;
if (!hasPermission) return null;
return <button>Update Team Settings</button>;
}
Important: These TRPC calls are automatically cached by React Query on the client, so you can call them multiple times throughout your component tree without worrying about redundant network requests (as long as the props are the same).
If you need to check multiple permissions for a resource efficiently:
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
const permissionService = new PermissionCheckService();
// Get all permissions for a specific resource
const permissions = await permissionService.getResourcePermissions({
userId,
teamId,
resource: Resource.EventType,
});
// permissions = ["eventType.create", "eventType.read", "eventType.update", "eventType.delete"]
Warning: This approach does NOT account for fallback roles. Only use this in places where you know PBAC is enabled and has been rolled out.
Permissions follow the format: resource.action
Available resources are defined in the Resource enum:
eventType - Event typesteam - Team settingsorganization - Organization settingsbooking - Bookingsinsights - Analytics and insightsrole - Custom rolesroutingForm - Routing formsworkflow - Workflowswebhook - Webhooksavailability - Availability schedulesooo - Out of office entrieswatchlist - Watchlist/blocklist entriesorganization.attributes - Organization attributesStandard create, read, update, delete operations:
create - Create new resourcesread - View resourcesupdate - Modify resourcesdelete - Remove resources* - All CRUD actions (wildcard)Special actions that don't fit the CRUD model:
invite - Invite members to team/orgremove - Remove members from team/orgchangeMemberRole - Change member roleslistMembers - View team/org memberslistMembersPrivate - View private team/org membersmanageBilling - Manage organization billingreadTeamBookings - View team bookingsreadOrgBookings - View organization bookingsreadRecordings - Access booking recordingsimpersonate - Impersonate team/org membersThe permission system has intelligent fallback logic for teams within organizations:
Team-Level Check First: When checking permissions for a team resource, the system first checks if the user has the required permission through their team membership.
Organization-Level Fallback: If the user doesn't have team-level permissions:
Example Flow:
// User is trying to access "eventType.read" on Team 123
// Team 123 is part of Organization 456
// 1. Check: Does user have "eventType.read" on Team 123?
// � No specific team permission
// 2. Check: Does user have "eventType.read" on Organization 456?
// � Yes! User has org-level permission
// � Access granted
This allows organization admins to manage resources across all teams without needing explicit permissions on each team.
When you need to add a permission that doesn't exist in the registry, follow these steps:
Update the PERMISSION_REGISTRY in packages/features/pbac/domain/types/permission-registry.ts:
export const PERMISSION_REGISTRY: PermissionRegistry = {
// ... existing resources
[Resource.Booking]: {
_resource: {
i18nKey: "pbac_resource_booking",
},
// ... existing actions
// Add your new custom action
[CustomAction.Export]: {
description: "Export booking data",
category: "booking",
i18nKey: "pbac_action_export",
descriptionI18nKey: "pbac_desc_export_bookings",
dependsOn: ["booking.read"], // Optional: specify dependencies
},
},
};
Key Fields:
description: Human-readable descriptioncategory: Grouping category for UIi18nKey: Translation key for action namedescriptionI18nKey: Translation key for descriptionscope: Optional array of [Scope.Team] or [Scope.Organization] to limit where permission appearsdependsOn: Optional array of permissions that must be enabled when this permission is enabledvisibleWhen: Optional visibility conditions (e.g., based on team privacy)Create a custom migration to add the permission to existing roles:
npx prisma migrate dev --create-only --name pbac_add_booking_export_permissions
Open the new migration file in packages/prisma/migrations/[timestamp]_pbac_add_booking_export_permissions/migration.sql:
-- Add the new "booking.export" permission to admin and owner roles
-- Owner role automatically gets it via wildcard (*.*) - no action needed!
-- Add to admin role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
VALUES
(gen_random_uuid(), 'admin_role', 'booking', 'export', NOW())
ON CONFLICT DO NOTHING;
-- Optionally add to member role if needed
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
VALUES
(gen_random_uuid(), 'member_role', 'booking', 'export', NOW())
ON CONFLICT DO NOTHING;
Important Notes:
*.* wildcard permission, so it automatically gets access to all new permissions. No migration needed!ON CONFLICT DO NOTHING to make migrations idempotentgen_random_uuid() for IDsnpx prisma migrate dev
// Server-side
const canExport = await permissionService.checkPermission({
userId,
teamId,
permission: "booking.export",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
// Client-side
const { data: canExport } = trpc.viewer.pbac.checkPermission.useQuery({
teamId,
permission: "booking.export",
});
A role for users who should manage events but not team settings:
const eventManagerPermissions = [
// Event Types
"eventType.create",
"eventType.read",
"eventType.update",
"eventType.delete",
// Bookings
"booking.read",
"booking.update",
"booking.readTeamBookings",
// Workflows
"workflow.create",
"workflow.read",
"workflow.update",
"workflow.delete",
// Basic team access
"team.read",
];
A role for users who should only view analytics:
const analyticsViewerPermissions = [
"insights.read",
"booking.read",
"booking.readTeamBookings",
"eventType.read",
"team.read",
];
A role for users who manage billing but not content:
const billingManagerPermissions = [
"organization.read",
"organization.manageBilling",
"team.read",
"organization.listMembers",
];
// Check if user can perform multiple operations
async function canUserManageEvent(userId: number, teamId: number) {
const permissionService = new PermissionCheckService();
// Get all event type permissions at once
const permissions = await permissionService.getResourcePermissions({
userId,
teamId,
resource: Resource.EventType,
});
const permissionMap = PermissionMapper.toActionMap(permissions, Resource.EventType);
return {
canCreate: permissionMap[CrudAction.Create] ?? false,
canRead: permissionMap[CrudAction.Read] ?? false,
canUpdate: permissionMap[CrudAction.Update] ?? false,
canDelete: permissionMap[CrudAction.Delete] ?? false,
};
}
import { trpc } from "@calcom/trpc/react";
function TeamManagementPanel({ teamId }: { teamId: number }) {
const { data: canInvite } = trpc.viewer.pbac.checkPermission.useQuery({
teamId,
permission: "team.invite",
});
const { data: canRemove } = trpc.viewer.pbac.checkPermission.useQuery({
teamId,
permission: "team.remove",
});
const { data: canChangeRoles } = trpc.viewer.pbac.checkPermission.useQuery({
teamId,
permission: "team.changeMemberRole",
});
return (
<div>
{canInvite && <InviteMemberButton teamId={teamId} />}
{canRemove && <RemoveMemberButton teamId={teamId} />}
{canChangeRoles && <ChangeRoleDropdown teamId={teamId} />}
</div>
);
}
getResourcePermissions() when you need to check many permissions for the same resourceresource.action)PERMISSION_REGISTRYteamId associated with itrole.read permissionparentId (organization)[Scope.Team] in the registry