Skip to content

Commit

Permalink
chore: minimum required roles guard api-v2 (#15576)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ThyMinimalDev authored Jun 26, 2024
1 parent 4f944ec commit 85c7d4e
Show file tree
Hide file tree
Showing 12 changed files with 291 additions and 26 deletions.
13 changes: 13 additions & 0 deletions apps/api/v2/src/lib/roles/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MembershipRole } from "@prisma/client";

export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
export const ORG_ROLES = [
`ORG_${MembershipRole.OWNER}`,
`ORG_${MembershipRole.ADMIN}`,
`ORG_${MembershipRole.MEMBER}`,
] as const;
export const TEAM_ROLES = [
`TEAM_${MembershipRole.OWNER}`,
`TEAM_${MembershipRole.ADMIN}`,
`TEAM_${MembershipRole.MEMBER}`,
] as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";

export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";

export const Roles = Reflector.createDecorator<MembershipRole[]>();
export const Roles = Reflector.createDecorator<
(typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
>();
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UserWithProfile } from "@/modules/users/users.repository";
Expand Down Expand Up @@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
await this.isPlatform(organizationId);

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

this.isMembershipAccepted(membership.accepted);
this.isRoleAllowed(membership.role, allowedRoles);
Expand Down
148 changes: 148 additions & 0 deletions apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";

import { Team } from "@calcom/prisma/client";

@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger("RolesGuard Logger");
constructor(private reflector: Reflector, private membershipRepository: MembershipsRepository) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const allowedRole = this.reflector.get(Roles, context.getHandler());

// User is not authenticated
if (!user) {
this.logger.log("User is not authenticated, denying access.");
return false;
}

// System admin can access everything
if (user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
return true;
}

// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
return false;
}

// Checking the role of the user within the organization
if (Boolean(orgId) && !Boolean(teamId)) {
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not a member of the organization.`);
}

if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
return hasMinimumRole({
checkRole: `ORG_${membership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}

// Checking the role of the user within the team
if (Boolean(teamId) && !Boolean(orgId)) {
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
throw new ForbiddenException(`User is not a member of the team.`);
}
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
return hasMinimumRole({
checkRole: `TEAM_${membership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
}

// Checking the role for team and org, org is above team in term of permissions
if (Boolean(teamId) && Boolean(orgId)) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);

if (!orgMembership) {
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not part of the organization.`);
}

// if the role checked is a TEAM role
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
// if the user is admin or owner of org, allow request because org > team
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
return true;
}

if (!teamMembership) {
this.logger.log(
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
);
}

// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
return hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}

// if allowed role is a ORG ROLE, check org membersip role
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
return hasMinimumRole({
checkRole: `ORG_${orgMembership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}

return false;
}
}

type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];

type HasMinimumTeamRoleProp = {
checkRole: (typeof TEAM_ROLES)[number];
minimumRole: string;
roles: typeof TEAM_ROLES;
};

type HasMinimumOrgRoleProp = {
checkRole: (typeof ORG_ROLES)[number];
minimumRole: string;
roles: typeof ORG_ROLES;
};

type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;

export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);

// minimum role given does not exist
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
throw new Error("Invalid role");
}

return checkedRoleIndex <= requiredRoleIndex;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
Expand Down Expand Up @@ -47,7 +47,7 @@ export class BillingController {

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

@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
@MembershipRoles(["OWNER", "ADMIN"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
Expand Down
17 changes: 17 additions & 0 deletions apps/api/v2/src/modules/memberships/memberships.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ export class MembershipsRepository {
return membership;
}

async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});

return membership;
}

async findMembershipByOrgId(orgId: number, userId: number) {
return this.findMembershipByTeamId(orgId, userId);
}

async isUserOrganizationAdmin(userId: number, organizationId: number) {
const adminMembership = await this.dbRead.prisma.membership.findFirst({
where: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
Expand Down Expand Up @@ -64,7 +64,7 @@ export class OAuthClientsController {

@Post("/")
@HttpCode(HttpStatus.CREATED)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@DocsCreatedResponse({
description: "Create an OAuth client",
Expand Down Expand Up @@ -96,7 +96,7 @@ export class OAuthClientsController {

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

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

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

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

@Delete("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
Expand All @@ -23,6 +24,7 @@ describe("Organizations Team Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;

let org: Team;
let team: Team;
Expand All @@ -41,6 +43,7 @@ describe("Organizations Team Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);

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

await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});

team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
Expand Down Expand Up @@ -31,7 +33,8 @@ export class OrganizationsTeamsController {
};
}

@UseGuards(IsTeamInOrg)
@UseGuards(IsTeamInOrg, RolesGuard)
@Roles("ORG_ADMIN")
@Get("/:teamId")
async getTeam(
@Param("orgId", ParseIntPipe) orgId: number,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
Expand All @@ -7,7 +8,7 @@ import { Module } from "@nestjs/common";

@Module({
imports: [PrismaModule, StripeModule],
providers: [OrganizationsRepository, OrganizationsService],
providers: [OrganizationsRepository, OrganizationsService, MembershipsRepository],
exports: [OrganizationsService, OrganizationsRepository],
controllers: [OrganizationsTeamsController],
})
Expand Down
Loading

0 comments on commit 85c7d4e

Please sign in to comment.