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

Move Group and User role assignments to Dynamo table #38

Merged
merged 10 commits into from
Jan 22, 2025
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: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,15 @@ deploy_dev: check_account_dev build

install:
yarn -D
pip install cfn-lint

test_live_integration: install
yarn test:live

test_unit: install
yarn typecheck
yarn lint
cfn-lint cloudformation/**/* --ignore-templates cloudformation/phony-swagger.yml
yarn prettier
yarn test:unit

Expand Down
5 changes: 4 additions & 1 deletion cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ Parameters:

Conditions:
IsProd: !Equals [!Ref RunEnvironment, 'prod']
IsDev: !Equals [!Ref RunEnvironment, 'dev']
ShouldAttachVpc:
!Equals [true, !Ref VpcRequired]

Expand Down Expand Up @@ -149,6 +148,7 @@ Resources:
IamGroupRolesTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
UpdateReplacePolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-iam-grouproles
Expand All @@ -165,6 +165,7 @@ Resources:
IamUserRolesTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
UpdateReplacePolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-iam-userroles
Expand All @@ -181,6 +182,7 @@ Resources:
EventRecordsTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
UpdateReplacePolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-events
Expand All @@ -206,6 +208,7 @@ Resources:
CacheRecordsTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
UpdateReplacePolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-cache
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"lint": "yarn workspaces run lint",
"prepare": "node .husky/install.mjs || true",
"typecheck": "yarn workspaces run typecheck",
"test:unit": "vitest run tests/unit && yarn workspace infra-core-ui run test:unit",
"test:unit": "vitest run tests/unit --config tests/unit/vitest.config.ts && yarn workspace infra-core-ui run test:unit",
"test:unit-ui": "yarn test:unit --ui",
"test:unit-watch": "vitest tests/unit",
"test:live": "vitest tests/live",
Expand All @@ -44,6 +44,7 @@
"jwks-rsa": "^3.1.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"node-cache": "^5.1.2",
"pluralize": "^8.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.2",
Expand Down Expand Up @@ -105,4 +106,4 @@
"resolutions": {
"pdfjs-dist": "^4.8.69"
}
}
}
114 changes: 114 additions & 0 deletions src/api/functions/authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
DynamoDBClient,
GetItemCommand,
QueryCommand,

Check warning on line 4 in src/api/functions/authorization.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'QueryCommand' is defined but never used. Allowed unused vars must match /^_/u
} from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { genericConfig } from "../../common/config.js";
import { DatabaseFetchError } from "../../common/errors/index.js";
import { allAppRoles, AppRoles } from "../../common/roles.js";
import { FastifyInstance } from "fastify";

export const AUTH_DECISION_CACHE_SECONDS = 180;

export async function getUserRoles(
dynamoClient: DynamoDBClient,
fastifyApp: FastifyInstance,
userId: string,
): Promise<AppRoles[]> {
const cachedValue = fastifyApp.nodeCache.get(`userroles-${userId}`);
if (cachedValue) {
fastifyApp.log.info(`Returning cached auth decision for user ${userId}`);
return cachedValue as AppRoles[];
}
const tableName = `${genericConfig["IAMTablePrefix"]}-userroles`;
const command = new GetItemCommand({
TableName: tableName,
Key: {
userEmail: { S: userId },
},
});
const response = await dynamoClient.send(command);
if (!response) {
throw new DatabaseFetchError({
message: "Could not get user roles",
});
}
if (!response.Item) {
return [];
}
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
if (!("roles" in items)) {
return [];
}
if (items["roles"][0] === "all") {
fastifyApp.nodeCache.set(
`userroles-${userId}`,
allAppRoles,
AUTH_DECISION_CACHE_SECONDS,
);
return allAppRoles;
}
fastifyApp.nodeCache.set(
`userroles-${userId}`,
items["roles"],
AUTH_DECISION_CACHE_SECONDS,
);
return items["roles"] as AppRoles[];
}

