From fb2d3f49d3d8bb384795c6689e5bdd0342182f4a Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 00:03:08 -0500 Subject: [PATCH 01/18] Create a DynamoDB table for SIG info --- terraform/modules/dynamo/main.tf | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index cdd5f491..f54cba68 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -300,3 +300,17 @@ resource "aws_dynamodb_table" "cache" { enabled = true } } + +resource "aws_dynamodb_table" "cache" { + billing_mode = "PAY_PER_REQUEST" + name = "${var.ProjectId}-sigs" + deletion_protection_enabled = true + hash_key = "primaryKey" + point_in_time_recovery { + enabled = false + } + attribute { + name = "primaryKey" + type = "S" + } +} From a63cfed1a9dbe1706a29913870def0e9ad9aebe1 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 00:05:06 -0500 Subject: [PATCH 02/18] fix terraform key --- terraform/modules/dynamo/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index f54cba68..135c3a73 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -301,7 +301,7 @@ resource "aws_dynamodb_table" "cache" { } } -resource "aws_dynamodb_table" "cache" { +resource "aws_dynamodb_table" "sig_info" { billing_mode = "PAY_PER_REQUEST" name = "${var.ProjectId}-sigs" deletion_protection_enabled = true From 87af5e5048af0e50ed2e6445da27c33969c188fd Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 00:06:17 -0500 Subject: [PATCH 03/18] Allow lambda access to sigs table --- terraform/modules/lambdas/main.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/terraform/modules/lambdas/main.tf b/terraform/modules/lambdas/main.tf index b8e78339..cca9c86f 100644 --- a/terraform/modules/lambdas/main.tf +++ b/terraform/modules/lambdas/main.tf @@ -233,6 +233,8 @@ resource "aws_iam_policy" "shared_iam_policy" { "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-linkry", "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-linkry/index/*", "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-keys", + "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-sigs", + "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-sigs/index/*", ] }, { From cc8c11ea4ec3d1c68e677790b4fdbca8c4afa1f4 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 00:53:40 -0500 Subject: [PATCH 04/18] Create a GET route for specific org info --- src/api/functions/organizations.ts | 89 ++++++++++++++++++++++++++++++ src/api/routes/membership.ts | 31 ----------- src/api/routes/organizations.ts | 49 +++++++++++++++- src/common/config.ts | 4 +- src/common/roles.ts | 5 +- src/common/types/organizations.ts | 24 ++++++++ 6 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 src/api/functions/organizations.ts create mode 100644 src/common/types/organizations.ts diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts new file mode 100644 index 00000000..cd1fd07c --- /dev/null +++ b/src/api/functions/organizations.ts @@ -0,0 +1,89 @@ +import { + GetItemCommand, + QueryCommand, + type DynamoDBClient, +} from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { genericConfig } from "common/config.js"; +import { + BaseError, + DatabaseFetchError, + ValidationError, +} from "common/errors/index.js"; +import { getOrganizationInfoResponse } from "common/types/organizations.js"; +import { type FastifyBaseLogger } from "fastify"; +import pino from "pino"; +import z from "zod"; + +export interface GetOrgInfoInputs { + id: string; + dynamoClient: DynamoDBClient; + logger: FastifyBaseLogger | pino.Logger; +} + +export async function getOrgInfo({ + id, + dynamoClient, + logger, +}: GetOrgInfoInputs) { + const query = new GetItemCommand({ + TableName: genericConfig.SigInfoTableName, + Key: { primaryKey: { S: `DEFINE#${id}` } }, + }); + let response = { leads: [] } as { + leads: { name: string; username: string; title: string | undefined }[]; + }; + try { + const responseMarshall = await dynamoClient.send(query); + if (!responseMarshall.Item) { + throw new ValidationError({ + message: "No information found for this organization.", + }); + } + const temp = unmarshall(responseMarshall.Item); + temp.id = temp.primaryKey.replace("DEFINE#", ""); + delete temp.primaryKey; + response = { ...temp, ...response }; + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Failed to get org metadata.", + }); + } + // Get leads + const leadsQuery = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: `LEAD#${id}` }, + }, + }); + try { + const responseMarshall = await dynamoClient.send(leadsQuery); + if (responseMarshall.Items) { + const unmarshalledLeads = responseMarshall.Items.map((x) => unmarshall(x)) + .filter((x) => x.username) + .map( + (x) => + ({ + name: x.name, + username: x.username, + title: x.title, + }) as { name: string; username: string; title: string | undefined }, + ); + response = { ...response, leads: unmarshalledLeads }; + } + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Failed to get org leads.", + }); + } + return response as z.infer; +} diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 4c9a25da..c80e8d30 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -44,37 +44,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { global: false, runFirst: true, }); - const getAuthorizedClients = async () => { - if (roleArns.Entra) { - fastify.log.info( - `Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`, - ); - const credentials = await getRoleCredentials(roleArns.Entra); - const clients = { - smClient: new SecretsManagerClient({ - region: genericConfig.AwsRegion, - credentials, - }), - dynamoClient: new DynamoDBClient({ - region: genericConfig.AwsRegion, - credentials, - }), - redisClient: fastify.redisClient, - }; - fastify.log.info( - `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, - ); - return clients; - } - fastify.log.debug( - "Did not assume Entra role as no env variable was present", - ); - return { - smClient: fastify.secretsManagerClient, - dynamoClient: fastify.dynamoClient, - redisClient: fastify.redisClient, - }; - }; const limitedRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(rateLimiter, { limit: 20, diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 09a419c5..4e335888 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -4,6 +4,21 @@ import fastifyCaching from "@fastify/caching"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withTags } from "api/components/index.js"; import { z } from "zod/v4"; +import { getOrganizationInfoResponse } from "common/types/organizations.js"; +import { + GetItemCommand, + QueryCommand, + ReplicaAlreadyExistsException, +} from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "common/config.js"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; +import { + BaseError, + DatabaseFetchError, + NotFoundError, +} from "common/errors/index.js"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { getOrgInfo } from "api/functions/organizations.js"; const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.register(fastifyCaching, { @@ -19,7 +34,7 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.get( "", { - schema: withTags(["Generic"], { + schema: withTags(["Organizations"], { summary: "Get a list of ACM @ UIUC sub-organizations.", response: { 200: { @@ -39,6 +54,38 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { reply.send(AllOrganizationList); }, ); + fastify.withTypeProvider().get( + "/:id", + { + schema: withTags(["Organizations"], { + summary: + "Get information about a specific ACM @ UIUC sub-organization.", + params: z.object({ + id: z + .enum(AllOrganizationList) + .meta({ description: "ACM @ UIUC organization to query." }), + }), + response: { + 200: { + description: "ACM @ UIUC sub-organization info.", + content: { + "application/json": { + schema: getOrganizationInfoResponse, + }, + }, + }, + }, + }), + }, + async (request, reply) => { + const response = await getOrgInfo({ + id: request.params.id, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }); + return reply.send(response); + }, + ); }; export default organizationsPlugin; diff --git a/src/common/config.ts b/src/common/config.ts index 411f5aa8..57a3d788 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -55,6 +55,7 @@ export type GenericConfigType = { UinHashingSecret: string; UinExtendedAttributeName: string; UserInfoTable: string; + SigInfoTableName: string; }; type EnvironmentConfigType = { @@ -94,7 +95,8 @@ const genericConfig: GenericConfigType = { TestingCredentialsSecret: "infra-core-api-testing-credentials", UinHashingSecret: "infra-core-api-uin-pepper", UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN", - UserInfoTable: "infra-core-api-user-info" + UserInfoTable: "infra-core-api-user-info", + SigInfoTableName: "infra-core-api-sigs" } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/common/roles.ts b/src/common/roles.ts index cf3ec8f5..0b5b1b37 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -17,8 +17,10 @@ export enum AppRoles { MANAGE_ORG_API_KEYS = "manage:orgApiKey", VIEW_INTERNAL_MEMBERSHIP_LIST = "view:internalMembershipList", VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList", - MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList" + MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList", + SIG_MANAGER = "manage:sigs" } + export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", ); @@ -40,4 +42,5 @@ export const AppRoleHumanMapper: Record = { [AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST]: "Internal Membership List Viewer", [AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Viewer", [AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Manager", + [AppRoles.SIG_MANAGER]: "SIG Manager", } diff --git a/src/common/types/organizations.ts b/src/common/types/organizations.ts new file mode 100644 index 00000000..2b4a04a1 --- /dev/null +++ b/src/common/types/organizations.ts @@ -0,0 +1,24 @@ +import { AllOrganizationList } from "@acm-uiuc/js-shared"; +import { z } from "zod/v4"; + + +export const orgLeadEntry = z.object({ + name: z.optional(z.string()), + username: z.email(), + title: z.optional(z.string()) +}) + +export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "OTHER"] as const as [string, ...string[]]; + +export const orgLinkEntry = z.object({ + type: z.enum(validOrgLinkTypes), + url: z.url() +}) + +export const getOrganizationInfoResponse = z.object({ + id: z.enum(AllOrganizationList), + description: z.optional(z.string()), + website: z.optional(z.url()), + leads: z.optional(z.array(orgLeadEntry)), + links: z.optional(z.array(orgLinkEntry)) +}) From 1e796a10e20786e8bee1929209a66abf9b46d8bb Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 01:28:14 -0500 Subject: [PATCH 05/18] Create UsernameIndex GSI for searching user roles in SIGs --- terraform/modules/dynamo/main.tf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index 135c3a73..d5cde7c4 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -313,4 +313,14 @@ resource "aws_dynamodb_table" "sig_info" { name = "primaryKey" type = "S" } + attribute { + name = "username" + type = "S" + } + global_secondary_index { + name = "UsernameIndex" + hash_key = "username" + range_key = "primaryKey" + projection_type = "KEYS_ONLY" + } } From 5c53d900750d146021c294e94282f93ef0d3c2a1 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 01:28:37 -0500 Subject: [PATCH 06/18] Write code to get user roles and org info --- src/api/functions/organizations.ts | 66 ++++++++++++++++++++++++++++ src/api/plugins/verifyUserOrgRole.ts | 43 ++++++++++++++++++ src/common/roles.ts | 12 ++++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/api/plugins/verifyUserOrgRole.ts diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts index cd1fd07c..fd831abf 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -1,3 +1,4 @@ +import { AllOrganizationList } from "@acm-uiuc/js-shared"; import { GetItemCommand, QueryCommand, @@ -10,6 +11,7 @@ import { DatabaseFetchError, ValidationError, } from "common/errors/index.js"; +import { OrgRole, orgRoles } from "common/roles.js"; import { getOrganizationInfoResponse } from "common/types/organizations.js"; import { type FastifyBaseLogger } from "fastify"; import pino from "pino"; @@ -21,6 +23,12 @@ export interface GetOrgInfoInputs { logger: FastifyBaseLogger | pino.Logger; } +export interface GetUserOrgRolesInputs { + username: string; + dynamoClient: DynamoDBClient; + logger: FastifyBaseLogger | pino.Logger; +} + export async function getOrgInfo({ id, dynamoClient, @@ -87,3 +95,61 @@ export async function getOrgInfo({ } return response as z.infer; } + +export async function getUserOrgRoles({ + username, + dynamoClient, + logger, +}: GetUserOrgRolesInputs) { + const query = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + IndexName: "UsernameIndex", + KeyConditionExpression: `username = :username`, + ExpressionAttributeValues: { + ":username": { S: username }, + }, + }); + try { + const response = await dynamoClient.send(query); + if (!response.Items) { + return []; + } + const unmarshalled = response.Items.map((x) => unmarshall(x)).map( + (x) => + ({ username: x.username, rawRole: x.primaryKey }) as { + username: string; + rawRole: string; + }, + ); + const cleanedRoles = []; + for (const item of unmarshalled) { + const splits = item.rawRole.split("#"); + if (splits.length !== 2) { + logger.warn(`Invalid PK in role definition: ${JSON.stringify(item)}`); + continue; + } + const [role, org] = splits; + if (!orgRoles.includes(role as OrgRole)) { + logger.warn(`Invalid role in role definition: ${JSON.stringify(item)}`); + continue; + } + if (!AllOrganizationList.includes(org)) { + logger.warn(`Invalid org in role definition: ${JSON.stringify(item)}`); + continue; + } + cleanedRoles.push({ + org, + role, + } as { org: (typeof AllOrganizationList)[number]; role: OrgRole }); + } + return cleanedRoles; + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Could not get roles for user.", + }); + } +} diff --git a/src/api/plugins/verifyUserOrgRole.ts b/src/api/plugins/verifyUserOrgRole.ts new file mode 100644 index 00000000..1309fab6 --- /dev/null +++ b/src/api/plugins/verifyUserOrgRole.ts @@ -0,0 +1,43 @@ +import { getUserOrgRoles } from "api/functions/organizations.js"; +import { UnauthorizedError } from "common/errors/index.js"; +import { OrgRoleDefinition } from "common/roles.js"; +import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import fp from "fastify-plugin"; + +const orgRolePlugin: FastifyPluginAsync = async (fastify, _options) => { + fastify.decorate( + "verifyOrgRole", + async ( + request: FastifyRequest, + _reply: FastifyReply, + validOrgRoles: OrgRoleDefinition[], + ) => { + const username = request.username; + if (!username) { + throw new UnauthorizedError({ + message: "Could not determine user identity.", + }); + } + const userRoles = await getUserOrgRoles({ + username, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }); + let isAuthorized = false; + for (const role of userRoles) { + if (validOrgRoles.includes(role)) { + isAuthorized = true; + break; + } + } + if (!isAuthorized) { + throw new UnauthorizedError({ + message: "User does not have the required role in this organization.", + }); + } + }, + ); +}; + +const fastifyOrgRolePlugin = fp(orgRolePlugin); +export default fastifyOrgRolePlugin; diff --git a/src/common/roles.ts b/src/common/roles.ts index 0b5b1b37..a7e93d7d 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -1,3 +1,5 @@ +import { AllOrganizationList } from "@acm-uiuc/js-shared"; + /* eslint-disable import/prefer-default-export */ export const runEnvironments = ["dev", "prod"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; @@ -18,7 +20,13 @@ export enum AppRoles { VIEW_INTERNAL_MEMBERSHIP_LIST = "view:internalMembershipList", VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList", MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList", - SIG_MANAGER = "manage:sigs" + ALL_ORG_MANAGER = "manage:orgDefinitions" +} +export const orgRoles = ["LEAD", "MEMBER"] as const; +export type OrgRole = typeof orgRoles[number]; +export type OrgRoleDefinition = { + org: typeof AllOrganizationList[number], + role: OrgRole } export const allAppRoles = Object.values(AppRoles).filter( @@ -42,5 +50,5 @@ export const AppRoleHumanMapper: Record = { [AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST]: "Internal Membership List Viewer", [AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Viewer", [AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Manager", - [AppRoles.SIG_MANAGER]: "SIG Manager", + [AppRoles.ALL_ORG_MANAGER]: "Organization Definition Manager", } From eae393266415d01a6f250618b20f4d38c5c7417d Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 01:39:24 -0500 Subject: [PATCH 07/18] Build basic unit tests --- tests/unit/organizations.test.ts | 159 ++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 12 deletions(-) diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index 7a605f77..6550ea62 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -1,18 +1,153 @@ import { afterAll, expect, test, beforeEach } from "vitest"; +import { + DynamoDBClient, + GetItemCommand, + QueryCommand, +} from "@aws-sdk/client-dynamodb"; +import { marshall } from "@aws-sdk/util-dynamodb"; import init from "../../src/api/index.js"; +import { describe } from "node:test"; +import { mockClient } from "aws-sdk-client-mock"; +import { genericConfig } from "../../src/common/config.js"; const app = await init(); -test("Test getting the list of organizations succeeds", async () => { - const response = await app.inject({ - method: "GET", - url: "/api/v1/organizations", +const ddbMock = mockClient(DynamoDBClient); + +const acmMeta = { + primaryKey: "DEFINE#ACM", + leadsEntraGroup: "a3c37a24-1e21-4338-813f-15478eb40137", + links: [ + { + type: "DISCORD", + url: "https://go.acm.illinois.edu/discord", + }, + ], + website: "https://www.acm.illinois.edu", +}; +describe("Organization info tests", async () => { + test("Test getting the list of organizations succeeds", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations", + }); + expect(response.statusCode).toBe(200); + await response.json(); + }); + test("Test getting info about an org succeeds", async () => { + ddbMock + .on(GetItemCommand, { + TableName: genericConfig.SigInfoTableName, + Key: { primaryKey: { S: "DEFINE#ACM" } }, + }) + .resolves({ + Item: marshall(acmMeta), + }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: "LEAD#ACM" }, + }, + }) + .resolves({ + Items: [ + { + primaryKey: "LEAD#ACM", + name: "John Doe", + title: "Chair", + username: "jdoe@illinois.edu", + }, + ].map((x) => marshall(x)), + }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations/ACM", + }); + expect(response.statusCode).toBe(200); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual({ + id: "ACM", + website: "https://www.acm.illinois.edu", + leads: [ + { + username: "jdoe@illinois.edu", + name: "John Doe", + title: "Chair", + }, + ], + links: [ + { + type: "DISCORD", + url: "https://go.acm.illinois.edu/discord", + }, + ], + }); + }); + test("Test getting info about an unknown org returns a ValidationError", async () => { + ddbMock + .on(GetItemCommand, { + TableName: genericConfig.SigInfoTableName, + Key: { primaryKey: { S: "DEFINE#ACM" } }, + }) + .resolves({ + Item: undefined, + }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: "LEAD#ACM" }, + }, + }) + .rejects(); + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations/ACM", + }); + expect(response.statusCode).toBe(400); + }); + test("Test that getting org with no leads succeeds", async () => { + ddbMock + .on(GetItemCommand, { + TableName: genericConfig.SigInfoTableName, + Key: { primaryKey: { S: "DEFINE#ACM" } }, + }) + .resolves({ + Item: marshall(acmMeta), + }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: "LEAD#ACM" }, + }, + }) + .resolves({ Items: [] }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations/ACM", + }); + expect(response.statusCode).toBe(200); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual({ + id: "ACM", + website: "https://www.acm.illinois.edu", + leads: [], + links: [ + { + type: "DISCORD", + url: "https://go.acm.illinois.edu/discord", + }, + ], + }); + }); + afterAll(async () => { + await app.close(); + }); + beforeEach(() => { + (app as any).nodeCache.flushAll(); }); - expect(response.statusCode).toBe(200); - await response.json(); -}); -afterAll(async () => { - await app.close(); -}); -beforeEach(() => { - (app as any).nodeCache.flushAll(); }); From f887a5fbc5ad31b8c6bae92d123b4a23ba66681e Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 02:42:13 -0500 Subject: [PATCH 08/18] Setup --- src/api/functions/organizations.ts | 7 +++-- src/api/routes/organizations.ts | 38 +++++++++++++++++++----- terraform/modules/dynamo/main.tf | 47 +++++++++++++++--------------- tests/unit/organizations.test.ts | 8 +++-- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts index fd831abf..24727a8c 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -44,9 +44,10 @@ export async function getOrgInfo({ try { const responseMarshall = await dynamoClient.send(query); if (!responseMarshall.Item) { - throw new ValidationError({ - message: "No information found for this organization.", - }); + logger.debug( + `Could not find SIG information for ${id}, returning default.`, + ); + return { id }; } const temp = unmarshall(responseMarshall.Item); temp.id = temp.primaryKey.replace("DEFINE#", ""); diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 4e335888..884de624 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -35,23 +35,47 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { "", { schema: withTags(["Organizations"], { - summary: "Get a list of ACM @ UIUC sub-organizations.", + summary: "Get info for all of ACM @ UIUC's sub-organizations.", response: { 200: { - description: "List of ACM @ UIUC sub-organizations.", + description: "List of ACM @ UIUC sub-organizations and info.", content: { "application/json": { - schema: z - .array(z.enum(AllOrganizationList)) - .default(AllOrganizationList), + schema: z.array(getOrganizationInfoResponse), }, }, }, }, }), }, - async (_request, reply) => { - reply.send(AllOrganizationList); + async (request, reply) => { + const promises = AllOrganizationList.map((x) => + getOrgInfo({ + id: x, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }), + ); + try { + const data = await Promise.allSettled(promises); + const successOnly = data + .filter((x) => x.status === "fulfilled") + .map((x) => x.value); + // return just the ID for anything not in the DB. + const successIds = successOnly.map((x) => x.id); + const unknownIds = AllOrganizationList.filter( + (x) => !successIds.includes(x), + ).map((x) => ({ id: x })); + return reply.send([...successOnly, ...unknownIds]); + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + request.log.error(e); + throw new DatabaseFetchError({ + message: "Failed to get org information.", + }); + } }, ); fastify.withTypeProvider().get( diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index d5cde7c4..a180374b 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -301,26 +301,27 @@ resource "aws_dynamodb_table" "cache" { } } -resource "aws_dynamodb_table" "sig_info" { - billing_mode = "PAY_PER_REQUEST" - name = "${var.ProjectId}-sigs" - deletion_protection_enabled = true - hash_key = "primaryKey" - point_in_time_recovery { - enabled = false - } - attribute { - name = "primaryKey" - type = "S" - } - attribute { - name = "username" - type = "S" - } - global_secondary_index { - name = "UsernameIndex" - hash_key = "username" - range_key = "primaryKey" - projection_type = "KEYS_ONLY" - } -} +# resource "aws_dynamodb_table" "sig_info" { +# billing_mode = "PAY_PER_REQUEST" +# name = "${var.ProjectId}-sigs" +# deletion_protection_enabled = true +# hash_key = "primaryKey" +# range_key = "entryId" +# point_in_time_recovery { +# enabled = false +# } +# attribute { +# name = "primaryKey" +# type = "S" +# } +# attribute { +# name = "username" +# type = "S" +# } +# global_secondary_index { +# name = "UsernameIndex" +# hash_key = "username" +# range_key = "primaryKey" +# projection_type = "KEYS_ONLY" +# } +# } diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index 6550ea62..be704b77 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -84,7 +84,7 @@ describe("Organization info tests", async () => { ], }); }); - test("Test getting info about an unknown org returns a ValidationError", async () => { + test("Test getting info about an unknown valid org returns just the ID", async () => { ddbMock .on(GetItemCommand, { TableName: genericConfig.SigInfoTableName, @@ -106,7 +106,11 @@ describe("Organization info tests", async () => { method: "GET", url: "/api/v1/organizations/ACM", }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(200); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual({ + id: "ACM", + }); }); test("Test that getting org with no leads succeeds", async () => { ddbMock From ee16b7701d5162ea62881e5f5bbf4ef3bbf554e0 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:00:04 -0500 Subject: [PATCH 09/18] Fix multi-lead support for organizations --- src/api/functions/organizations.ts | 17 +++++----- src/api/package.json | 1 - src/api/routes/organizations.ts | 28 ++++++---------- terraform/modules/dynamo/main.tf | 52 ++++++++++++++++-------------- tests/unit/organizations.test.ts | 29 +++++++++++------ 5 files changed, 65 insertions(+), 62 deletions(-) diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts index 24727a8c..1c3e8938 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -1,9 +1,5 @@ import { AllOrganizationList } from "@acm-uiuc/js-shared"; -import { - GetItemCommand, - QueryCommand, - type DynamoDBClient, -} from "@aws-sdk/client-dynamodb"; +import { QueryCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import { genericConfig } from "common/config.js"; import { @@ -34,22 +30,25 @@ export async function getOrgInfo({ dynamoClient, logger, }: GetOrgInfoInputs) { - const query = new GetItemCommand({ + const query = new QueryCommand({ TableName: genericConfig.SigInfoTableName, - Key: { primaryKey: { S: `DEFINE#${id}` } }, + KeyConditionExpression: `primaryKey = :definitionId`, + ExpressionAttributeValues: { + ":definitionId": { S: `DEFINE#${id}` }, + }, }); let response = { leads: [] } as { leads: { name: string; username: string; title: string | undefined }[]; }; try { const responseMarshall = await dynamoClient.send(query); - if (!responseMarshall.Item) { + if (!responseMarshall.Items || responseMarshall.Items.length === 0) { logger.debug( `Could not find SIG information for ${id}, returning default.`, ); return { id }; } - const temp = unmarshall(responseMarshall.Item); + const temp = unmarshall(responseMarshall.Items[0]); temp.id = temp.primaryKey.replace("DEFINE#", ""); delete temp.primaryKey; response = { ...temp, ...response }; diff --git a/src/api/package.json b/src/api/package.json index f8cd6941..a9a91075 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -27,7 +27,6 @@ "@azure/msal-node": "^3.5.1", "@fastify/auth": "^5.0.1", "@fastify/aws-lambda": "^6.0.0", - "@fastify/caching": "^9.0.1", "@fastify/cors": "^11.0.1", "@fastify/static": "^8.1.1", "@fastify/swagger": "^9.5.0", diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 884de624..5cfe694f 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -1,36 +1,28 @@ import { FastifyPluginAsync } from "fastify"; import { AllOrganizationList } from "@acm-uiuc/js-shared"; -import fastifyCaching from "@fastify/caching"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withTags } from "api/components/index.js"; import { z } from "zod/v4"; import { getOrganizationInfoResponse } from "common/types/organizations.js"; -import { - GetItemCommand, - QueryCommand, - ReplicaAlreadyExistsException, -} from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "common/config.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; -import { - BaseError, - DatabaseFetchError, - NotFoundError, -} from "common/errors/index.js"; -import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { BaseError, DatabaseFetchError } from "common/errors/index.js"; import { getOrgInfo } from "api/functions/organizations.js"; +export const ORG_DATA_CACHED_DURATION = 300; +export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`; + const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { - fastify.register(fastifyCaching, { - privacy: fastifyCaching.privacy.PUBLIC, - serverExpiresIn: 60 * 60 * 4, - expiresIn: 60 * 60 * 4, - }); fastify.register(rateLimiter, { limit: 60, duration: 60, rateLimitIdentifier: "organizations", }); + fastify.addHook("onSend", async (request, reply, payload) => { + if (request.method === "GET") { + reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); + } + return payload; + }); fastify.get( "", { diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index a180374b..6bb42abc 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -301,27 +301,31 @@ resource "aws_dynamodb_table" "cache" { } } -# resource "aws_dynamodb_table" "sig_info" { -# billing_mode = "PAY_PER_REQUEST" -# name = "${var.ProjectId}-sigs" -# deletion_protection_enabled = true -# hash_key = "primaryKey" -# range_key = "entryId" -# point_in_time_recovery { -# enabled = false -# } -# attribute { -# name = "primaryKey" -# type = "S" -# } -# attribute { -# name = "username" -# type = "S" -# } -# global_secondary_index { -# name = "UsernameIndex" -# hash_key = "username" -# range_key = "primaryKey" -# projection_type = "KEYS_ONLY" -# } -# } +resource "aws_dynamodb_table" "sig_info" { + billing_mode = "PAY_PER_REQUEST" + name = "${var.ProjectId}-sigs" + deletion_protection_enabled = true + hash_key = "primaryKey" + range_key = "entryId" + point_in_time_recovery { + enabled = false + } + attribute { + name = "primaryKey" + type = "S" + } + attribute { + name = "entryId" + type = "S" + } + attribute { + name = "username" + type = "S" + } + global_secondary_index { + name = "UsernameIndex" + hash_key = "username" + range_key = "primaryKey" + projection_type = "KEYS_ONLY" + } +} diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index be704b77..eddb3aa7 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -35,12 +35,15 @@ describe("Organization info tests", async () => { }); test("Test getting info about an org succeeds", async () => { ddbMock - .on(GetItemCommand, { + .on(QueryCommand, { TableName: genericConfig.SigInfoTableName, - Key: { primaryKey: { S: "DEFINE#ACM" } }, + KeyConditionExpression: "primaryKey = :definitionId", + ExpressionAttributeValues: { + ":definitionId": { S: "DEFINE#ACM" }, + }, }) .resolves({ - Item: marshall(acmMeta), + Items: [marshall(acmMeta)], }); ddbMock .on(QueryCommand, { @@ -86,12 +89,15 @@ describe("Organization info tests", async () => { }); test("Test getting info about an unknown valid org returns just the ID", async () => { ddbMock - .on(GetItemCommand, { + .on(QueryCommand, { TableName: genericConfig.SigInfoTableName, - Key: { primaryKey: { S: "DEFINE#ACM" } }, + KeyConditionExpression: "primaryKey = :definitionId", + ExpressionAttributeValues: { + ":definitionId": { S: "DEFINE#ACM" }, + }, }) .resolves({ - Item: undefined, + Items: undefined, }); ddbMock .on(QueryCommand, { @@ -101,7 +107,7 @@ describe("Organization info tests", async () => { ":leadName": { S: "LEAD#ACM" }, }, }) - .rejects(); + .resolves({ Items: [] }); const response = await app.inject({ method: "GET", url: "/api/v1/organizations/ACM", @@ -114,12 +120,15 @@ describe("Organization info tests", async () => { }); test("Test that getting org with no leads succeeds", async () => { ddbMock - .on(GetItemCommand, { + .on(QueryCommand, { TableName: genericConfig.SigInfoTableName, - Key: { primaryKey: { S: "DEFINE#ACM" } }, + KeyConditionExpression: "primaryKey = :definitionId", + ExpressionAttributeValues: { + ":definitionId": { S: "DEFINE#ACM" }, + }, }) .resolves({ - Item: marshall(acmMeta), + Items: [marshall(acmMeta)], }); ddbMock .on(QueryCommand, { From 519f6bc0032f1ba74a1874091179a45b93757d00 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:04:02 -0500 Subject: [PATCH 10/18] Cache sub-organzations as well --- terraform/modules/frontend/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/frontend/main.tf b/terraform/modules/frontend/main.tf index 92db83c2..483c1c9c 100644 --- a/terraform/modules/frontend/main.tf +++ b/terraform/modules/frontend/main.tf @@ -200,7 +200,7 @@ resource "aws_cloudfront_distribution" "app_cloudfront_distribution" { } } ordered_cache_behavior { - path_pattern = "/api/v1/organizations" + path_pattern = "/api/v1/organizations*" target_origin_id = "LambdaFunction" viewer_protocol_policy = "redirect-to-https" allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] From c8229d4993f0e20276f04b8478f68a0b69de3f59 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:20:43 -0500 Subject: [PATCH 11/18] Setup an assets bucket for user uploads --- terraform/envs/qa/main.tf | 19 +++++ terraform/envs/qa/variables.tf | 7 ++ terraform/modules/assets/main.tf | 117 ++++++++++++++++++++++++++ terraform/modules/assets/variables.tf | 18 ++++ 4 files changed, 161 insertions(+) create mode 100644 terraform/modules/assets/main.tf create mode 100644 terraform/modules/assets/variables.tf diff --git a/terraform/envs/qa/main.tf b/terraform/envs/qa/main.tf index 44136506..84505781 100644 --- a/terraform/envs/qa/main.tf +++ b/terraform/envs/qa/main.tf @@ -118,7 +118,26 @@ module "frontend" { LinkryPublicDomain = var.LinkryPublicDomain LinkryKvArn = aws_cloudfront_key_value_store.linkry_kv.arn } + +module "assets" { + source = "../../modules/assets" + BucketPrefix = local.bucket_prefix + AssetsPublicDomain = var.AssetsPublicDomain + ProjectId = var.ProjectId + CoreCertificateArn = var.CoreCertificateArn +} // QA only - setup Route 53 records +resource "aws_route53_record" "assets" { + for_each = toset(["A", "AAAA"]) + zone_id = "Z04502822NVIA85WM2SML" + type = each.key + name = var.AssetsPublicDomain + alias { + name = module.assets.main_cloudfront_domain_name + zone_id = "Z2FDTNDATAQYW2" + evaluate_target_health = false + } +} resource "aws_route53_record" "frontend" { for_each = toset(["A", "AAAA"]) zone_id = "Z04502822NVIA85WM2SML" diff --git a/terraform/envs/qa/variables.tf b/terraform/envs/qa/variables.tf index a1848b67..83f2e278 100644 --- a/terraform/envs/qa/variables.tf +++ b/terraform/envs/qa/variables.tf @@ -13,6 +13,13 @@ variable "CoreCertificateArn" { default = "arn:aws:acm:us-east-1:427040638965:certificate/63ccdf0b-d2b5-44f0-b589-eceffb935c23" } + +variable "AssetsPublicDomain" { + type = string + default = "assets.aws.qa.acmuiuc.org" +} + + variable "CorePublicDomain" { type = string default = "core.aws.qa.acmuiuc.org" diff --git a/terraform/modules/assets/main.tf b/terraform/modules/assets/main.tf new file mode 100644 index 00000000..ea397380 --- /dev/null +++ b/terraform/modules/assets/main.tf @@ -0,0 +1,117 @@ +resource "aws_s3_bucket" "this" { + bucket = "${var.BucketPrefix}-${var.ProjectId}-assets" +} + +resource "aws_s3_bucket_lifecycle_configuration" "this" { + bucket = aws_s3_bucket.this.id + + rule { + id = "AbortIncompleteMultipartUploads" + status = "Enabled" + + abort_incomplete_multipart_upload { + days_after_initiation = 1 + } + } + + rule { + id = "ObjectLifecycle" + status = "Enabled" + + filter {} + + transition { + days = 30 + storage_class = "INTELLIGENT_TIERING" + } + + noncurrent_version_transition { + noncurrent_days = 30 + storage_class = "STANDARD_IA" + } + + noncurrent_version_expiration { + noncurrent_days = 60 + } + } +} + + +resource "aws_cloudfront_distribution" "app_cloudfront_distribution" { + http_version = "http2and3" + origin { + origin_id = "S3Bucket" + origin_access_control_id = aws_cloudfront_origin_access_control.this.id + domain_name = aws_s3_bucket.this.bucket_regional_domain_name + } + aliases = [var.AssetsPublicDomain] + enabled = true + is_ipv6_enabled = true + default_cache_behavior { + compress = true + target_origin_id = "S3Bucket" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # caching-optimized + } + viewer_certificate { + acm_certificate_arn = var.CoreCertificateArn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" + } + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + price_class = "PriceClass_100" +} + +resource "aws_cloudfront_origin_access_control" "this" { + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" + name = "${var.ProjectId}-assets-oac" +} + +resource "aws_s3_bucket_policy" "frontend_bucket_policy" { + bucket = aws_s3_bucket.this.id + policy = jsonencode(({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "cloudfront.amazonaws.com" + }, + Action = "s3:GetObject", + Resource = "${aws_s3_bucket.this.arn}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.this.arn + } + } + }, + { + Effect = "Allow", + Principal = { + Service = "cloudfront.amazonaws.com" + }, + Action = "s3:ListBucket", + Resource = aws_s3_bucket.frontend.arn + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.this.arn + } + } + } + ] + + })) +} + +output "main_cloudfront_domain_name" { + value = aws_cloudfront_distribution.app_cloudfront_distribution.domain_name +} diff --git a/terraform/modules/assets/variables.tf b/terraform/modules/assets/variables.tf new file mode 100644 index 00000000..5f11a309 --- /dev/null +++ b/terraform/modules/assets/variables.tf @@ -0,0 +1,18 @@ +variable "ProjectId" { + type = string + description = "Prefix before each resource" +} + +variable "BucketPrefix" { + type = string +} + +variable "CoreCertificateArn" { + type = string + description = "Core ACM ARN" +} + +variable "AssetsPublicDomain" { + type = string + description = "Core Assets Public Host" +} From 0e6d9395023609d1ecedf619890e65b57ad540ca Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:21:58 -0500 Subject: [PATCH 12/18] Update live test --- tests/live/organizations.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/live/organizations.test.ts b/tests/live/organizations.test.ts index 7aade598..59e67dd6 100644 --- a/tests/live/organizations.test.ts +++ b/tests/live/organizations.test.ts @@ -8,6 +8,11 @@ test("getting organizations", async () => { expect(response.status).toBe(200); const responseJson = (await response.json()) as string[]; expect(responseJson.length).greaterThan(0); - expect(responseJson).toContain("ACM"); - expect(responseJson).toContain("Infrastructure Committee"); +}); + +test("getting organizations", async () => { + const response = await fetch(`${baseEndpoint}/api/v1/organizations`); + expect(response.status).toBe(200); + const responseJson = (await response.json()) as string[]; + expect(responseJson.length).greaterThan(0); }); From 1ea7900bb7cc735f234cf3d1779708532d7b6a4f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:22:22 -0500 Subject: [PATCH 13/18] Update test --- tests/live/organizations.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/live/organizations.test.ts b/tests/live/organizations.test.ts index 59e67dd6..06979a34 100644 --- a/tests/live/organizations.test.ts +++ b/tests/live/organizations.test.ts @@ -10,9 +10,7 @@ test("getting organizations", async () => { expect(responseJson.length).greaterThan(0); }); -test("getting organizations", async () => { - const response = await fetch(`${baseEndpoint}/api/v1/organizations`); +test("getting ACM organization info", async () => { + const response = await fetch(`${baseEndpoint}/api/v1/organizations/ACM`); expect(response.status).toBe(200); - const responseJson = (await response.json()) as string[]; - expect(responseJson.length).greaterThan(0); }); From 38316f1e9c1d545ce6dce911f6683ec144ac4012 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:22:49 -0500 Subject: [PATCH 14/18] update terraform --- terraform/modules/assets/main.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/modules/assets/main.tf b/terraform/modules/assets/main.tf index ea397380..d5a84a84 100644 --- a/terraform/modules/assets/main.tf +++ b/terraform/modules/assets/main.tf @@ -37,7 +37,7 @@ resource "aws_s3_bucket_lifecycle_configuration" "this" { } -resource "aws_cloudfront_distribution" "app_cloudfront_distribution" { +resource "aws_cloudfront_distribution" "this" { http_version = "http2and3" origin { origin_id = "S3Bucket" @@ -100,7 +100,7 @@ resource "aws_s3_bucket_policy" "frontend_bucket_policy" { Service = "cloudfront.amazonaws.com" }, Action = "s3:ListBucket", - Resource = aws_s3_bucket.frontend.arn + Resource = aws_s3_bucket.this.arn Condition = { StringEquals = { "AWS:SourceArn" = aws_cloudfront_distribution.this.arn From d5acf7107d98e4ecd947a64c77d6c23edacb02e4 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:23:10 -0500 Subject: [PATCH 15/18] Fix the cf dist name --- terraform/modules/assets/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/assets/main.tf b/terraform/modules/assets/main.tf index d5a84a84..04639cf4 100644 --- a/terraform/modules/assets/main.tf +++ b/terraform/modules/assets/main.tf @@ -113,5 +113,5 @@ resource "aws_s3_bucket_policy" "frontend_bucket_policy" { } output "main_cloudfront_domain_name" { - value = aws_cloudfront_distribution.app_cloudfront_distribution.domain_name + value = aws_cloudfront_distribution.this.domain_name } From a800752249d49b185a3f3e7befb32da58a9d0f3d Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:25:00 -0500 Subject: [PATCH 16/18] Reduce route53 cost --- infracost-usage.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infracost-usage.yml b/infracost-usage.yml index 52e70f83..9ac255c8 100644 --- a/infracost-usage.yml +++ b/infracost-usage.yml @@ -286,9 +286,9 @@ resource_type_default_usage: spectrum_data_scanned_tb: 1 backup_storage_gb: 217 aws_route53_record: - monthly_standard_queries: 12500000 # Monthly number of Standard queries. - monthly_latency_based_queries: 8333333 # Monthly number of Latency Based Routing queries. - monthly_geo_queries: 7142857 # Monthly number of Geo DNS and Geoproximity queries. + monthly_standard_queries: 1250000 # Monthly number of Standard queries. + monthly_latency_based_queries: 0 # Monthly number of Latency Based Routing queries. + monthly_geo_queries: 0 # Monthly number of Geo DNS and Geoproximity queries. aws_route53_resolver_endpoint: monthly_queries: 12500000 # Monthly number of DNS queries processed through the endpoints. aws_s3_bucket_analytics_configuration: From 97953f1bd5e648862867c3984cfcb39873aba04f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:31:15 -0500 Subject: [PATCH 17/18] Do not allow bucket enumeration for assets --- terraform/modules/assets/main.tf | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/terraform/modules/assets/main.tf b/terraform/modules/assets/main.tf index 04639cf4..3ef89904 100644 --- a/terraform/modules/assets/main.tf +++ b/terraform/modules/assets/main.tf @@ -94,19 +94,6 @@ resource "aws_s3_bucket_policy" "frontend_bucket_policy" { } } }, - { - Effect = "Allow", - Principal = { - Service = "cloudfront.amazonaws.com" - }, - Action = "s3:ListBucket", - Resource = aws_s3_bucket.this.arn - Condition = { - StringEquals = { - "AWS:SourceArn" = aws_cloudfront_distribution.this.arn - } - } - } ] })) From 451964b3c0c2be7352a26789840196679d1a770c Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 17 Sep 2025 03:35:30 -0500 Subject: [PATCH 18/18] Use assets in prod environment --- terraform/envs/prod/main.tf | 8 ++++++++ terraform/envs/prod/variables.tf | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/terraform/envs/prod/main.tf b/terraform/envs/prod/main.tf index 4989d5b1..698cc31d 100644 --- a/terraform/envs/prod/main.tf +++ b/terraform/envs/prod/main.tf @@ -116,6 +116,14 @@ module "frontend" { LinkryKvArn = aws_cloudfront_key_value_store.linkry_kv.arn } +module "assets" { + source = "../../modules/assets" + BucketPrefix = local.bucket_prefix + AssetsPublicDomain = var.AssetsPublicDomain + ProjectId = var.ProjectId + CoreCertificateArn = var.CoreCertificateArn +} + resource "aws_lambda_event_source_mapping" "queue_consumer" { depends_on = [module.lambdas, module.sqs_queues] for_each = local.queue_arns diff --git a/terraform/envs/prod/variables.tf b/terraform/envs/prod/variables.tf index 7edf08cd..f64e25da 100644 --- a/terraform/envs/prod/variables.tf +++ b/terraform/envs/prod/variables.tf @@ -19,6 +19,12 @@ variable "PrioritySNSAlertArn" { } +variable "AssetsPublicDomain" { + type = string + default = "assets.acm.illinois.edu" +} + + variable "CoreCertificateArn" { type = string default = "arn:aws:acm:us-east-1:298118738376:certificate/aeb93d9e-b0b7-4272-9c12-24ca5058c77e"