Skip to content

Commit d02689d

Browse files
chore: minimum required roles guard api-v2 (#15576)
* chore: minimum required roles guard api-v2 * fixup! chore: minimum required roles guard api-v2 * fixup! fixup! chore: minimum required roles guard api-v2 * fixup! Merge branch 'chore-roles-guard-api-v2' of github.com:calcom/cal.com into chore-roles-guard-api-v2 * fixup! Merge branch 'chore-roles-guard-api-v2' of github.com:calcom/cal.com into chore-roles-guard-api-v2
1 parent 7c992e2 commit d02689d

File tree

12 files changed

+291
-26
lines changed

12 files changed

+291
-26
lines changed
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { MembershipRole } from "@prisma/client";
2+
3+
export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
4+
export const ORG_ROLES = [
5+
`ORG_${MembershipRole.OWNER}`,
6+
`ORG_${MembershipRole.ADMIN}`,
7+
`ORG_${MembershipRole.MEMBER}`,
8+
] as const;
9+
export const TEAM_ROLES = [
10+
`TEAM_${MembershipRole.OWNER}`,
11+
`TEAM_${MembershipRole.ADMIN}`,
12+
`TEAM_${MembershipRole.MEMBER}`,
13+
] as const;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Reflector } from "@nestjs/core";
2+
import { MembershipRole } from "@prisma/client";
3+
4+
export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
12
import { Reflector } from "@nestjs/core";
2-
import { MembershipRole } from "@prisma/client";
33

4-
export const Roles = Reflector.createDecorator<MembershipRole[]>();
4+
export const Roles = Reflector.createDecorator<
5+
(typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
6+
>();

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
1+
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
22
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
33
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
44
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
2727
await this.isPlatform(organizationId);
2828

2929
const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id);
30-
const allowedRoles = this.reflector.get(Roles, context.getHandler());
30+
const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler());
3131

3232
this.isMembershipAccepted(membership.accepted);
3333
this.isRoleAllowed(membership.role, allowedRoles);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
2+
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
3+
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
4+
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
5+
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
6+
import { Reflector } from "@nestjs/core";
7+
import { Request } from "express";
8+
9+
import { Team } from "@calcom/prisma/client";
10+
11+
@Injectable()
12+
export class RolesGuard implements CanActivate {
13+
private readonly logger = new Logger("RolesGuard Logger");
14+
constructor(private reflector: Reflector, private membershipRepository: MembershipsRepository) {}
15+
16+
async canActivate(context: ExecutionContext): Promise<boolean> {
17+
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
18+
const teamId = request.params.teamId as string;
19+
const orgId = request.params.orgId as string;
20+
const user = request.user as GetUserReturnType;
21+
const allowedRole = this.reflector.get(Roles, context.getHandler());
22+
23+
// User is not authenticated
24+
if (!user) {
25+
this.logger.log("User is not authenticated, denying access.");
26+
return false;
27+
}
28+
29+
// System admin can access everything
30+
if (user.isSystemAdmin) {
31+
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
32+
return true;
33+
}
34+
35+
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
36+
if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
37+
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
38+
return false;
39+
}
40+
41+
// Checking the role of the user within the organization
42+
if (Boolean(orgId) && !Boolean(teamId)) {
43+
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
44+
if (!membership) {
45+
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
46+
throw new ForbiddenException(`User is not a member of the organization.`);
47+
}
48+
49+
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
50+
return hasMinimumRole({
51+
checkRole: `ORG_${membership.role}`,
52+
minimumRole: allowedRole,
53+
roles: ORG_ROLES,
54+
});
55+
}
56+
}
57+
58+
// Checking the role of the user within the team
59+
if (Boolean(teamId) && !Boolean(orgId)) {
60+
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
61+
if (!membership) {
62+
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
63+
throw new ForbiddenException(`User is not a member of the team.`);
64+
}
65+
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
66+
return hasMinimumRole({
67+
checkRole: `TEAM_${membership.role}`,
68+
minimumRole: allowedRole,
69+
roles: TEAM_ROLES,
70+
});
71+
}
72+
}
73+
74+
// Checking the role for team and org, org is above team in term of permissions
75+
if (Boolean(teamId) && Boolean(orgId)) {
76+
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
77+
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
78+
79+
if (!orgMembership) {
80+
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
81+
throw new ForbiddenException(`User is not part of the organization.`);
82+
}
83+
84+
// if the role checked is a TEAM role
85+
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
86+
// if the user is admin or owner of org, allow request because org > team
87+
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
88+
return true;
89+
}
90+
91+
if (!teamMembership) {
92+
this.logger.log(
93+
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
94+
);
95+
throw new ForbiddenException(
96+
"User is not part of the team and/or, is not an admin nor an owner of the organization."
97+
);
98+
}
99+
100+
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
101+
return hasMinimumRole({
102+
checkRole: `TEAM_${teamMembership.role}`,
103+
minimumRole: allowedRole,
104+
roles: TEAM_ROLES,
105+
});
106+
}
107+
108+
// if allowed role is a ORG ROLE, check org membersip role
109+
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
110+
return hasMinimumRole({
111+
checkRole: `ORG_${orgMembership.role}`,
112+
minimumRole: allowedRole,
113+
roles: ORG_ROLES,
114+
});
115+
}
116+
}
117+
118+
return false;
119+
}
120+
}
121+
122+
type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];
123+
124+
type HasMinimumTeamRoleProp = {
125+
checkRole: (typeof TEAM_ROLES)[number];
126+
minimumRole: string;
127+
roles: typeof TEAM_ROLES;
128+
};
129+
130+
type HasMinimumOrgRoleProp = {
131+
checkRole: (typeof ORG_ROLES)[number];
132+
minimumRole: string;
133+
roles: typeof ORG_ROLES;
134+
};
135+
136+
type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;
137+
138+
export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
139+
const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
140+
const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);
141+
142+
// minimum role given does not exist
143+
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
144+
throw new Error("Invalid role");
145+
}
146+
147+
return checkedRoleIndex <= requiredRoleIndex;
148+
}

