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

chore: CE-443 Automate COMS access requests #801

Merged
merged 9 commits into from
Dec 9, 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
10 changes: 9 additions & 1 deletion backend/src/auth/decorators/token.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,13 @@ import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const Token = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
//Extract token from request
const request = ctx.switchToHttp().getRequest();
return request.token;
if (request.token) {
return request.token;
}
// If the token is not directly accessible in the request object, take it from the headers
let token = request.headers.authorization;
if (token && token.indexOf("Bearer ") === 0) {
token = token.substring(7);
}
return token;
});
8 changes: 8 additions & 0 deletions backend/src/auth/decorators/user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";

// Returns the user off of the request object.
// Sample usage: foo(@User() user) {...}
export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
});
7 changes: 5 additions & 2 deletions backend/src/auth/jwtrole.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { Role } from "../enum/role.enum";
import { ROLES_KEY } from "./decorators/roles.decorator";
import { IS_PUBLIC_KEY } from "./decorators/public.decorator";

// A list of routes that are exceptions to the READ_ONLY role only being allowed to make get requests
const READ_ONLY_EXCEPTIONS = ["/api/v1/officer/request-coms-access/:officer_guid"];

@Injectable()
/**
* An API guard used to authorize controller methods. This guard checks for othe @Roles decorator, and compares it against the role_names of the authenticated user's jwt.
Expand Down Expand Up @@ -57,8 +60,8 @@ export class JwtRoleGuard extends AuthGuard("jwt") implements CanActivate {
// Check if the user has the readonly role
const hasReadOnlyRole = userRoles.includes(Role.READ_ONLY);

// If the user has readonly role, allow only GET requests
if (hasReadOnlyRole) {
// If the user has readonly role, allow only GET requests unless the route is in the list of exceptions
if (hasReadOnlyRole && !READ_ONLY_EXCEPTIONS.includes(request.route.path)) {
if (request.method !== "GET") {
this.logger.debug(`User with readonly role attempted ${request.method} method`);
throw new ForbiddenException("Access denied: Read-only users cannot perform this action");
Expand Down
5 changes: 5 additions & 0 deletions backend/src/helpers/axios-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ export const post = async (apiToken: string, url: string, data: any, headers?: a
const config = generateConfig(apiToken, headers);
return axios.post(url, data, config);
};

export const put = async (apiToken: string, url: string, data: any, headers?: any) => {
const config = generateConfig(apiToken, headers);
return axios.put(url, data, config);
};
7 changes: 7 additions & 0 deletions backend/src/v1/officer/entities/officer.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export class Officer {
@Column()
auth_user_guid: UUID;

@ApiProperty({
example: false,
description: "Indicates whether an officer has been enrolled in COMS",
})
@Column()
coms_enrolled_ind: boolean;

user_roles: string[];
@AfterLoad()
updateUserRoles() {
Expand Down
10 changes: 9 additions & 1 deletion backend/src/v1/officer/officer.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from "@nestjs/common";
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Put } from "@nestjs/common";
import { OfficerService } from "./officer.service";
import { CreateOfficerDto } from "./dto/create-officer.dto";
import { UpdateOfficerDto } from "./dto/update-officer.dto";
Expand All @@ -7,6 +7,8 @@ import { Role } from "../../enum/role.enum";
import { JwtRoleGuard } from "../../auth/jwtrole.guard";
import { ApiTags } from "@nestjs/swagger";
import { UUID } from "crypto";
import { User } from "../../auth/decorators/user.decorator";
import { Token } from "../../auth/decorators/token.decorator";

@ApiTags("officer")
@UseGuards(JwtRoleGuard)
Expand Down Expand Up @@ -65,6 +67,12 @@ export class OfficerController {
return this.officerService.update(id, updateOfficerDto);
}

@Put("/request-coms-access/:officer_guid")
@Roles(Role.CEEB, Role.COS_OFFICER, Role.READ_ONLY)
requestComsAccess(@Token() token, @Param("officer_guid") officer_guid: UUID, @User() user) {
return this.officerService.requestComsAccess(token, officer_guid, user);
}

@Delete(":id")
@Roles(Role.COS_OFFICER)
remove(@Param("id") id: string) {
Expand Down
36 changes: 36 additions & 0 deletions backend/src/v1/officer/officer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { PersonService } from "../person/person.service";
import { OfficeService } from "../office/office.service";
import { UUID } from "crypto";
import { CssService } from "../../external_api/css/css.service";
import { Role } from "../../enum/role.enum";
import { put } from "../../helpers/axios-api";

@Injectable()
export class OfficerService {
Expand Down Expand Up @@ -172,6 +174,40 @@ export class OfficerService {
return this.findOne(officer_guid);
}

/**
* This function requests the appropriate level of access to the storage bucket in COMS.
* If successful, the officer's record in the officer table has its `coms_enrolled_ind` indicator set to true.
* @param requestComsAccessDto An object containing the officer guid
* @returns the updated record of the officer who was granted access to COMS
*/
async requestComsAccess(token: string, officer_guid: UUID, user: any): Promise<Officer> {
try {
const currentRoles = user.client_roles;
const permissions = currentRoles.includes(Role.READ_ONLY) ? ["READ"] : ["READ", "CREATE", "UPDATE", "DELETE"];
const comsPayload = {
accessKeyId: process.env.OBJECTSTORE_ACCESS_KEY,
bucket: process.env.OBJECTSTORE_BUCKET,
bucketName: process.env.OBJECTSTORE_BUCKET_NAME,
key: process.env.OBJECTSTORE_KEY,
endpoint: process.env.OBJECTSTORE_HTTPS_URL,
secretAccessKey: process.env.OBJECTSTORE_SECRET_KEY,
permCodes: permissions,
};
const comsUrl = `${process.env.OBJECTSTORE_API_URL}/bucket`;
await put(token, comsUrl, comsPayload);
const officerRes = await this.officerRepository
.createQueryBuilder("officer")
.update()
.set({ coms_enrolled_ind: true })
.where({ officer_guid: officer_guid })
.returning("*")
.execute();
return officerRes.raw[0];
} catch (error) {
this.logger.error("An error occurred while requesting COMS access.", error);
}
}

