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

Automatically grant COMS permissions for new users #130

Merged
merged 5 commits into from
Aug 26, 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
2 changes: 1 addition & 1 deletion .github/environments/values.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ config:
FRONTEND_CHES_ROADMAP_BCC: NRM.PermittingAndData@gov.bc.ca
FRONTEND_CHES_SUBMISSION_CC: NRM.PermittingAndData@gov.bc.ca
FRONTEND_COMS_APIPATH: https://coms-dev.api.gov.bc.ca/api/v1
FRONTEND_COMS_BUCKETID: 1f9e1451-c130-4804-aeb0-b78b5b109c47
FRONTEND_COMS_BUCKETID: 5aa446ca-23c5-4f3b-9300-d8623bc4d101
FRONTEND_GEOCODER_APIPATH: https://geocoder.api.gov.bc.ca
FRONTEND_OIDC_AUTHORITY: https://dev.loginproxy.gov.bc.ca/auth/realms/standard
FRONTEND_OIDC_CLIENTID: nr-permit-connect-navigator-service-5188
Expand Down
7 changes: 7 additions & 0 deletions app/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@
"env": "SERVER_ENV",
"logFile": "SERVER_LOGFILE",
"logLevel": "SERVER_LOGLEVEL",
"objectStorage": {
"accessKeyId": "OBJECTSTORAGE_ACCESSKEYID",
"bucket": "OBJECTSTORAGE_BUCKET",
"endpoint": "OBJECTSTORAGE_ENDPOINT",
"key": "OBJECTSTORAGE_KEY",
"secretAccessKey": "OBJECTSTORAGE_SECRETACCESSKEY"
},
"oidc": {
"authority": "SERVER_OIDC_AUTHORITY",
"clientId": "SERVER_OIDC_CLIENTID",
Expand Down
43 changes: 24 additions & 19 deletions app/src/controllers/roadmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,34 @@ const controller = {
if (req.body.selectedFileIds && req.body.selectedFileIds.length) {
const attachments: Array<EmailAttachment> = [];

const comsObjects = await comsService.getObjects(req.headers, req.body.selectedFileIds);
if (req.currentContext?.bearerToken) {
const comsObjects = await comsService.getObjects(req.currentContext?.bearerToken, req.body.selectedFileIds);

// Attempt to get the requested documents from COMS
// If succesful it is converted to base64 encoding and added to the attachment list
const objectPromises = req.body.selectedFileIds.map(async (id) => {
const { status, headers, data } = await comsService.getObject(req.headers, id);
// Attempt to get the requested documents from COMS
// If succesful it is converted to base64 encoding and added to the attachment list
const objectPromises = req.body.selectedFileIds.map(async (id) => {
const { status, headers, data } = await comsService.getObject(
req.currentContext?.bearerToken as string,
id
);

if (status === 200) {
const filename = comsObjects.find((x: { id: string }) => x.id === id)?.name;
if (filename) {
attachments.push({
content: Buffer.from(data).toString('base64'),
contentType: headers['content-type'],
encoding: 'base64',
filename: filename
});
} else {
throw new Error(`Unable to obtain filename for file ${id}`);
if (status === 200) {
const filename = comsObjects.find((x: { id: string }) => x.id === id)?.name;
if (filename) {
attachments.push({
content: Buffer.from(data).toString('base64'),
contentType: headers['content-type'],
encoding: 'base64',
filename: filename
});
} else {
throw new Error(`Unable to obtain filename for file ${id}`);
}
}
}
});
});

await Promise.all(objectPromises);
await Promise.all(objectPromises);
}

// All succesful so attachment list is added to payload
req.body.emailData.attachments = attachments;
Expand Down
10 changes: 7 additions & 3 deletions app/src/interfaces/IExpress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import * as core from 'express-serve-static-core';
import type { CurrentAuthorization } from '../types/CurrentAuthorization';
import type { CurrentContext } from '../types/CurrentContext';

declare module 'express-serve-static-core' {
export interface Request {
currentAuthorization: CurrentAuthorization;
currentContext: CurrentContext;
}
}

interface Query extends core.Query {}

interface Params extends core.ParamsDictionary {}

export interface Request<P extends Params = never, Q extends Query = never, B = never> extends core.Request {
currentAuthorization?: CurrentAuthorization;
currentContext?: CurrentContext;
params: P;
query: Q;
body: B;
Expand Down
1 change: 1 addition & 0 deletions app/src/middleware/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const currentContext = (initiative: Initiative) => {
}

if (isValid) {
currentContext.bearerToken = bearerToken;
currentContext.tokenPayload = isValid as jwt.JwtPayload;
const user = await userService.login(currentContext.tokenPayload);

Expand Down
2 changes: 1 addition & 1 deletion app/src/middleware/requireSomeGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const requireSomeGroup = async (req: Request, _res: Response, next: NextF

// Auto assign all PROPONENT groups if user has none
if (groups && groups.length === 0) {
await yarsService.assignGroup(sub, Initiative.HOUSING, GroupName.PROPONENT);
await yarsService.assignGroup(req.currentContext.bearerToken, sub, Initiative.HOUSING, GroupName.PROPONENT);
groups = await yarsService.getSubjectGroups(sub);
}

Expand Down
39 changes: 32 additions & 7 deletions app/src/services/coms.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import axios from 'axios';
import config from 'config';

import { Action } from '../utils/enums/application';

import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type { IncomingHttpHeaders } from 'http';

/**
* @function comsAxios
Expand All @@ -22,28 +23,52 @@ function comsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
}

const service = {
/**
* @function createBucket
* Creates a bucket record. Bucket should exist in S3. If the set of bucket, endpoint and key match
* an existing record, the user will be added to that existing bucket with the provided permissions
* instead of generating a new bucket record.
* This endpoint can be used to grant the current user permission to upload to a new or existing bucket.
* @param {string} bearerToken The bearer token of the authorized user
* @param {Action[]} permissions An array of permissions to grant the user
*/
async createBucket(bearerToken: string, permissions: Array<Action>) {
const { data } = await comsAxios({
headers: { Authorization: `Bearer ${bearerToken}` }
}).put('/bucket', {
accessKeyId: config.get('server.objectStorage.accessKeyId'),
bucket: config.get('server.objectStorage.bucket'),
bucketName: 'PCNS',
endpoint: config.get('server.objectStorage.endpoint'),
secretAccessKey: config.get('server.objectStorage.secretAccessKey'),
key: config.get('server.objectStorage.key'),
permCodes: permissions
});
return data;
},

/**
* @function getObject
* Get an object
* @param {IncomingHttpHeaders} incomingHeaders The request headers
* @param {string} bearerToken The bearer token of the authorized user
* @param {string} objectId The id for the object to get
*/
async getObject(incomingHeaders: IncomingHttpHeaders, objectId: string) {
async getObject(bearerToken: string, objectId: string) {
const { status, headers, data } = await comsAxios({
responseType: 'arraybuffer',
headers: { Authorization: incomingHeaders.authorization }
headers: { Authorization: `Bearer ${bearerToken}` }
}).get(`/object/${objectId}`);
return { status, headers, data };
},

/**
* @function getObjects
* Gets a list of objects
* @param {IncomingHttpHeaders} incomingHeaders The request headers
* @param {string} bearerToken The bearer token of the authorized user
* @param {string[]} objectIds Array of object ids to get
*/
async getObjects(incomingHeaders: IncomingHttpHeaders, objectIds: Array<string>) {
const { data } = await comsAxios({ headers: { Authorization: incomingHeaders.authorization } }).get('/object', {
async getObjects(bearerToken: string, objectIds: Array<string>) {
const { data } = await comsAxios({ headers: { Authorization: `Bearer ${bearerToken}` } }).get('/object', {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safety: There should be a conditional if here to drop the Authorization header completely if bearerToken is either null or undefined.

params: { objectId: objectIds }
});

Expand Down
49 changes: 47 additions & 2 deletions app/src/services/yars.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
/* eslint-disable no-useless-catch */

import { comsService } from '.';
import prisma from '../db/dataConnection';
import { Initiative, GroupName } from '../utils/enums/application';
import { Initiative, GroupName, Action } from '../utils/enums/application';

const service = {
assignGroup: async (sub: string, initiative: Initiative, group: GroupName) => {
/**
* @function assignGroup
* Assigns an identity to the given group
* Assigns permissions to COMS based on the given group
* @param {string | undefined} bearerToken The bearer token of the authorized user
* @param {string} identityId Identity ID of the authorized user
* @param {Initiative} initiative The initiative to associate with the group
* @param {GroupName} group The group to add the user to
* @returns {Promise<{identityId: string;roleId: number;}>} The result of running the create operation
*/
assignGroup: async (bearerToken: string | undefined, sub: string, initiative: Initiative, group: GroupName) => {
try {
const i = await prisma.initiative.findFirstOrThrow({
where: {
Expand All @@ -26,6 +37,19 @@ const service = {
}
});

const comsPermsMap = new Map<GroupName, Array<Action>>([
[GroupName.PROPONENT, [Action.CREATE]],
[GroupName.NAVIGATOR, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
[GroupName.SUPERVISOR, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
[GroupName.ADMIN, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
[GroupName.DEVELOPER, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]]
]);

const comsPerms = comsPermsMap.get(group);
if (comsPerms && bearerToken) {
await comsService.createBucket(bearerToken, comsPerms);
}

return { identityId: result.sub, roleId: result.group_id };
} catch (e: unknown) {
throw e;
Expand Down Expand Up @@ -59,6 +83,15 @@ const service = {
}
},

/**
* @function getGroupPolicyDetails
* Gets a list of group/role/policy/resource/action matching the given parameters
* @param {string} groupId Group ID to match on
* @param {Initiative} initiativeCode Initiative code to match on
* @param {string} resourceName Resource name to match on
* @param {string} actionName Action name to match on
* @returns The result of running the findMany operation
*/
getGroupPolicyDetails: async (
groupId: number,
initiativeCode: Initiative,
Expand Down Expand Up @@ -89,6 +122,12 @@ const service = {
}
},

/**
* @function getGroupPermissions
* Gets a list of resource/actions associated with the given groupId
* @param {number} groupId Group ID to search
* @returns The result of running the findMany operation
*/
getGroupPermissions: async (groupId: number) => {
try {
const result = await prisma.group_role_policy_vw.findMany({
Expand All @@ -108,6 +147,12 @@ const service = {
}
},

/**
* @function getPolicyAttributes
* Gets a list of attributes associated with the given policyId
* @param {number} policyId Policy ID to search
* @returns The result of running the findMany operation
*/
getPolicyAttributes: async (policyId: number) => {
try {
const result = await prisma.policy_attribute.findMany({
Expand Down
1 change: 1 addition & 0 deletions app/src/types/CurrentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AuthType, Initiative } from '../utils/enums/application';

export type CurrentContext = {
authType?: AuthType;
bearerToken?: string;
initiative?: Initiative;
tokenPayload?: jwt.JwtPayload;
userId?: string;
Expand Down
75 changes: 62 additions & 13 deletions app/tests/unit/controllers/roadmap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ afterEach(() => {
jest.resetAllMocks();
});

const CURRENT_CONTEXT = { authType: 'BEARER', tokenPayload: null, userId: 'abc-123' };
const CURRENT_CONTEXT = { authType: 'BEARER', bearerToken: 'sometoken', tokenPayload: null, userId: 'abc-123' };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: Consider testing scenarios where the bearerToken has a falsy value (null and/or undefined) and making sure that your code is still behaving as anticipated.


describe('send', () => {
const next = jest.fn();
Expand Down Expand Up @@ -188,10 +188,59 @@ describe('send', () => {
await roadmapController.send(req as any, res as any, next);

expect(getObjectsSpy).toHaveBeenCalledTimes(1);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds);
expect(getObjectSpy).toHaveBeenCalledTimes(2);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.headers, req.body.selectedFileIds[1]);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.currentContext.bearerToken, req.body.selectedFileIds[1]);
expect(emailSpy).toHaveBeenCalledTimes(1);
expect(emailSpy).toHaveBeenCalledWith(req.body.emailData);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(emailResponse.data);
});

it('should not call COMS without a bearer token', async () => {
const req = {
body: {
activityId: '123-123',
selectedFileIds: ['123', '456'],
emailData: {
body: 'Some message text',
bodyType: 'text',
from: 'test@gov.bc.ca',
to: 'hello@gov.bc.ca',
subject: 'Unit tests',
attachments: [
{
content: Buffer.from('foo').toString('base64'),
contentType: 'filetype',
encoding: 'base64',
filename: 'foo'
},
{
content: Buffer.from('foo').toString('base64'),
contentType: 'filetype',
encoding: 'base64',
filename: 'bar'
}
]
}
},
currentContext: { ...CURRENT_CONTEXT, bearerToken: null },
headers: {}
};

const emailResponse = {
data: 'foo',
status: 201
};

emailSpy.mockResolvedValue(emailResponse);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
await roadmapController.send(req as any, res as any, next);

expect(getObjectsSpy).toHaveBeenCalledTimes(0);
expect(getObjectSpy).toHaveBeenCalledTimes(0);
expect(emailSpy).toHaveBeenCalledTimes(1);
expect(emailSpy).toHaveBeenCalledWith(req.body.emailData);
expect(res.status).toHaveBeenCalledWith(201);
Expand Down Expand Up @@ -270,10 +319,10 @@ describe('send', () => {
await roadmapController.send(req as any, res as any, next);

expect(getObjectsSpy).toHaveBeenCalledTimes(1);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds);
expect(getObjectSpy).toHaveBeenCalledTimes(2);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.headers, req.body.selectedFileIds[1]);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.currentContext.bearerToken, req.body.selectedFileIds[1]);
expect(emailSpy).toHaveBeenCalledTimes(1);
expect(emailSpy).toHaveBeenCalledWith(req.body.emailData);
expect(createNoteSpy).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -329,10 +378,10 @@ describe('send', () => {
await roadmapController.send(req as any, res as any, next);

expect(getObjectsSpy).toHaveBeenCalledTimes(1);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds);
expect(getObjectSpy).toHaveBeenCalledTimes(2);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.headers, req.body.selectedFileIds[1]);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.currentContext.bearerToken, req.body.selectedFileIds[1]);
expect(emailSpy).toHaveBeenCalledTimes(0);
expect(res.status).toHaveBeenCalledTimes(0);
expect(next).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -389,10 +438,10 @@ describe('send', () => {
await roadmapController.send(req as any, res as any, next);

expect(getObjectsSpy).toHaveBeenCalledTimes(1);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds);
expect(getObjectsSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds);
expect(getObjectSpy).toHaveBeenCalledTimes(2);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.headers, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.headers, req.body.selectedFileIds[1]);
expect(getObjectSpy).toHaveBeenNthCalledWith(1, req.currentContext.bearerToken, req.body.selectedFileIds[0]);
expect(getObjectSpy).toHaveBeenNthCalledWith(2, req.currentContext.bearerToken, req.body.selectedFileIds[1]);
expect(emailSpy).toHaveBeenCalledTimes(0);
expect(res.status).toHaveBeenCalledTimes(0);
expect(next).toHaveBeenCalledTimes(1);
Expand Down
Loading
Loading