Skip to content

Commit 6bf641a

Browse files
authored
Move Group and User role assignments to Dynamo table (#38)
* implement reading permissions from dynamodb * fix imports * fix unit tests * update paths * update auth update endpoints * remove outdated config * fix import * fix cfn-lint issues * fix dependencies * fix excess erroring with user roles
1 parent f1d3906 commit 6bf641a

17 files changed

+238
-85
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,15 @@ deploy_dev: check_account_dev build
6666

6767
install:
6868
yarn -D
69+
pip install cfn-lint
6970

7071
test_live_integration: install
7172
yarn test:live
7273

7374
test_unit: install
7475
yarn typecheck
7576
yarn lint
77+
cfn-lint cloudformation/**/* --ignore-templates cloudformation/phony-swagger.yml
7678
yarn prettier
7779
yarn test:unit
7880

cloudformation/main.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ Parameters:
2525

2626
Conditions:
2727
IsProd: !Equals [!Ref RunEnvironment, 'prod']
28-
IsDev: !Equals [!Ref RunEnvironment, 'dev']
2928
ShouldAttachVpc:
3029
!Equals [true, !Ref VpcRequired]
3130

@@ -149,6 +148,7 @@ Resources:
149148
IamGroupRolesTable:
150149
Type: 'AWS::DynamoDB::Table'
151150
DeletionPolicy: "Retain"
151+
UpdateReplacePolicy: "Retain"
152152
Properties:
153153
BillingMode: 'PAY_PER_REQUEST'
154154
TableName: infra-core-api-iam-grouproles
@@ -165,6 +165,7 @@ Resources:
165165
IamUserRolesTable:
166166
Type: 'AWS::DynamoDB::Table'
167167
DeletionPolicy: "Retain"
168+
UpdateReplacePolicy: "Retain"
168169
Properties:
169170
BillingMode: 'PAY_PER_REQUEST'
170171
TableName: infra-core-api-iam-userroles
@@ -181,6 +182,7 @@ Resources:
181182
EventRecordsTable:
182183
Type: 'AWS::DynamoDB::Table'
183184
DeletionPolicy: "Retain"
185+
UpdateReplacePolicy: "Retain"
184186
Properties:
185187
BillingMode: 'PAY_PER_REQUEST'
186188
TableName: infra-core-api-events
@@ -206,6 +208,7 @@ Resources:
206208
CacheRecordsTable:
207209
Type: 'AWS::DynamoDB::Table'
208210
DeletionPolicy: "Retain"
211+
UpdateReplacePolicy: "Retain"
209212
Properties:
210213
BillingMode: 'PAY_PER_REQUEST'
211214
TableName: infra-core-api-cache

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"lint": "yarn workspaces run lint",
1818
"prepare": "node .husky/install.mjs || true",
1919
"typecheck": "yarn workspaces run typecheck",
20-
"test:unit": "vitest run tests/unit && yarn workspace infra-core-ui run test:unit",
20+
"test:unit": "vitest run tests/unit --config tests/unit/vitest.config.ts && yarn workspace infra-core-ui run test:unit",
2121
"test:unit-ui": "yarn test:unit --ui",
2222
"test:unit-watch": "vitest tests/unit",
2323
"test:live": "vitest tests/live",
@@ -44,6 +44,7 @@
4444
"jwks-rsa": "^3.1.0",
4545
"moment": "^2.30.1",
4646
"moment-timezone": "^0.5.45",
47+
"node-cache": "^5.1.2",
4748
"pluralize": "^8.0.0",
4849
"zod": "^3.23.8",
4950
"zod-to-json-schema": "^3.23.2",
@@ -105,4 +106,4 @@
105106
"resolutions": {
106107
"pdfjs-dist": "^4.8.69"
107108
}
108-
}
109+
}