remove(id: number) {
return `This action removes a #${id} officer`;
}
Expand Down
8 changes: 7 additions & 1 deletion charts/app/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@
{{- $objectstoreUrl := (get $secretData "objectstoreUrl" | b64dec | default "") }}
{{- $objectstoreHttpsUrl := (get $secretData "objectstoreHttpsUrl" | b64dec | default "") }}
{{- $objectstoreBackupDirectory := (get $secretData "objectstoreBackupDirectory" | b64dec | default "") }}
{{- $objectstoreKey := (get $secretData "objectstoreKey" | b64dec | default "") }}
{{- $objectstoreBucket := (get $secretData "objectstoreBucket" | b64dec | default "") }}
{{- $objectstoreBucketName := (get $secretData "objectstoreBucketName" | b64dec | default "") }}
{{- $objectstoreSecretKey := (get $secretData "objectstoreSecretKey" | b64dec | default "") }}
{{- $objectstoreApiUrl := (get $secretData "objectstoreApiUrl" | b64dec | default "") }}
{{- $jwksUri := (get $secretData "jwksUri" | b64dec | default "") }}
{{- $jwtIssuer := (get $secretData "jwtIssuer" | b64dec | default "") }}
{{- $keycloakClientId := (get $secretData "keycloakClientId" | b64dec | default "") }}
Expand Down Expand Up @@ -99,8 +102,11 @@ data:
OBJECTSTORE_URL: {{ $objectstoreUrl | b64enc | quote }}
OBJECTSTORE_HTTPS_URL: {{ $objectstoreHttpsUrl | b64enc | quote }}
OBJECTSTORE_BACKUP_DIRECTORY: {{ $objectstoreBackupDirectory | b64enc | quote }}
OBJECTSTORE_KEY: {{ $objectstoreKey | b64enc | quote }}
OBJECTSTORE_BUCKET: {{ $objectstoreBucket | b64enc | quote }}
OBJECTSTORE_BUCKET_NAME: {{ $objectstoreBucketName | b64enc | quote }}
OBJECTSTORE_SECRET_KEY: {{ $objectstoreSecretKey | b64enc | quote }}
OBJECTSTORE_API_URL: {{ $objectstoreApiUrl | b64enc | quote }}
{{- end }}
{{- if not (lookup "v1" "Secret" .Release.Namespace (printf "%s-webeoc" .Release.Name)) }}
---
Expand Down Expand Up @@ -156,4 +162,4 @@ data:
password: {{ $databasePassword | quote }}
{{- end }}

