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 swagger access info and form endpoint permissions #160

Merged
merged 6 commits into from
Jun 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Another example [here](https://co-pilot.dev/changelog)
- new command to run both e2e and unit test ([#148](https://github.com/chingu-x/chingu-dashboard-be/pull/148))
- allow edit and delete for tech stack item([#152](https://github.com/chingu-x/chingu-dashboard-be/pull/152))
- Add voyage project submission status to `/me` endpoint ([#158](https://github.com/chingu-x/chingu-dashboard-be/pull/158))
- Add swagger access info, add forms authorization and e2e tests ([#160](https://github.com/chingu-x/chingu-dashboard-be/pull/160))


### Changed
Expand Down
1 change: 1 addition & 0 deletions prisma/seed/data/form-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Please also update src/global/constants/formTypeId.ts after updating this file
export default [
{
name: "team",
Expand Down
10 changes: 9 additions & 1 deletion src/ability/ability.factory/ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AbilityBuilder, PureAbility } from "@casl/ability";
import { PrismaSubjects } from "../prisma-generated-types";
import { createPrismaAbility, PrismaQuery } from "@casl/prisma";
import { UserReq } from "../../global/types/CustomRequest";
import { formTypeId } from "../../global/constants/formTypeId";

export enum Action {
Manage = "manage",
Expand All @@ -28,7 +29,6 @@ export class AbilityFactory {
can(Action.Manage, "all");
} else if (user.roles.includes("voyager")) {
can([Action.Submit], "Voyage");
can([Action.Read], "Form");
can([Action.Manage], "VoyageTeam", {
id: { in: user.voyageTeams.map((vt) => vt.teamId) },
});
Expand All @@ -37,6 +37,14 @@ export class AbilityFactory {
in: user.voyageTeams.map((vt) => vt.memberId),
},
});
can([Action.Submit, Action.Read], "Form");
} else {
// all other users
can([Action.Submit, Action.Read], "Form", {
formTypeId: {
in: [formTypeId["user"]],
},
});
}
return build({
detectSubjectType: (object) => object.__caslSubjectType__,
Expand Down
17 changes: 17 additions & 0 deletions src/ability/conditions/forms.ability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { UserReq } from "../../global/types/CustomRequest";
import { abilityFactory } from "./shared.ability";
import { Form } from "@prisma/client";
import { ForbiddenError } from "@casl/ability";
import { Action } from "../ability.factory/ability.factory";

// for read and submit - if they can read, they should be able to submit, so we can use the same check
export const canReadAndSubmitForms = (user: UserReq, form: Form) => {
const ability = abilityFactory.defineAbility(user);

ForbiddenError.from(ability)
.setMessage("Cannot read or submit")
.throwUnlessCan(Action.Submit, {
...form,
__caslSubjectType__: "Form",
});
};
6 changes: 4 additions & 2 deletions src/ability/conditions/voyage-teams.ability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ export const manageOwnVoyageTeamWithIdParam = (
};

/***
For future reference, this works, but it has to be of voyageTeamType without a special select statement
For future reference, this works, but it has to be of voyageTeamType *** without a special select statement ***
So it should be used for cases when a team Id is not supplied (this would be an extra database query in most cases)
but for cases when teamId is supplied as a parameter or in the req body, we can use that instead as there would
less database query

edit 1: non basic select type seems to work for the forms endpoint, please check this
***/
export const manageOwnVoyageTeam = (user: UserReq, voyageTeam: VoyageTeam) => {
// mockUser for testing only
// mockUser for testing only, normally we would get this from user
const mockUser = {
voyageTeams: [{ teamId: 2, memberId: 1 }],
userId: "userId",
Expand Down
42 changes: 30 additions & 12 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { AT_MAX_AGE, RT_MAX_AGE } from "../global/constants";
import { RevokeRTDto } from "./dto/revoke-refresh-token.dto";
import { CheckAbilities } from "../global/decorators/abilities.decorator";
import { Action } from "../ability/ability.factory/ability.factory";
import { CustomRequest } from "../global/types/CustomRequest";
import { Response } from "express";

@ApiTags("Auth")
@Controller("auth")
Expand All @@ -48,7 +50,8 @@ export class AuthController {
@ApiOperation({
summary: "Public Route: Signup, and send a verification email",
description:
"Please use a 'real' email if you want to receive a verification email.",
"[access]: public" +
"<br>Note: Please use a 'real' email if you want to receive a verification email.",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -71,8 +74,9 @@ export class AuthController {
@ApiOperation({
summary: "Resend the verification email",
description:
"Please use a 'real' email if you want to receive a verification email.<br/>" +
"response will always be 200, due to privacy reason",
"[access]: unverified user, user, voyage, admin" +
"<br>Please use a 'real' email if you want to receive a verification email." +
"<br>response will always be 200, due to privacy reason",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -92,7 +96,9 @@ export class AuthController {

@ApiOperation({
summary: "Verifies the users email",
description: "Using a token sent to their email when sign up",
description:
"[access]: unverified user, user, voyager, admin" +
"<br>Using a token sent to their email when sign up",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -115,6 +121,7 @@ export class AuthController {
@ApiOperation({
summary:
"Public Route: When a user logs in, sets access token and refresh token (http cookies).",
description: "<br>[access]: public",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -140,8 +147,8 @@ export class AuthController {
@Post("login")
async login(
@Body() body: LoginDto,
@Request() req,
@Res({ passthrough: true }) res,
@Request() req: CustomRequest,
@Res({ passthrough: true }) res: Response,
) {
const { access_token, refresh_token } = await this.authService.login(
req.user,
Expand All @@ -163,6 +170,7 @@ export class AuthController {
@ApiOperation({
summary:
"Bypass access token jwt guard. Refresh an access token, with a valid refresh token in cookies",
description: "<br>[access]: public",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -184,7 +192,10 @@ export class AuthController {
@Public()
@UseGuards(JwtRefreshAuthGuard)
@Post("refresh")
async refresh(@Request() req, @Res({ passthrough: true }) res) {
async refresh(
@Request() req: CustomRequest,
@Res({ passthrough: true }) res: Response,
) {
const { access_token, refresh_token } = await this.authService.refresh(
req.user,
);
Expand All @@ -206,7 +217,8 @@ export class AuthController {
summary:
"[Admin only]: Revokes user's refresh token, with a valid user id or email",
description:
"using the user's id or email, removes user's refresh token",
"[access]: admin" +
"<br>using the user's id or email, removes user's refresh token",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -231,7 +243,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@CheckAbilities({ action: Action.Manage, subject: "all" })
@Delete("refresh/revoke")
async revoke(@Body() body: RevokeRTDto, @Res() res) {
async revoke(@Body() body: RevokeRTDto, @Res() res: Response) {
await this.authService.revokeRefreshToken(body);
res.status(HttpStatus.OK).json({
message: "User Refresh token Successfully revoke.",
Expand All @@ -242,6 +254,7 @@ export class AuthController {
@ApiOperation({
summary:
"When a user logs out, access and refresh tokens are cleared from cookies, refresh token is set to null in the database.",
description: "[access]: user, voyager, admin",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -261,7 +274,10 @@ export class AuthController {
})
@UseGuards(JwtAuthGuard)
@Post("logout")
async logout(@Request() req, @Res({ passthrough: true }) res) {
async logout(
@Request() req: CustomRequest,
@Res({ passthrough: true }) res: Response,
) {
const cookies = req.cookies;

if (!cookies?.refresh_token)
Expand All @@ -279,7 +295,8 @@ export class AuthController {
summary:
"Public route: Request a password reset - email with password reset link (if the account exists)",
description:
"Please use a 'real' email if you want to receive a password reset email.",
"[access]: unverified user, user, voyager, admin" +
"<br>Please use a 'real' email if you want to receive a password reset email.",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -299,7 +316,8 @@ export class AuthController {
@ApiOperation({
summary: "Public route: Reset user password",
description:
"The reset token is emailed to them when the request a password reset.",
"[access]: public" +
"<br>The reset token is emailed to them when the request a password reset.",
})
@ApiResponse({
status: HttpStatus.OK,
Expand Down
11 changes: 9 additions & 2 deletions src/forms/forms.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HttpStatus,
Param,
ParseIntPipe,
Request,
} from "@nestjs/common";
import { FormsService } from "./forms.service";
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
Expand All @@ -16,6 +17,7 @@ import {

import { CheckAbilities } from "../global/decorators/abilities.decorator";
import { Action } from "../ability/ability.factory/ability.factory";
import { CustomRequest } from "../global/types/CustomRequest";

@Controller("forms")
@ApiTags("Forms")
Expand All @@ -26,6 +28,7 @@ export class FormsController {
@ApiOperation({
summary: "[Roles: admin] gets all forms from the database",
description:
"[access]: admin <br>" +
"Returns all forms details with questions. <br>" +
"This is currently for development purpose, or admin in future",
})
Expand Down Expand Up @@ -54,6 +57,7 @@ export class FormsController {
@ApiOperation({
summary: "Gets a form with questions given a form ID",
description:
"[access]: user (user for type), voyager, admin <br>" +
"Returns form details of a form, with questions. <br>" +
"This is currently for development purpose, or admin in future",
})
Expand All @@ -79,7 +83,10 @@ export class FormsController {
description: "form ID",
example: 1,
})
getFormById(@Param("formId", ParseIntPipe) formId: number) {
return this.formsService.getFormById(formId);
getFormById(
@Request() req: CustomRequest,
@Param("formId", ParseIntPipe) formId: number,
) {
return this.formsService.getFormById(formId, req);
}
}
11 changes: 10 additions & 1 deletion src/forms/forms.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { Prisma, Question } from "@prisma/client";
import { canReadAndSubmitForms } from "../ability/conditions/forms.ability";
import { CustomRequest } from "../global/types/CustomRequest";

export const formSelect = {
id: true,
Expand Down Expand Up @@ -107,17 +109,24 @@ export class FormsService {
return forms;
}

async getFormById(formId: number) {
async getFormById(formId: number, req: CustomRequest) {
const form = await this.prisma.form.findUnique({
where: {
id: formId,
},
select: formSelect,
});

if (!form)
throw new NotFoundException(
`Invalid formId: Form (id:${formId}) does not exist.`,
);

canReadAndSubmitForms(req.user, {
...form,
formTypeId: form.formType.id,
});

const subQuestionsIds = [];

form.questions.forEach((question) => {
Expand Down
7 changes: 7 additions & 0 deletions src/global/constants/formTypeId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// TODO: would be nice to be able to pull this from the database on app start, once.
export const formTypeId = {
team: 1,
"voyage member": 2,
user: 3,
meeting: 4,
};
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ async function bootstrap() {
if (process.env.NODE_ENV !== "production") {
const config = new DocumentBuilder()
.setTitle("Chingu Dashboard Project")
.setDescription("The api for chingu dashboard")
.setDescription(
"Chingu Dashboard API<br> default access: logged in (user)",
)
.setVersion("1.0")
.addBearerAuth()
.addOAuth2()
Expand Down
4 changes: 4 additions & 0 deletions src/sprints/sprints.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Delete,
ValidationPipe,
HttpStatus,
Request,
} from "@nestjs/common";
import { SprintsService } from "./sprints.service";
import { UpdateTeamMeetingDto } from "./dto/update-team-meeting.dto";
Expand All @@ -35,6 +36,7 @@ import {
} from "../global/responses/errors";
import { FormResponse, ResponseResponse } from "../forms/forms.response";
import { CreateCheckinFormDto } from "./dto/create-checkin-form.dto";
import { CustomRequest } from "../global/types/CustomRequest";
import { VoyageTeamMemberValidationPipe } from "../pipes/voyage-team-member-validation";

@Controller()
Expand Down Expand Up @@ -409,10 +411,12 @@ export class SprintsController {
getMeetingFormQuestionsWithResponses(
@Param("meetingId", ParseIntPipe) meetingId: number,
@Param("formId", ParseIntPipe) formId: number,
@Request() req: CustomRequest,
) {
return this.sprintsService.getMeetingFormQuestionsWithResponses(
meetingId,
formId,
req,
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/sprints/sprints.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { UpdateMeetingFormResponseDto } from "./dto/update-meeting-form-response
import { CreateCheckinFormDto } from "./dto/create-checkin-form.dto";
import { GlobalService } from "../global/global.service";
import { FormTitles } from "../global/constants/formTitles";
import { CustomRequest } from "../global/types/CustomRequest";

@Injectable()
export class SprintsService {
Expand Down Expand Up @@ -403,6 +404,7 @@ export class SprintsService {
async getMeetingFormQuestionsWithResponses(
meetingId: number,
formId: number,
req: CustomRequest,
) {
const meeting = await this.prisma.teamMeeting.findUnique({
where: {
Expand All @@ -427,7 +429,7 @@ export class SprintsService {

// this will also check if formId exist in getFormById
if (!formResponseMeeting && (await this.isMeetingForm(formId)))
return this.formServices.getFormById(formId);
return this.formServices.getFormById(formId, req);

return this.prisma.form.findUnique({
where: {
Expand Down
3 changes: 2 additions & 1 deletion src/teams/teams.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class TeamsController {

@ApiOperation({
summary: "[Roles: Admin] Gets all voyage teams.",
description: "For development/admin purpose",
description: "[access]: admin <br> For development/admin purpose",
})
@ApiResponse({
status: HttpStatus.OK,
Expand All @@ -48,6 +48,7 @@ export class TeamsController {
@ApiOperation({
summary:
"[Roles: Admin] Gets all teams for a voyage given a voyageId (int).",
description: "[access]: admin <br>",
})
// Will need to be fixed to be RESTful
@ApiResponse({
Expand Down
Loading
Loading