src/api/functions/authorization.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
DynamoDBClient,
3+
GetItemCommand,
4+
QueryCommand,
5+
} from "@aws-sdk/client-dynamodb";
6+
import { unmarshall } from "@aws-sdk/util-dynamodb";
7+
import { genericConfig } from "../../common/config.js";
8+
import { DatabaseFetchError } from "../../common/errors/index.js";
9+
import { allAppRoles, AppRoles } from "../../common/roles.js";
10+
import { FastifyInstance } from "fastify";
11+
12+
export const AUTH_DECISION_CACHE_SECONDS = 180;
13+
14+
export async function getUserRoles(
15+
dynamoClient: DynamoDBClient,
16+
fastifyApp: FastifyInstance,
17+
userId: string,
18+
): Promise<AppRoles[]> {
19+
const cachedValue = fastifyApp.nodeCache.get(`userroles-${userId}`);
20+
if (cachedValue) {
21+
fastifyApp.log.info(`Returning cached auth decision for user ${userId}`);
22+
return cachedValue as AppRoles[];
23+
}
24+
const tableName = `${genericConfig["IAMTablePrefix"]}-userroles`;
25+
const command = new GetItemCommand({
26+
TableName: tableName,
27+
Key: {
28+
userEmail: { S: userId },
29+
},
30+
});
31+
const response = await dynamoClient.send(command);
32+
if (!response) {
33+
throw new DatabaseFetchError({
34+
message: "Could not get user roles",
35+
});
36+
}
37+
if (!response.Item) {
38+
return [];
39+
}
40+
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
41+
if (!("roles" in items)) {
42+
return [];
43+
}
44+
if (items["roles"][0] === "all") {
45+
fastifyApp.nodeCache.set(
46+
`userroles-${userId}`,
47+
allAppRoles,
48+
AUTH_DECISION_CACHE_SECONDS,
49+
);
50+
return allAppRoles;
51+
}
52+
fastifyApp.nodeCache.set(
53+
`userroles-${userId}`,
54+
items["roles"],
55+
AUTH_DECISION_CACHE_SECONDS,
56+
);
57+
return items["roles"] as AppRoles[];
58+
}
59+
60+
export async function getGroupRoles(
61+
dynamoClient: DynamoDBClient,
62+
fastifyApp: FastifyInstance,
63+
groupId: string,
64+
) {
65+
const cachedValue = fastifyApp.nodeCache.get(`grouproles-${groupId}`);
66+
if (cachedValue) {
67+
fastifyApp.log.info(`Returning cached auth decision for group ${groupId}`);
68+
return cachedValue as AppRoles[];
69+
}
70+
const tableName = `${genericConfig["IAMTablePrefix"]}-grouproles`;
71+
const command = new GetItemCommand({
72+
TableName: tableName,
73+
Key: {
74+
groupUuid: { S: groupId },
75+
},
76+
});
77+
const response = await dynamoClient.send(command);
78+
if (!response) {
79+
throw new DatabaseFetchError({
80+
message: "Could not get group roles for user",
81+
});
82+
}
83+
if (!response.Item) {
84+
fastifyApp.nodeCache.set(
85+
`grouproles-${groupId}`,
86+
[],
87+
AUTH_DECISION_CACHE_SECONDS,
88+
);
89+
return [];
90+
}
91+
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
92+
if (!("roles" in items)) {
93+
fastifyApp.nodeCache.set(
94+
`grouproles-${groupId}`,
95+
[],
96+
AUTH_DECISION_CACHE_SECONDS,
97+
);
98+
return [];
99+
}
100+
if (items["roles"][0] === "all") {
101+
fastifyApp.nodeCache.set(
102+
`grouproles-${groupId}`,
103+
allAppRoles,
104+
AUTH_DECISION_CACHE_SECONDS,
105+
);
106+
return allAppRoles;
107+
}
108+
fastifyApp.nodeCache.set(
109+
`grouproles-${groupId}`,
110+
items["roles"],
111+
AUTH_DECISION_CACHE_SECONDS,
112+
);
113+
return items["roles"] as AppRoles[];
114+
}

src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as dotenv from "dotenv";
1818
import iamRoutes from "./routes/iam.js";
1919
import ticketsPlugin from "./routes/tickets.js";
2020
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
21+
import NodeCache from "node-cache";
2122

2223
dotenv.config();
2324

@@ -68,6 +69,7 @@ async function init() {
6869
app.runEnvironment = process.env.RunEnvironment as RunEnvironment;
6970
app.environmentConfig =
7071
environmentConfig[app.runEnvironment as RunEnvironment];
72+
app.nodeCache = new NodeCache({ checkperiod: 30 });
7173
app.addHook("onRequest", (req, _, done) => {
7274
req.startTime = now();
7375
const hostname = req.hostname;

src/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"prettier:write": "prettier --write *.ts **/*.ts"
1616
},
1717
"dependencies": {
18-
"@aws-sdk/client-sts": "^3.726.0"
18+
"@aws-sdk/client-sts": "^3.726.0",
19+
"node-cache": "^5.1.2"
1920
}
2021
}

src/api/plugins/auth.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
UnauthorizedError,
1515
} from "../../common/errors/index.js";
1616
import { genericConfig, SecretConfig } from "../../common/config.js";
17+
import { getGroupRoles, getUserRoles } from "../functions/authorization.js";
18+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
1719

1820
function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
1921
const _intersection = new Set<T>();
@@ -55,6 +57,10 @@ const smClient = new SecretsManagerClient({
5557
region: genericConfig.AwsRegion,
5658
});
5759