{{- end }}
{{- end }}
3 changes: 3 additions & 0 deletions charts/app/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ global:
objectstoreAccessKey: ~
objectstoreUrl: ~
objectstoreBackupDirectory: ~
objectstoreKey: ~
objectstoreBucket: ~
objectstoreBucketName: ~
objectstoreSecretKey: ~
objectstoreApiUrl: ~
jwksUri: ~
jwtIssuer: ~
keycloakClientId: ~
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/store/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { DrugAdministeredChanges } from "./migrations/migration-20";
import { AddCat1TypeAndLocationType } from "./migrations/migration-21";
import { AddActiveComplaintsViewType } from "./migrations/migration-22";
import { AssessmentTypeUpdates } from "./migrations/migration-23";
import { AddComsEnrolledInd } from "./migrations/migration-24";

const BaseMigration = {
0: (state: any) => {
Expand Down Expand Up @@ -54,6 +55,7 @@ migration = {
...AddCat1TypeAndLocationType,
...AddActiveComplaintsViewType,
...AssessmentTypeUpdates,
...AddComsEnrolledInd,
};

export default migration;
12 changes: 12 additions & 0 deletions frontend/src/app/store/migrations/migration-24.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Refresh the profile for coms access indicator
export const AddComsEnrolledInd = {
24: (state: any) => {
return {
...state,
app: {
...state.app,
profile: {},
},
};
},
};
14 changes: 13 additions & 1 deletion frontend/src/app/store/reducers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Profile from "@apptypes/app/profile";
import { UUID } from "crypto";
import { Officer } from "@apptypes/person/person";
import config from "@/config";
import { generateApiParameters, get, patch } from "@common/api";
import { generateApiParameters, get, patch, put } from "@common/api";
import { AUTH_TOKEN, getUserAgency } from "@service/user-service";

import { DropdownOption } from "@apptypes/app/drop-down-option";
Expand Down Expand Up @@ -370,6 +370,7 @@ export const getTokenProfile = (): AppThunk => async (dispatch) => {
let zoneDescription = "";
let agency = "";
let personGuid = "";
let comsEnrolledInd = response.coms_enrolled_ind;

if (response.office_guid !== null) {
const {
Expand All @@ -385,6 +386,14 @@ export const getTokenProfile = (): AppThunk => async (dispatch) => {
personGuid = person_guid;
}

if (!comsEnrolledInd) {
const requestComsAccessParams = generateApiParameters(
`${config.API_BASE_URL}/v1/officer/request-coms-access/${response.officer_guid}`,
);
const res = await put<Officer>(dispatch, requestComsAccessParams);
comsEnrolledInd = res.coms_enrolled_ind;
}

const profile: Profile = {
givenName: given_name,
surName: family_name,
Expand All @@ -397,6 +406,7 @@ export const getTokenProfile = (): AppThunk => async (dispatch) => {
zoneDescription: zoneDescription,
agency,
personGuid,
comsEnrolledInd,
};

dispatch(setTokenProfile(profile));
Expand Down Expand Up @@ -531,6 +541,7 @@ const initialState: AppState = {
zoneDescription: "",
agency: "",
personGuid: "",
comsEnrolledInd: null,
},
isSidebarOpen: true,

Expand Down Expand Up @@ -585,6 +596,7 @@ const reducer = (state: AppState = initialState, action: any): AppState => {
zoneDescription: payload.zoneDescription,
agency: payload.agency,
personGuid: payload.personGuid,
comsEnrolledInd: payload.comsEnrolledInd,
};

return { ...state, profile };
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const persistConfig = {
storage,
blacklist: ["app"],
whitelist: ["codeTables", "officers"],
version: 23, // This needs to be incremented every time a new migration is added
version: 24, // This needs to be incremented every time a new migration is added
debug: true,
migrate: createMigrate(migration, { debug: false }),
};
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/types/app/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export default interface Profile {
zoneDescription: string;
agency: string;
personGuid: string;
comsEnrolledInd: boolean | null;
}
1 change: 1 addition & 0 deletions frontend/src/app/types/person/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Officer {
office_guid: OfficeGUID;
person_guid: Person;
user_roles: string[];
coms_enrolled_ind: boolean;
}

export interface OfficeGUID {
Expand Down
7 changes: 7 additions & 0 deletions migrations/migrations/V0.32.0__CE-443.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
--
-- alter officer table - add large_carnivore_ind
--
ALTER TABLE officer ADD coms_enrolled_ind boolean DEFAULT false;

comment on column species_code.large_carnivore_ind is
'A boolean indicator representing if an officer has been enrolled in COMS';
Loading