feat: pbac (#21296)

* inital pbac setup

* fix tests

* docs intro

* more docs

* add comments in docs

* revert pr_todo

* update to use action + custom as defaults

* use enums

* move to change user role + multi check

* add beforeEach import

* docs: add PERMISSIONS.md documenting team/organization role usages

Co-Authored-By: sean@cal.com <sean@cal.com>

* docs: add permission string alternatives for helper functions

Co-Authored-By: sean@cal.com <sean@cal.com>

* feat: enhance permission registry and default role permissions

Co-Authored-By: sean@cal.com <sean@cal.com>

* add routers

* add hooks + rsc. Also adjsuted test to use prismock

* make hooks type safe + add some tests

* add a permission context - trpc router to populate context

* update docs

* explore global and default roles member,admin,owner

* fix tests

* use kysley and abstract uril

* tidy up

* update trpc to use pbac route in endpoint + distinct on teamId

* make transform util

* remove role service dependancy from permission check class

* checkTeamId fall back to orgId if team is parent

* todos

* remove manual migrations to launch softly

* address feedback

* move permission checks to a repo

* move role service database calls to repo. improve mocks

* update tests to have describe import

* fix types

* remove router that is not used to pass build

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2025-05-20 15:22:52 +01:00
committed by GitHub
co-authored by sean@cal.com Devin AI
parent 1740e34c79
commit b9fadf82a7
21 changed files with 2249 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
# Cal.com Permission Documentation
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
## Permissions by Resource
### Team Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
### Event Type Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
### Booking Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
### Organization Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
### API Key Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
## Helper Functions and Utilities
The following helper functions are commonly used for permission checks:
| Function | Description | File Path |
|----------|-------------|-----------|
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
## Permission Level Enums
The codebase uses several enums to define permission levels:
### MembershipRole Enum
```typescript
enum MembershipRole {
OWNER = "OWNER",
ADMIN = "ADMIN",
MEMBER = "MEMBER"
}
```
### Entity Permission Level Enum
```typescript
enum ENTITY_PERMISSION_LEVEL {
NONE,
USER_ONLY_WRITE,
TEAM_READ_ONLY,
TEAM_WRITE
}
```
## Common Permission Patterns
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
```typescript
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
```
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
```typescript
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
```
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
```typescript
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
}
```
4. **Entity Permission Check**: Entity operations use permission level checks.
```typescript
const permissionLevel = await getEntityPermissionLevel(entity, userId);
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
```
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
```typescript
const membershipIdsWhereUserIsAdminOwner = (
await prisma.membership.findMany({
where: {
userId: user.id,
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
id: true,
},
})
).map((membership) => membership.id);
```
## Migration to PBAC
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
```typescript
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
// Instead of:
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Use:
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
```
## Permission String Alternatives for Helper Functions
The following table provides permission string alternatives for common helper functions:
| Helper Function | Permission String Alternative | Description |
|-----------------|-------------------------------|-------------|
| isTeamAdmin | `team.*` | Grants all team permissions |
| isTeamAdmin | `team.update` | Update team settings |
| isTeamAdmin | `team.invite` | Invite team members |
| isTeamAdmin | `team.remove` | Remove team members |
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
| isTeamOwner | `team.delete` | Delete team |
| isTeamMember | `team.read` | Read-only access to team |
## Entity Permission Functions and Resources
The following functions are used to check permissions for entities with userId and teamId properties:
| Function | Description | Permission String Alternative |
|----------|-------------|-------------------------------|
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
### Resources Used with Entity Permission Functions
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|----------|-------------------|---------------------|------------------|
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
```typescript
// Instead of:
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Use:
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
```
This allows for more granular permission control and the creation of custom roles with specific permissions.
+4
View File
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { permissionsRouter } from "@calcom/trpc/server/routers/viewer/pbac/_router";
export default createNextApiHandler(permissionsRouter);
+424
View File
@@ -0,0 +1,424 @@
# Permission-Based Access Control (PBAC) System
## Overview
The PBAC system provides fine-grained access control for Cal.com using a combination of CRUD-based permissions and custom actions, while maintaining backward compatibility with the existing role system.
## Implementation
### Permission Store
The system uses a centralized Zustand store with optimized data structures for fast permission lookups:
```typescript
interface TeamPermissions {
roleId: string;
permissions: Set<PermissionString>; // O(1) lookup
}
interface PermissionStore {
teamPermissions: Map<number, TeamPermissions>; // O(1) lookup
setTeamPermissions: (permissions: Record<number, { roleId: string; permissions: PermissionString[] }>) => void;
hasPermission: (teamId: number, permission: PermissionString) => boolean;
hasPermissions: (teamId: number, permissions: PermissionString[]) => boolean;
}
```
### Context Provider
The system uses a React context provider that fetches all permissions once at the root level:
```tsx
<PermissionProvider>
<App />
</PermissionProvider>
```
The provider automatically:
- Fetches permissions for all teams the user has access to
- Caches the results (5-minute stale time)
- Updates the central store
- Provides loading states
### Permission Hooks
Two main hooks are provided for checking permissions:
```typescript
// Check single permission
const { hasPermission, isLoading } = usePermission(teamId, "team.update");
// Check multiple permissions
const { hasPermissions, isLoading } = usePermissions(teamId, ["team.update", "team.invite"]);
```
## Permission Format
Permissions follow two formats:
1. CRUD Permissions: `${resource}.${action}`
2. Custom Actions: `custom:${resource}.${action}`
```typescript
// CRUD Permission Examples
"eventType.create"
"booking.read"
// Custom Action Examples
"custom:team.invite"
"custom:booking.readRecordings"
```
## Role Types
1. **Default Roles** (MembershipRole)
- OWNER: Full access (`*.*`)
- ADMIN: Extensive management permissions
- MEMBER: Basic read permissions
2. **Custom Roles**
- Team-specific roles with granular permissions
- Can be assigned alongside default roles
## Permission Structure
### CRUD Actions
```typescript
export enum CrudAction {
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
```
### Custom Actions
```typescript
export enum CustomAction {
Manage = 'manage', // Full control over a resource
Invite = 'invite', // Invite members to team/org
Remove = 'remove', // Remove members from team/org
Override = 'override', // Override availability
ReadRecordings = 'readRecordings', // Access booking recordings
ManageBilling = 'manageBilling', // Manage org billing
}
```
### Resources
```typescript
export enum Resource {
EventType = 'eventType',
Booking = 'booking',
Team = 'team',
Organization = 'organization',
Insights = 'insights',
Availability = 'availability',
Workflow = 'workflow',
RoutingForm = 'routingForm',
}
```
## Usage Guide
### 1. Setup Provider
```tsx
// app/layout.tsx or similar
import { PermissionProvider } from "@calcom/features/pbac/context/PermissionProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<PermissionProvider>
{children}
</PermissionProvider>
);
}
```
### 2. Use in Components
```tsx
import { usePermission, usePermissions } from "@calcom/features/pbac/hooks/usePermission";
function TeamSettings({ teamId }: { teamId: number }) {
// Single permission check
const { hasPermission, isLoading } = usePermission(teamId, "team.update");
// Multiple permissions check
const { hasPermissions } = usePermissions(teamId, [
"team.update",
"team.invite"
]);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{hasPermission && <button>Update Team</button>}
{hasPermissions && <button>Update and Invite</button>}
</div>
);
}
```
### 3. Performance Considerations
- Permission checks are O(1) using Map and Set data structures
- Permissions are fetched once and cached for 5 minutes
- No redundant API calls for permission checks
- Automatic updates when permissions change via store
## Common Permission Combinations
### Event Manager Role
```typescript
const permissions = [
// CRUD Permissions
"eventType.create",
"eventType.read",
"eventType.update",
"eventType.delete",
// Custom Actions
"custom:booking.readRecordings",
"custom:availability.override"
];
```
### Analytics Role
```typescript
const permissions = [
// CRUD Permissions
"insights.read",
"booking.read",
"eventType.read",
// Custom Actions
"custom:organization.manageBilling"
];
```
### Team Admin Role
```typescript
const permissions = [
// CRUD Permissions
"team.create",
"team.read",
"team.update",
// Custom Actions
"custom:team.invite",
"custom:team.remove",
"custom:availability.override"
];
```
## Database Schema
```prisma
model Role {
id String @id @default(cuid())
name String
teamId Int?
permissions RolePermission[]
}
model RolePermission {
id String @id @default(cuid())
roleId String
resource String
action String
isCustom Boolean @default(false) // Indicates if this is a custom action
role Role @relation(fields: [roleId], references: [id])
}
```
## Example Use Cases
### 1. Event Type Management
```typescript
// Full event type management
[
"eventType.create",
"eventType.read",
"eventType.update",
"eventType.delete"
]
// Read-only access
["eventType.read"]
```
### 2. Team Management with Custom Actions
```typescript
[
"team.read",
"team.update",
"custom:team.invite",
"custom:team.remove"
]
```
### 3. Booking Management with Recordings
```typescript
[
"booking.read",
"booking.update",
"custom:booking.readRecordings"
]
```
## Usage
### Server-Side (React Server Components)
```typescript
import { cookies, headers } from "next/headers";
import { checkUserPermissionInTeam } from "@calcom/features/pbac/lib/server/checkPermissions";
// In a Server Component
export default async function TeamSettings({ params }: { params: { teamId: string } }) {
const session = buildLegacyRequest(await headers(), await cookies())
if(!session?.user?.id){
return null
}
const hasPermission = await checkUserPermissionInTeam({
userId: session.user.id,
teamId: parseInt(params.teamId),
permission: "team.update",
});
if (!hasPermission) {
return <div>Not authorized</div>;
}
return <div>Team Settings</div>;
}
// Check multiple permissions
const hasPermissions = await checkMultiplePermissionsInTeam({
userId: session.user.id,
teamId: teamId,
permissions: ["team.update", "team.invite"],
});
```
### Client-Side (React Components)
```typescript
import { usePermission, usePermissions } from "@calcom/features/pbac/hooks/usePermission";
// In a React Component
function TeamSettingsButton({ teamId }: { teamId: number }) {
// Single permission check
const { hasPermission, isLoading } = usePermission(teamId, "team.update");
if (isLoading) return <div>Loading...</div>;
if (!hasPermission) return null;
return <button>Update Team Settings</button>;
}
// Multiple permissions check
function TeamAdminPanel({ teamId }: { teamId: number }) {
const { hasPermissions, isLoading } = usePermissions(teamId, [
"team.update",
"team.invite"
]);
if (isLoading) return <div>Loading...</div>;
if (!hasPermissions) return <div>Insufficient permissions</div>;
return <div>Admin Panel</div>;
}
```
### Available Permissions
Permissions follow the format `resource.action` where:
- `resource` is the entity being accessed (e.g., team, eventType, booking)
- `action` is the operation being performed (e.g., create, read, update, delete)
Common permissions include:
- `team.create` - Create teams
- `team.update` - Update team settings
- `team.invite` - Invite team members
- `team.remove` - Remove team members
- `eventType.create` - Create event types
- `eventType.update` - Update event types
- `booking.read` - Read booking details
For a complete list of permissions, see [PERMISSIONS.md](./PERMISSIONS.md).
### Caching
The client-side hooks automatically cache permission results for 5 minutes to reduce API calls. The cache can be invalidated by calling the TRPC mutation to update permissions.
## Default Roles and Permissions
The system comes with three pre-configured default roles:
### 1. Owner Role (owner_role)
- Has full access to all resources via wildcard permission (`*.*`)
- Automatically assigned to team/organization creators
- Cannot be modified or deleted
- Permissions:
```typescript
"*.*" // Grants access to all actions on all resources
```
### 2. Admin Role (admin_role)
- Has extensive management permissions
- Can manage team settings and members
- Permissions:
```typescript
// Booking permissions
"booking.*" // All booking operations
"booking.readTeamBookings"
"booking.readOrgBookings"
// Event Type permissions
"eventType.*" // All event type operations
// Team management
"team.invite"
"team.remove"
"team.changeMemberRole"
// Organization permissions
"organization.listMembers"
"organization.read"
"organization.update"
// Other resource permissions
"apiKey.*" // All API key operations
"routingForm.*" // All routing form operations
"workflow.*" // All workflow operations
"insights.read" // Read access to insights
```
### 3. Member Role (member_role)
- Basic read access to resources
- Default role for new team members
- Permissions:
```typescript
"booking.read"
"eventType.read"
"team.read"
"organization.read"
"routingForm.read"
```
### Using Default Roles
You can reference these roles programmatically using the constants provided in `@calcom/features/pbac/lib/constants`:
```typescript
import { DEFAULT_ROLES } from "@calcom/features/pbac/lib/constants";
// Reference roles
const ownerRoleId = DEFAULT_ROLES.OWNER; // 'owner_role'
const adminRoleId = DEFAULT_ROLES.ADMIN; // 'admin_role'
const memberRoleId = DEFAULT_ROLES.MEMBER; // 'member_role'
```
### Role Assignment
1. Default roles are automatically assigned during:
- Team creation (creator gets OWNER role)
- Member invitation (gets MEMBER role by default)
- Role changes through team management UI
2. Roles can be changed by team owners and admins through:
- Team member management interface
- Organization member management interface
- API endpoints (with proper permissions)
@@ -0,0 +1,20 @@
export function PermissionProvider({ children }: { children: React.ReactNode }) {
// const { data: permissions, isLoading } = trpc.viewer.pbac.getUserPermissions.useQuery(undefined, {
// // Since permissions don't change often, we can cache them for a while
// staleTime: 5 * 60 * 1000, // 5 minutes
// });
// const setTeamPermissions = usePermissionStore((state) => state.setTeamPermissions);
// useEffect(() => {
// if (permissions) {
// setTeamPermissions(permissions);
// }
// }, [permissions, setTeamPermissions]);
if (isLoading) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,24 @@
import { usePermissionStore } from "../store/permission-store";
import type { PermissionString } from "../types/permission-registry";
export function usePermission(teamId: number, permission: PermissionString) {
const hasPermission = usePermissionStore((state) => state.hasPermission(teamId, permission));
const teamPermissions = usePermissionStore((state) => state.teamPermissions);
const isLoading = !teamPermissions.has(teamId);
return {
hasPermission,
isLoading,
};
}
export function usePermissions(teamId: number, permissions: PermissionString[]) {
const hasPermissions = usePermissionStore((state) => state.hasPermissions(teamId, permissions));
const teamPermissions = usePermissionStore((state) => state.teamPermissions);
const isLoading = !teamPermissions.has(teamId);
return {
hasPermissions,
isLoading,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Default role IDs used in the PBAC system
* These IDs match the ones created in the migration
*/
export const DEFAULT_ROLES = {
OWNER: "owner_role",
ADMIN: "admin_role",
MEMBER: "member_role",
} as const;
/**
* Type for default role IDs
*/
export type DefaultRoleId = (typeof DEFAULT_ROLES)[keyof typeof DEFAULT_ROLES];
/**
* Mapping of default roles to their descriptions
*/
export const DEFAULT_ROLE_DESCRIPTIONS = {
[DEFAULT_ROLES.OWNER]: "pbac_owner_role_description",
[DEFAULT_ROLES.ADMIN]: "pbac_admin_role_description",
[DEFAULT_ROLES.MEMBER]: "pbac_member_role_description",
} as const;
@@ -0,0 +1,28 @@
import { PermissionCheckService } from "../../services/permission-check.service";
import type { PermissionString } from "../../types/permission-registry";
export async function checkUserPermissionInTeam({
userId,
teamId,
permission,
}: {
userId: number;
teamId: number;
permission: PermissionString;
}) {
const permissionCheckService = new PermissionCheckService();
return permissionCheckService.hasPermission({ userId, teamId }, permission);
}
export async function checkMultiplePermissionsInTeam({
userId,
teamId,
permissions,
}: {
userId: number;
teamId: number;
permissions: PermissionString[];
}) {
const permissionCheckService = new PermissionCheckService();
return permissionCheckService.hasPermissions({ userId, teamId }, permissions);
}
@@ -0,0 +1,154 @@
import { jsonObjectFrom, jsonArrayFrom } from "kysely/helpers/postgres";
import kysely from "@calcom/kysely";
import type { PermissionString } from "../types/permission-registry";
type DbPermission = {
resource: string | null;
action: string | null;
};
type DbRole = {
id: string | null;
permissions: DbPermission[];
};
type DbMembership = {
teamId: number;
role: DbRole | null;
};
export class PermissionRepository {
async getMembershipByMembershipId(membershipId: number) {
return kysely
.selectFrom("Membership")
.leftJoin("Team", "Team.id", "Membership.teamId")
.select([
"Membership.id",
"Membership.teamId",
"Membership.userId",
"Membership.customRoleId",
"Team.parentId as team_parentId",
])
.where("Membership.id", "=", membershipId)
.executeTakeFirst();
}
async getMembershipByUserAndTeam(userId: number, teamId: number) {
return kysely
.selectFrom("Membership")
.leftJoin("Team", "Team.id", "Membership.teamId")
.select([
"Membership.id",
"Membership.teamId",
"Membership.userId",
"Membership.customRoleId",
"Team.parentId as team_parentId",
])
.where("Membership.userId", "=", userId)
.where("Membership.teamId", "=", teamId)
.executeTakeFirst();
}
async getOrgMembership(userId: number, parentId: number) {
return kysely
.selectFrom("Membership")
.select(["Membership.id", "Membership.teamId", "Membership.userId", "Membership.customRoleId"])
.where("Membership.userId", "=", userId)
.where("Membership.teamId", "=", parentId)
.executeTakeFirst();
}
async getUserMemberships(userId: number): Promise<DbMembership[]> {
return kysely
.selectFrom("Membership")
.leftJoin("Role", "Role.id", "Membership.customRoleId")
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
.select((eb) => [
"Membership.teamId",
jsonObjectFrom(
eb
.selectFrom("Role")
.select((eb) => [
"Role.id",
jsonArrayFrom(
eb
.selectFrom("RolePermission")
.select(["RolePermission.resource", "RolePermission.action"])
.whereRef("RolePermission.roleId", "=", "Role.id")
).as("permissions"),
])
.whereRef("Role.id", "=", "Membership.customRoleId")
).as("role"),
])
.where("Membership.userId", "=", userId)
.distinctOn(["Membership.teamId"])
.execute();
}
async checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean> {
const [resource, action] = permission.split(".");
const hasPermission = await kysely
.selectFrom("RolePermission")
.select("id")
.where("roleId", "=", roleId)
.where((eb) =>
eb.or([
// Global wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
// Resource wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", action)]),
// Action wildcard
eb.and([eb("resource", "=", resource), eb("action", "=", "*")]),
// Exact match
eb.and([eb("resource", "=", resource), eb("action", "=", action)]),
])
)
.executeTakeFirst();
return !!hasPermission;
}
async checkRolePermissions(roleId: string, permissions: PermissionString[]): Promise<boolean> {
const permissionPairs = permissions.map((p) => {
const [resource, action] = p.split(".");
return { resource, action };
});
const matchingPermissionsCount = await kysely
.selectFrom("RolePermission")
.select((eb) => eb.fn.countAll().as("count"))
.where("roleId", "=", roleId)
.where((eb) =>
eb.or([
// Global wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
// Resource wildcards
eb.and([
eb("resource", "=", "*"),
eb(
"action",
"in",
permissionPairs.map((p) => p.action)
),
]),
// Action wildcards
eb.and([
eb(
"resource",
"in",
permissionPairs.map((p) => p.resource)
),
eb("action", "=", "*"),
]),
// Exact matches
eb.or(
permissionPairs.map((p) => eb.and([eb("resource", "=", p.resource), eb("action", "=", p.action)]))
),
])
)
.executeTakeFirstOrThrow();
return Number(matchingPermissionsCount.count) >= permissions.length;
}
}
@@ -0,0 +1,113 @@
import type { ExpressionBuilder } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import kysely from "@calcom/kysely";
import type { DB } from "@calcom/kysely/types";
import prisma from "@calcom/prisma";
import { RoleType } from "@calcom/prisma/enums";
import type { PermissionString, Resource, CrudAction, CustomAction } from "../types/permission-registry";
export class RoleRepository {
async findRoleByName(name: string, teamId?: number) {
return kysely
.selectFrom("Role")
.selectAll()
.where("name", "=", name)
.where((eb) => (teamId ? eb("teamId", "=", teamId) : eb("teamId", "is", null)))
.executeTakeFirst();
}
async createRole(data: { name: string; description?: string; teamId?: number; type: RoleType }) {
return prisma.role.create({
data: {
name: data.name,
description: data.description,
teamId: data.teamId,
type: data.type,
},
});
}
async createRolePermissions(roleId: string, permissions: PermissionString[]) {
return prisma.rolePermission.createMany({
data: permissions.map((permission) => {
const [resource, action] = permission.split(".") as [Resource, CrudAction | CustomAction];
return { roleId, resource, action };
}),
});
}
async findRoleWithPermissions(roleId: string) {
const role = await kysely
.selectFrom("Role")
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
.select([
"Role.id",
"Role.name",
"Role.description",
"Role.teamId",
"Role.type",
"Role.createdAt",
"Role.updatedAt",
(eb: ExpressionBuilder<DB, "Role" | "RolePermission">) =>
jsonArrayFrom(
eb
.selectFrom("RolePermission")
.select(["resource", "action", "id"])
.whereRef("RolePermission.roleId", "=", "Role.id")
).as("permissions"),
])
.where("Role.id", "=", roleId)
.executeTakeFirst();
return role;
}
async findTeamRoles(teamId: number) {
return kysely
.selectFrom("Role")
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
.select([
"Role.id",
"Role.name",
"Role.description",
"Role.teamId",
"Role.type",
"Role.createdAt",
"Role.updatedAt",
(eb: ExpressionBuilder<DB, "Role" | "RolePermission">) =>
jsonArrayFrom(
eb
.selectFrom("RolePermission")
.select(["resource", "action", "id"])
.whereRef("RolePermission.roleId", "=", "Role.id")
).as("permissions"),
])
.where((eb: ExpressionBuilder<DB, "Role" | "RolePermission">) =>
eb.or([eb("Role.teamId", "=", teamId), eb("Role.type", "=", RoleType.SYSTEM)])
)
.execute();
}
async deleteRolePermissions(roleId: string) {
return kysely.deleteFrom("RolePermission").where("roleId", "=", roleId).execute();
}
async deleteRole(roleId: string) {
return kysely.deleteFrom("Role").where("id", "=", roleId).returningAll().executeTakeFirst();
}
async updateMembershipRole(membershipId: number, roleId: string | null) {
return kysely
.updateTable("Membership")
.set({ customRoleId: roleId })
.where("id", "=", membershipId)
.returningAll()
.executeTakeFirstOrThrow();
}
async transaction<T>(callback: (trx: typeof kysely) => Promise<T>): Promise<T> {
return kysely.transaction().execute(callback);
}
}
@@ -0,0 +1,217 @@
import { vi, type Mock, describe, it, expect, beforeEach } from "vitest";
import type { MembershipRole } from "@calcom/prisma/enums";
import { PermissionRepository } from "../../repository/permission.repository";
import { PermissionCheckService } from "../permission-check.service";
vi.mock("../../repository/permission.repository", () => ({
PermissionRepository: vi.fn(),
}));
type MockRepository = {
[K in keyof PermissionRepository]: Mock;
};
describe("PermissionCheckService", () => {
let service: PermissionCheckService;
let mockRepository: MockRepository;
beforeEach(() => {
vi.clearAllMocks();
mockRepository = {
getMembershipByMembershipId: vi.fn(),
getMembershipByUserAndTeam: vi.fn(),
getOrgMembership: vi.fn(),
getUserMemberships: vi.fn(),
checkRolePermission: vi.fn(),
checkRolePermissions: vi.fn(),
};
vi.mocked(PermissionRepository).mockImplementation(
() => mockRepository as unknown as PermissionRepository
);
service = new PermissionCheckService();
});
describe("hasPermission", () => {
it("should check permission by membershipId", async () => {
const membership = {
id: 1,
teamId: 1,
userId: 1,
accepted: true,
role: "ADMIN" as MembershipRole,
customRoleId: "admin_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
team_parentId: null,
};
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(membership);
mockRepository.checkRolePermission.mockResolvedValueOnce(true);
const result = await service.hasPermission({ membershipId: 1 }, "eventType.create");
expect(result).toBe(true);
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.create");
});
it("should return false if neither team nor org has permission", async () => {
const teamMembership = {
id: 1,
teamId: 2,
userId: 1,
accepted: true,
role: "MEMBER" as MembershipRole,
customRoleId: "team_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
team_parentId: 1,
};
const orgMembership = {
id: 2,
teamId: 1,
userId: 1,
accepted: true,
role: "MEMBER" as MembershipRole,
customRoleId: "org_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
};
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(teamMembership);
mockRepository.checkRolePermission.mockResolvedValueOnce(false);
mockRepository.getOrgMembership.mockResolvedValueOnce(orgMembership);
mockRepository.checkRolePermission.mockResolvedValueOnce(false);
const result = await service.hasPermission({ membershipId: 1 }, "eventType.create");
expect(result).toBe(false);
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("team_role", "eventType.create");
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 1);
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("org_role", "eventType.create");
});
});
describe("hasPermissions", () => {
it("should check multiple permissions (AND condition)", async () => {
const membership = {
id: 1,
teamId: 1,
userId: 1,
accepted: true,
role: "ADMIN" as MembershipRole,
customRoleId: "admin_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
team_parentId: null,
};
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(membership);
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
const result = await service.hasPermissions({ membershipId: 1 }, ["eventType.create", "team.invite"]);
expect(result).toBe(true);
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", [
"eventType.create",
"team.invite",
]);
});
it("should check multiple permissions in parent organization if team permissions not found", async () => {
const teamMembership = {
id: 1,
teamId: 2,
userId: 1,
accepted: true,
role: "MEMBER" as MembershipRole,
customRoleId: "team_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
team_parentId: 1,
};
const orgMembership = {
id: 2,
teamId: 1,
userId: 1,
accepted: true,
role: "ADMIN" as MembershipRole,
customRoleId: "org_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
};
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(teamMembership);
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
mockRepository.getOrgMembership.mockResolvedValueOnce(orgMembership);
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
const result = await service.hasPermissions({ membershipId: 1 }, ["eventType.create", "team.invite"]);
expect(result).toBe(true);
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("team_role", [
"eventType.create",
"team.invite",
]);
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 1);
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("org_role", [
"eventType.create",
"team.invite",
]);
});
it("should return false if neither team nor org has all required permissions", async () => {
const teamMembership = {
id: 1,
teamId: 2,
userId: 1,
accepted: true,
role: "MEMBER" as MembershipRole,
customRoleId: "team_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
team_parentId: 1,
};
const orgMembership = {
id: 2,
teamId: 1,
userId: 1,
accepted: true,
role: "MEMBER" as MembershipRole,
customRoleId: "org_role",
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
};
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(teamMembership);
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
mockRepository.getOrgMembership.mockResolvedValueOnce(orgMembership);
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
const result = await service.hasPermissions({ membershipId: 1 }, ["eventType.create", "team.invite"]);
expect(result).toBe(false);
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("team_role", [
"eventType.create",
"team.invite",
]);
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 1);
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("org_role", [
"eventType.create",
"team.invite",
]);
});
});
});
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { PermissionString } from "../../types/permission-registry";
import { CrudAction, Resource } from "../../types/permission-registry";
import { PERMISSION_REGISTRY } from "../../types/permission-registry";
import { PermissionService } from "../permission.service";
describe("PermissionService", () => {
let service: PermissionService;
beforeEach(() => {
service = new PermissionService();
});
describe("validatePermission", () => {
it("should validate a valid permission", () => {
expect(service.validatePermission("eventType.create" as PermissionString)).toBe(true);
expect(service.validatePermission("team.invite" as PermissionString)).toBe(true);
});
it("should reject an invalid permission", () => {
expect(service.validatePermission("invalid.action" as PermissionString)).toBe(false);
expect(service.validatePermission("eventType.invalid" as PermissionString)).toBe(false);
});
});
describe("validatePermissions", () => {
it("should validate multiple valid permissions", () => {
const permissions: PermissionString[] = [
"eventType.create",
"team.invite",
"organization.manageBilling",
];
expect(service.validatePermissions(permissions)).toBe(true);
});
it("should reject if any permission is invalid", () => {
const permissions = [
"eventType.create",
"invalid.action",
"organization.billing",
] as PermissionString[];
expect(service.validatePermissions(permissions)).toBe(false);
});
});
describe("getAllPermissions", () => {
it("should return all permissions from registry", () => {
const permissions = service.getAllPermissions();
const expectedCount = Object.values(PERMISSION_REGISTRY).reduce(
(count, resource) => count + Object.keys(resource).length,
0
);
expect(permissions).toHaveLength(expectedCount);
});
});
describe("getPermissionsByCategory", () => {
it("should return permissions for a specific category", () => {
const eventPermissions = service.getPermissionsByCategory("event");
expect(eventPermissions.every((p) => p.category === "event")).toBe(true);
});
it("should return empty array for non-existent category", () => {
const permissions = service.getPermissionsByCategory("nonexistent");
expect(permissions).toHaveLength(0);
});
});
describe("getPermissionCategories", () => {
it("should return all unique categories", () => {
const categories = service.getPermissionCategories();
const uniqueCategories = new Set(
Object.values(PERMISSION_REGISTRY).flatMap((resource) =>
Object.values(resource).map((p) => p.category)
)
);
expect(categories).toHaveLength(uniqueCategories.size);
expect(categories.every((category) => typeof category === "string")).toBe(true);
});
});
describe("getPermissionsByResource", () => {
it("should return permissions for a specific resource", () => {
const eventTypePermissions = service.getPermissionsByResource(Resource.EventType);
expect(eventTypePermissions.every((p) => p.resource === Resource.EventType)).toBe(true);
});
it("should return empty array for non-existent resource", () => {
const permissions = service.getPermissionsByResource("nonexistent" as Resource);
expect(permissions).toHaveLength(0);
});
});
describe("getPermissionsByAction", () => {
it("should return permissions for a specific action", () => {
const createPermissions = service.getPermissionsByAction(CrudAction.Create);
expect(createPermissions.every((p) => p.action === CrudAction.Create)).toBe(true);
});
it("should return empty array for non-existent action", () => {
const permissions = service.getPermissionsByAction("nonexistent" as CrudAction);
expect(permissions).toHaveLength(0);
});
});
});
@@ -0,0 +1,276 @@
import { vi, type Mock, describe, it, expect, beforeEach } from "vitest";
import { RoleType } from "@calcom/prisma/enums";
import { RoleRepository } from "../../repository/role.repository";
import type { PermissionString } from "../../types/permission-registry";
import { RoleService } from "../role.service";
vi.mock("../../repository/role.repository", () => ({
RoleRepository: vi.fn(),
}));
type MockRepository = {
[K in keyof RoleRepository]: Mock;
};
describe("RoleService", () => {
let service: RoleService;
let mockRepository: MockRepository;
beforeEach(() => {
vi.clearAllMocks();
mockRepository = {
findRoleByName: vi.fn(),
createRole: vi.fn(),
createRolePermissions: vi.fn(),
findRoleWithPermissions: vi.fn(),
findTeamRoles: vi.fn(),
deleteRolePermissions: vi.fn(),
deleteRole: vi.fn(),
updateMembershipRole: vi.fn(),
transaction: vi.fn(),
};
vi.mocked(RoleRepository).mockImplementation(() => mockRepository as unknown as RoleRepository);
service = new RoleService();
});
describe("createRole", () => {
it("should create a role with permissions", async () => {
const roleData = {
name: "Test Role",
teamId: 1,
permissions: ["eventType.create", "eventType.read"] as PermissionString[],
};
const mockRole = {
id: "role-id",
name: roleData.name,
teamId: roleData.teamId,
type: RoleType.CUSTOM,
};
const mockPermissions = [
{ roleId: "role-id", resource: "eventType", action: "create" },
{ roleId: "role-id", resource: "eventType", action: "read" },
];
mockRepository.findRoleByName.mockResolvedValueOnce(null);
mockRepository.transaction.mockImplementationOnce(async (callback) => {
mockRepository.createRole.mockResolvedValueOnce(mockRole);
mockRepository.createRolePermissions.mockResolvedValueOnce({ count: 2 });
return callback(mockRepository);
});
const result = await service.createRole(roleData);
expect(result).toBeDefined();
expect(mockRepository.createRole).toHaveBeenCalledWith({
name: roleData.name,
teamId: roleData.teamId,
type: RoleType.CUSTOM,
});
expect(mockRepository.createRolePermissions).toHaveBeenCalledWith(mockRole.id, roleData.permissions);
});
it("should throw error if role name already exists", async () => {
const roleData = {
name: "Test Role",
teamId: 1,
permissions: ["eventType.create"] as PermissionString[],
};
mockRepository.findRoleByName.mockResolvedValueOnce({
id: "existing-role",
name: roleData.name,
});
await expect(service.createRole(roleData)).rejects.toThrow(
`Role with name "${roleData.name}" already exists`
);
});
});
describe("getRole", () => {
it("should return role with permissions", async () => {
const roleId = "role-id";
const role = {
id: roleId,
name: "Test Role",
teamId: 1,
type: RoleType.CUSTOM,
permissions: [
{ resource: "eventType", action: "create" },
{ resource: "eventType", action: "read" },
],
};
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(role);
const result = await service.getRole(roleId);
expect(result).toBeDefined();
expect(result?.permissions).toHaveLength(2);
});
it("should return null for non-existent role", async () => {
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
const result = await service.getRole("non-existent");
expect(result).toBeNull();
});
});
describe("getTeamRoles", () => {
it("should return all roles for a team", async () => {
const teamId = 1;
const roles = [
{
id: "role-1",
name: "Role 1",
teamId,
type: RoleType.CUSTOM,
permissions: [{ roleId: "role-1", resource: "eventType", action: "create" }],
},
{
id: "role-2",
name: "Role 2",
teamId,
type: RoleType.CUSTOM,
permissions: [{ roleId: "role-2", resource: "eventType", action: "read" }],
},
];
mockRepository.findTeamRoles.mockResolvedValueOnce(roles);
const result = await service.getTeamRoles(teamId);
expect(result).toHaveLength(2);
expect(result[0].permissions).toHaveLength(1);
});
});
describe("updateRolePermissions", () => {
it("should update role permissions", async () => {
const roleId = "role-id";
const permissions = ["eventType.create", "eventType.read"] as PermissionString[];
const mockRole = {
id: roleId,
name: "Test Role",
teamId: 1,
type: RoleType.CUSTOM,
permissions: [
{ roleId, resource: "eventType", action: "create" },
{ roleId, resource: "eventType", action: "read" },
],
};
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(mockRole);
mockRepository.transaction.mockImplementationOnce(async (callback) => {
mockRepository.deleteRolePermissions.mockResolvedValueOnce({ count: 2 });
mockRepository.createRolePermissions.mockResolvedValueOnce({ count: 2 });
return callback(mockRepository);
});
const result = await service.updateRolePermissions(roleId, permissions);
expect(result).toBeDefined();
expect(mockRepository.deleteRolePermissions).toHaveBeenCalledWith(roleId);
expect(mockRepository.createRolePermissions).toHaveBeenCalledWith(roleId, permissions);
});
it("should throw error if role does not exist", async () => {
const roleId = "non-existent-role";
const permissions = ["eventType.create"] as PermissionString[];
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
await expect(service.updateRolePermissions(roleId, permissions)).rejects.toThrow("Role not found");
});
it("should throw error if trying to update system role", async () => {
const roleId = "system-role";
const permissions = ["eventType.create"] as PermissionString[];
mockRepository.findRoleWithPermissions.mockResolvedValueOnce({
id: roleId,
type: RoleType.SYSTEM,
});
await expect(service.updateRolePermissions(roleId, permissions)).rejects.toThrow(
"Cannot update default roles"
);
});
});
describe("deleteRole", () => {
it("should delete a custom role", async () => {
const roleId = "role-id";
const mockRole = {
id: roleId,
type: RoleType.CUSTOM,
};
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(mockRole);
mockRepository.transaction.mockImplementationOnce(async (callback) => {
mockRepository.deleteRolePermissions.mockResolvedValueOnce({ count: 1 });
mockRepository.deleteRole.mockResolvedValueOnce(mockRole);
return callback(mockRepository);
});
const result = await service.deleteRole(roleId);
expect(result).toBeDefined();
expect(mockRepository.deleteRolePermissions).toHaveBeenCalledWith(roleId);
expect(mockRepository.deleteRole).toHaveBeenCalledWith(roleId);
});
it("should throw error if trying to delete system role", async () => {
const roleId = "system-role";
mockRepository.findRoleWithPermissions.mockResolvedValueOnce({
id: roleId,
type: RoleType.SYSTEM,
});
await expect(service.deleteRole(roleId)).rejects.toThrow("Cannot delete default roles");
});
it("should throw error if role does not exist", async () => {
const roleId = "non-existent";
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
await expect(service.deleteRole(roleId)).rejects.toThrow("Role not found");
});
});
describe("changeUserRole", () => {
const membershipId = 123;
const roleId = "role-id";
it("should change user's role successfully", async () => {
const mockRole = {
id: roleId,
name: "Test Role",
teamId: 1,
type: RoleType.CUSTOM,
permissions: [
{ roleId, resource: "eventType", action: "create" },
{ roleId, resource: "eventType", action: "read" },
],
};
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(mockRole);
mockRepository.updateMembershipRole.mockResolvedValueOnce({
id: membershipId,
customRoleId: roleId,
});
const result = await service.changeUserRole(membershipId, roleId);
expect(result).toBeDefined();
expect(mockRepository.updateMembershipRole).toHaveBeenCalledWith(membershipId, roleId);
});
it("should throw error if role does not exist", async () => {
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
await expect(service.changeUserRole(membershipId, roleId)).rejects.toThrow("Role not found");
expect(mockRepository.updateMembershipRole).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,108 @@
import { PermissionRepository } from "../repository/permission.repository";
import type { PermissionString } from "../types/permission-registry";
export function transformDbPermissionsToTeamPermissions(
memberships: {
teamId: number;
role: { id: string | null; permissions: { resource: string | null; action: string | null }[] } | null;
}[]
): Record<number, { roleId: string; permissions: PermissionString[] }> {
const teamPermissions: Record<number, { roleId: string; permissions: PermissionString[] }> = {};
for (const membership of memberships) {
if (!membership.teamId || !membership.role?.id || !membership.role.permissions) continue;
const validPermissions = membership.role.permissions.filter(
(p): p is { resource: string; action: string } =>
typeof p.resource === "string" &&
typeof p.action === "string" &&
p.resource !== null &&
p.action !== null
);
teamPermissions[membership.teamId] = {
roleId: membership.role.id,
permissions: validPermissions.map((p) => `${p.resource}.${p.action}` as PermissionString),
};
}
return teamPermissions;
}
export class PermissionCheckService {
private repository: PermissionRepository;
constructor() {
this.repository = new PermissionRepository();
}
async getUserPermissions(userId: number) {
const memberships = await this.repository.getUserMemberships(userId);
return transformDbPermissionsToTeamPermissions(memberships);
}
async hasPermission(
query: { membershipId?: number; userId?: number; teamId?: number },
permission: PermissionString
): Promise<boolean> {
const { membership, orgMembership } = await this.getMembership(query);
// Check team membership first
if (membership?.customRoleId) {
const hasTeamPermission = await this.repository.checkRolePermission(
membership.customRoleId,
permission
);
if (hasTeamPermission) return true;
}
// If no team permission and there's an org membership, check org permissions
if (orgMembership?.customRoleId) {
return this.repository.checkRolePermission(orgMembership.customRoleId, permission);
}
return false;
}
async hasPermissions(
query: { membershipId?: number; userId?: number; teamId?: number },
permissions: PermissionString[]
): Promise<boolean> {
const { membership, orgMembership } = await this.getMembership(query);
// Check team membership first
if (membership?.customRoleId) {
const hasTeamPermissions = await this.repository.checkRolePermissions(
membership.customRoleId,
permissions
);
if (hasTeamPermissions) return true;
}
// If no team permissions and there's an org membership, check org permissions
if (orgMembership?.customRoleId) {
return this.repository.checkRolePermissions(orgMembership.customRoleId, permissions);
}
return false;
}
private async getMembership(query: { membershipId?: number; userId?: number; teamId?: number }) {
let membership = null;
let orgMembership = null;
// Get the team membership
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);
}
// If we found a membership and the team has a parent org, get the org membership
if (membership?.team_parentId) {
orgMembership = await this.repository.getOrgMembership(membership.userId, membership.team_parentId);
}
return { membership, orgMembership };
}
}
@@ -0,0 +1,93 @@
import type {
PermissionString,
Resource,
CrudAction,
CustomAction,
PermissionDetails,
} from "../types/permission-registry";
import { PERMISSION_REGISTRY } from "../types/permission-registry";
export class PermissionService {
validatePermission(permission: PermissionString): boolean {
const [resource, action] = permission.split(".") as [Resource, CrudAction | CustomAction];
return !!PERMISSION_REGISTRY[resource]?.[action];
}
validatePermissions(permissions: PermissionString[]): boolean {
return permissions.every((permission) => this.validatePermission(permission));
}
// Helper function to check if a permission matches a pattern (including wildcards)
permissionMatches(pattern: PermissionString, permission: PermissionString): boolean {
// Handle full wildcard
if (pattern === "*.*") return true;
const [patternResource, patternAction] = pattern.split(".") as [
Resource | "*",
CrudAction | CustomAction | "*"
];
const [permissionResource, permissionAction] = permission.split(".") as [
Resource,
CrudAction | CustomAction
];
// Check if resource matches (either exact match or wildcard)
const resourceMatches = patternResource === "*" || patternResource === permissionResource;
// Check if action matches (either exact match or wildcard)
const actionMatches = patternAction === "*" || patternAction === permissionAction;
return resourceMatches && actionMatches;
}
// Helper function to create a permission string
createPermissionString(
resource: Resource | "*",
action: CrudAction | CustomAction | "*",
isCustom = false
): PermissionString {
const prefix = isCustom ? "custom:" : "";
return `${prefix}${resource}.${action}` as PermissionString;
}
// Helper function to get all permissions as an array
getAllPermissions(): Array<{ resource: Resource; action: CrudAction | CustomAction } & PermissionDetails> {
const permissions: Array<{ resource: Resource; action: CrudAction | CustomAction } & PermissionDetails> =
[];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, actions]) => {
Object.entries(actions).forEach(([action, details]) => {
permissions.push({
resource: resource as Resource,
action: action as CrudAction | CustomAction,
...details,
});
});
});
return permissions;
}
getPermissionsByCategory(category: string) {
return this.getAllPermissions().filter((p) => p.category === category);
}
getPermissionCategories(): string[] {
return Array.from(new Set(this.getAllPermissions().map((p) => p.category)));
}
getPermissionsByResource(resource: Resource) {
const resourcePermissions = PERMISSION_REGISTRY[resource];
if (!resourcePermissions) return [];
return Object.entries(resourcePermissions).map(([action, details]) => ({
resource,
action: action as CrudAction | CustomAction,
...details,
}));
}
getPermissionsByAction(action: CrudAction | CustomAction) {
return this.getAllPermissions().filter((p) => p.action === action);
}
}
@@ -0,0 +1,134 @@
import { MembershipRole, RoleType } from "@calcom/prisma/enums";
import { RoleRepository } from "../repository/role.repository";
import type { PermissionString } from "../types/permission-registry";
import { PermissionService } from "./permission.service";
// These IDs must match the ones in the migration
const DEFAULT_ROLE_IDS = {
[MembershipRole.OWNER]: "owner_role",
[MembershipRole.ADMIN]: "admin_role",
[MembershipRole.MEMBER]: "member_role",
} as const;
export class RoleService {
private repository: RoleRepository;
private permissionService: PermissionService;
constructor() {
this.repository = new RoleRepository();
this.permissionService = new PermissionService();
}
async createRole(data: {
name: string;
description?: string;
teamId?: number;
permissions: PermissionString[];
}) {
// Check if role name conflicts with default roles
const existingRole = await this.repository.findRoleByName(data.name, data.teamId);
if (existingRole) {
throw new Error(`Role with name "${data.name}" already exists`);
}
// Validate permissions
if (!this.permissionService.validatePermissions(data.permissions)) {
throw new Error("Invalid permissions provided");
}
return this.repository.transaction(async (trx) => {
// Create role
const role = await this.repository.createRole({
name: data.name,
description: data.description,
teamId: data.teamId,
type: RoleType.CUSTOM,
});
// Create permissions
const permissions = await this.repository.createRolePermissions(role.id, data.permissions);
return { ...role, permissions };
});
}
async getDefaultRoleId(role: MembershipRole): Promise<string> {
return DEFAULT_ROLE_IDS[role];
}
async assignRoleToMember(roleId: string, membershipId: number) {
return this.repository.updateMembershipRole(membershipId, roleId);
}
async getRolePermissions(roleId: string) {
const role = await this.repository.findRoleWithPermissions(roleId);
return role?.permissions ?? [];
}
async removeRoleFromMember(membershipId: number) {
return this.repository.updateMembershipRole(membershipId, null);
}
async changeUserRole(membershipId: number, roleId: string) {
// Verify role exists first
const role = await this.getRole(roleId);
if (!role) {
throw new Error("Role not found");
}
return this.repository.updateMembershipRole(membershipId, roleId);
}
async deleteRole(roleId: string) {
const role = await this.repository.findRoleWithPermissions(roleId);
if (!role) {
throw new Error("Role not found");
}
// Don't allow deleting default roles
if (role.type === RoleType.SYSTEM) {
throw new Error("Cannot delete default roles");
}
return this.repository.transaction(async (trx) => {
// Delete permissions first
await this.repository.deleteRolePermissions(roleId);
// Then delete the role
return this.repository.deleteRole(roleId);
});
}
async getRole(roleId: string) {
return this.repository.findRoleWithPermissions(roleId);
}
async getTeamRoles(teamId: number) {
return this.repository.findTeamRoles(teamId);
}
async updateRolePermissions(roleId: string, permissions: PermissionString[]) {
const role = await this.getRole(roleId);
if (!role) {
throw new Error("Role not found");
}
// Don't allow updating default roles
if (role.type === RoleType.SYSTEM) {
throw new Error("Cannot update default roles");
}
// Validate permissions
if (!this.permissionService.validatePermissions(permissions)) {
throw new Error("Invalid permissions provided");
}
return this.repository.transaction(async (trx) => {
// Delete existing permissions
await this.repository.deleteRolePermissions(roleId);
// Create new permissions
const newPermissions = await this.repository.createRolePermissions(roleId, permissions);
return { ...role, permissions: newPermissions };
});
}
}
@@ -0,0 +1,41 @@
import { create } from "zustand";
import type { PermissionString } from "../types/permission-registry";
export interface TeamPermissions {
roleId: string;
permissions: Set<PermissionString>;
}
interface PermissionStore {
teamPermissions: Map<number, TeamPermissions>;
setTeamPermissions: (
permissions: Record<number, { roleId: string; permissions: PermissionString[] }>
) => void;
hasPermission: (teamId: number, permission: PermissionString) => boolean;
hasPermissions: (teamId: number, permissions: PermissionString[]) => boolean;
}
export const usePermissionStore = create<PermissionStore>((set, get) => ({
teamPermissions: new Map(),
setTeamPermissions: (permissions) => {
const permissionsMap = new Map();
Object.entries(permissions).forEach(([teamId, { roleId, permissions: perms }]) => {
permissionsMap.set(Number(teamId), {
roleId,
permissions: new Set(perms),
});
});
set({ teamPermissions: permissionsMap });
},
hasPermission: (teamId, permission) => {
const teamPerms = get().teamPermissions.get(teamId);
if (!teamPerms) return false;
return teamPerms.permissions.has(permission);
},
hasPermissions: (teamId, permissions) => {
const teamPerms = get().teamPermissions.get(teamId);
if (!teamPerms) return false;
return permissions.every((p) => teamPerms.permissions.has(p));
},
}));
@@ -0,0 +1,125 @@
export enum CrudAction {
Create = "create",
Read = "read",
Update = "update",
Delete = "delete",
All = "*", // Wildcard for all CRUD actions
}
export enum Resource {
EventType = "eventType",
Booking = "booking",
Team = "team",
Organization = "organization",
Insights = "insights",
Availability = "availability",
Workflow = "workflow",
RoutingForm = "routingForm",
ApiKey = "apiKey",
All = "*", // Wildcard for all resources
}
export enum CustomAction {
Manage = "manage", // Full control over a resource
Invite = "invite", // Invite members to team/org
Remove = "remove", // Remove members from team/org
Override = "override", // Override availability
ReadRecordings = "readRecordings", // Access booking recordings
ManageBilling = "manageBilling", // Manage org billing
ChangeMemberRole = "changeMemberRole", // Change role of team members
ListMembers = "listMembers", // List organization members
ReadTeamBookings = "readTeamBookings", // Read team bookings
ReadOrgBookings = "readOrgBookings", // Read organization bookings
FindKeyOfType = "findKeyOfType", // Find API keys by type
All = "*", // Wildcard for all custom actions
}
export type CrudScope = `${Resource}.${CrudAction}` | `${Resource}.*` | `*.*`;
export type CustomScope = `${Resource}.${CustomAction}` | `${Resource}.*` | `*.*`;
export type PermissionString = CrudScope | CustomScope;
export type PermissionDetails = {
description: string;
category: string;
};
export type ResourcePermissions = {
[K in CrudAction | CustomAction]?: PermissionDetails;
};
export type PermissionRegistry = {
[K in Resource]: ResourcePermissions;
};
// Keep in mind these are on a team/organization level, not a user level
export const PERMISSION_REGISTRY: PermissionRegistry = {
[Resource.All]: {
[CrudAction.All]: { description: "All actions on all resources", category: "system" },
},
[Resource.EventType]: {
[CrudAction.Create]: { description: "Create event types", category: "event" },
[CrudAction.Read]: { description: "View event types", category: "event" },
[CrudAction.Update]: { description: "Update event types", category: "event" },
[CrudAction.Delete]: { description: "Delete event types", category: "event" },
[CustomAction.Manage]: { description: "All actions on event types", category: "event" },
},
[Resource.Team]: {
[CrudAction.Create]: { description: "Create teams", category: "team" },
[CrudAction.Read]: { description: "View team details", category: "team" },
[CrudAction.Update]: { description: "Update settings", category: "team" },
[CrudAction.Delete]: { description: "Delete team", category: "team" },
[CustomAction.Invite]: { description: "Invite team members", category: "team" },
[CustomAction.Remove]: { description: "Remove team members", category: "team" },
[CustomAction.ChangeMemberRole]: { description: "Change role of team members", category: "team" },
[CustomAction.Manage]: { description: "All actions on teams", category: "team" },
},
[Resource.Organization]: {
[CrudAction.Create]: { description: "Create organization", category: "org" },
[CrudAction.Read]: { description: "View organization details", category: "org" },
[CustomAction.ListMembers]: { description: "List organization members", category: "org" },
[CustomAction.Invite]: { description: "Invite organization members", category: "org" },
[CustomAction.Remove]: { description: "Remove organization members", category: "org" },
[CustomAction.ManageBilling]: { description: "Manage organization billing", category: "org" },
[CrudAction.Update]: { description: "Edit organization settings", category: "org" },
[CustomAction.Manage]: { description: "All actions on organizations", category: "org" },
},
[Resource.Booking]: {
[CrudAction.Read]: { description: "View bookings", category: "booking" },
[CustomAction.ReadTeamBookings]: { description: "View team bookings", category: "booking" },
[CustomAction.ReadOrgBookings]: { description: "View organization bookings", category: "booking" },
[CustomAction.ReadRecordings]: { description: "View booking recordings", category: "booking" },
[CrudAction.Update]: { description: "Update bookings", category: "booking" },
[CustomAction.Manage]: { description: "All actions on bookings", category: "booking" },
},
[Resource.Insights]: {
[CrudAction.Read]: { description: "View team insights and analytics", category: "insights" },
[CustomAction.Manage]: { description: "Manage team insights and analytics", category: "insights" },
},
[Resource.Availability]: {
[CrudAction.Read]: { description: "View availability", category: "availability" },
[CrudAction.Update]: { description: "Update own availability", category: "availability" },
[CustomAction.Override]: { description: "Override team member availability", category: "availability" },
[CustomAction.Manage]: { description: "Manage all availability settings", category: "availability" },
},
[Resource.Workflow]: {
[CrudAction.Create]: { description: "Create workflows", category: "workflow" },
[CrudAction.Read]: { description: "View workflows", category: "workflow" },
[CrudAction.Update]: { description: "Update workflows", category: "workflow" },
[CrudAction.Delete]: { description: "Delete workflows", category: "workflow" },
[CustomAction.Manage]: { description: "All actions on workflows", category: "workflow" },
},
[Resource.RoutingForm]: {
[CrudAction.Create]: { description: "Create routing forms", category: "routing" },
[CrudAction.Read]: { description: "View routing forms", category: "routing" },
[CrudAction.Update]: { description: "Update routing forms", category: "routing" },
[CrudAction.Delete]: { description: "Delete routing forms", category: "routing" },
[CustomAction.Manage]: { description: "All actions on routing forms", category: "routing" },
},
[Resource.ApiKey]: {
[CrudAction.Create]: { description: "Create API keys", category: "apiKey" },
[CrudAction.Read]: { description: "View API keys", category: "apiKey" },
[CustomAction.FindKeyOfType]: { description: "Find API keys by type", category: "apiKey" },
[CrudAction.Delete]: { description: "Delete API keys", category: "apiKey" },
[CustomAction.Manage]: { description: "All actions on API keys", category: "apiKey" },
},
};
@@ -0,0 +1,56 @@
-- CreateEnum
CREATE TYPE "RoleType" AS ENUM ('SYSTEM', 'CUSTOM');
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "customRoleId" TEXT;
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"teamId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"type" "RoleType" NOT NULL DEFAULT 'CUSTOM',
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Role_teamId_idx" ON "Role"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_teamId_key" ON "Role"("name", "teamId");
-- CreateIndex
CREATE INDEX "RolePermission_roleId_idx" ON "RolePermission"("roleId");
-- CreateIndex
CREATE INDEX "RolePermission_action_idx" ON "RolePermission"("action");
-- CreateIndex
CREATE UNIQUE INDEX "RolePermission_roleId_resource_action_key" ON "RolePermission"("roleId", "resource", "action");
-- CreateIndex
CREATE INDEX "Membership_customRoleId_idx" ON "Membership"("customRoleId");
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_customRoleId_fkey" FOREIGN KEY ("customRoleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Role" ADD CONSTRAINT "Role_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+39
View File
@@ -501,6 +501,7 @@ model Team {
// Available for Organization only
delegationCredentials DelegationCredential[]
domainWideDelegations DomainWideDelegation[]
roles Role[] // Added for Role relation
features TeamFeatures[]
@@ -585,6 +586,8 @@ model Membership {
userId Int
accepted Boolean @default(false)
role MembershipRole
customRoleId String?
customRole Role? @relation(fields: [customRoleId], references: [id])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
@@ -597,6 +600,7 @@ model Membership {
@@index([userId])
@@index([accepted])
@@index([role])
@@index([customRoleId])
}
model VerificationToken {
@@ -2265,3 +2269,38 @@ model WorkflowOptOutContact {
@@unique([type, value])
}
enum RoleType {
SYSTEM
CUSTOM
}
model Role {
id String @id @default(cuid())
name String
description String?
teamId Int? // null for global roles
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
permissions RolePermission[]
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type RoleType @default(CUSTOM)
@@unique([name, teamId])
@@index([teamId])
}
model RolePermission {
id String @id @default(cuid())
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
resource String
action String
createdAt DateTime @default(now())
@@unique([roleId, resource, action])
@@index([roleId])
// TODO: come back to this with indexs.
@@index([action])
}
@@ -32,6 +32,7 @@ import { oAuthRouter } from "./oAuth/_router";
import { oooRouter } from "./ooo/_router";
import { viewerOrganizationsRouter } from "./organizations/_router";
import { paymentsRouter } from "./payments/_router";
// import { permissionsRouter } from "./pbac/_router";
import { routingFormsRouter } from "./routing-forms/_router";
import { slotsRouter } from "./slots/_router";
import { ssoRouter } from "./sso/_router";
@@ -69,6 +70,7 @@ export const viewerRouter = mergeRouters(
insights: insightsRouter,
payments: paymentsRouter,
filterSegments: filterSegmentsRouter,
// pbac: permissionsRouter,
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.
appRoutingForms: app_RoutingForms,
@@ -0,0 +1,73 @@
import { z } from "zod";
import {
checkUserPermissionInTeam,
checkMultiplePermissionsInTeam,
} from "@calcom/features/pbac/lib/server/checkPermissions";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { CrudAction, CustomAction, Resource } from "@calcom/features/pbac/types/permission-registry";
import type { PermissionString } from "@calcom/features/pbac/types/permission-registry";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
// Create a Zod schema for PermissionString that validates the format
const permissionStringSchema = z.custom<PermissionString>((val) => {
if (typeof val !== "string") return false;
const [resource, action] = val.split(".");
const isValidResource = Object.values(Resource).includes(resource as Resource);
const isValidAction =
Object.values(CrudAction).includes(action as CrudAction) ||
Object.values(CustomAction).includes(action as CustomAction);
return isValidResource && isValidAction;
}, "Invalid permission string format. Must be 'resource.action' where resource and action are valid enums");
export const permissionsRouter = router({
getUserPermissions: authedProcedure.query(async ({ ctx }) => {
if (!ctx.user?.id) return {};
const permissionCheckService = new PermissionCheckService();
return await permissionCheckService.getUserPermissions(ctx.user.id);
}),
checkPermission: authedProcedure
.input(
z.object({
teamId: z.number(),
permission: permissionStringSchema,
})
)
.query(async ({ ctx, input }) => {
if (!ctx.user?.id) {
return false;
}
return checkUserPermissionInTeam({
userId: ctx.user.id,
teamId: input.teamId,
permission: input.permission,
});
}),
checkPermissions: authedProcedure
.input(
z.object({
teamId: z.number(),
permissions: z.array(permissionStringSchema),
})
)
.query(async ({ ctx, input }) => {
if (!ctx.user?.id) {
return false;
}
return checkMultiplePermissionsInTeam({
userId: ctx.user.id,
teamId: input.teamId,
permissions: input.permissions,
});
}),
});