60+
const dynamoClient = new DynamoDBClient({
61+
region: genericConfig.AwsRegion,
62+
});
63+
5864
export const getSecretValue = async (
5965
secretId: string,
6066
): Promise<Record<string, string | number | boolean> | null | SecretConfig> => {
@@ -159,17 +165,19 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
159165
request.tokenPayload = verifiedTokenData;
160166
request.username = verifiedTokenData.email || verifiedTokenData.sub;
161167
const expectedRoles = new Set(validRoles);
162-
if (
163-
verifiedTokenData.groups &&
164-
fastify.environmentConfig.GroupRoleMapping
165-
) {
166-
for (const group of verifiedTokenData.groups) {
167-
if (fastify.environmentConfig["GroupRoleMapping"][group]) {
168-
for (const role of fastify.environmentConfig["GroupRoleMapping"][
169-
group
170-
]) {
168+
if (verifiedTokenData.groups) {
169+
const groupRoles = await Promise.allSettled(
170+
verifiedTokenData.groups.map((x) =>
171+
getGroupRoles(dynamoClient, fastify, x),
172+
),
173+
);
174+
for (const result of groupRoles) {
175+
if (result.status === "fulfilled") {
176+
for (const role of result.value) {
171177
userRoles.add(role);
172178
}
179+
} else {
180+
request.log.warn(`Failed to get group roles: ${result.reason}`);
173181
}
174182
}
175183
} else {
@@ -188,14 +196,22 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
188196
}
189197
}
190198
}
199+
191200
// add user-specific role overrides
192-
if (request.username && fastify.environmentConfig.UserRoleMapping) {
193-
if (fastify.environmentConfig["UserRoleMapping"][request.username]) {
194-
for (const role of fastify.environmentConfig["UserRoleMapping"][
195-
request.username
196-
]) {
201+
if (request.username) {
202+
try {
203+
const userAuth = await getUserRoles(
204+
dynamoClient,
205+
fastify,
206+
request.username,
207+
);
208+
for (const role of userAuth) {
197209
userRoles.add(role);
198210
}
211+
} catch (e) {
212+
request.log.warn(
213+
`Failed to get user role mapping for ${request.username}: ${e}`,
214+
);
199215
}
200216
}
201217
if (
@@ -216,13 +232,14 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
216232
});
217233
}
218234
if (err instanceof Error) {
219-
request.log.error(`Failed to verify JWT: ${err.toString()}`);
235+
request.log.error(`Failed to verify JWT: ${err.toString()} `);
236+
throw err;
220237
}
221238
throw new UnauthenticatedError({
222239
message: "Invalid token.",
223240
});
224241
}
225-
request.log.info(`authenticated request from ${request.username}`);
242+
request.log.info(`authenticated request from ${request.username} `);
226243
return userRoles;
227244
},
228245
);

src/api/routes/iam.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FastifyPluginAsync } from "fastify";
2-
import { AppRoles } from "../../common/roles.js";
2+
import { allAppRoles, AppRoles } from "../../common/roles.js";
33
import { zodToJsonSchema } from "zod-to-json-schema";
44
import {
55
addToTenant,
@@ -34,6 +34,10 @@ import {
3434
EntraGroupActions,
3535
entraGroupMembershipListResponse,
3636
} from "../../common/types/iam.js";
37+
import {
38+
AUTH_DECISION_CACHE_SECONDS,
39+
getGroupRoles,
40+
} from "../functions/authorization.js";
3741

3842
const dynamoClient = new DynamoDBClient({
3943
region: genericConfig.AwsRegion,
@@ -44,7 +48,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
4448
Body: undefined;
4549
Querystring: { groupId: string };
4650
}>(
47-
"/groupRoles/:groupId",
51+
"/groups/:groupId/roles",
4852
{
4953
schema: {
5054
querystring: {
@@ -61,19 +65,10 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
6165
},
6266
},
6367
async (request, reply) => {
64-
const groupId = (request.params as Record<string, string>).groupId;
6568
try {
66-
const command = new GetItemCommand({
67-
TableName: `${genericConfig.IAMTablePrefix}-grouproles`,
68-
Key: { groupUuid: { S: groupId } },
69-
});
70-
const response = await dynamoClient.send(command);
71-
if (!response.Item) {
72-
throw new NotFoundError({
73-
endpointName: `/api/v1/iam/groupRoles/${groupId}`,
74-
});
75-
}
76-
reply.send(unmarshall(response.Item));
69+
const groupId = (request.params as Record<string, string>).groupId;
70+
const roles = await getGroupRoles(dynamoClient, fastify, groupId);
71+
return reply.send(roles);
7772
} catch (e: unknown) {
7873
if (e instanceof BaseError) {
7974
throw e;
@@ -90,7 +85,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
9085
Body: GroupMappingCreatePostRequest;
9186
Querystring: { groupId: string };
9287
}>(
93-
"/groupRoles/:groupId",
88+
"/groups/:groupId/roles",
9489
{
9590
schema: {
9691
querystring: {
@@ -125,9 +120,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
125120
createdAt: timestamp,
126121
}),
127122
});
128-
129123
await dynamoClient.send(command);
124+
fastify.nodeCache.set(
125+
`grouproles-${groupId}`,
126+
request.body.roles,
127+
AUTH_DECISION_CACHE_SECONDS,
128+
);
130129
} catch (e: unknown) {
130+
fastify.nodeCache.del(`grouproles-${groupId}`);
131131
if (e instanceof BaseError) {
132132
throw e;
133133
}
@@ -140,7 +140,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
140140
reply.send({ message: "OK" });
141141
request.log.info(
142142
{ type: "audit", actor: request.username, target: groupId },
143-
`set group ID roles to ${request.body.roles.toString()}`,
143+
`set target roles to ${request.body.roles.toString()}`,
144144
);
145145
},
146146
);

0 commit comments

Comments
 (0)