Skip to content

Commit 45278a2

Browse files
authored
feat: api v2 pbac support (calcom#24402)
* feat: Pbac decorator and guard * feat: v2 roles endpoints * fix: test * fix: starting v2 * fix: test error * fix: test api keys * fix fixture * test permission creation * feat: permissions endpoints * refactors * refactor: project structure * test: role permissions crud * test: permissions endpoint negative tests * docs: org, team permissions swagger * unit tests for validator * Update roles.guard.ts * fix type * test: error messages * refactor: dont throw error in pbac * delete redundant test file * feedback: logging error * fix: persist role.permissions when updating role.otherProperty * refactor: use output service to return permissions * refactor: service functions return current permissions * refactor: remove OrganizationsRepository from providers * refactor: try catch possibly duplicate create * refactor: require min length name if provided * refactor: org role has orgId and team role teamId * fix: pbac guard caching * fix: e2e tests in parallel * refactor: use IsTeamInOrg guard for orgs teams roles and permissions endpoints * refactor: use redis service getter and setter * refactor: invalidate team permissions cache when permissions change * refactor: delete keys instead of versioning when caching
1 parent 2e8b89c commit 45278a2

59 files changed

Lines changed: 11938 additions & 5621 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/v2/src/modules/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module";
77
import { MembershipsModule } from "@/modules/memberships/memberships.module";
88
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
99
import { RedisModule } from "@/modules/redis/redis.module";
10+
import { RolesModule } from "@/modules/roles/roles.module";
1011
import { TokensModule } from "@/modules/tokens/tokens.module";
1112
import { UsersModule } from "@/modules/users/users.module";
1213
import { Module } from "@nestjs/common";
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
2122
MembershipsModule,
2223
TokensModule,
2324
DeploymentsModule,
25+
RolesModule,
2426
],
2527
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
2628
exports: [NextAuthGuard, ApiAuthGuard],
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Reflector } from "@nestjs/core";
2+
3+
import type { PermissionString } from "@calcom/platform-libraries/pbac";
4+
5+
export const Pbac = Reflector.createDecorator<PermissionString[]>();
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
2+
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
3+
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
4+
import { RedisService } from "@/modules/redis/redis.service";
5+
import {
6+
Injectable,
7+
CanActivate,
8+
ExecutionContext,
9+
ForbiddenException,
10+
UnauthorizedException,
11+
BadRequestException,
12+
} from "@nestjs/common";
13+
import { Reflector } from "@nestjs/core";
14+
import { Request } from "express";
15+
16+
import type { PermissionString } from "@calcom/platform-libraries/pbac";
17+
import { PermissionCheckService, FeaturesRepository } from "@calcom/platform-libraries/pbac";
18+
19+
export const REDIS_PBAC_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:has:pbac:guard:pbac`;
20+
export const REDIS_REQUIRED_PERMISSIONS_CACHE_KEY = (
21+
userId: number,
22+
teamId: number,
23+
requiredPermissions: PermissionString[]
24+
) =>
25+
`apiv2:user:${userId}:team:${teamId}:requiredPermissions:${requiredPermissions
26+
.sort()
27+
.join(",")}:guard:pbac`;
28+
29+
@Injectable()
30+
export class PbacGuard implements CanActivate {
31+
constructor(
32+
private reflector: Reflector,
33+
private prismaReadService: PrismaReadService,
34+
private readonly redisService: RedisService
35+
) {}
36+
37+
async canActivate(context: ExecutionContext): Promise<boolean> {
38+
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
39+
const user = request.user as ApiAuthGuardUser;
40+
const teamId = request.params.teamId;
41+
const orgId = request.params.orgId;
42+
const requiredPermissions = this.reflector.get(Pbac, context.getHandler());
43+
44+
const effectiveTeamId = teamId || orgId;
45+
if (!user) {
46+
throw new UnauthorizedException("PbacGuard - the request does not have an authorized user provided");
47+
}
48+
if (!effectiveTeamId) {
49+
throw new BadRequestException(
50+
"PbacGuard - can't check pbac because no teamId or orgId provided within the request url"
51+
);
52+
}
53+
54+
if (!requiredPermissions || requiredPermissions.length === 0) {
55+
request.pbacAuthorizedRequest = false;
56+
return true;
57+
}
58+
59+
const hasPbacEnabled = await this.hasPbacEnabled(Number(effectiveTeamId));
60+
if (!hasPbacEnabled) {
61+
request.pbacAuthorizedRequest = false;
62+
return true;
63+
}
64+
65+
const hasRequiredPermissions = await this.checkUserHasRequiredPermissions(
66+
user.id,
67+
Number(effectiveTeamId),
68+
requiredPermissions
69+
);
70+
71+
if (!hasRequiredPermissions) {
72+
request.pbacAuthorizedRequest = false;
73+
return true;
74+
}
75+
76+
request.pbacAuthorizedRequest = true;
77+
return true;
78+
}
79+
80+
async hasPbacEnabled(teamId: number) {
81+
const cachedHasPbacEnabled = await this.getCachePbacEnabled(teamId);
82+
83+
if (cachedHasPbacEnabled) {
84+
return cachedHasPbacEnabled;
85+
}
86+
87+
const pbacFeatureFlag = "pbac";
88+
const featuresRepository = new FeaturesRepository(this.prismaReadService.prisma);
89+
const hasPbacEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, pbacFeatureFlag);
90+
91+
if (hasPbacEnabled) {
92+
await this.setCachePbacEnabled(teamId, hasPbacEnabled);
93+
}
94+
95+
return hasPbacEnabled;
96+
}
97+
98+
async checkUserHasRequiredPermissions(
99+
userId: number,
100+
teamId: number,
101+
requiredPermissions: PermissionString[]
102+
) {
103+
const cachedAccess = await this.getCacheRequiredPermissions(userId, teamId, requiredPermissions);
104+
105+
if (cachedAccess) {
106+
return cachedAccess;
107+
}
108+
109+
const permissionCheckService = new PermissionCheckService();
110+
const hasRequiredPermissions = await permissionCheckService.checkPermissions({
111+
userId,
112+
teamId,
113+
permissions: requiredPermissions,
114+
fallbackRoles: [],
115+
});
116+
117+
if (hasRequiredPermissions) {
118+
await this.setCacheRequiredPermissions(userId, teamId, requiredPermissions, hasRequiredPermissions);
119+
}
120+
121+
return hasRequiredPermissions;
122+
}
123+
124+
private async getCacheRequiredPermissions(
125+
userId: number,
126+
teamId: number,
127+
requiredPermissions: PermissionString[]
128+
): Promise<boolean | null> {
129+
return this.redisService.get<boolean>(
130+
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions)
131+
);
132+
}
133+
134+
private async setCacheRequiredPermissions(
135+
userId: number,
136+
teamId: number,
137+
requiredPermissions: PermissionString[],
138+
hasRequired: boolean
139+
): Promise<void> {
140+
await this.redisService.set<boolean>(
141+
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions),
142+
hasRequired,
143+
{ ttl: 300_000 }
144+
);
145+
}
146+
147+
private async getCachePbacEnabled(teamId: number) {
148+
const cachedResult = await this.redisService.get<boolean>(REDIS_PBAC_CACHE_KEY(teamId));
149+
return cachedResult;
150+
}
151+
152+
private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) {
153+
await this.redisService.set<boolean>(REDIS_PBAC_CACHE_KEY(teamId), pbacEnabled, {
154+
ttl: 300_000,
155+
});
156+
}
157+
158+
throwForbiddenError(
159+
userId: number,
160+
teamId: string,
161+
orgId: string,
162+
requiredPermissions: PermissionString[]
163+
) {
164+
let errorMessage = `PbacGuard - user with id=${userId} does not have the minimum required permissions=${requiredPermissions.join(
165+
","
166+
)} `;
167+
if (teamId) {
168+
errorMessage += `within team with id=${teamId}`;
169+
}
170+
if (orgId) {
171+
errorMessage += `within organization with id=${orgId}`;
172+
}
173+
errorMessage += `.`;
174+
175+
throw new ForbiddenException(errorMessage);
176+
}
177+
}

apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger }
77
import { Reflector } from "@nestjs/core";
88
import { Request } from "express";
99

10-
import type { Team } from "@calcom/prisma/client";
11-
1210
@Injectable()
1311
export class RolesGuard implements CanActivate {
1412
private readonly logger = new Logger("RolesGuard Logger");
@@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate {
1917
) {}
2018

2119
async canActivate(context: ExecutionContext): Promise<boolean> {
22-
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
20+
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
21+
22+
if (request.pbacAuthorizedRequest === true) {
23+
this.logger.debug("PBAC authorized request, skipping legacy role checking");
24+
return true;
25+
}
26+
2327
const teamId = request.params.teamId as string;
2428
const orgId = request.params.orgId as string;
2529
const user = request.user as ApiAuthGuardUser;

apps/api/v2/src/modules/organizations/organizations.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
3939
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
4040
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
4141
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
42+
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
4243
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
4344
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
4445
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
@@ -50,6 +51,7 @@ import { OrganizationsTeamsInviteController } from "@/modules/organizations/team
5051
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
5152
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
5253
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
54+
import { OrganizationsTeamsRolesModule } from "@/modules/organizations/teams/roles/organizations-teams-roles.module";
5355
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
5456
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
5557
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
@@ -96,6 +98,8 @@ import { Module } from "@nestjs/common";
9698
TeamsModule,
9799
OrganizationsDelegationCredentialModule,
98100
OrganizationsOrganizationsModule,
101+
OrganizationsRolesModule,
102+
OrganizationsTeamsRolesModule,
99103
OrganizationsStripeModule,
100104
OrganizationsTeamsRoutingFormsModule,
101105
MembershipsModule,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
3+
4+
import type { PermissionString } from "@calcom/platform-libraries/pbac";
5+
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
6+
7+
import { OrgPermissionStringValidator } from "../permissions/inputs/validators/org-permission-string.validator";
8+
9+
export const orgPermissionEnum = [...getAllPermissionStringsForScope(Scope.Organization)] as const;
10+
11+
export class BaseOrgRoleInput {
12+
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
13+
@IsString()
14+
@IsOptional()
15+
color?: string;
16+
17+
@ApiPropertyOptional({ description: "Description of the role" })
18+
@IsString()
19+
@IsOptional()
20+
description?: string;
21+
22+
@ApiPropertyOptional({
23+
description:
24+
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
25+
enum: orgPermissionEnum,
26+
isArray: true,
27+
example: ["eventType.read", "eventType.create", "booking.read"],
28+
})
29+
@IsArray()
30+
@IsString({ each: true })
31+
@Validate(OrgPermissionStringValidator, { each: true })
32+
@IsOptional()
33+
permissions?: PermissionString[];
34+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
import { IsString, MinLength } from "class-validator";
3+
4+
import { BaseOrgRoleInput } from "./base-org-role.input";
5+
6+
export class CreateOrgRoleInput extends BaseOrgRoleInput {
7+
@ApiProperty({ description: "Name of the role", minLength: 1 })
8+
@IsString()
9+
@MinLength(1)
10+
name!: string;
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsString, IsOptional, MinLength } from "class-validator";
3+
4+
import { BaseOrgRoleInput } from "./base-org-role.input";
5+
6+
export class UpdateOrgRoleInput extends BaseOrgRoleInput {
7+
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
8+
@IsString()
9+
@MinLength(1)
10+
@IsOptional()
11+
name?: string;
12+
}

0 commit comments

Comments
 (0)