diff --git a/PERMISSIONS.md b/PERMISSIONS.md new file mode 100644 index 0000000000..3efd4b9862 --- /dev/null +++ b/PERMISSIONS.md @@ -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. diff --git a/apps/web/pages/api/trpc/pbac/[trpc].ts b/apps/web/pages/api/trpc/pbac/[trpc].ts new file mode 100644 index 0000000000..2f928e6d81 --- /dev/null +++ b/apps/web/pages/api/trpc/pbac/[trpc].ts @@ -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); diff --git a/packages/features/pbac/README.md b/packages/features/pbac/README.md new file mode 100644 index 0000000000..f9c0f82673 --- /dev/null +++ b/packages/features/pbac/README.md @@ -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; // O(1) lookup +} + +interface PermissionStore { + teamPermissions: Map; // O(1) lookup + setTeamPermissions: (permissions: Record) => 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 + + + +``` + +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 ( + + {children} + + ); +} +``` + +### 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
Loading...
; + + return ( +
+ {hasPermission && } + {hasPermissions && } +
+ ); +} +``` + +### 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
Not authorized
; + } + + return
Team Settings
; +} + +// 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
Loading...
; + if (!hasPermission) return null; + + return ; +} + +// Multiple permissions check +function TeamAdminPanel({ teamId }: { teamId: number }) { + const { hasPermissions, isLoading } = usePermissions(teamId, [ + "team.update", + "team.invite" + ]); + + if (isLoading) return
Loading...
; + if (!hasPermissions) return
Insufficient permissions
; + + return
Admin Panel
; +} +``` + +### 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) diff --git a/packages/features/pbac/context/PermissionProvider.tsx b/packages/features/pbac/context/PermissionProvider.tsx new file mode 100644 index 0000000000..892fd765a0 --- /dev/null +++ b/packages/features/pbac/context/PermissionProvider.tsx @@ -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}; +} diff --git a/packages/features/pbac/hooks/usePermission.ts b/packages/features/pbac/hooks/usePermission.ts new file mode 100644 index 0000000000..f86c4ef6f5 --- /dev/null +++ b/packages/features/pbac/hooks/usePermission.ts @@ -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, + }; +} diff --git a/packages/features/pbac/lib/constants.ts b/packages/features/pbac/lib/constants.ts new file mode 100644 index 0000000000..77be89212e --- /dev/null +++ b/packages/features/pbac/lib/constants.ts @@ -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; diff --git a/packages/features/pbac/lib/server/checkPermissions.ts b/packages/features/pbac/lib/server/checkPermissions.ts new file mode 100644 index 0000000000..75eb5b6950 --- /dev/null +++ b/packages/features/pbac/lib/server/checkPermissions.ts @@ -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); +} diff --git a/packages/features/pbac/repository/permission.repository.ts b/packages/features/pbac/repository/permission.repository.ts new file mode 100644 index 0000000000..1c5c1ef813 --- /dev/null +++ b/packages/features/pbac/repository/permission.repository.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/features/pbac/repository/role.repository.ts b/packages/features/pbac/repository/role.repository.ts new file mode 100644 index 0000000000..a28fe04360 --- /dev/null +++ b/packages/features/pbac/repository/role.repository.ts @@ -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) => + 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) => + jsonArrayFrom( + eb + .selectFrom("RolePermission") + .select(["resource", "action", "id"]) + .whereRef("RolePermission.roleId", "=", "Role.id") + ).as("permissions"), + ]) + .where((eb: ExpressionBuilder) => + 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(callback: (trx: typeof kysely) => Promise): Promise { + return kysely.transaction().execute(callback); + } +} diff --git a/packages/features/pbac/services/__tests__/permission-check.service.test.ts b/packages/features/pbac/services/__tests__/permission-check.service.test.ts new file mode 100644 index 0000000000..e834538d66 --- /dev/null +++ b/packages/features/pbac/services/__tests__/permission-check.service.test.ts @@ -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", + ]); + }); + }); +}); diff --git a/packages/features/pbac/services/__tests__/permission.service.test.ts b/packages/features/pbac/services/__tests__/permission.service.test.ts new file mode 100644 index 0000000000..fbfd2f79cc --- /dev/null +++ b/packages/features/pbac/services/__tests__/permission.service.test.ts @@ -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); + }); + }); +}); diff --git a/packages/features/pbac/services/__tests__/role.service.test.ts b/packages/features/pbac/services/__tests__/role.service.test.ts new file mode 100644 index 0000000000..ea2c3ea193 --- /dev/null +++ b/packages/features/pbac/services/__tests__/role.service.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/features/pbac/services/permission-check.service.ts b/packages/features/pbac/services/permission-check.service.ts new file mode 100644 index 0000000000..952e3e517f --- /dev/null +++ b/packages/features/pbac/services/permission-check.service.ts @@ -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 { + const teamPermissions: Record = {}; + + 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 { + 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 { + 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 }; + } +} diff --git a/packages/features/pbac/services/permission.service.ts b/packages/features/pbac/services/permission.service.ts new file mode 100644 index 0000000000..1ae14feade --- /dev/null +++ b/packages/features/pbac/services/permission.service.ts @@ -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); + } +} diff --git a/packages/features/pbac/services/role.service.ts b/packages/features/pbac/services/role.service.ts new file mode 100644 index 0000000000..faf36d315a --- /dev/null +++ b/packages/features/pbac/services/role.service.ts @@ -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 { + 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 }; + }); + } +} diff --git a/packages/features/pbac/store/permission-store.ts b/packages/features/pbac/store/permission-store.ts new file mode 100644 index 0000000000..4d2b3d3d54 --- /dev/null +++ b/packages/features/pbac/store/permission-store.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +import type { PermissionString } from "../types/permission-registry"; + +export interface TeamPermissions { + roleId: string; + permissions: Set; +} + +interface PermissionStore { + teamPermissions: Map; + setTeamPermissions: ( + permissions: Record + ) => void; + hasPermission: (teamId: number, permission: PermissionString) => boolean; + hasPermissions: (teamId: number, permissions: PermissionString[]) => boolean; +} + +export const usePermissionStore = create((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)); + }, +})); diff --git a/packages/features/pbac/types/permission-registry.ts b/packages/features/pbac/types/permission-registry.ts new file mode 100644 index 0000000000..2a3e7c9fe4 --- /dev/null +++ b/packages/features/pbac/types/permission-registry.ts @@ -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" }, + }, +}; diff --git a/packages/prisma/migrations/20250520071309_add_pbac_tables/migration.sql b/packages/prisma/migrations/20250520071309_add_pbac_tables/migration.sql new file mode 100644 index 0000000000..80eb6811f6 --- /dev/null +++ b/packages/prisma/migrations/20250520071309_add_pbac_tables/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 39c1a73a3e..b6077346a2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) +} diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index af09f0c6f7..a10c7b710b 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -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, diff --git a/packages/trpc/server/routers/viewer/pbac/_router.tsx b/packages/trpc/server/routers/viewer/pbac/_router.tsx new file mode 100644 index 0000000000..53d4de31bf --- /dev/null +++ b/packages/trpc/server/routers/viewer/pbac/_router.tsx @@ -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((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, + }); + }), +});