Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add role and permission guard decorators to some existing routes #112

Merged
merged 10 commits into from
Mar 19, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Another example [here](https://co-pilot.dev/changelog)
- Restructure seed/index.ts to work with e2e tests, and add --runInBand to e2e scripts[#101](https://github.com/chingu-x/chingu-dashboard-be/pull/101)
- Update changelog ([#104](https://github.com/chingu-x/chingu-dashboard-be/pull/104))
- Update test.yml to run e2e tests on pull requests to the main branch [#105](https://github.com/chingu-x/chingu-dashboard-be/pull/105)
- Add role and permission guard to some existing routes
[#112](https://github.com/chingu-x/chingu-dashboard-be/pull/112)

### Fixed
- Fix failed tests in app and ideation due to the change from jwt token response to http cookies ([#98](https://github.com/chingu-x/chingu-dashboard-be/pull/98))
Expand Down
8 changes: 5 additions & 3 deletions src/auth/guards/permissions.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ export class PermissionsGuard implements CanActivate {
const { user, params } = context.switchToHttp().getRequest();

if (requiredPermissions.includes(AppPermissions.OWN_TEAM)) {
// Admin can bypass this
if (user.roles.includes(AppRoles.Admin)) return true;
if (!params.teamId) {
throw new InternalServerErrorException(
"This permission guard requires :teamId param",
);
}
const canAccess = user.voyageTeams?.includes(
parseInt(params?.teamId),
);

const canAccess = user.voyageTeams
?.map((t) => t.teamId)
?.includes(parseInt(params?.teamId));

if (!canAccess) {
throw new ForbiddenException(
Expand Down
8 changes: 7 additions & 1 deletion src/auth/strategies/at.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ export class AtStrategy extends PassportStrategy(Strategy, "jwt-at") {
async validate(payload: any) {
const userInDb = await this.usersService.getUserRolesById(payload.sub);

// Note: Update global/types/CustomRequest when updating this
return {
userId: payload.sub,
email: payload.email,
roles: userInDb.roles,
voyageTeams: userInDb.voyageTeamMembers.map((t) => t.voyageTeamId),
voyageTeams: userInDb.voyageTeamMembers.map((t) => {
return {
teamId: t.voyageTeamId,
memberId: t.id,
};
}),
};
}
}
52 changes: 30 additions & 22 deletions src/features/features.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import {
ExtendedFeaturesResponse,
FeatureResponse,
} from "./features.response";
import { AppPermissions } from "../auth/auth.permissions";
import { Permissions } from "../global/decorators/permissions.decorator";
import { CustomRequest } from "../global/types/CustomRequest";

@Controller()
@ApiTags("Voyage - Features")
Expand All @@ -42,7 +45,7 @@ export class FeaturesController {

@ApiOperation({
summary:
"Adds a new feature for a team given a teamId (int) and that the user is logged in.",
"[Permission: own_team] Adds a new feature for a team given a teamId (int) and that the user is logged in.",
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
Expand All @@ -51,19 +54,24 @@ export class FeaturesController {
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description:
"Invalid uuid or teamID. User is not authorized to perform this action.",
description: "User is not authorized to perform this action.",
type: UnauthorizedErrorResponse,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: "Invalid teamId",
type: BadRequestErrorResponse,
})
@ApiResponse({
status: HttpStatus.CREATED,
description: "Successfully created a new feature.",
type: FeatureResponse,
})
@Permissions(AppPermissions.OWN_TEAM)
@Post("/:teamId/features")
@ApiCreatedResponse({ type: Feature })
async createFeature(
@Request() req,
@Request() req: CustomRequest,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() createFeatureDto: CreateFeatureDto,
) {
Expand All @@ -75,7 +83,8 @@ export class FeaturesController {
}

@ApiOperation({
summary: "Gets all feature category options.",
summary:
"Gets all feature category options. e.g. Must have, should have, nice to have",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -89,11 +98,13 @@ export class FeaturesController {
}

@ApiOperation({
summary: "Gets one feature given a featureId (int).",
summary:
"[Permission: own_team] Gets all features for a team given a teamId (int).",
})
@ApiResponse({
status: HttpStatus.OK,
description: "Successfully found feature.",
description: "Successfully got all features for project.",
isArray: true,
type: ExtendedFeaturesResponse,
})
@ApiResponse({
Expand All @@ -104,21 +115,22 @@ export class FeaturesController {
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Feature with given ID does not exist.",
description:
"Could not find features for project. Team with given ID does not exist.",
type: NotFoundErrorResponse,
})
@Get("/features/:featureId")
findOneFeature(@Param("featureId", ParseIntPipe) featureId: number) {
return this.featuresService.findOneFeature(featureId);
@Permissions(AppPermissions.OWN_TEAM)
@Get("/:teamId/features")
findAllFeatures(@Param("teamId", ParseIntPipe) teamId: number) {
return this.featuresService.findAllFeatures(teamId);
}

@ApiOperation({
summary: "Gets all features for a team given a teamId (int).",
summary: "Gets one feature given a featureId (int).",
})
@ApiResponse({
status: HttpStatus.OK,
description: "Successfully got all features for project.",
isArray: true,
description: "Successfully found feature.",
type: ExtendedFeaturesResponse,
})
@ApiResponse({
Expand All @@ -129,16 +141,12 @@ export class FeaturesController {
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description:
"Could not find features for project. Team with given ID does not exist.",
description: "Feature with given ID does not exist.",
type: NotFoundErrorResponse,
})
@Get("/:teamId/features")
findAllFeatures(
@Request() req,
@Param("teamId", ParseIntPipe) teamId: number,
) {
return this.featuresService.findAllFeatures(req, teamId);
@Get("/features/:featureId")
findOneFeature(@Param("featureId", ParseIntPipe) featureId: number) {
return this.featuresService.findOneFeature(featureId);
}

@ApiOperation({
Expand Down
48 changes: 20 additions & 28 deletions src/features/features.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ export class FeaturesService {
) {
const { featureCategoryId, description } = createFeatureDto;

const teamMember =
await this.globalService.validateLoggedInAndTeamMember(
teamId,
req.user.userId,
);

const validCategory = await this.prisma.featureCategory.findFirst({
where: {
id: featureCategoryId,
Expand Down Expand Up @@ -58,7 +52,10 @@ export class FeaturesService {

const newFeature = await this.prisma.projectFeature.create({
data: {
teamMemberId: teamMember.id,
teamMemberId: this.globalService.getVoyageTeamMemberId(
req,
teamId,
),
featureCategoryId,
description,
order: newOrder,
Expand All @@ -80,11 +77,13 @@ export class FeaturesService {
}
}

async findOneFeature(featureId: number) {
async findAllFeatures(teamId: number) {
try {
const projectFeature = await this.prisma.projectFeature.findFirst({
const allTeamFeatures = await this.prisma.projectFeature.findMany({
where: {
id: featureId,
addedBy: {
voyageTeamId: teamId,
},
},
select: {
id: true,
Expand Down Expand Up @@ -112,32 +111,26 @@ export class FeaturesService {
},
},
},
orderBy: [{ category: { id: "asc" } }, { order: "asc" }],
});

if (!projectFeature) {
if (!allTeamFeatures) {
throw new NotFoundException(
`FeatureId (id: ${featureId}) does not exist.`,
`TeamId (id: ${teamId}) does not exist.`,
);
}

return projectFeature;
return allTeamFeatures;
} catch (e) {
throw e;
}
}

async findAllFeatures(req, teamId: number) {
await this.globalService.validateLoggedInAndTeamMember(
teamId,
req.user.userId,
);

async findOneFeature(featureId: number) {
try {
const allTeamFeatures = await this.prisma.projectFeature.findMany({
const projectFeature = await this.prisma.projectFeature.findFirst({
where: {
addedBy: {
voyageTeamId: teamId,
},
id: featureId,
},
select: {
id: true,
Expand Down Expand Up @@ -165,16 +158,15 @@ export class FeaturesService {
},
},
},
orderBy: [{ category: { id: "asc" } }, { order: "asc" }],
});

if (!allTeamFeatures) {
if (!projectFeature) {
throw new NotFoundException(
`TeamId (id: ${teamId}) does not exist.`,
`FeatureId (id: ${featureId}) does not exist.`,
);
}

return allTeamFeatures;
return projectFeature;
} catch (e) {
throw e;
}
Expand Down Expand Up @@ -335,7 +327,7 @@ export class FeaturesService {
},
});
}
const newCategoryFeaturesList = await this.findAllFeatures(req, teamId);
const newCategoryFeaturesList = await this.findAllFeatures(teamId);
return newCategoryFeaturesList;
}

Expand Down
6 changes: 5 additions & 1 deletion src/forms/forms.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import { ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
import { FormResponse } from "./forms.response";
import { NotFoundErrorResponse } from "../global/responses/errors";

import { AppRoles } from "../auth/auth.roles";
import { Roles } from "../global/decorators/roles.decorator";

@Controller("forms")
@ApiTags("Forms")
export class FormsController {
constructor(private readonly formsService: FormsService) {}

@Get()
@ApiOperation({
summary: "gets all forms from the database",
summary: "[Roles: admin] gets all forms from the database",
description:
"Returns all forms details with questions. <br>" +
"This is currently for development purpose, or admin in future",
Expand All @@ -28,6 +31,7 @@ export class FormsController {
type: FormResponse,
isArray: true,
})
@Roles(AppRoles.Admin)
cherylli marked this conversation as resolved.
Show resolved Hide resolved
getAllForms() {
return this.formsService.getAllForms();
}
Expand Down
18 changes: 17 additions & 1 deletion src/global/global.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CustomRequest } from "./types/CustomRequest";

@Injectable()
export class GlobalService {
constructor(private prisma: PrismaService) {}

//verifies user is logged in by using uuid from cookie and teamId to pull a teamMember.
// TODO: remove as it's replaced by permission guard
public async validateLoggedInAndTeamMember(teamId: number, uuid: any) {
const teamMember = await this.prisma.voyageTeamMember.findFirst({
where: {
Expand All @@ -26,4 +32,14 @@ export class GlobalService {
}
return teamMember;
}

public getVoyageTeamMemberId(req: CustomRequest, teamId: number) {
const teamMemberId = req.user.voyageTeams.find(
(t) => t.teamId == teamId,
)?.memberId;
if (!teamMemberId) {
throw new BadRequestException(`Invalid Team Id (id: ${teamId}).`);
}
return teamMemberId;
}
}
13 changes: 13 additions & 0 deletions src/global/types/CustomRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type VoyageTeam = {
teamId: number;
memberId: number;
};

export interface CustomRequest extends Request {
user: {
userId: string;
email: string;
roles: string[];
voyageTeams: VoyageTeam[];
};
}
Loading