diff --git a/Makefile b/Makefile index caec2c01..af53f386 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ deploy_dev: check_account_dev build install: yarn -D + pip install cfn-lint test_live_integration: install yarn test:live @@ -73,6 +74,7 @@ test_live_integration: install test_unit: install yarn typecheck yarn lint + cfn-lint cloudformation/**/* --ignore-templates cloudformation/phony-swagger.yml yarn prettier yarn test:unit diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 22650060..84c5e026 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -25,7 +25,6 @@ Parameters: Conditions: IsProd: !Equals [!Ref RunEnvironment, 'prod'] - IsDev: !Equals [!Ref RunEnvironment, 'dev'] ShouldAttachVpc: !Equals [true, !Ref VpcRequired] @@ -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 @@ -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 @@ -181,6 +182,7 @@ Resources: EventRecordsTable: Type: 'AWS::DynamoDB::Table' DeletionPolicy: "Retain" + UpdateReplacePolicy: "Retain" Properties: BillingMode: 'PAY_PER_REQUEST' TableName: infra-core-api-events @@ -206,6 +208,7 @@ Resources: CacheRecordsTable: Type: 'AWS::DynamoDB::Table' DeletionPolicy: "Retain" + UpdateReplacePolicy: "Retain" Properties: BillingMode: 'PAY_PER_REQUEST' TableName: infra-core-api-cache diff --git a/package.json b/package.json index b89ba266..5b5825cc 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -105,4 +106,4 @@ "resolutions": { "pdfjs-dist": "^4.8.69" } -} \ No newline at end of file +} diff --git a/src/api/functions/authorization.ts b/src/api/functions/authorization.ts new file mode 100644 index 00000000..603ca2e2 --- /dev/null +++ b/src/api/functions/authorization.ts @@ -0,0 +1,114 @@ +import { + DynamoDBClient, + GetItemCommand, + QueryCommand, +} 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 { + 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[]; +} diff --git a/src/api/index.ts b/src/api/index.ts index 8be00137..4fc737f5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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(); @@ -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; diff --git a/src/api/package.json b/src/api/package.json index 568ec136..1141ef62 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -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" } } diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index b615330e..2361f7ab 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -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(setA: Set, setB: Set): Set { const _intersection = new Set(); @@ -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 | null | SecretConfig> => { @@ -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 { @@ -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 ( @@ -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; }, ); diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 901a684f..9a99caf6 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -1,5 +1,5 @@ import { FastifyPluginAsync } from "fastify"; -import { AppRoles } from "../../common/roles.js"; +import { allAppRoles, AppRoles } from "../../common/roles.js"; import { zodToJsonSchema } from "zod-to-json-schema"; import { addToTenant, @@ -34,6 +34,10 @@ import { EntraGroupActions, entraGroupMembershipListResponse, } from "../../common/types/iam.js"; +import { + AUTH_DECISION_CACHE_SECONDS, + getGroupRoles, +} from "../functions/authorization.js"; const dynamoClient = new DynamoDBClient({ region: genericConfig.AwsRegion, @@ -44,7 +48,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { Body: undefined; Querystring: { groupId: string }; }>( - "/groupRoles/:groupId", + "/groups/:groupId/roles", { schema: { querystring: { @@ -61,19 +65,10 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }, }, async (request, reply) => { - const groupId = (request.params as Record).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).groupId; + const roles = await getGroupRoles(dynamoClient, fastify, groupId); + return reply.send(roles); } catch (e: unknown) { if (e instanceof BaseError) { throw e; @@ -90,7 +85,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { Body: GroupMappingCreatePostRequest; Querystring: { groupId: string }; }>( - "/groupRoles/:groupId", + "/groups/:groupId/roles", { schema: { querystring: { @@ -125,9 +120,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { 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; } @@ -140,7 +140,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { 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()}`, ); }, ); diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 79b874c8..73770980 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -2,6 +2,7 @@ import { FastifyRequest, FastifyInstance, FastifyReply } from "fastify"; import { AppRoles, RunEnvironment } from "../common/roles.js"; import { AadToken } from "./plugins/auth.js"; import { ConfigType } from "../common/config.js"; +import NodeCache from "node-cache"; declare module "fastify" { interface FastifyInstance { authenticate: ( @@ -20,6 +21,7 @@ declare module "fastify" { ) => Promise; runEnvironment: RunEnvironment; environmentConfig: ConfigType; + nodeCache: NodeCache; } interface FastifyRequest { startTime: number; diff --git a/src/common/config.ts b/src/common/config.ts index 893610f4..b0609d60 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -6,14 +6,10 @@ type ArrayOfValueOrArray = Array>; type OriginType = string | boolean | RegExp; type ValueOrArray = T | ArrayOfValueOrArray; -type GroupRoleMapping = Record; type AzureRoleMapping = Record; -type UserRoleMapping = Record; export type ConfigType = { - GroupRoleMapping: GroupRoleMapping; AzureRoleMapping: AzureRoleMapping; - UserRoleMapping: UserRoleMapping; ValidCorsOrigins: ValueOrArray | OriginFunction; AadValidClientId: string; }; @@ -60,18 +56,6 @@ const genericConfig: GenericConfigType = { const environmentConfig: EnvironmentConfigType = { dev: { - GroupRoleMapping: { - [infraChairsGroupId]: allAppRoles, // Infra Chairs - "940e4f9e-6891-4e28-9e29-148798495cdb": allAppRoles, // ACM Infra Team - "f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6": allAppRoles, // Infra Leads - "0": allAppRoles, // Dummy Group for development only - "1": [], // Dummy Group for development only - "scanner-only": [AppRoles.TICKETS_SCANNER], - }, - UserRoleMapping: { - "infra-unit-test-nogrp@acm.illinois.edu": [AppRoles.TICKETS_SCANNER], - "kLkvWTYwNnJfBkIK7mBi4niXXHYNR7ygbV8utlvFxjw": allAppRoles - }, AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, ValidCorsOrigins: [ "http://localhost:3000", @@ -84,27 +68,6 @@ const environmentConfig: EnvironmentConfigType = { AadValidClientId: "39c28870-94e4-47ee-b4fb-affe0bf96c9f", }, prod: { - GroupRoleMapping: { - [infraChairsGroupId]: allAppRoles, // Infra Chairs - [officersGroupId]: allAppRoles, // Officers - [execCouncilGroupId]: [AppRoles.EVENTS_MANAGER, AppRoles.IAM_INVITE_ONLY], // Exec - }, - UserRoleMapping: { - "jlevine4@illinois.edu": allAppRoles, - "kaavyam2@illinois.edu": [AppRoles.TICKETS_SCANNER], - "hazellu2@illinois.edu": [AppRoles.TICKETS_SCANNER], - "cnwos@illinois.edu": [AppRoles.TICKETS_SCANNER], - "alfan2@illinois.edu": [AppRoles.TICKETS_SCANNER], - "naomil4@illinois.edu": [ - AppRoles.TICKETS_SCANNER, - AppRoles.TICKETS_MANAGER, - ], - "akori3@illinois.edu": [ - AppRoles.TICKETS_SCANNER, - AppRoles.TICKETS_MANAGER, - ], - "tarasha2@illinois.edu": [AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER] - }, AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, ValidCorsOrigins: [ "https://acm.illinois.edu", diff --git a/src/common/types/iam.ts b/src/common/types/iam.ts index c7699881..86e932f8 100644 --- a/src/common/types/iam.ts +++ b/src/common/types/iam.ts @@ -22,14 +22,17 @@ export const invitePostRequestSchema = z.object({ export type InviteUserPostRequest = z.infer; export const groupMappingCreatePostSchema = z.object({ - roles: z - .array(z.nativeEnum(AppRoles)) - .min(1) - .refine((items) => new Set(items).size === items.length, { - message: "All roles must be unique, no duplicate values allowed", - }), + roles: z.union([ + z.array(z.nativeEnum(AppRoles)) + .min(1) + .refine((items) => new Set(items).size === items.length, { + message: "All roles must be unique, no duplicate values allowed", + }), + z.tuple([z.literal("all")]), + ]), }); + export type GroupMappingCreatePostRequest = z.infer< typeof groupMappingCreatePostSchema >; diff --git a/tests/unit/discordEvent.test.ts b/tests/unit/discordEvent.test.ts index c4e7efda..3d138293 100644 --- a/tests/unit/discordEvent.test.ts +++ b/tests/unit/discordEvent.test.ts @@ -87,7 +87,7 @@ describe("Test Events <-> Discord integration", () => { beforeEach(() => { ddbMock.reset(); smMock.reset(); - vi.resetAllMocks(); + vi.clearAllMocks(); vi.useFakeTimers(); }); }); diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index c8389654..f1d4961a 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -42,7 +42,7 @@ const app = await init(); describe("Test Modify Group and List Group Routes", () => { beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); smMock.on(GetSecretValueCommand).resolves({ SecretString: JSON.stringify({ jwt_key: "test_jwt_key" }), }); @@ -130,7 +130,7 @@ describe("Test Modify Group and List Group Routes", () => { await app.close(); }); beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); vi.useFakeTimers(); (getEntraIdToken as any).mockImplementation(async () => { return "ey.test.token"; diff --git a/tests/unit/entraInviteUser.test.ts b/tests/unit/entraInviteUser.test.ts index bc1505f6..40e7e2ca 100644 --- a/tests/unit/entraInviteUser.test.ts +++ b/tests/unit/entraInviteUser.test.ts @@ -95,7 +95,7 @@ describe("Test Microsoft Entra ID user invitation", () => { }); beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); vi.useFakeTimers(); // Re-implement the mock (getEntraIdToken as any).mockImplementation(async () => { diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts new file mode 100644 index 00000000..94159d03 --- /dev/null +++ b/tests/unit/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: "./tests/unit/vitest.setup.ts", + }, +}); diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts new file mode 100644 index 00000000..07e837fe --- /dev/null +++ b/tests/unit/vitest.setup.ts @@ -0,0 +1,31 @@ +import { vi } from "vitest"; +import { allAppRoles, AppRoles } from "../../src/common/roles.js"; +import { group } from "console"; + +vi.mock( + import("../../src/api/functions/authorization.js"), + async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getUserRoles: vi.fn(async (_, __, userEmail) => { + const mockUserRoles = { + "infra-unit-test-nogrp@acm.illinois.edu": [AppRoles.TICKETS_SCANNER], + kLkvWTYwNnJfBkIK7mBi4niXXHYNR7ygbV8utlvFxjw: allAppRoles, + }; + + return mockUserRoles[userEmail] || []; + }), + + getGroupRoles: vi.fn(async (_, __, groupId) => { + const mockGroupRoles = { + "0": allAppRoles, + "1": [], + "scanner-only": [AppRoles.TICKETS_SCANNER], + }; + + return mockGroupRoles[groupId] || []; + }), + }; + }, +); diff --git a/yarn.lock b/yarn.lock index 40e3f987..f956643e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4323,7 +4323,7 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" -clone@^2.1.1: +clone@2.x, clone@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== @@ -7160,6 +7160,13 @@ nmtree@^1.0.6: dependencies: commander "^2.11.0" +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-ical@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.18.0.tgz#919ab65f43cdfebb4ac9a1c2acca2b5e62cc003f"