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:
co-authored by
sean@cal.com
Devin AI
parent
1740e34c79
commit
b9fadf82a7
+189
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user