Skip to content

Commit 2b76fe4

Browse files
committed
chore: update COMS service
Use pseudo service account where required. Update how COMS permissions are applied for external users.
1 parent d5968b1 commit 2b76fe4

5 files changed

Lines changed: 125 additions & 80 deletions

File tree

app/src/controllers/accessRequest.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const createUserAccessRequestController = async (
8787
}
8888

8989
// Assign new group
90-
await assignGroup(tx, req.currentContext.bearerToken, user.sub, accessRequest.groupId);
90+
await assignGroup(tx, user.sub, accessRequest.groupId);
9191

9292
// Mock an access request for the response
9393
data = {
@@ -98,7 +98,7 @@ export const createUserAccessRequestController = async (
9898
};
9999
} else if (isAdmin) {
100100
if (accessRequest.grant) {
101-
await assignGroup(tx, req.currentContext.bearerToken, user.sub, accessRequest.groupId);
101+
await assignGroup(tx, user.sub, accessRequest.groupId);
102102
// Mock an access request for the response
103103
data = {
104104
userId: userResponse?.userId,
@@ -157,7 +157,7 @@ export const processUserAccessRequestController = async (
157157
throw new Problem(409, { detail: 'User must be an IDIR user to be assigned this role' });
158158
}
159159

160-
await assignGroup(tx, undefined, userResponse.sub, accessRequest.groupId);
160+
await assignGroup(tx, userResponse.sub, accessRequest.groupId);
161161
} else {
162162
// Remove requested group if provided - otherwise remove all user groups for initiative
163163
const groupsToRemove = accessRequest.groupId

app/src/middleware/requireSomeGroup.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { transactionWrapper } from '../db/utils/transactionWrapper.ts';
2+
import { assignPermissions } from '../services/coms.ts';
23
import { assignGroup, getGroups, getSubjectGroups } from '../services/yars.ts';
34
import { Problem } from '../utils/index.ts';
45
import { GroupName, IdentityProviderKind, Initiative } from '../utils/enums/application.ts';
@@ -17,30 +18,37 @@ import type { PrismaTransactionClient } from '../db/dataConnection.ts';
1718
*/
1819
export const requireSomeGroup = async (req: Request, _res: Response, next: NextFunction) => {
1920
await transactionWrapper<void>(async (tx: PrismaTransactionClient) => {
20-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21-
const idp = (req.currentContext?.tokenPayload as any).identity_provider;
22-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23-
const sub = (req.currentContext?.tokenPayload as any).sub;
21+
const idp = req.currentContext?.tokenPayload?.identity_provider;
22+
const sub = req.currentContext?.tokenPayload?.sub;
23+
24+
if (!sub) {
25+
throw new Problem(403, {
26+
detail: 'Unable to obtain token sub',
27+
instance: req.originalUrl
28+
});
29+
}
2430

2531
let groups = await getSubjectGroups(tx, sub);
2632

2733
if (idp !== IdentityProviderKind.IDIR) {
2834
const required = [Initiative.ELECTRIFICATION, Initiative.HOUSING, Initiative.PCNS];
2935
const missing = required.filter((x) => !groups.some((g) => g.initiativeCode === x));
30-
await Promise.all(
31-
missing.map(async (x) => {
32-
const g = await getGroups(tx, x);
33-
34-
await assignGroup(
35-
tx,
36-
req.currentContext.bearerToken,
37-
sub,
38-
g.find((x) => x.name === GroupName.PROPONENT)?.groupId
39-
);
40-
})
41-
);
42-
43-
groups = await getSubjectGroups(tx, sub);
36+
37+
if (missing.length) {
38+
await Promise.all(
39+
missing.map(async (x) => {
40+
const g = await getGroups(tx, x);
41+
42+
const groupId = g.find((x) => x.name === GroupName.PROPONENT)?.groupId;
43+
if (groupId) await assignGroup(tx, sub, groupId);
44+
})
45+
);
46+
47+
groups = await getSubjectGroups(tx, sub);
48+
49+
// Assign COMS permissions
50+
await assignPermissions(tx, req.currentContext);
51+
}
4452
}
4553

4654
if (groups.length === 0) {

app/src/services/coms.ts

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,45 @@
11
import axios from 'axios';
22
import config from 'config';
33

4-
import { Action } from '../utils/enums/application.ts';
5-
import { Problem, uuidValidateV4 } from '../utils/index.ts';
4+
import { Action, GroupName } from '../utils/enums/application.ts';
5+
import { getCurrentSubject, Problem, uuidValidateV4 } from '../utils/index.ts';
6+
import { getSubjectGroups } from './yars.ts';
67

78
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
9+
import type { PrismaTransactionClient } from '../db/dataConnection.ts';
10+
import type { CurrentContext } from '../types/stuff';
11+
12+
/**
13+
* PCNS Groups to COMS Permission mappings
14+
*/
15+
const COMS_PERM_MAP = new Map<GroupName, Action[]>([
16+
[GroupName.PROPONENT, [Action.CREATE]],
17+
[GroupName.NAVIGATOR_READ_ONLY, [Action.READ]],
18+
[GroupName.NAVIGATOR, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
19+
[GroupName.SUPERVISOR, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
20+
[GroupName.ADMIN, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
21+
[GroupName.DEVELOPER, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]]
22+
]);
823

924
/**
1025
* Returns an Axios instance for the COMS API
26+
* Injects pseudo service account basic auth if no other auth provided
1127
* @param options Axios request config options
1228
* @returns An axios instance
1329
*/
1430
function comsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
31+
// Inject pseudo service account if no other auth provided
32+
if (!options.headers?.Authorization) {
33+
const ak = config.get<string>('server.objectStorage.accessKeyId');
34+
const sak = config.get<string>('server.objectStorage.secretAccessKey');
35+
const b64 = Buffer.from(ak + ':' + sak).toString('base64');
36+
37+
if (!options.headers) options.headers = {};
38+
options.headers.Authorization = `Basic ${b64}`;
39+
options.headers['x-amz-bucket'] = config.get('server.objectStorage.bucket');
40+
options.headers['x-amz-endpoint'] = config.get('server.objectStorage.endpoint');
41+
}
42+
1543
// Create axios instance
1644
const instance = axios.create({
1745
baseURL: config.get('frontend.coms.apiPath'),
@@ -23,27 +51,20 @@ function comsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
2351
}
2452

2553
/**
26-
* Creates a bucket record. Bucket should exist in S3. If the set of bucket, endpoint and key match
27-
* an existing record, the user will be added to that existing bucket with the provided permissions
28-
* instead of generating a new bucket record.
29-
* This endpoint can be used to grant the current user permission to upload to a new or existing bucket.
30-
* @param bearerToken The bearer token of the authorized user
31-
* @param permissions An array of permissions to grant the user
54+
* Obtain a bucket record. This actually utilizes the COMS create bucket endpoint as its the only way to
55+
* obtain full bucket information using their pseudo service account access.
3256
* @returns The created bucket data
3357
*/
34-
export const createBucket = async (bearerToken: string, permissions: Action[]) => {
35-
const { data } = await comsAxios({
36-
headers: { Authorization: `Bearer ${bearerToken}` }
37-
}).put('/bucket', {
58+
export const getBucket = async () => {
59+
const { status, headers, data } = await comsAxios().put('/bucket', {
3860
accessKeyId: config.get('server.objectStorage.accessKeyId'),
3961
bucket: config.get('server.objectStorage.bucket'),
4062
bucketName: 'PCNS',
4163
endpoint: config.get('server.objectStorage.endpoint'),
4264
secretAccessKey: config.get('server.objectStorage.secretAccessKey'),
43-
key: config.get('server.objectStorage.key'),
44-
permCodes: permissions
65+
key: config.get('server.objectStorage.key')
4566
});
46-
return data;
67+
return { status, headers, data };
4768
};
4869

4970
/**
@@ -62,3 +83,61 @@ export const getObject = async (bearerToken: string, objectId: string) => {
6283
}).get(`/object/${objectId}`);
6384
return { status, headers, data };
6485
};
86+
87+
/**
88+
* Obtain the current user information in COMS
89+
* @param currentContext The current context of the Express request
90+
* @returns The COMS response
91+
*/
92+
export const searchUser = async (currentContext: CurrentContext) => {
93+
const { status, headers, data } = await comsAxios({
94+
headers: { Authorization: `Bearer ${currentContext.bearerToken}` }
95+
// Update to use username (sub) if COMS ever opens that up
96+
}).get('/user', { params: { identityId: getCurrentSubject(currentContext).split('@')[0].toUpperCase() } });
97+
return { status, headers, data };
98+
};
99+
100+
/**
101+
* Assigns COMS permissions to the current user based on their current groups
102+
* @param tx Prisma transaction client
103+
* @param currentContext The current context of the Express request
104+
*/
105+
export const assignPermissions = async (tx: PrismaTransactionClient, currentContext: CurrentContext) => {
106+
const sub = currentContext.tokenPayload?.sub;
107+
108+
if (!sub) {
109+
throw new Problem(403, {
110+
detail: 'Unable to obtain token sub'
111+
});
112+
}
113+
114+
const groups = await getSubjectGroups(tx, sub);
115+
const groupNames = new Set(groups.map((x) => x.name));
116+
117+
const actions = new Set<Action>([...groupNames].flatMap((groupName) => COMS_PERM_MAP.get(groupName) ?? []));
118+
119+
if (actions.size > 0 && currentContext) {
120+
try {
121+
const [user, bucket] = await Promise.all([searchUser(currentContext), getBucket()]);
122+
123+
const { userId } = user.data[0];
124+
const { bucketId } = bucket.data;
125+
126+
if (!userId || !bucketId) {
127+
throw new Error('Unable to obtain userId or bucketId');
128+
}
129+
130+
const permissions = [...actions].flatMap((action) => ({ permCode: action, userId }));
131+
132+
await comsAxios().put(`/permission/bucket/${bucketId}`, permissions);
133+
} catch (e) {
134+
if (axios.isAxiosError(e)) {
135+
const detail = e.response?.data.detail;
136+
const status = e.response ? e.response.status : 500;
137+
throw new Problem(status, { detail }, { extra: { comsError: e.response?.data } });
138+
} else {
139+
throw new Problem(500, { detail: 'Server Error' });
140+
}
141+
}
142+
}
143+
};

app/src/services/yars.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
/* TODO: Create group policy details type and set explicit return types */
2-
3-
import { createBucket } from './coms.ts';
4-
import { Initiative, GroupName, Action } from '../utils/enums/application.ts';
2+
import { Initiative, GroupName } from '../utils/enums/application.ts';
53

64
import type { PrismaTransactionClient } from '../db/dataConnection.ts';
75
import type { Group } from '../types/index.ts';
@@ -10,16 +8,14 @@ import type { Group } from '../types/index.ts';
108
* Assigns an identity to the given group
119
* Assigns permissions to COMS based on the given group
1210
* @param tx Prisma transaction client
13-
* @param bearerToken The bearer token of the authorized user
1411
* @param sub Subject of the authorized user
1512
* @param groupId The group ID to add the user to
1613
* @returns A Promise that resolve to an object with a subject and role id
1714
*/
1815
export const assignGroup = async (
1916
tx: PrismaTransactionClient,
20-
bearerToken: string | undefined,
2117
sub: string,
22-
groupId?: number
18+
groupId: number
2319
): Promise<{ sub: string; roleId: number }> => {
2420
const groupResult = await tx.group.findFirstOrThrow({
2521
where: {
@@ -34,20 +30,6 @@ export const assignGroup = async (
3430
}
3531
});
3632

37-
const comsPermsMap = new Map<GroupName, Action[]>([
38-
[GroupName.PROPONENT, [Action.CREATE]],
39-
[GroupName.NAVIGATOR, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
40-
[GroupName.NAVIGATOR_READ_ONLY, [Action.READ]],
41-
[GroupName.SUPERVISOR, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
42-
[GroupName.ADMIN, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]],
43-
[GroupName.DEVELOPER, [Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE]]
44-
]);
45-
46-
const comsPerms = comsPermsMap.get(groupResult.name as GroupName);
47-
if (comsPerms && bearerToken) {
48-
await createBucket(bearerToken, comsPerms);
49-
}
50-
5133
return { sub: result.sub, roleId: result.groupId };
5234
};
5335

app/tests/unit/services/coms.spec.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import axios from 'axios';
2-
import config from 'config';
32

43
import * as comsService from '../../../src/services/coms.ts';
5-
import { Action } from '../../../src/utils/enums/application.ts';
64

75
// Mock config library - @see {@link https://stackoverflow.com/a/64819698}
86
jest.mock('config');
9-
let mockedConfig = config as jest.MockedObjectDeep<typeof config>;
10-
117
jest.mock('axios');
128
let mockedAxios = axios as jest.MockedObjectDeep<typeof axios>;
139

1410
beforeEach(() => {
15-
mockedConfig = config as jest.MockedObjectDeep<typeof config>;
1611
mockedAxios = axios as jest.MockedObjectDeep<typeof axios>;
1712

1813
// Replace any instances with the mocked instance
@@ -28,25 +23,6 @@ afterEach(() => {
2823
jest.resetAllMocks();
2924
});
3025

31-
describe('createBucket', () => {
32-
it('calls POST /bucket with correct body and returns result', async () => {
33-
mockedConfig.get.mockImplementation(() => '');
34-
mockedAxios.put.mockResolvedValueOnce({ data: { object: 'ABC' } });
35-
const response = await comsService.createBucket('BEARER', [Action.CREATE]);
36-
37-
expect(mockedAxios.put).toHaveBeenCalledWith('/bucket', {
38-
accessKeyId: expect.any(String),
39-
bucket: expect.any(String),
40-
bucketName: 'PCNS',
41-
endpoint: expect.any(String),
42-
secretAccessKey: expect.any(String),
43-
key: expect.any(String),
44-
permCodes: [Action.CREATE]
45-
});
46-
expect(response).toStrictEqual({ object: 'ABC' });
47-
});
48-
});
49-
5026
describe('getObject', () => {
5127
it('calls GET /object with correct parameters and returns result', async () => {
5228
mockedAxios.get.mockResolvedValueOnce({ status: 200, headers: '', data: { object: 'ABC' } });

0 commit comments

Comments
 (0)