Skip to content

Commit 3750b36

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 3750b36

5 files changed

Lines changed: 123 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: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
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';
811

912
/**
1013
* Returns an Axios instance for the COMS API
14+
* Injects pseudo service account basic auth if no other auth provided
1115
* @param options Axios request config options
1216
* @returns An axios instance
1317
*/
1418
function comsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
19+
// Inject pseudo service account if no other auth provided
20+
if (!options.headers?.Authorization) {
21+
const ak = config.get<string>('server.objectStorage.accessKeyId');
22+
const sak = config.get<string>('server.objectStorage.secretAccessKey');
23+
const b64 = Buffer.from(ak + ':' + sak).toString('base64');
24+
25+
if (!options.headers) options.headers = {};
26+
options.headers.Authorization = `basic ${b64}`;
27+
options.headers['x-amz-bucket'] = config.get('server.objectStorage.bucket');
28+
options.headers['x-amz-endpoint'] = config.get('server.objectStorage.endpoint');
29+
}
30+
1531
// Create axios instance
1632
const instance = axios.create({
1733
baseURL: config.get('frontend.coms.apiPath'),
@@ -23,27 +39,20 @@ function comsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
2339
}
2440

2541
/**
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
42+
* Obtain a bucket record. This actually utilizes the COMS create bucket endpoint as its the only way to
43+
* obtain full bucket information using their pseudo service account access.
3244
* @returns The created bucket data
3345
*/
34-
export const createBucket = async (bearerToken: string, permissions: Action[]) => {
35-
const { data } = await comsAxios({
36-
headers: { Authorization: `Bearer ${bearerToken}` }
37-
}).put('/bucket', {
46+
export const getBucket = async () => {
47+
const { status, headers, data } = await comsAxios().put('/bucket', {
3848
accessKeyId: config.get('server.objectStorage.accessKeyId'),
3949
bucket: config.get('server.objectStorage.bucket'),
4050
bucketName: 'PCNS',
4151
endpoint: config.get('server.objectStorage.endpoint'),
4252
secretAccessKey: config.get('server.objectStorage.secretAccessKey'),
43-
key: config.get('server.objectStorage.key'),
44-
permCodes: permissions
53+
key: config.get('server.objectStorage.key')
4554
});
46-
return data;
55+
return { status, headers, data };
4756
};
4857

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

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)