apps/api/v2/src/modules/billing/controllers/billing.controller.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AppConfig } from "@/config/type";
22
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
3-
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
3+
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
44
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
55
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
66
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
@@ -47,7 +47,7 @@ export class BillingController {
4747

4848
@Get("/:teamId/check")
4949
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
50-
@Roles(["OWNER", "ADMIN", "MEMBER"])
50+
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
5151
async checkTeamBilling(
5252
@Param("teamId") teamId: number
5353
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -64,7 +64,7 @@ export class BillingController {
6464

6565
@Post("/:teamId/subscribe")
6666
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
67-
@Roles(["OWNER", "ADMIN"])
67+
@MembershipRoles(["OWNER", "ADMIN"])
6868
async subscribeTeamToStripe(
6969
@Param("teamId") teamId: number,
7070
@Body() input: SubscribeToPlanInput

apps/api/v2/src/modules/memberships/memberships.repository.ts

+17
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ export class MembershipsRepository {
1818
return membership;
1919
}
2020

21+
async findMembershipByTeamId(teamId: number, userId: number) {
22+
const membership = await this.dbRead.prisma.membership.findUnique({
23+
where: {
24+
userId_teamId: {
25+
userId: userId,
26+
teamId: teamId,
27+
},
28+
},
29+
});
30+
31+
return membership;
32+
}
33+
34+
async findMembershipByOrgId(orgId: number, userId: number) {
35+
return this.findMembershipByTeamId(orgId, userId);
36+
}
37+
2138
async isUserOrganizationAdmin(userId: number, organizationId: number) {
2239
const adminMembership = await this.dbRead.prisma.membership.findFirst({
2340
where: {

apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getEnv } from "@/env";
22
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
33
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
4-
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
4+
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
55
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
66
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
77
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -64,7 +64,7 @@ export class OAuthClientsController {
6464

6565
@Post("/")
6666
@HttpCode(HttpStatus.CREATED)
67-
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
67+
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
6868
@DocsOperation({ description: AUTH_DOCUMENTATION })
6969
@DocsCreatedResponse({
7070
description: "Create an OAuth client",
@@ -96,7 +96,7 @@ export class OAuthClientsController {
9696

9797
@Get("/")
9898
@HttpCode(HttpStatus.OK)
99-
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
99+
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
100100
@DocsOperation({ description: AUTH_DOCUMENTATION })
101101
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
102102
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
@@ -107,7 +107,7 @@ export class OAuthClientsController {
107107

108108
@Get("/:clientId")
109109
@HttpCode(HttpStatus.OK)
110-
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
110+
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
111111
@DocsOperation({ description: AUTH_DOCUMENTATION })
112112
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
113113
const client = await this.oauthClientRepository.getOAuthClient(clientId);
@@ -119,7 +119,7 @@ export class OAuthClientsController {
119119

120120
@Get("/:clientId/managed-users")
121121
@HttpCode(HttpStatus.OK)
122-
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
122+
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
123123
@DocsOperation({ description: AUTH_DOCUMENTATION })
124124
async getOAuthClientManagedUsersById(
125125
@Param("clientId") clientId: string,
@@ -137,7 +137,7 @@ export class OAuthClientsController {
137137

138138
@Patch("/:clientId")
139139
@HttpCode(HttpStatus.OK)
140-
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
140+
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
141141
@DocsOperation({ description: AUTH_DOCUMENTATION })
142142
async updateOAuthClient(
143143
@Param("clientId") clientId: string,
@@ -150,7 +150,7 @@ export class OAuthClientsController {
150150

151151
@Delete("/:clientId")
152152
@HttpCode(HttpStatus.OK)
153-
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
153+
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
154154
@DocsOperation({ description: AUTH_DOCUMENTATION })
155155
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
156156
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);

apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.e2e-spec.ts

+9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
88
import { Test } from "@nestjs/testing";
99
import { User } from "@prisma/client";
1010
import * as request from "supertest";
11+
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
1112
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
1213
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
1314
import { withApiAuth } from "test/utils/withApiAuth";
@@ -23,6 +24,7 @@ describe("Organizations Team Endpoints", () => {
2324
let userRepositoryFixture: UserRepositoryFixture;
2425
let organizationsRepositoryFixture: TeamRepositoryFixture;
2526
let teamsRepositoryFixture: TeamRepositoryFixture;
27+
let membershipsRepositoryFixture: MembershipRepositoryFixture;
2628

2729
let org: Team;
2830
let team: Team;
@@ -41,6 +43,7 @@ describe("Organizations Team Endpoints", () => {
4143
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
4244
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
4345
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
46+
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
4447

4548
user = await userRepositoryFixture.create({
4649
email: userEmail,
@@ -52,6 +55,12 @@ describe("Organizations Team Endpoints", () => {
5255
isOrganization: true,
5356
});
5457

58+
await membershipsRepositoryFixture.create({
59+
role: "ADMIN",
60+
user: { connect: { id: user.id } },
61+
team: { connect: { id: org.id } },
62+
});
63+
5564
team = await teamsRepositoryFixture.create({
5665
name: "Test org team",
5766
isOrganization: false,

apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
22
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
33
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
4+
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
45
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
56
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
7+
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
68
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
79
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
810
import { ApiTags as DocsTags } from "@nestjs/swagger";
@@ -31,7 +33,8 @@ export class OrganizationsTeamsController {
3133
};
3234
}
3335

34-
@UseGuards(IsTeamInOrg)
36+
@UseGuards(IsTeamInOrg, RolesGuard)
37+
@Roles("ORG_ADMIN")
3538
@Get("/:teamId")
3639
async getTeam(
3740
@Param("orgId", ParseIntPipe) orgId: number,

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
12
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
23
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
34
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
@@ -7,7 +8,7 @@ import { Module } from "@nestjs/common";
78

89
@Module({
910
imports: [PrismaModule, StripeModule],
10-
providers: [OrganizationsRepository, OrganizationsService],
11+
providers: [OrganizationsRepository, OrganizationsService, MembershipsRepository],
1112
exports: [OrganizationsService, OrganizationsRepository],
1213
controllers: [OrganizationsTeamsController],
1314
})

0 commit comments

Comments
 (0)