diff --git a/src/backend/src/common/enums/users.enum.ts b/src/backend/src/common/enums/users.enum.ts index c452f1bf2..c49779449 100644 --- a/src/backend/src/common/enums/users.enum.ts +++ b/src/backend/src/common/enums/users.enum.ts @@ -1,3 +1,4 @@ export enum UserTypesEnum { + DRAFTER = 'DRAFTER', MPO = 'MPO', } diff --git a/src/backend/src/common/enums/yes-no-input.enum.ts b/src/backend/src/common/enums/yes-no-input.enum.ts new file mode 100644 index 000000000..ffe2e777b --- /dev/null +++ b/src/backend/src/common/enums/yes-no-input.enum.ts @@ -0,0 +1,4 @@ +export enum YesNoInput { + YES = 'YES', + NO = 'NO', +} diff --git a/src/backend/src/common/interfaces/form-field.interface.ts b/src/backend/src/common/interfaces/form-field.interface.ts new file mode 100644 index 000000000..75b5a20c5 --- /dev/null +++ b/src/backend/src/common/interfaces/form-field.interface.ts @@ -0,0 +1,8 @@ +import { UserTypesEnum } from '../enums/users.enum'; + +export interface IFormField { + key: keyof T; + type: 'text'; // add ORs for future support if needed + isRichText: boolean; + allowedUserTypesEdit: Array; // null if no role restrictions apply +} diff --git a/src/backend/src/common/validators/form-field-role.validator.ts b/src/backend/src/common/validators/form-field-role.validator.ts new file mode 100644 index 000000000..d5fc0f207 --- /dev/null +++ b/src/backend/src/common/validators/form-field-role.validator.ts @@ -0,0 +1,37 @@ +import { ForbiddenException } from '@nestjs/common'; +import { UserTypesEnum } from '../enums/users.enum'; +import { IFormField } from '../interfaces/form-field.interface'; + +/** + * @method validateRoleForFormField + * + * @description + * This method validates role access to Form Fields values + */ +export const validateRoleForFormField = ( + metadata: IFormField, + updatedValue: any, + storedValue: any, + userType: UserTypesEnum, + path: string, +) => { + if (!updatedValue) return; // if value not edited - no need to validate permissions + + if (typeof updatedValue === 'string' && updatedValue === storedValue) return; // if value is not updated by the current user; + + if (!metadata?.allowedUserTypesEdit) return; // if allowedUserTypesEdit is null, all roles can edit this field/key + + if ( + metadata.allowedUserTypesEdit.includes(UserTypesEnum.MPO) && + userType !== UserTypesEnum.MPO + ) { + // if allowed user types is MPO and the user is not an MPO user, throw error + throw new ForbiddenException({ + path: path, + message: `You do not have permissions to edit certain section of this document. Please reach out to your MPO to proceed.`, + }); + } + + // allow otherwise + return; +}; diff --git a/src/backend/src/migrations/1676414798877-piaIntakeAddJsonbColumns.ts b/src/backend/src/migrations/1676414798877-piaIntakeAddJsonbColumns.ts new file mode 100644 index 000000000..5cf219da7 --- /dev/null +++ b/src/backend/src/migrations/1676414798877-piaIntakeAddJsonbColumns.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PiaIntakeAddJsonbColumns1676414798877 + implements MigrationInterface +{ + name = 'piaIntakeAddJsonbColumns1676414798877'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "pia-intake" ADD "collection_use_and_disclosure" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" ADD "storing_personal_information" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" ADD "security_personal_information" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" ADD "accuracy_correction_and_retention" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" ADD "personal_information_banks" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" ADD "additional_risks" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "pia-intake" DROP COLUMN "additional_risks"`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" DROP COLUMN "personal_information_banks"`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" DROP COLUMN "accuracy_correction_and_retention"`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" DROP COLUMN "security_personal_information"`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" DROP COLUMN "storing_personal_information"`, + ); + await queryRunner.query( + `ALTER TABLE "pia-intake" DROP COLUMN "collection_use_and_disclosure"`, + ); + } +} diff --git a/src/backend/src/modules/pia-intake/constants/pia-intake-allowed-sort-fields.ts b/src/backend/src/modules/pia-intake/constants/pia-intake-allowed-sort-fields.ts index 16d38e19b..f23d5d0da 100644 --- a/src/backend/src/modules/pia-intake/constants/pia-intake-allowed-sort-fields.ts +++ b/src/backend/src/modules/pia-intake/constants/pia-intake-allowed-sort-fields.ts @@ -1,6 +1,7 @@ -import { PiaIntakeEntity } from '../entities/pia-intake.entity'; +export type PiaIntakeAllowedSortFieldsType = + | 'drafterName' + | 'updatedAt' + | 'createdAt'; -export const PiaIntakeAllowedSortFields: Array = [ - 'drafterName', - 'updatedAt', -]; +export const PiaIntakeAllowedSortFields: Array = + ['drafterName', 'updatedAt', 'createdAt']; diff --git a/src/backend/src/modules/pia-intake/dto/create-pia-intake.dto.ts b/src/backend/src/modules/pia-intake/dto/create-pia-intake.dto.ts index 86b51689d..8c9a85d2c 100644 --- a/src/backend/src/modules/pia-intake/dto/create-pia-intake.dto.ts +++ b/src/backend/src/modules/pia-intake/dto/create-pia-intake.dto.ts @@ -3,42 +3,23 @@ import { IsDateString, IsEmail, IsEnum, + IsNotEmptyObject, + IsObject, IsOptional, IsString, + ValidateNested, } from '@nestjs/class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { GovMinistriesEnum } from '../../../common/enums/gov-ministries.enum'; import { PiaIntakeStatusEnum } from '../enums/pia-intake-status.enum'; - -export const piaIntakeEntityMock = { - title: 'Test PIA for screening King Richard', - ministry: GovMinistriesEnum.TOURISM_ARTS_CULTURE_AND_SPORT, - branch: 'Entertainment', - status: PiaIntakeStatusEnum.INCOMPLETE, - drafterName: 'Will Smith', - drafterTitle: 'Actor', - drafterEmail: 'will@test.bc.gov.in', - leadName: 'King Richard', - leadTitle: 'Chief Guiding Officer', - leadEmail: 'king@test.bc.gov.in', - mpoName: 'Reinaldo Marcus Green', - mpoEmail: 'reinaldo@test.bc.gov.in', - initiativeDescription: `*King Richard* is a 2021 American biographical sports drama film directed by [Reinaldo Marcus Green](https://en.wikipedia.org/wiki/Reinaldo_Marcus_Green) and written by [Zach Baylin](https://en.wikipedia.org/wiki/Zach_Baylin). The film stars [Will Smith](https://en.wikipedia.org/wiki/Will_Smith) as Richard Williams, the father and coach of famed tennis players [Venus](https://en.wikipedia.org/wiki/Venus_Williams) and [Serena Williams](https://en.wikipedia.org/wiki/Serena_Williams) (both of whom served as executive producers on the film), with [Aunjanue Ellis](https://en.wikipedia.org/wiki/Aunjanue_Ellis), [Saniyya Sidney](https://en.wikipedia.org/wiki/Saniyya_Sidney), [Demi Singleton](https://en.wikipedia.org/wiki/Demi_Singleton), [Tony Goldwyn](https://en.wikipedia.org/wiki/Tony_Goldwyn), and [Jon Bernthal](https://en.wikipedia.org/wiki/Jon_Bernthal) in supporting roles.`, - initiativeScope: `Richard Williams lives in [Compton, California](https://en.wikipedia.org/wiki/Compton,_California), with his wife Brandy, his three step-daughters, and his two daughters, Venus and Serena. Richard aspires to turn Venus and Serena into professional tennis players; he has prepared a plan for success since before they were born. Richard and Brandy coach Venus and Serena on a daily basis, while also working as a security guard and a nurse, respectively. Richard works tirelessly to find a professional coach for the girls, creating brochures and videotapes to advertise their skills, but has not had success.`, - dataElementsInvolved: `Cast Involved: - - 1. Will Smith as Richard Williams - 2. Aunjanue Ellis as Oracene "Brandy" Price - 3. Saniyya Sidney as Venus Williams - 4. Demi Singleton as Serena Williams - 5. Jon Bernthal as Rick Macci - 6. Tony Goldwyn as Paul Cohen - 7. Mikayla LaShae Bartholomew as Tunde Price - `, - hasAddedPiToDataElements: false, - submittedAt: new Date(), - riskMitigation: `The film was released on [Blu-ray](https://en.wikipedia.org/wiki/Blu-ray) and [DVD](https://en.wikipedia.org/wiki/DVD) February 8, 2022 by [Warner Bros. Home Entertainment](https://en.wikipedia.org/wiki/Warner_Bros._Home_Entertainment), with the 4K Ultra HD release through [Warner Archive Collection](https://en.wikipedia.org/wiki/Warner_Archive_Collection) on the same date.`, -}; +import { AccuracyCorrectionAndRetention } from '../jsonb-classes/accuracy-correction-and-retention'; +import { AdditionalRisks } from '../jsonb-classes/additional-risks'; +import { CollectionUseAndDisclosure } from '../jsonb-classes/collection-use-and-disclosure'; +import { PersonalInformationBanks } from '../jsonb-classes/personal-information-banks'; +import { SecurityPersonalInformation } from '../jsonb-classes/security-personal-information'; +import { StoringPersonalInformation } from '../jsonb-classes/storing-personal-information'; +import { piaIntakeEntityMock } from '../mocks/create-pia-intake.mock'; export class CreatePiaIntakeDto { @IsString() @@ -205,4 +186,76 @@ export class CreatePiaIntakeDto { example: new Date(), }) submittedAt: Date; + + @IsObject() + @IsOptional() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => CollectionUseAndDisclosure) + @ApiProperty({ + type: CollectionUseAndDisclosure, + required: false, + example: piaIntakeEntityMock.collectionUseAndDisclosure, + }) + collectionUseAndDisclosure: CollectionUseAndDisclosure; + + @IsObject() + @IsOptional() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => StoringPersonalInformation) + @ApiProperty({ + type: StoringPersonalInformation, + required: false, + example: piaIntakeEntityMock.storingPersonalInformation, + }) + storingPersonalInformation: StoringPersonalInformation; + + @IsObject() + @IsOptional() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => SecurityPersonalInformation) + @ApiProperty({ + type: SecurityPersonalInformation, + required: false, + example: piaIntakeEntityMock.securityPersonalInformation, + }) + securityPersonalInformation: SecurityPersonalInformation; + + @IsObject() + @IsOptional() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => AccuracyCorrectionAndRetention) + @ApiProperty({ + type: AccuracyCorrectionAndRetention, + required: false, + example: piaIntakeEntityMock.accuracyCorrectionAndRetention, + }) + accuracyCorrectionAndRetention: AccuracyCorrectionAndRetention; + + @IsObject() + @IsOptional() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => PersonalInformationBanks) + @ApiProperty({ + type: PersonalInformationBanks, + required: false, + example: piaIntakeEntityMock.personalInformationBanks, + }) + personalInformationBanks: PersonalInformationBanks; + + @IsObject() + @IsOptional() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => AdditionalRisks) + @ApiProperty({ + type: AdditionalRisks, + required: false, + example: piaIntakeEntityMock.additionalRisks, + }) + additionalRisks: AdditionalRisks; } diff --git a/src/backend/src/modules/pia-intake/dto/pia-intake-find-query.dto.ts b/src/backend/src/modules/pia-intake/dto/pia-intake-find-query.dto.ts index 96de8ec7c..1006a1de2 100644 --- a/src/backend/src/modules/pia-intake/dto/pia-intake-find-query.dto.ts +++ b/src/backend/src/modules/pia-intake/dto/pia-intake-find-query.dto.ts @@ -10,11 +10,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { GovMinistriesEnum } from 'src/common/enums/gov-ministries.enum'; import { SortOrderEnum } from 'src/common/enums/sort-order.enum'; -import { PiaIntakeAllowedSortFields } from '../constants/pia-intake-allowed-sort-fields'; -import { PiaIntakeEntity } from '../entities/pia-intake.entity'; +import { + PiaIntakeAllowedSortFields, + PiaIntakeAllowedSortFieldsType, +} from '../constants/pia-intake-allowed-sort-fields'; import { PiaFilterDrafterByCurrentUserEnum } from '../enums/pia-filter-drafter-by-current-user.enum'; import { PiaIntakeStatusEnum } from '../enums/pia-intake-status.enum'; -import { piaIntakeEntityMock } from './create-pia-intake.dto'; +import { piaIntakeEntityMock } from '../mocks/create-pia-intake.mock'; export class PiaIntakeFindQuery { @ApiProperty({ @@ -86,7 +88,7 @@ export class PiaIntakeFindQuery { @IsString() @IsIn(PiaIntakeAllowedSortFields) @IsOptional() - readonly sortBy?: keyof PiaIntakeEntity; + readonly sortBy?: PiaIntakeAllowedSortFieldsType; @ApiProperty({ required: false, diff --git a/src/backend/src/modules/pia-intake/entities/pia-intake.entity.ts b/src/backend/src/modules/pia-intake/entities/pia-intake.entity.ts index b6e06ce80..7cd801c53 100644 --- a/src/backend/src/modules/pia-intake/entities/pia-intake.entity.ts +++ b/src/backend/src/modules/pia-intake/entities/pia-intake.entity.ts @@ -2,6 +2,12 @@ import { GovMinistriesEnum } from '../../../common/enums/gov-ministries.enum'; import { Column, Entity } from 'typeorm'; import { BaseEntity } from '../../../common/entities/base.entity'; import { PiaIntakeStatusEnum } from '../enums/pia-intake-status.enum'; +import { CollectionUseAndDisclosure } from '../jsonb-classes/collection-use-and-disclosure'; +import { StoringPersonalInformation } from '../jsonb-classes/storing-personal-information'; +import { SecurityPersonalInformation } from '../jsonb-classes/security-personal-information'; +import { AccuracyCorrectionAndRetention } from '../jsonb-classes/accuracy-correction-and-retention'; +import { PersonalInformationBanks } from '../jsonb-classes/personal-information-banks'; +import { AdditionalRisks } from '../jsonb-classes/additional-risks'; @Entity('pia-intake') export class PiaIntakeEntity extends BaseEntity { @@ -129,4 +135,46 @@ export class PiaIntakeEntity extends BaseEntity { default: null, }) submittedAt: Date; + + @Column({ + name: 'collection_use_and_disclosure', + type: 'jsonb', + nullable: true, + }) + collectionUseAndDisclosure: CollectionUseAndDisclosure; + + @Column({ + name: 'storing_personal_information', + type: 'jsonb', + nullable: true, + }) + storingPersonalInformation: StoringPersonalInformation; + + @Column({ + name: 'security_personal_information', + type: 'jsonb', + nullable: true, + }) + securityPersonalInformation: SecurityPersonalInformation; + + @Column({ + name: 'accuracy_correction_and_retention', + type: 'jsonb', + nullable: true, + }) + accuracyCorrectionAndRetention: AccuracyCorrectionAndRetention; + + @Column({ + name: 'personal_information_banks', + type: 'jsonb', + nullable: true, + }) + personalInformationBanks: PersonalInformationBanks; + + @Column({ + name: 'additional_risks', + type: 'jsonb', + nullable: true, + }) + additionalRisks: AdditionalRisks; } diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/accuracy.ts b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/accuracy.ts new file mode 100644 index 000000000..57911ff90 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/accuracy.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from '@nestjs/class-validator'; + +export class Accuracy { + @IsString() + @IsOptional() + description?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/correction.ts b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/correction.ts new file mode 100644 index 000000000..b7623cedd --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/correction.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsOptional } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class Correction { + @IsEnum(YesNoInput) + @IsOptional() + haveProcessInPlace?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + willDocument?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + willConductNotifications?: YesNoInput; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/index.ts new file mode 100644 index 000000000..ad674f863 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/index.ts @@ -0,0 +1,29 @@ +import { + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { Accuracy } from './accuracy'; +import { Correction } from './correction'; +import { Retention } from './retention'; + +export class AccuracyCorrectionAndRetention { + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => Accuracy) + accuracy: Accuracy; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => Correction) + correction: Correction; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => Retention) + retention: Retention; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/retention.ts b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/retention.ts new file mode 100644 index 000000000..ed03e07e7 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/accuracy-correction-and-retention/retention.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsOptional, IsString } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class Retention { + @IsEnum(YesNoInput) + @IsOptional() + usePIForDecision?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + haveApprovedInfoSchedule?: YesNoInput; + + @IsString() + @IsOptional() + describeRetention?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/additional-risks/additionalRisk.ts b/src/backend/src/modules/pia-intake/jsonb-classes/additional-risks/additionalRisk.ts new file mode 100644 index 000000000..c3380207e --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/additional-risks/additionalRisk.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString } from '@nestjs/class-validator'; + +export class AdditionalRisk { + @IsString() + risk: string; + + @IsString() + @IsOptional() + response: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/additional-risks/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/additional-risks/index.ts new file mode 100644 index 000000000..196528291 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/additional-risks/index.ts @@ -0,0 +1,10 @@ +import { IsArray, ValidateNested } from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { AdditionalRisk } from './additionalRisk'; + +export class AdditionalRisks { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdditionalRisk) + risks: Array; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/collection-notice.ts b/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/collection-notice.ts new file mode 100644 index 000000000..407e2af86 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/collection-notice.ts @@ -0,0 +1,59 @@ +import { IsOptional, IsString } from '@nestjs/class-validator'; +import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { IFormField } from 'src/common/interfaces/form-field.interface'; +import { validateRoleForFormField } from 'src/common/validators/form-field-role.validator'; + +export class CollectionNotice { + @IsString() + @IsOptional() + drafterInput?: string; + + @IsString() + @IsOptional() + mpoInput?: string; +} + +export const CollectionNoticeMetadata: Array> = [ + { + key: 'drafterInput', + type: 'text', + isRichText: true, + allowedUserTypesEdit: null, // any user can edit this field + }, + { + key: 'mpoInput', + type: 'text', + isRichText: true, + allowedUserTypesEdit: [UserTypesEnum.MPO], // only MPO users can edit this field + }, +]; + +/** + * @method validateRoleForCollectionNotice + * + * @description + * This method validates role access to CollectionNotice values + */ +export const validateRoleForCollectionNotice = ( + updatedValue: CollectionNotice, + storedValue: CollectionNotice, + userType: UserTypesEnum, +) => { + if (!updatedValue) return; + + const keys = Object.keys(updatedValue) as Array; + + keys.forEach((key) => { + const updatedKeyValue = updatedValue?.[key]; + const storedKeyValue = storedValue?.[key]; + const metadata = CollectionNoticeMetadata.find((m) => m.key === key); + + validateRoleForFormField( + metadata, + updatedKeyValue, + storedKeyValue, + userType, + `collectionNotice.${key}`, + ); + }); +}; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/index.ts new file mode 100644 index 000000000..470ed52bd --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/index.ts @@ -0,0 +1,59 @@ +import { + IsArray, + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { + CollectionNotice, + validateRoleForCollectionNotice, +} from './collection-notice'; +import { + StepWalkthrough, + validateRoleForStepWalkthrough, +} from './steps-walkthrough'; + +export class CollectionUseAndDisclosure { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StepWalkthrough) + steps: Array; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => CollectionNotice) + collectionNotice: CollectionNotice; +} + +/** + * @method validateRoleForCollectionUseAndDisclosure + * + * @description + * This method validates role access to collectionUseAndDisclosure + */ +export const validateRoleForCollectionUseAndDisclosure = ( + updatedValue: CollectionUseAndDisclosure, + storedValue: CollectionUseAndDisclosure, + userType: UserTypesEnum, +) => { + if (!updatedValue) return; + + // steps walkthrough validations + const updatedSteps = updatedValue?.steps; + const storedSteps = storedValue?.steps; + if (updatedSteps?.length) { + updatedSteps.forEach((step: StepWalkthrough, i: number) => { + validateRoleForStepWalkthrough(step, storedSteps?.[i], userType); + }); + } + + // collection notice validations + validateRoleForCollectionNotice( + updatedValue?.collectionNotice, + storedValue?.collectionNotice, + userType, + ); +}; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/steps-walkthrough.ts b/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/steps-walkthrough.ts new file mode 100644 index 000000000..4fe6b86f7 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/collection-use-and-disclosure/steps-walkthrough.ts @@ -0,0 +1,79 @@ +import { IsOptional, IsString } from '@nestjs/class-validator'; +import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { IFormField } from 'src/common/interfaces/form-field.interface'; +import { validateRoleForFormField } from 'src/common/validators/form-field-role.validator'; + +export class StepWalkthrough { + @IsString() + @IsOptional() + drafterInput?: string; + + @IsString() + @IsOptional() + mpoInput?: string; + + @IsString() + @IsOptional() + foippaInput?: string; + + @IsString() + @IsOptional() + OtherInput?: string; +} + +export const StepWalkthroughMetadata: Array> = [ + { + key: 'drafterInput', + type: 'text', + isRichText: false, + allowedUserTypesEdit: null, // any user can edit this field + }, + { + key: 'mpoInput', + type: 'text', + isRichText: false, + allowedUserTypesEdit: [UserTypesEnum.MPO], // only MPO users can edit this field + }, + { + key: 'foippaInput', + type: 'text', + isRichText: false, + allowedUserTypesEdit: [UserTypesEnum.MPO], // only MPO users can edit this field + }, + { + key: 'OtherInput', + type: 'text', + isRichText: false, + allowedUserTypesEdit: [UserTypesEnum.MPO], // only MPO users can edit this field + }, +]; + +/** + * @method validateRoleForStepWalkthrough + * + * @description + * This method validates role access to StepWalkthrough values + */ +export const validateRoleForStepWalkthrough = ( + updatedStep: StepWalkthrough, + storedStep: StepWalkthrough, + userType: UserTypesEnum, +) => { + if (!updatedStep) return; + + const keys = Object.keys(updatedStep) as Array; + + keys.forEach((key) => { + const updatedValue = updatedStep?.[key]; + const storedValue = storedStep?.[key]; + const metadata = StepWalkthroughMetadata.find((m) => m.key === key); + + validateRoleForFormField( + metadata, + updatedValue, + storedValue, + userType, + `steps.${key}`, + ); + }); +}; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/personal-information-banks/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/personal-information-banks/index.ts new file mode 100644 index 000000000..9565c8162 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/personal-information-banks/index.ts @@ -0,0 +1,15 @@ +import { + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { ResultingPIB } from './resulting-pib'; + +export class PersonalInformationBanks { + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ResultingPIB) + resultingPIB: ResultingPIB; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/personal-information-banks/resulting-pib.ts b/src/backend/src/modules/pia-intake/jsonb-classes/personal-information-banks/resulting-pib.ts new file mode 100644 index 000000000..3fcbfea78 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/personal-information-banks/resulting-pib.ts @@ -0,0 +1,28 @@ +import { IsEnum, IsOptional, IsString } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class ResultingPIB { + @IsEnum(YesNoInput) + @IsOptional() + willResultInPIB?: YesNoInput; + + @IsString() + @IsOptional() + descriptionInformationType?: string; + + @IsString() + @IsOptional() + mainMinistryInvolved?: string; + + @IsString() + @IsOptional() + otherMinistryInvolved?: string; + + @IsString() + @IsOptional() + managingPersonName?: string; + + @IsString() + @IsOptional() + managingPersonPhone?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/access-to-personal-information.ts b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/access-to-personal-information.ts new file mode 100644 index 000000000..4bc388837 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/access-to-personal-information.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsOptional, IsString } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class AccessToPersonalInformation { + @IsEnum(YesNoInput) + @IsOptional() + onlyCertainRolesAccessInformation?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + accessApproved?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + useAuditLogs?: YesNoInput; + + @IsString() + @IsOptional() + additionalStrategies?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/index.ts new file mode 100644 index 000000000..a08d23bab --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/index.ts @@ -0,0 +1,22 @@ +import { + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { DigitalToolsAndSystemsToolsAndAssessment } from './section-tools-and-assessment'; +import { DigitalToolsAndSystemsStorage } from './section-storage'; + +export class DigitalToolsAndSystems { + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DigitalToolsAndSystemsToolsAndAssessment) + toolsAndAssessment: DigitalToolsAndSystemsToolsAndAssessment; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DigitalToolsAndSystemsStorage) + storage: DigitalToolsAndSystemsStorage; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/section-storage.ts b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/section-storage.ts new file mode 100644 index 000000000..4ebf41e50 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/section-storage.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class DigitalToolsAndSystemsStorage { + @IsEnum(YesNoInput) + @IsOptional() + onGovServers?: YesNoInput; + + @IsString() + @IsOptional() + whereDetails?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/section-tools-and-assessment.ts b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/section-tools-and-assessment.ts new file mode 100644 index 000000000..a1dc7ac5b --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/digital-tools-and-systems/section-tools-and-assessment.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class DigitalToolsAndSystemsToolsAndAssessment { + @IsEnum(YesNoInput) + @IsOptional() + involveDigitalToolsAndSystems?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + haveSecurityAssessment?: YesNoInput; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/index.ts new file mode 100644 index 000000000..fedcdaf8d --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/security-personal-information/index.ts @@ -0,0 +1,22 @@ +import { + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { AccessToPersonalInformation } from './access-to-personal-information'; +import { DigitalToolsAndSystems } from './digital-tools-and-systems/index'; + +export class SecurityPersonalInformation { + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DigitalToolsAndSystems) + digitalToolsAndSystems: DigitalToolsAndSystems; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => AccessToPersonalInformation) + accessToPersonalInformation: AccessToPersonalInformation; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/index.ts new file mode 100644 index 000000000..a13a40fb7 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/index.ts @@ -0,0 +1,43 @@ +import { + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { DisclosureStorage } from './section-storage'; +import { DisclosureContract } from './section-contract'; +import { DisclosureControls } from './section-controls'; +import { DisclosureTrackAccess } from './section-track-access'; +import { DisclosureRisks } from './section-risks'; + +export class DisclosuresOutsideCanada { + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DisclosureStorage) + storage: DisclosureStorage; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DisclosureContract) + contract: DisclosureContract; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DisclosureControls) + controls: DisclosureControls; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DisclosureTrackAccess) + trackAccess: DisclosureTrackAccess; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DisclosureRisks) + risks: DisclosureRisks; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-contract.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-contract.ts new file mode 100644 index 000000000..5273966c0 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-contract.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class DisclosureContract { + @IsEnum(YesNoInput) + @IsOptional() + relyOnExistingContract?: YesNoInput; + + @IsString() + @IsOptional() + enterpriseServiceAccessDetails?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-controls.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-controls.ts new file mode 100644 index 000000000..95a9a49f8 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-controls.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from '@nestjs/class-validator'; + +export class DisclosureControls { + @IsString() + @IsOptional() + unauthorizedAccessMeasures?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-risks.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-risks.ts new file mode 100644 index 000000000..d438097a8 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-risks.ts @@ -0,0 +1,39 @@ +import { + IsArray, + IsOptional, + IsString, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; + +class PrivacyRisk { + @IsString() + risk: string; + + @IsString() + @IsOptional() + impact: string; + + @IsString() + @IsOptional() + likelihoodOfUnauthorizedAccess: string; + + @IsString() + @IsOptional() + levelOfPrivacyRisk: string; + + @IsString() + @IsOptional() + riskResponse: string; + + @IsString() + @IsOptional() + outstandingRisk: string; +} + +export class DisclosureRisks { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PrivacyRisk) + privacyRisks: Array; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-storage.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-storage.ts new file mode 100644 index 000000000..356a93d0a --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-storage.ts @@ -0,0 +1,41 @@ +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +class ServiceProviderDetails { + @IsString() + name: string; + + @IsString() + @IsOptional() + cloudInfraName: string; + + @IsString() + @IsOptional() + details: string; +} + +export class DisclosureStorage { + @IsEnum(YesNoInput) + @IsOptional() + sensitiveInfoStoredByServiceProvider?: YesNoInput; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ServiceProviderDetails) + serviceProviderList: Array; + + @IsString() + @IsOptional() + disclosureDetails?: string; + + @IsString() + @IsOptional() + contractualTerms?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-track-access.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-track-access.ts new file mode 100644 index 000000000..362c3ad28 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/disclosures-outside-canda/section-track-access.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from '@nestjs/class-validator'; + +export class DisclosureTrackAccess { + @IsString() + @IsOptional() + trackAccessDetails?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/index.ts new file mode 100644 index 000000000..e12e8a7ae --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/index.ts @@ -0,0 +1,29 @@ +import { + IsNotEmptyObject, + IsObject, + ValidateNested, +} from '@nestjs/class-validator'; +import { Type } from 'class-transformer'; +import { DisclosuresOutsideCanada } from './disclosures-outside-canda'; +import { PersonalInformation } from './personal-information'; +import { SensitivePersonalInformation } from './sensitive-personal-information'; + +export class StoringPersonalInformation { + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => PersonalInformation) + personalInformation: PersonalInformation; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => SensitivePersonalInformation) + sensitivePersonalInformation: SensitivePersonalInformation; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DisclosuresOutsideCanada) + disclosuresOutsideCanada: DisclosuresOutsideCanada; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/personal-information.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/personal-information.ts new file mode 100644 index 000000000..ebd79ed0f --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/personal-information.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class PersonalInformation { + @IsEnum(YesNoInput) + @IsOptional() + storedOutsideCanada?: YesNoInput; + + @IsString() + @IsOptional() + whereDetails?: string; +} diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/sensitive-personal-information.ts b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/sensitive-personal-information.ts new file mode 100644 index 000000000..da55e4ab0 --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/storing-personal-information/sensitive-personal-information.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional } from '@nestjs/class-validator'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; + +export class SensitivePersonalInformation { + @IsEnum(YesNoInput) + @IsOptional() + doesInvolve?: YesNoInput; + + @IsEnum(YesNoInput) + @IsOptional() + disclosedOutsideCanada?: YesNoInput; +} diff --git a/src/backend/src/modules/pia-intake/mocks/create-pia-intake.mock.ts b/src/backend/src/modules/pia-intake/mocks/create-pia-intake.mock.ts new file mode 100644 index 000000000..7585a44e8 --- /dev/null +++ b/src/backend/src/modules/pia-intake/mocks/create-pia-intake.mock.ts @@ -0,0 +1,150 @@ +import { GovMinistriesEnum } from 'src/common/enums/gov-ministries.enum'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; +import { PiaIntakeStatusEnum } from '../enums/pia-intake-status.enum'; +import { CreatePiaIntakeDto } from '../dto/create-pia-intake.dto'; + +export const piaIntakeEntityMock: CreatePiaIntakeDto = { + title: 'Test PIA for screening King Richard', + ministry: GovMinistriesEnum.TOURISM_ARTS_CULTURE_AND_SPORT, + branch: 'Entertainment', + status: PiaIntakeStatusEnum.INCOMPLETE, + drafterName: 'Will Smith', + drafterTitle: 'Actor', + drafterEmail: 'will@test.bc.gov.in', + leadName: 'King Richard', + leadTitle: 'Chief Guiding Officer', + leadEmail: 'king@test.bc.gov.in', + mpoName: 'Reinaldo Marcus Green', + mpoEmail: 'reinaldo@test.bc.gov.in', + initiativeDescription: `*King Richard* is a 2021 American biographical sports drama film directed by [Reinaldo Marcus Green](https://en.wikipedia.org/wiki/Reinaldo_Marcus_Green) and written by [Zach Baylin](https://en.wikipedia.org/wiki/Zach_Baylin). The film stars [Will Smith](https://en.wikipedia.org/wiki/Will_Smith) as Richard Williams, the father and coach of famed tennis players [Venus](https://en.wikipedia.org/wiki/Venus_Williams) and [Serena Williams](https://en.wikipedia.org/wiki/Serena_Williams) (both of whom served as executive producers on the film), with [Aunjanue Ellis](https://en.wikipedia.org/wiki/Aunjanue_Ellis), [Saniyya Sidney](https://en.wikipedia.org/wiki/Saniyya_Sidney), [Demi Singleton](https://en.wikipedia.org/wiki/Demi_Singleton), [Tony Goldwyn](https://en.wikipedia.org/wiki/Tony_Goldwyn), and [Jon Bernthal](https://en.wikipedia.org/wiki/Jon_Bernthal) in supporting roles.`, + initiativeScope: `Richard Williams lives in [Compton, California](https://en.wikipedia.org/wiki/Compton,_California), with his wife Brandy, his three step-daughters, and his two daughters, Venus and Serena. Richard aspires to turn Venus and Serena into professional tennis players; he has prepared a plan for success since before they were born. Richard and Brandy coach Venus and Serena on a daily basis, while also working as a security guard and a nurse, respectively. Richard works tirelessly to find a professional coach for the girls, creating brochures and videotapes to advertise their skills, but has not had success.`, + dataElementsInvolved: `Cast Involved: + + 1. Will Smith as Richard Williams + 2. Aunjanue Ellis as Oracene "Brandy" Price + 3. Saniyya Sidney as Venus Williams + 4. Demi Singleton as Serena Williams + 5. Jon Bernthal as Rick Macci + 6. Tony Goldwyn as Paul Cohen + 7. Mikayla LaShae Bartholomew as Tunde Price + `, + hasAddedPiToDataElements: false, + submittedAt: new Date(), + riskMitigation: `The film was released on [Blu-ray](https://en.wikipedia.org/wiki/Blu-ray) and [DVD](https://en.wikipedia.org/wiki/DVD) February 8, 2022 by [Warner Bros. Home Entertainment](https://en.wikipedia.org/wiki/Warner_Bros._Home_Entertainment), with the 4K Ultra HD release through [Warner Archive Collection](https://en.wikipedia.org/wiki/Warner_Archive_Collection) on the same date.`, + collectionUseAndDisclosure: { + steps: [ + { + drafterInput: 'Make a Checklist.', + mpoInput: null, + foippaInput: null, + OtherInput: null, + }, + { + drafterInput: 'Set Your Budget.', + mpoInput: null, + foippaInput: null, + OtherInput: null, + }, + ], + collectionNotice: { + drafterInput: 'Test Input', + mpoInput: null, + }, + }, + storingPersonalInformation: { + personalInformation: { + storedOutsideCanada: YesNoInput.YES, + whereDetails: 'USA', + }, + sensitivePersonalInformation: { + doesInvolve: YesNoInput.YES, + disclosedOutsideCanada: YesNoInput.NO, + }, + disclosuresOutsideCanada: { + storage: { + sensitiveInfoStoredByServiceProvider: YesNoInput.YES, + serviceProviderList: [ + { + name: 'Amazon', + cloudInfraName: 'AWS', + details: 'Stored in cloud', + }, + ], + disclosureDetails: 'S3 storage in us-east-1: US East (N. Virginia)', + contractualTerms: 'None', + }, + contract: { + relyOnExistingContract: YesNoInput.YES, + enterpriseServiceAccessDetails: 'S3', + }, + controls: { + unauthorizedAccessMeasures: 'IAM rules are in effect', + }, + trackAccess: { + trackAccessDetails: 'IAM', + }, + risks: { + privacyRisks: [ + { + risk: 'Leak of Creds', + impact: 'Access of instance', + likelihoodOfUnauthorizedAccess: 'Medium', + levelOfPrivacyRisk: 'Medium', + riskResponse: 'immediately revoke', + outstandingRisk: 'None', + }, + ], + }, + }, + }, + securityPersonalInformation: { + digitalToolsAndSystems: { + toolsAndAssessment: { + involveDigitalToolsAndSystems: YesNoInput.NO, + haveSecurityAssessment: YesNoInput.NO, + }, + storage: { + onGovServers: YesNoInput.NO, + whereDetails: 'on AWS Cloud', + }, + }, + accessToPersonalInformation: { + onlyCertainRolesAccessInformation: YesNoInput.YES, + accessApproved: YesNoInput.YES, + useAuditLogs: YesNoInput.NO, + additionalStrategies: 'PEM file access', + }, + }, + accuracyCorrectionAndRetention: { + accuracy: { + description: 'Integrate with 3rd party validators', + }, + correction: { + haveProcessInPlace: YesNoInput.YES, + willDocument: YesNoInput.YES, + willConductNotifications: YesNoInput.YES, + }, + retention: { + usePIForDecision: YesNoInput.YES, + haveApprovedInfoSchedule: YesNoInput.NO, + describeRetention: 'will store in S3 Glacier Deep Archive', + }, + }, + personalInformationBanks: { + resultingPIB: { + willResultInPIB: YesNoInput.YES, + descriptionInformationType: 'Name and address of the user', + mainMinistryInvolved: 'Citizen Services', + managingPersonName: 'John Doe', + managingPersonPhone: '(587-555-555)', + }, + }, + additionalRisks: { + risks: [ + { + risk: 'Leak 1', + response: 'Response 1', + }, + ], + }, +}; diff --git a/src/backend/src/modules/pia-intake/pia-intake.controller.ts b/src/backend/src/modules/pia-intake/pia-intake.controller.ts index 7679c7555..51bc05f3f 100644 --- a/src/backend/src/modules/pia-intake/pia-intake.controller.ts +++ b/src/backend/src/modules/pia-intake/pia-intake.controller.ts @@ -47,6 +47,9 @@ export class PiaIntakeController { @ApiCreatedResponse({ description: 'Successfully submitted a PIA-intake form', }) + @ApiForbiddenResponse({ + description: `You do not have permissions to edit certain section of this document. Please reach out to your MPO to proceed.`, + }) async create( @Body() createPiaIntakeDto: CreatePiaIntakeDto, @Req() req: IRequest, diff --git a/src/backend/src/modules/pia-intake/pia-intake.service.ts b/src/backend/src/modules/pia-intake/pia-intake.service.ts index f2a92b262..eff149828 100644 --- a/src/backend/src/modules/pia-intake/pia-intake.service.ts +++ b/src/backend/src/modules/pia-intake/pia-intake.service.ts @@ -27,6 +27,9 @@ import { PaginatedRO } from 'src/common/paginated.ro'; import { SortOrderEnum } from 'src/common/enums/sort-order.enum'; import { PiaFilterDrafterByCurrentUserEnum } from './enums/pia-filter-drafter-by-current-user.enum'; import { PiaIntakeStatusEnum } from './enums/pia-intake-status.enum'; +import { PiaIntakeAllowedSortFieldsType } from './constants/pia-intake-allowed-sort-fields'; +import { validateRoleForCollectionUseAndDisclosure } from './jsonb-classes/collection-use-and-disclosure'; +import { UserTypesEnum } from 'src/common/enums/users.enum'; @Injectable() export class PiaIntakeService { @@ -42,6 +45,10 @@ export class PiaIntakeService { // update submittedAt column if it is first time submit this.updateSubmittedAt(createPiaIntakeDto); + // sending DRAFTER to userType as only a drafter can create a new PIA; + // A user could have MPO privileges, however while creating a PIA he/she is acting as a drafter + this.validateJsonbFields(createPiaIntakeDto, null, UserTypesEnum.DRAFTER); + const piaInfoForm: PiaIntakeEntity = await this.piaIntakeRepository.save({ ...createPiaIntakeDto, createdByGuid: user.idir_user_guid, @@ -72,7 +79,7 @@ export class PiaIntakeService { const existingRecord = await this.findOneBy({ id }); // Validate if the user has access to the pia-intake form. Throw appropriate exceptions if not - this.validateUserAccess(user, userRoles, existingRecord); + const userType = this.validateUserAccess(user, userRoles, existingRecord); // check if the user is not acting on / updating a stale version if (existingRecord.saveId !== updatePiaIntakeDto.saveId) { @@ -82,6 +89,9 @@ export class PiaIntakeService { }); } + // validate jsonb fields for role access + this.validateJsonbFields(updatePiaIntakeDto, existingRecord, userType); + // remove the provided saveId delete updatePiaIntakeDto.saveId; @@ -271,7 +281,9 @@ export class PiaIntakeService { /* ********** CONDITIONAL WHERE CLAUSE ENDS ********** */ /* ********** SORT LOGIC BEGINS ********** */ - const orderBy: Partial> = {}; + const orderBy: Partial< + Record + > = {}; // if sortBy is provided, sort the filtered records by the provided field // sortOrder can be as provided or by default descending @@ -387,20 +399,20 @@ export class PiaIntakeService { user: KeycloakUser, userRoles: RolesEnum[], piaIntake: PiaIntakeEntity, - ) { + ): UserTypesEnum { if (!piaIntake.isActive) { throw new GoneException(); } // Scenario 1: A self-submitted PIA if (user.idir_user_guid === piaIntake.createdByGuid) { - return true; + return UserTypesEnum.DRAFTER; } // Scenario 2: PIA is submitted to the ministry I'm an MPO of const { mpoMinistries } = this.getMpoMinistriesByRoles(userRoles); if (mpoMinistries.includes(piaIntake.ministry)) { - return true; + return UserTypesEnum.MPO; } // Throw Forbidden user access if none of the above scenarios are met @@ -417,4 +429,34 @@ export class PiaIntakeService { if (!dto.submittedAt && dto.status === PiaIntakeStatusEnum.MPO_REVIEW) dto.submittedAt = new Date(); }; + + /** + * @method validateJsonbFields + * + * @param userType - type of the logged in user + * It can be DRAFTER when initially creating the PIA or while editing it + * It shall also be DRAFTER when an MPO is drafting a PIA + * It can be MPO when MPO is reviewing someone else's PIA + * + * @description + * This method validates role access to the following fields, if needed: + * 1. collectionUseAndDisclosure + * 2. storingPersonalInformation + * 3. securityPersonalInformation + * 4. accuracyCorrectionAndRetention + * 5. personalInformationBanks + * 6. additionalRisks + */ + validateJsonbFields( + updatedValue: CreatePiaIntakeDto | UpdatePiaIntakeDto, + storedValue: PiaIntakeEntity, + userType: UserTypesEnum, + ) { + validateRoleForCollectionUseAndDisclosure( + updatedValue?.collectionUseAndDisclosure, + storedValue?.collectionUseAndDisclosure, + userType, + ); + // space for future validators, as needed + } } diff --git a/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts b/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts index b4d73dad9..e8387ec7c 100644 --- a/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts +++ b/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts @@ -41,6 +41,8 @@ import { PiaFilterDrafterByCurrentUserEnum } from 'src/modules/pia-intake/enums/ import { Not } from 'typeorm/find-options/operator/Not'; import { SortOrderEnum } from 'src/common/enums/sort-order.enum'; import { UpdatePiaIntakeDto } from 'src/modules/pia-intake/dto/update-pia-intake.dto'; +import { emptyJsonbValues } from 'test/util/mocks/data/pia-empty-jsonb-values.mock'; +import { UserTypesEnum } from 'src/common/enums/users.enum'; /** * @Description @@ -107,7 +109,8 @@ describe('PiaIntakeService', () => { omitBaseKeysSpy.mockClear(); }); - it('succeeds calling the database repository with correct data', async () => { + // Scenario 1 + it('succeeds calling the database when a drafter creates PIA with the correct data ', async () => { const createPiaIntakeDto: CreatePiaIntakeDto = { ...createPiaIntakeMock }; const piaIntakeEntity = { ...piaIntakeEntityMock }; const getPiaIntakeRO = { ...getPiaIntakeROMock }; @@ -140,6 +143,7 @@ describe('PiaIntakeService', () => { expect(result).toEqual(getPiaIntakeRO); }); + // Scenario 2 it('succeeds and update submittedAt with current value if status is changed to MPO_REVIEW', async () => { const createPiaIntakeDto: CreatePiaIntakeDto = { ...createPiaIntakeMock, @@ -164,6 +168,81 @@ describe('PiaIntakeService', () => { expect(createPiaIntakeDto.submittedAt).toBeDefined(); expect(createPiaIntakeDto.submittedAt).toBeInstanceOf(Date); }); + + // Scenario 3 + it('succeeds for jsonb columns with empty values', async () => { + const createPiaIntakeDto: CreatePiaIntakeDto = { + ...createPiaIntakeMock, + ...emptyJsonbValues, + }; + + const piaIntakeEntity = { + ...piaIntakeEntityMock, + ...emptyJsonbValues, + }; + + const getPiaIntakeRO = { + ...getPiaIntakeROMock, + ...emptyJsonbValues, + }; + + const user: KeycloakUser = { ...keycloakUserMock }; + + piaIntakeRepository.save = jest.fn(async () => { + delay(10); + return piaIntakeEntity; + }); + + omitBaseKeysSpy.mockReturnValue(getPiaIntakeRO); + + const result = await service.create(createPiaIntakeDto, user); + + expect(piaIntakeRepository.save).toHaveBeenCalledWith({ + ...createPiaIntakeDto, + createdByGuid: user.idir_user_guid, + createdByUsername: user.idir_username, + updatedByGuid: user.idir_user_guid, + updatedByUsername: user.idir_username, + updatedByDisplayName: user.display_name, + drafterEmail: user.email, + }); + + expect(omitBaseKeysSpy).toHaveBeenCalledWith(piaIntakeEntity, [ + 'updatedByDisplayName', + ]); + + expect(result).toEqual(getPiaIntakeRO); + }); + + // Scenario 4 + it('fails when a drafter tries to create fields they do not have permissions to ', async () => { + const createPiaIntakeDto: CreatePiaIntakeDto = { + ...createPiaIntakeMock, + collectionUseAndDisclosure: { + steps: [ + { + drafterInput: 'Make a Checklist.', + mpoInput: 'I do not have privilege to edit this', + foippaInput: 'I do not have privilege to edit this', + OtherInput: 'I do not have privilege to edit this', + }, + ], + collectionNotice: { + drafterInput: 'Test Input', + mpoInput: 'I do not have privilege to edit this', + }, + }, + }; + + const user: KeycloakUser = { ...keycloakUserMock }; + + await expect(service.create(createPiaIntakeDto, user)).rejects.toThrow( + new ForbiddenException({ + path: 'steps.mpoInput', + message: `You do not have permissions to edit certain section of this document. Please reach out to your MPO to proceed.`, + }), + ); + }); }); /** @@ -1209,7 +1288,7 @@ describe('PiaIntakeService', () => { return { ...piaIntakeEntityMock }; }); - service.validateUserAccess = jest.fn(() => true); + service.validateUserAccess = jest.fn(() => UserTypesEnum.DRAFTER); omitBaseKeysSpy.mockImplementation(() => getPiaIntakeROMock); @@ -1252,7 +1331,7 @@ describe('PiaIntakeService', () => { return piaIntakeMock; }); - service.validateUserAccess = jest.fn(() => true); + service.validateUserAccess = jest.fn(() => UserTypesEnum.DRAFTER); piaIntakeRepository.save = jest.fn(async () => { delay(10); @@ -1302,7 +1381,7 @@ describe('PiaIntakeService', () => { return piaIntakeROMock; }); - service.validateUserAccess = jest.fn(() => true); + service.validateUserAccess = jest.fn(() => UserTypesEnum.DRAFTER); piaIntakeRepository.save = jest.fn(async () => { delay(10); @@ -1353,7 +1432,7 @@ describe('PiaIntakeService', () => { return piaIntakeMock; }); - service.validateUserAccess = jest.fn(() => true); + service.validateUserAccess = jest.fn(() => UserTypesEnum.DRAFTER); await expect( service.update(id, updatePiaIntakeDto, user, userRoles), @@ -1397,7 +1476,7 @@ describe('PiaIntakeService', () => { return piaIntakeROMock; }); - service.validateUserAccess = jest.fn(() => true); + service.validateUserAccess = jest.fn(() => UserTypesEnum.DRAFTER); piaIntakeRepository.save = jest.fn(async () => { delay(10); @@ -1409,6 +1488,92 @@ describe('PiaIntakeService', () => { expect(updatePiaIntakeDto.submittedAt).toBeDefined(); expect(updatePiaIntakeDto.submittedAt).toBeInstanceOf(Date); }); + + // Scenario 5: fails with Forbidden if DRAFTER tries to update fields they don't have permissions to + it("fails with Forbidden if DRAFTER tries to update fields they don't have permissions to", async () => { + const piaIntakeMock = { ...piaIntakeEntityMock, saveId: 10 }; + + const updatePiaIntakeDto = { + collectionUseAndDisclosure: { + steps: [ + { + drafterInput: 'Make a Checklist.', + mpoInput: null, + foippaInput: null, + OtherInput: null, + }, + ], + collectionNotice: { + drafterInput: 'Test Input', + mpoInput: 'I do not have access to update this field', + }, + }, + saveId: 10, + }; + + const userType = UserTypesEnum.DRAFTER; + + const user: KeycloakUser = { ...keycloakUserMock }; + const userRoles = [RolesEnum.MPO_CITZ]; + const id = 1; + + service.findOneBy = jest.fn(async () => { + delay(10); + return piaIntakeMock; + }); + + service.validateUserAccess = jest.fn(() => userType); + + await expect( + service.update(id, updatePiaIntakeDto, user, userRoles), + ).rejects.toThrow( + new ForbiddenException({ + path: 'collectionNotice.mpoInput', + message: + 'You do not have permissions to edit certain section of this document. Please reach out to your MPO to proceed.', + }), + ); + }); + + // Scenario 6: succeeds if scenario 5 updates are requested by an MPO + it('succeeds if scenario 5 updates are requested by an MPO', async () => { + const piaIntakeMock = { ...piaIntakeEntityMock, saveId: 10 }; + + const updatePiaIntakeDto = { + collectionUseAndDisclosure: { + steps: [ + { + drafterInput: 'Make a Checklist.', + mpoInput: null, + foippaInput: null, + OtherInput: null, + }, + ], + collectionNotice: { + drafterInput: 'Test Input', + mpoInput: 'I now DO have access to update this field', + }, + }, + saveId: 10, + }; + + const userType = UserTypesEnum.MPO; + + const user: KeycloakUser = { ...keycloakUserMock }; + const userRoles = [RolesEnum.MPO_CITZ]; + const id = 1; + + service.findOneBy = jest.fn(async () => { + delay(10); + return piaIntakeMock; + }); + + service.validateUserAccess = jest.fn(() => userType); + + await expect( + service.update(id, updatePiaIntakeDto, user, userRoles), + ).resolves.not.toThrow(); + }); }); /** @@ -1519,8 +1684,8 @@ describe('PiaIntakeService', () => { } }); - // Scenario 2: Test succeeds when the record is self submitted irrespective of user role or ministry they belong - it('succeeds when the record is self submitted irrespective of user role or ministry they belong', () => { + // Scenario 2: Test succeeds and returns userType - drafter when the record is self submitted + it('succeeds and returns userType - drafter when the record is self submitted', () => { const user: KeycloakUser = { ...keycloakUserMock, idir_user_guid: 'TEST_USER', @@ -1533,11 +1698,11 @@ describe('PiaIntakeService', () => { const result = service.validateUserAccess(user, userRoles, piaIntake); - expect(result).toBe(true); + expect(result).toBe(UserTypesEnum.DRAFTER); }); - // Scenario 3: Test succeeds when PIA is not self-submitted, but submitted to the ministry I belong and MPO of - it('succeeds when PIA is not self-submitted, but submitted to the ministry I belong and MPO of', () => { + // Scenario 3: succeeds and returns userType - MPO when PIA is not self-submitted, but submitted to the ministry I belong and MPO of + it('succeeds and returns userType - MPO when PIA is not self-submitted, but submitted to the ministry I belong and MPO of', () => { const user: KeycloakUser = { ...keycloakUserMock, idir_user_guid: 'USER_1', @@ -1551,7 +1716,7 @@ describe('PiaIntakeService', () => { const result = service.validateUserAccess(user, userRoles, piaIntake); - expect(result).toBe(true); + expect(result).toBe(UserTypesEnum.MPO); }); // Scenario 4: Test fails when the record is not self-submitted, but submitted to the ministry I belong and NOT MPO of diff --git a/src/backend/test/util/mocks/data/pia-empty-jsonb-values.mock.ts b/src/backend/test/util/mocks/data/pia-empty-jsonb-values.mock.ts new file mode 100644 index 000000000..995b909d4 --- /dev/null +++ b/src/backend/test/util/mocks/data/pia-empty-jsonb-values.mock.ts @@ -0,0 +1,85 @@ +export const emptyJsonbValues = { + collectionUseAndDisclosure: { + steps: [], + collectionNotice: { + drafterInput: null, + mpoInput: null, + }, + }, + storingPersonalInformation: { + personalInformation: { + storedOutsideCanada: null, + whereDetails: null, + }, + sensitivePersonalInformation: { + doesInvolve: null, + disclosedOutsideCanada: null, + }, + disclosuresOutsideCanada: { + storage: { + sensitiveInfoStoredByServiceProvider: null, + serviceProviderList: [], + disclosureDetails: null, + contractualTerms: null, + }, + contract: { + relyOnExistingContract: null, + enterpriseServiceAccessDetails: null, + }, + controls: { + unauthorizedAccessMeasures: null, + }, + trackAccess: { + trackAccessDetails: null, + }, + risks: { + privacyRisks: [], + }, + }, + }, + securityPersonalInformation: { + digitalToolsAndSystems: { + toolsAndAssessment: { + involveDigitalToolsAndSystems: null, + haveSecurityAssessment: null, + }, + storage: { + onGovServers: null, + whereDetails: null, + }, + }, + accessToPersonalInformation: { + onlyCertainRolesAccessInformation: null, + accessApproved: null, + useAuditLogs: null, + additionalStrategies: null, + }, + }, + accuracyCorrectionAndRetention: { + accuracy: { + description: null, + }, + correction: { + haveProcessInPlace: null, + willDocument: null, + willConductNotifications: null, + }, + retention: { + usePIForDecision: null, + haveApprovedInfoSchedule: null, + describeRetention: null, + }, + }, + personalInformationBanks: { + resultingPIB: { + willResultInPIB: null, + descriptionInformationType: null, + mainMinistryInvolved: null, + managingPersonName: null, + managingPersonPhone: null, + }, + }, + additionalRisks: { + risks: [], + }, +}; diff --git a/src/backend/test/util/mocks/data/pia-intake.mock.ts b/src/backend/test/util/mocks/data/pia-intake.mock.ts index c97c67a3f..d57426be1 100644 --- a/src/backend/test/util/mocks/data/pia-intake.mock.ts +++ b/src/backend/test/util/mocks/data/pia-intake.mock.ts @@ -1,4 +1,5 @@ import { GovMinistriesEnum } from 'src/common/enums/gov-ministries.enum'; +import { YesNoInput } from 'src/common/enums/yes-no-input.enum'; import { CreatePiaIntakeDto } from 'src/modules/pia-intake/dto/create-pia-intake.dto'; import { PiaIntakeEntity } from 'src/modules/pia-intake/entities/pia-intake.entity'; import { PiaIntakeStatusEnum } from 'src/modules/pia-intake/enums/pia-intake-status.enum'; @@ -34,16 +35,157 @@ const piaIntakeDataMock = { status: PiaIntakeStatusEnum.MPO_REVIEW, saveId: 1, submittedAt: new Date(), + collectionUseAndDisclosure: { + steps: [ + { + drafterInput: 'Make a Checklist.', + mpoInput: 'Agreed', + foippaInput: 'Agreed', + OtherInput: 'Agreed', + }, + { + drafterInput: 'Set Your Budget.', + mpoInput: 'Set precise budget', + foippaInput: 'Agreed', + OtherInput: 'Agreed', + }, + ], + collectionNotice: { + drafterInput: 'Test Input', + mpoInput: 'Updated Input', + }, + }, + storingPersonalInformation: { + personalInformation: { + storedOutsideCanada: YesNoInput.YES, + whereDetails: 'USA', + }, + sensitivePersonalInformation: { + doesInvolve: YesNoInput.YES, + disclosedOutsideCanada: YesNoInput.NO, + }, + disclosuresOutsideCanada: { + storage: { + sensitiveInfoStoredByServiceProvider: YesNoInput.YES, + serviceProviderList: [ + { + name: 'Amazon', + cloudInfraName: 'AWS', + details: 'Stored in cloud', + }, + ], + disclosureDetails: 'S3 storage in us-east-1: US East (N. Virginia)', + contractualTerms: 'None', + }, + contract: { + relyOnExistingContract: YesNoInput.YES, + enterpriseServiceAccessDetails: 'S3', + }, + controls: { + unauthorizedAccessMeasures: 'IAM rules are in effect', + }, + trackAccess: { + trackAccessDetails: 'IAM', + }, + risks: { + privacyRisks: [ + { + risk: 'Leak of Creds', + impact: 'Access of instance', + likelihoodOfUnauthorizedAccess: 'Medium', + levelOfPrivacyRisk: 'Medium', + riskResponse: 'immediately revoke', + outstandingRisk: 'None', + }, + ], + }, + }, + }, + securityPersonalInformation: { + digitalToolsAndSystems: { + toolsAndAssessment: { + involveDigitalToolsAndSystems: YesNoInput.NO, + haveSecurityAssessment: YesNoInput.NO, + }, + storage: { + onGovServers: YesNoInput.NO, + whereDetails: 'on AWS Cloud', + }, + }, + accessToPersonalInformation: { + onlyCertainRolesAccessInformation: YesNoInput.YES, + accessApproved: YesNoInput.YES, + useAuditLogs: YesNoInput.NO, + additionalStrategies: 'PEM file access', + }, + }, + accuracyCorrectionAndRetention: { + accuracy: { + description: 'Integrate with 3rd party validators', + }, + correction: { + haveProcessInPlace: YesNoInput.YES, + willDocument: YesNoInput.YES, + willConductNotifications: YesNoInput.YES, + }, + retention: { + usePIForDecision: YesNoInput.YES, + haveApprovedInfoSchedule: YesNoInput.NO, + describeRetention: 'will store in S3 Glacier Deep Archive', + }, + }, + personalInformationBanks: { + resultingPIB: { + willResultInPIB: YesNoInput.YES, + descriptionInformationType: 'Name and address of the user', + mainMinistryInvolved: 'Citizen Services', + managingPersonName: 'John Doe', + managingPersonPhone: '(587-555-555)', + }, + }, + additionalRisks: { + risks: [ + { + risk: 'Leak 1', + response: 'Response 1', + }, + ], + }, +}; + +const collectionUseAndDisclosureWithDrafterPermissions = { + collectionUseAndDisclosure: { + steps: [ + { + drafterInput: 'Make a Checklist.', + mpoInput: null, + foippaInput: null, + OtherInput: null, + }, + { + drafterInput: 'Set Your Budget.', + mpoInput: null, + foippaInput: null, + OtherInput: null, + }, + ], + collectionNotice: { + drafterInput: 'Test Input', + mpoInput: null, + }, + }, }; export const piaIntakeEntityMock: PiaIntakeEntity = { ...baseEntityMock, ...piaIntakeDataMock, ...{ updatedByDisplayName: 'Richard, King CITZ:EX' }, + ...collectionUseAndDisclosureWithDrafterPermissions, }; export const createPiaIntakeMock: CreatePiaIntakeDto = { ...piaIntakeDataMock, + ...collectionUseAndDisclosureWithDrafterPermissions, }; export const getPiaIntakeROMock: GetPiaIntakeRO = { @@ -53,4 +195,5 @@ export const getPiaIntakeROMock: GetPiaIntakeRO = { createdAt: baseEntityMock.createdAt, updatedAt: baseEntityMock.updatedAt, }, + ...collectionUseAndDisclosureWithDrafterPermissions, };