export async function getGroupRoles(
dynamoClient: DynamoDBClient,
fastifyApp: FastifyInstance,
groupId: string,
) {
const cachedValue = fastifyApp.nodeCache.get(`grouproles-${groupId}`);
if (cachedValue) {
fastifyApp.log.info(`Returning cached auth decision for group ${groupId}`);
return cachedValue as AppRoles[];
}
const tableName = `${genericConfig["IAMTablePrefix"]}-grouproles`;
const command = new GetItemCommand({
TableName: tableName,
Key: {
groupUuid: { S: groupId },
},
});
const response = await dynamoClient.send(command);
if (!response) {
throw new DatabaseFetchError({
message: "Could not get group roles for user",
});
}
if (!response.Item) {
fastifyApp.nodeCache.set(
`grouproles-${groupId}`,
[],
AUTH_DECISION_CACHE_SECONDS,
);
return [];
}
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
if (!("roles" in items)) {
fastifyApp.nodeCache.set(
`grouproles-${groupId}`,
[],
AUTH_DECISION_CACHE_SECONDS,
);
return [];
}
if (items["roles"][0] === "all") {
fastifyApp.nodeCache.set(
`grouproles-${groupId}`,
allAppRoles,
AUTH_DECISION_CACHE_SECONDS,
);
return allAppRoles;
}
fastifyApp.nodeCache.set(
`grouproles-${groupId}`,
items["roles"],
AUTH_DECISION_CACHE_SECONDS,
);
return items["roles"] as AppRoles[];
}
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as dotenv from "dotenv";
import iamRoutes from "./routes/iam.js";
import ticketsPlugin from "./routes/tickets.js";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import NodeCache from "node-cache";

dotenv.config();

Expand Down Expand Up @@ -68,6 +69,7 @@ async function init() {
app.runEnvironment = process.env.RunEnvironment as RunEnvironment;
app.environmentConfig =
environmentConfig[app.runEnvironment as RunEnvironment];
app.nodeCache = new NodeCache({ checkperiod: 30 });
app.addHook("onRequest", (req, _, done) => {
req.startTime = now();
const hostname = req.hostname;
Expand Down
3 changes: 2 additions & 1 deletion src/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"prettier:write": "prettier --write *.ts **/*.ts"
},
"dependencies": {
"@aws-sdk/client-sts": "^3.726.0"
"@aws-sdk/client-sts": "^3.726.0",
"node-cache": "^5.1.2"
}
}
49 changes: 33 additions & 16 deletions src/api/plugins/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
UnauthorizedError,
} from "../../common/errors/index.js";
import { genericConfig, SecretConfig } from "../../common/config.js";
import { getGroupRoles, getUserRoles } from "../functions/authorization.js";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

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

const dynamoClient = new DynamoDBClient({
region: genericConfig.AwsRegion,
});

export const getSecretValue = async (
secretId: string,
): Promise<Record<string, string | number | boolean> | null | SecretConfig> => {
Expand Down Expand Up @@ -159,17 +165,19 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
request.tokenPayload = verifiedTokenData;
request.username = verifiedTokenData.email || verifiedTokenData.sub;
const expectedRoles = new Set(validRoles);
if (
verifiedTokenData.groups &&
fastify.environmentConfig.GroupRoleMapping
) {
for (const group of verifiedTokenData.groups) {
if (fastify.environmentConfig["GroupRoleMapping"][group]) {
for (const role of fastify.environmentConfig["GroupRoleMapping"][
group
]) {
if (verifiedTokenData.groups) {
const groupRoles = await Promise.allSettled(
verifiedTokenData.groups.map((x) =>
getGroupRoles(dynamoClient, fastify, x),
),
);
for (const result of groupRoles) {
if (result.status === "fulfilled") {
for (const role of result.value) {
userRoles.add(role);
}
} else {
request.log.warn(`Failed to get group roles: ${result.reason}`);
}
}
} else {
Expand All @@ -188,14 +196,22 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
}
}
}

// add user-specific role overrides
if (request.username && fastify.environmentConfig.UserRoleMapping) {
if (fastify.environmentConfig["UserRoleMapping"][request.username]) {
for (const role of fastify.environmentConfig["UserRoleMapping"][
request.username
]) {
if (request.username) {
try {
const userAuth = await getUserRoles(
dynamoClient,
fastify,
request.username,
);
for (const role of userAuth) {
userRoles.add(role);
}
} catch (e) {
request.log.warn(
`Failed to get user role mapping for ${request.username}: ${e}`,
);
}
}
if (
Expand All @@ -216,13 +232,14 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
});
}
if (err instanceof Error) {
request.log.error(`Failed to verify JWT: ${err.toString()}`);
request.log.error(`Failed to verify JWT: ${err.toString()} `);
throw err;
}
throw new UnauthenticatedError({
message: "Invalid token.",
});
}
request.log.info(`authenticated request from ${request.username}`);
request.log.info(`authenticated request from ${request.username} `);
return userRoles;
},
);
Expand Down
34 changes: 17 additions & 17 deletions src/api/routes/iam.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FastifyPluginAsync } from "fastify";
import { AppRoles } from "../../common/roles.js";
import { allAppRoles, AppRoles } from "../../common/roles.js";

