Skip to content

Commit b09d70e

Browse files
J-pilonmauberti-bc
andauthored
SIMSBIOHUB 878: Team Middleware for Authorization (#339)
* update middleware * explicit pk names * feat: adds service for team authorization * test: TeamAuthorizationService * feat: creates models for team authorization * feat: adds repository for team authorization queries * test: TeamAuthorizationRepository * refact: calls TeamAuthorizationService to determine if user is authorized * test: AuthorizationService * refact: repository methods use queryBuilder pattern so queries read like sql * refact: sql statement to find team policy by submission_feature_id * test: findTeamPolicyBySubmissionFeature repository method * refact: calls repository method to find a team policy by submission_feature_id to determine if the user is authorized * test: TeamAuthorizationService * misc fix * feat: adds team discriminator to data_request routes * refact: removes the not yet implemented 'ticket' discriminator type * refact: adds system_user discriminator to confirm user is in the system * feat: updates '/data-requests' route handler to support team_ids filter * feat: adds method to TeamRepository to get teams by system_user_id * feat: adds method to TeamService to get team_ids by system_user_id * feat: adds team_ids filter to DataRequestRepository * refact: findDataRequests to use system_user_id to get data_requests for the user's teams * test: DataRequestRepository team_ids filter * test: DataRequestService * fix: joins team_member and team tables to get teams for system_user_id * fix: misc * refact: removes policy from TeamAuthorizationEntity and makes submissionId required * refact: removes team_ids param * misc * refact: adds service method to get data requests by system user id * misc * refact: adds repository method to get data requests associated to the system user's team * refact: for non-admin data-request route calls findDataRequestsBySystemUserId with system_user_id * refact: parseQueryParams to be less verbose * refact: removes findTeamMembershipByPolicy * refact: removes getTeamIdsBySystemUserId * refact: returns record_end_date and throws an error if expired * cleanup * misc * refact: returns false if the record has expired instead of throwing an error * clean up * refact: reduces duplication by mutating the base query to apply filters * fix: failing tests --------- Co-authored-by: Macgregor Aubertin-Young <macgregor.aubertin-young@gov.bc.ca>
1 parent 0d5b4af commit b09d70e

File tree

16 files changed

+723
-154
lines changed

16 files changed

+723
-154
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from 'zod';
2+
3+
export const TeamPolicyRecord = z.object({
4+
team_policy_id: z.string().uuid(),
5+
record_end_date: z.string().nullable()
6+
});
7+
8+
export type TeamPolicyRecord = z.infer<typeof TeamPolicyRecord>;
9+
10+
export const DataRequestRecord = z.object({
11+
data_request_id: z.string().uuid(),
12+
record_end_date: z.string().nullable()
13+
});
14+
15+
export type DataRequestRecord = z.infer<typeof DataRequestRecord>;

api/src/paths/data-request/index.test.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { describe } from 'mocha';
33
import sinon from 'sinon';
44
import sinonChai from 'sinon-chai';
55
import { createDataRequest, findDataRequests } from '.';
6+
import { SYSTEM_ROLE } from '../../constants/roles';
67
import * as db from '../../database/db';
78
import { ApiError } from '../../errors/api-error';
89
import { DataRequestWithStatus } from '../../models/data-request';
10+
import { SystemUserExtended } from '../../models/user';
911
import { DataRequestService } from '../../services/data-request-service';
12+
import { UserService } from '../../services/user-service';
1013
import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db';
1114

1215
chai.use(sinonChai);
@@ -29,6 +32,38 @@ describe('data-request', () => {
2932
}
3033
};
3134

35+
const mockAdminUser: SystemUserExtended = {
36+
system_user_id: 1,
37+
user_identifier: 'admin@example.com',
38+
user_guid: 'admin-guid',
39+
user_identity_source_id: 1,
40+
identity_source: 'IDIR',
41+
record_effective_date: '2025-01-01',
42+
record_end_date: null,
43+
create_date: '2025-01-01',
44+
create_user: 1,
45+
update_date: null,
46+
update_user: null,
47+
revision_count: 0,
48+
display_name: 'Admin User',
49+
email: 'admin@example.com',
50+
given_name: 'Admin',
51+
family_name: 'User',
52+
agency: null,
53+
notes: null,
54+
role_ids: [1],
55+
role_names: [SYSTEM_ROLE.SYSTEM_ADMIN]
56+
};
57+
58+
const mockNonAdminUser: SystemUserExtended = {
59+
...mockAdminUser,
60+
system_user_id: 2,
61+
user_identifier: 'member@example.com',
62+
user_guid: 'member-guid',
63+
role_ids: [2],
64+
role_names: [SYSTEM_ROLE.MEMBER]
65+
};
66+
3267
describe('findDataRequests', () => {
3368
it('throws error if DB connection fails to open', async () => {
3469
const mockDBConnection = getMockDBConnection({
@@ -52,37 +87,51 @@ describe('data-request', () => {
5287
}
5388
});
5489

55-
it('calls DataRequestService.findDataRequests with no filters and returns 200', async () => {
90+
it('calls DataRequestService.findDataRequestsBySystemUserId for non-admin with no filters and returns 200', async () => {
5691
const mockDBConnection = getMockDBConnection({
92+
systemUserId: () => mockNonAdminUser.system_user_id,
5793
commit: sinon.stub(),
5894
rollback: sinon.stub(),
5995
release: sinon.stub()
6096
});
6197
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
98+
sinon.stub(UserService.prototype, 'getUserById').resolves(mockNonAdminUser);
6299

63-
const stub = sinon.stub(DataRequestService.prototype, 'findDataRequests').resolves([mockDataRequestWithStatus]);
100+
const stub = sinon
101+
.stub(DataRequestService.prototype, 'findDataRequestsBySystemUserId')
102+
.resolves([mockDataRequestWithStatus]);
64103

65104
const requestHandler = findDataRequests();
66105
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
67106

68107
await requestHandler(mockReq, mockRes, mockNext);
69108

70-
expect(stub).to.have.been.calledOnceWith({});
109+
expect(stub).to.have.been.calledOnceWith(mockNonAdminUser.system_user_id, {
110+
date_from: undefined,
111+
date_to: undefined,
112+
requested_by: undefined,
113+
team_id: undefined,
114+
status: undefined
115+
});
71116
expect(mockDBConnection.commit).to.have.been.calledOnce;
72117
expect(mockDBConnection.release).to.have.been.calledOnce;
73118
expect(mockRes.statusValue).to.equal(200);
74119
expect(mockRes.jsonValue).to.eql([mockDataRequestWithStatus]);
75120
});
76121

77-
it('parses query params and passes filters to service', async () => {
122+
it('parses query params and passes filters to service for non-admin', async () => {
78123
const mockDBConnection = getMockDBConnection({
124+
systemUserId: () => mockNonAdminUser.system_user_id,
79125
commit: sinon.stub(),
80126
rollback: sinon.stub(),
81127
release: sinon.stub()
82128
});
83129
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
130+
sinon.stub(UserService.prototype, 'getUserById').resolves(mockNonAdminUser);
84131

85-
const stub = sinon.stub(DataRequestService.prototype, 'findDataRequests').resolves([mockDataRequestWithStatus]);
132+
const stub = sinon
133+
.stub(DataRequestService.prototype, 'findDataRequestsBySystemUserId')
134+
.resolves([mockDataRequestWithStatus]);
86135

87136
const requestHandler = findDataRequests();
88137
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
@@ -97,7 +146,7 @@ describe('data-request', () => {
97146

98147
await requestHandler(mockReq, mockRes, mockNext);
99148

100-
expect(stub).to.have.been.calledOnceWith({
149+
expect(stub).to.have.been.calledOnceWith(mockNonAdminUser.system_user_id, {
101150
date_from: '2025-01-01',
102151
date_to: '2025-01-31',
103152
requested_by: 1,
@@ -109,13 +158,14 @@ describe('data-request', () => {
109158

110159
it('rolls back and rethrows if service throws', async () => {
111160
const mockDBConnection = getMockDBConnection({
161+
systemUserId: () => mockAdminUser.system_user_id,
112162
commit: sinon.stub(),
113163
rollback: sinon.stub(),
114164
release: sinon.stub()
115165
});
116166
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
117-
118-
sinon.stub(DataRequestService.prototype, 'findDataRequests').rejects(new Error('Service error'));
167+
sinon.stub(UserService.prototype, 'getUserById').resolves(mockAdminUser);
168+
sinon.stub(DataRequestService.prototype, 'findDataRequestsBySystemUserId').rejects(new Error('Service error'));
119169

120170
const requestHandler = findDataRequests();
121171
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

api/src/paths/data-request/index.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Request, RequestHandler } from 'express';
22
import { Operation } from 'express-openapi';
3-
import { SYSTEM_ROLE } from '../../constants/roles';
43
import { getDBConnection } from '../../database/db';
54
import { DataRequestFilters } from '../../models/data-request';
65
import {
@@ -20,8 +19,7 @@ export const GET: Operation = [
2019
return {
2120
and: [
2221
{
23-
validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN],
24-
discriminator: 'SystemRole'
22+
discriminator: 'SystemUser'
2523
}
2624
]
2725
};
@@ -72,7 +70,7 @@ GET.apiDoc = {
7270
in: 'query',
7371
name: 'team_id',
7472
required: false,
75-
schema: { type: 'string', format: 'uuid', description: 'Filter by team ID' }
73+
schema: { type: 'string', format: 'uuid', description: 'Filter by a single team ID' }
7674
},
7775
{
7876
in: 'query',
@@ -139,9 +137,10 @@ export function findDataRequests(): RequestHandler {
139137
await connection.open();
140138

141139
const filters = parseQueryParams(req);
140+
const systemUserId = connection.systemUserId();
142141

143142
const dataRequestService = new DataRequestService(connection);
144-
const dataRequests = await dataRequestService.findDataRequests(filters);
143+
const dataRequests = await dataRequestService.findDataRequestsBySystemUserId(systemUserId, filters);
145144

146145
await connection.commit();
147146

@@ -190,16 +189,16 @@ export function createDataRequest(): RequestHandler {
190189

191190
/**
192191
* Parses query params from the request into a filters object for data request list.
193-
* Returns empty object when no filter params are present.
192+
* Query params are strings or undefined per the OpenAPI spec.
194193
*/
195194
function parseQueryParams(req: Request<unknown, unknown, unknown, DataRequestFilters>): DataRequestFilters {
196-
const { date_from, date_to, requested_by, team_id, status } = req.query;
195+
const q = req.query;
197196
const filters: DataRequestFilters = {
198-
...(date_from && { date_from: String(date_from) }),
199-
...(date_to && { date_to: String(date_to) }),
200-
...(requested_by !== undefined && { requested_by: Number(requested_by) }),
201-
...(team_id && { team_id: String(team_id) }),
202-
...(status && { status: String(status) as DataRequestFilters['status'] })
197+
date_from: q.date_from ?? undefined,
198+
date_to: q.date_to ?? undefined,
199+
requested_by: q.requested_by ? Number(q.requested_by) : undefined,
200+
team_id: q.team_id ?? undefined,
201+
status: q.status ?? undefined
203202
};
204203
return filters;
205204
}

api/src/paths/data-request/{dataRequestId}/index.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,53 @@ import { getLogger } from '../../../utils/logger';
1111
const defaultLog = getLogger('paths/data-request/{dataRequestId}');
1212

1313
export const GET: Operation = [
14-
authorizeRequestHandler(() => {
14+
authorizeRequestHandler((req) => {
1515
return {
16-
and: [
16+
or: [
1717
{
1818
validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN],
1919
discriminator: 'SystemRole'
20+
},
21+
{
22+
discriminator: 'Team',
23+
entity: 'data_request',
24+
dataRequestId: req.params.dataRequestId
2025
}
2126
]
2227
};
2328
}),
2429
getDataRequestById()
2530
];
2631
export const PUT: Operation = [
27-
authorizeRequestHandler(() => {
32+
authorizeRequestHandler((req) => {
2833
return {
29-
and: [
34+
or: [
3035
{
3136
validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN],
3237
discriminator: 'SystemRole'
38+
},
39+
{
40+
discriminator: 'Team',
41+
entity: 'data_request',
42+
dataRequestId: req.params.dataRequestId
3343
}
3444
]
3545
};
3646
}),
3747
updateDataRequest()
3848
];
3949
export const DELETE: Operation = [
40-
authorizeRequestHandler(() => {
50+
authorizeRequestHandler((req) => {
4151
return {
42-
and: [
52+
or: [
4353
{
4454
validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN],
4555
discriminator: 'SystemRole'
56+
},
57+
{
58+
discriminator: 'Team',
59+
entity: 'data_request',
60+
dataRequestId: req.params.dataRequestId
4661
}
4762
]
4863
};

api/src/paths/submission/{submissionId}/features/{submissionFeatureId}/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ export const GET: Operation = [
1414
return {
1515
and: [
1616
{
17-
submissionId: Number(req.params.submissionId),
17+
discriminator: 'Team',
18+
entity: 'submission_feature',
1819
submissionFeatureId: Number(req.params.submissionFeatureId),
19-
discriminator: 'AccessPolicy'
20+
submissionId: Number(req.params.submissionId)
2021
}
2122
]
2223
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { expect } from 'chai';
2+
import { describe } from 'mocha';
3+
import { QueryResult } from 'pg';
4+
import sinon from 'sinon';
5+
import { getMockDBConnection } from '../../__mocks__/db';
6+
import { TeamAuthorizationRepository } from './team-authorization-repository';
7+
8+
describe('TeamAuthorizationRepository', () => {
9+
afterEach(() => {
10+
sinon.restore();
11+
});
12+
13+
describe('findTeamMembershipByDataRequest', () => {
14+
it('returns a record when the user has team access to the data request', async () => {
15+
const mockRow = { data_request_id: 'dr-1', record_end_date: null };
16+
const mockResponse = { rowCount: 1, rows: [mockRow] } as unknown as Promise<QueryResult<any>>;
17+
const mockConnection = getMockDBConnection({ knex: async () => mockResponse });
18+
19+
const repository = new TeamAuthorizationRepository(mockConnection);
20+
const result = await repository.findTeamMembershipByDataRequest(1, 'dr-1');
21+
22+
expect(result).to.eql(mockRow);
23+
});
24+
25+
it('returns null when the user does not have team access to the data request', async () => {
26+
const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise<QueryResult<any>>;
27+
const mockConnection = getMockDBConnection({ knex: async () => mockResponse });
28+
29+
const repository = new TeamAuthorizationRepository(mockConnection);
30+
const result = await repository.findTeamMembershipByDataRequest(1, 'dr-1');
31+
32+
expect(result).to.be.null;
33+
});
34+
});
35+
36+
describe('findTeamPolicyBySubmissionFeature', () => {
37+
it('returns a record when the user has team policy access to the submission feature', async () => {
38+
const mockRow = { team_policy_id: 'tp-1', record_end_date: null };
39+
const mockResponse = { rowCount: 1, rows: [mockRow] } as unknown as QueryResult<any>;
40+
const mockConnection = getMockDBConnection({ sql: async () => mockResponse });
41+
42+
const repository = new TeamAuthorizationRepository(mockConnection);
43+
const result = await repository.findTeamPolicyBySubmissionFeature(1, 100);
44+
45+
expect(result).to.eql(mockRow);
46+
});
47+
48+
it('returns null when the user does not have team policy access to the submission feature', async () => {
49+
const mockResponse = { rowCount: 0, rows: [] } as unknown as QueryResult<any>;
50+
const mockConnection = getMockDBConnection({ sql: async () => mockResponse });
51+
52+
const repository = new TeamAuthorizationRepository(mockConnection);
53+
const result = await repository.findTeamPolicyBySubmissionFeature(1, 100);
54+
55+
expect(result).to.be.null;
56+
});
57+
});
58+
});

0 commit comments

Comments
 (0)