Check warning on line 2 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'allAppRoles' is defined but never used. Allowed unused vars must match /^_/u
import { zodToJsonSchema } from "zod-to-json-schema";
import {
addToTenant,
Expand All @@ -18,11 +18,11 @@
} from "../../common/errors/index.js";
import {
DynamoDBClient,
GetItemCommand,

Check warning on line 21 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'GetItemCommand' is defined but never used. Allowed unused vars must match /^_/u
PutItemCommand,
} from "@aws-sdk/client-dynamodb";
import { genericConfig } from "../../common/config.js";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";

Check warning on line 25 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'unmarshall' is defined but never used. Allowed unused vars must match /^_/u
import {
InviteUserPostRequest,
invitePostRequestSchema,
Expand All @@ -34,6 +34,10 @@
EntraGroupActions,
entraGroupMembershipListResponse,
} from "../../common/types/iam.js";
import {
AUTH_DECISION_CACHE_SECONDS,
getGroupRoles,
} from "../functions/authorization.js";

const dynamoClient = new DynamoDBClient({
region: genericConfig.AwsRegion,
Expand All @@ -44,7 +48,7 @@
Body: undefined;
Querystring: { groupId: string };
}>(
"/groupRoles/:groupId",
"/groups/:groupId/roles",
{
schema: {
querystring: {
Expand All @@ -61,19 +65,10 @@
},
},
async (request, reply) => {
const groupId = (request.params as Record<string, string>).groupId;
try {
const command = new GetItemCommand({
TableName: `${genericConfig.IAMTablePrefix}-grouproles`,
Key: { groupUuid: { S: groupId } },
});
const response = await dynamoClient.send(command);
if (!response.Item) {
throw new NotFoundError({
endpointName: `/api/v1/iam/groupRoles/${groupId}`,
});
}
reply.send(unmarshall(response.Item));
const groupId = (request.params as Record<string, string>).groupId;
const roles = await getGroupRoles(dynamoClient, fastify, groupId);
return reply.send(roles);
} catch (e: unknown) {
if (e instanceof BaseError) {
throw e;
Expand All @@ -90,7 +85,7 @@
Body: GroupMappingCreatePostRequest;
Querystring: { groupId: string };
}>(
"/groupRoles/:groupId",
"/groups/:groupId/roles",
{
schema: {
querystring: {
Expand Down Expand Up @@ -125,9 +120,14 @@
createdAt: timestamp,
}),
});

await dynamoClient.send(command);
fastify.nodeCache.set(
`grouproles-${groupId}`,
request.body.roles,
AUTH_DECISION_CACHE_SECONDS,
);
} catch (e: unknown) {
fastify.nodeCache.del(`grouproles-${groupId}`);
if (e instanceof BaseError) {
throw e;
}
Expand All @@ -140,7 +140,7 @@
reply.send({ message: "OK" });
request.log.info(
{ type: "audit", actor: request.username, target: groupId },
`set group ID roles to ${request.body.roles.toString()}`,
`set target roles to ${request.body.roles.toString()}`,
);
},
);
Expand Down
Loading
Loading