diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 0aa4532..098673d 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -21,6 +21,7 @@ import { ConfidentialClientApplication } from "@azure/msal-node"; import { getItemFromCache, insertItemIntoCache } from "./cache.js"; import { EntraGroupActions, + EntraGroupMetadata, EntraInvitationResponse, ProfilePatchRequest, } from "../../common/types/iam.js"; @@ -553,3 +554,54 @@ export async function listGroupIDsByEmail( }); } } + +/** + * Retrieves metadata for a specific Entra ID group. + * @param token - Entra ID token authorized to take this action. + * @param groupId - The group ID to fetch metadata for. + * @throws {EntraGroupError} If fetching the group metadata fails. + * @returns {Promise} The group's metadata. + */ +export async function getGroupMetadata( + token: string, + groupId: string, +): Promise { + if (!validateGroupId(groupId)) { + throw new EntraGroupError({ + message: "Invalid group ID format", + group: groupId, + }); + } + try { + const url = `https://graph.microsoft.com/v1.0/groups/${groupId}?$select=id,displayName,mail,description`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { + error?: { message?: string }; + }; + throw new EntraGroupError({ + message: errorData?.error?.message ?? response.statusText, + group: groupId, + }); + } + + const data = (await response.json()) as EntraGroupMetadata; + return data; + } catch (error) { + if (error instanceof EntraGroupError) { + throw error; + } + + throw new EntraGroupError({ + message: error instanceof Error ? error.message : String(error), + group: groupId, + }); + } +} diff --git a/src/api/routes/apiKey.ts b/src/api/routes/apiKey.ts index f84b0aa..78d40da 100644 --- a/src/api/routes/apiKey.ts +++ b/src/api/routes/apiKey.ts @@ -22,6 +22,8 @@ import { ValidationError, } from "common/errors/index.js"; import { z } from "zod"; +import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rateLimiter, { @@ -86,6 +88,49 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { message: "Could not create API key.", }); } + request.log.debug("Constructing SQS payload to send email notification."); + const sqsPayload: SQSPayload = { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [request.username!], + subject: "Important: API Key Created", + content: ` +This email confirms that an API key for the Core API has been generated from your account. + +Key ID: acmuiuc_${keyId} + +Key Description: ${description} + +IP address: ${request.ip}. + +Roles: ${roles.join(", ")}. + +If you did not create this API key, please secure your account and notify the ACM Infrastructure team. + `, + callToActionButton: { + name: "View API Keys", + url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`, + }, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + if (result.MessageId) { + request.log.info(`Queued notification with ID ${result.MessageId}.`); + } return reply.status(201).send({ apiKey, expiresAt, @@ -149,6 +194,45 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { message: "Could not delete API key.", }); } + request.log.debug("Constructing SQS payload to send email notification."); + const sqsPayload: SQSPayload = { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [request.username!], + subject: "Important: API Key Deleted", + content: ` +This email confirms that an API key for the Core API has been deleted from your account. + +Key ID: acmuiuc_${keyId} + +IP address: ${request.ip}. + +If you did not delete this API key, please secure your account and notify the ACM Infrastructure team. + `, + callToActionButton: { + name: "View API Keys", + url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`, + }, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + if (result.MessageId) { + request.log.info(`Queued notification with ID ${result.MessageId}.`); + } return reply.status(204).send(); }, ); diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 3ac9044..fc14eb3 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -3,6 +3,7 @@ import { AppRoles } from "../../common/roles.js"; import { addToTenant, getEntraIdToken, + getGroupMetadata, listGroupMembers, modifyGroup, patchUserProfile, @@ -39,6 +40,10 @@ import { Modules } from "common/modules.js"; import { groupId, withRoles, withTags } from "api/components/index.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; +import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { v4 as uuidv4 } from "uuid"; +import { randomUUID } from "crypto"; const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -305,6 +310,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { await getAuthorizedClients(), fastify.environmentConfig.AadValidClientId, ); + const groupMetadataPromise = getGroupMetadata(entraIdToken, groupId); const addResults = await Promise.allSettled( request.body.add.map((email) => modifyGroup( @@ -327,15 +333,19 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { ), ), ); + const groupMetadata = await groupMetadataPromise; const response: Record[]> = { success: [], failure: [], }; const logPromises = []; + const addedEmails = []; + const removedEmails = []; for (let i = 0; i < addResults.length; i++) { const result = addResults[i]; if (result.status === "fulfilled") { response.success.push({ email: request.body.add[i] }); + addedEmails.push(request.body.add[i]); logPromises.push( createAuditLogEntry({ dynamoClient: fastify.dynamoClient, @@ -378,6 +388,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const result = removeResults[i]; if (result.status === "fulfilled") { response.success.push({ email: request.body.remove[i] }); + removedEmails.push(request.body.remove[i]); logPromises.push( createAuditLogEntry({ dynamoClient: fastify.dynamoClient, @@ -416,6 +427,95 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { } } } + const sqsAddedPayloads = addedEmails + .filter((x) => !!x) + .map((x) => { + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [x], + subject: "You have been added to an access group", + content: ` +Hello, + +We're letting you know that you have been added to the "${groupMetadata.displayName}" access group by ${request.username}. Changes may take up to 2 hours to reflect in all systems. + +No action is required from you at this time. + `, + }, + }; + }); + const sqsRemovedPayloads = removedEmails + .filter((x) => !!x) + .map((x) => { + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [x], + subject: "You have been removed from an access group", + content: ` +Hello, + +We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}. + +No action is required from you at this time. + `, + }, + }; + }); + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + if (sqsAddedPayloads.length > 0) { + request.log.debug("Sending added emails"); + let chunkId = 0; + for (let i = 0; i < sqsAddedPayloads.length; i += 10) { + chunkId += 1; + const chunk = sqsAddedPayloads.slice(i, i + 10); + const removedQueued = await fastify.sqsClient.send( + new SendMessageBatchCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + Entries: chunk.map((x) => ({ + Id: randomUUID(), + MessageBody: JSON.stringify(x), + })), + }), + ); + request.log.info( + `Sent added emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`, + ); + } + } + if (sqsRemovedPayloads.length > 0) { + request.log.debug("Sending removed emails"); + let chunkId = 0; + for (let i = 0; i < sqsRemovedPayloads.length; i += 10) { + chunkId += 1; + const chunk = sqsRemovedPayloads.slice(i, i + 10); + const removedQueued = await fastify.sqsClient.send( + new SendMessageBatchCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + Entries: chunk.map((x) => ({ + Id: randomUUID(), + MessageBody: JSON.stringify(x), + })), + }), + ); + request.log.info( + `Sent removed emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`, + ); + } + } await Promise.allSettled(logPromises); reply.status(202).send(response); }, diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index 8195d1e..e77197e 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -139,7 +139,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [originalRequestor], subject: "Room Reservation Request Status Change", - content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId} to view details.`, + content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit the management portal for more details.`, + callToActionButton: { + name: "View Room Request", + url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId}`, + }, }, }; if (!fastify.sqsClient) { @@ -353,7 +357,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [notificationRecipients[fastify.runEnvironment].OfficerBoard], subject: "New Room Reservation Request", - content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId} to view details.`, + content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit the management portal for more details.`, + callToActionButton: { + name: "View Room Request", + url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId}`, + }, }, }; if (!fastify.sqsClient) { diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 21b3830..29f0a13 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -17,7 +17,7 @@ import { StripeLinkCreateParams, } from "api/functions/stripe.js"; import { getSecretValue } from "api/plugins/auth.js"; -import { genericConfig } from "common/config.js"; +import { environmentConfig, genericConfig } from "common/config.js"; import { BaseError, DatabaseFetchError, @@ -407,7 +407,11 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [unmarshalledEntry.userId], subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`, - content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`, + content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} paid by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`, + callToActionButton: { + name: "View Your Stripe Links", + url: `${fastify.environmentConfig.UserFacingUrl}/stripe`, + }, }, }; if (!fastify.sqsClient) { diff --git a/src/api/sqs/handlers/templates/notification.ts b/src/api/sqs/handlers/templates/notification.ts index 7cb3ea3..f2af331 100644 --- a/src/api/sqs/handlers/templates/notification.ts +++ b/src/api/sqs/handlers/templates/notification.ts @@ -33,6 +33,24 @@ const template = /*html*/ ` {{nl2br content}} + + {{#if callToActionButton}} + + + + + + +
+ + {{callToActionButton.name}} + +
+ + + {{/if}} +

; diff --git a/src/common/types/sqsMessage.ts b/src/common/types/sqsMessage.ts index 09fd217..178e1f2 100644 --- a/src/common/types/sqsMessage.ts +++ b/src/common/types/sqsMessage.ts @@ -63,6 +63,10 @@ export const sqsPayloadSchemas = { bcc: z.optional(z.array(z.string().email()).min(1)), subject: z.string().min(1), content: z.string().min(1), + callToActionButton: z.object({ + name: z.string().min(1), + url: z.string().min(1).url() + }).optional() }) ) } as const; diff --git a/tests/unit/apiKey.test.ts b/tests/unit/apiKey.test.ts index 3858ffb..f3a5c38 100644 --- a/tests/unit/apiKey.test.ts +++ b/tests/unit/apiKey.test.ts @@ -12,6 +12,8 @@ import { } from "@aws-sdk/client-dynamodb"; import { AppRoles } from "../../src/common/roles.js"; import { createApiKey } from "../../src/api/functions/apiKey.js"; +import { randomUUID } from "crypto"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; // Mock the createApiKey function vi.mock("../../src/api/functions/apiKey.js", () => { @@ -28,6 +30,7 @@ vi.mock("../../src/api/functions/apiKey.js", () => { // Mock DynamoDB client const dynamoMock = mockClient(DynamoDBClient); +const sqsMock = mockClient(SQSClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -37,6 +40,7 @@ const app = await init(); describe("API Key Route Tests", () => { beforeEach(() => { dynamoMock.reset(); + sqsMock.reset(); vi.clearAllMocks(); dynamoMock.on(TransactWriteItemsCommand).resolves({}); @@ -61,6 +65,8 @@ describe("API Key Route Tests", () => { describe("Create API Key", () => { test("Should create an API key successfully", async () => { + const queueId = randomUUID(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); const testJwt = createJwt(); await app.ready(); @@ -79,10 +85,13 @@ describe("API Key Route Tests", () => { expect(response.body.apiKey).toBe("acmuiuc_test123_abcdefg12345"); expect(createApiKey).toHaveBeenCalledTimes(1); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(1); }); test("Should create an API key with expiration", async () => { const testJwt = createJwt(); + const queueId = randomUUID(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); await app.ready(); const expiryTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now @@ -104,9 +113,11 @@ describe("API Key Route Tests", () => { expect(response.body.expiresAt).toBe(expiryTime); expect(createApiKey).toHaveBeenCalledTimes(1); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(1); }); test("Should not create an API key for invalid API key roles", async () => { + sqsMock.on(SendMessageCommand).rejects({}); const testJwt = createJwt(); await app.ready(); @@ -131,6 +142,7 @@ describe("API Key Route Tests", () => { expect(createApiKey).toHaveBeenCalledTimes(0); expect(dynamoMock.calls()).toHaveLength(0); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should handle DynamoDB insertion error", async () => { @@ -138,7 +150,7 @@ describe("API Key Route Tests", () => { dynamoMock .on(TransactWriteItemsCommand) .rejects(new Error("DynamoDB error")); - + sqsMock.on(SendMessageCommand).rejects({}); const testJwt = createJwt(); await app.ready(); @@ -157,6 +169,7 @@ describe("API Key Route Tests", () => { expect(response.body.message).toBe("Could not create API key."); expect(createApiKey).toHaveBeenCalledTimes(1); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should require authorization", async () => { @@ -177,6 +190,8 @@ describe("API Key Route Tests", () => { describe("Delete API Key", () => { test("Should delete an API key successfully", async () => { + const queueId = randomUUID(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); const testJwt = createJwt(); await app.ready(); @@ -212,10 +227,12 @@ describe("API Key Route Tests", () => { expect(response.body).toHaveProperty("message"); expect(response.body.message).toBe("Key does not exist."); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should handle DynamoDB deletion error", async () => { // Mock the DynamoDB client to throw a generic error + sqsMock.on(SendMessageCommand).rejects(); dynamoMock .on(TransactWriteItemsCommand) .rejects(new Error("DynamoDB error")); @@ -233,6 +250,7 @@ describe("API Key Route Tests", () => { expect(response.body).toHaveProperty("message"); expect(response.body.message).toBe("Could not delete API key."); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should require authentication", async () => { diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index 534fe0c..6eb55af 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -4,6 +4,11 @@ import { createJwt } from "./auth.test.js"; import supertest from "supertest"; import { describe } from "node:test"; import { EntraGroupError } from "../../src/common/errors/index.js"; +import { + SendMessageBatchCommand, + SendMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; // Mock required dependencies - their real impl's are defined in the beforeEach section. vi.mock("../../src/api/functions/entraId.js", () => { @@ -21,9 +26,14 @@ vi.mock("../../src/api/functions/entraId.js", () => { listGroupMembers: vi.fn().mockImplementation(async () => { return ""; }), + getGroupMetadata: vi.fn().mockImplementation(async () => { + return { id: "abc123", displayName: "thing" }; + }), }; }); +const sqsMock = mockClient(SQSClient); + import { modifyGroup, listGroupMembers, @@ -31,15 +41,23 @@ import { resolveEmailToOid, } from "../../src/api/functions/entraId.js"; import { EntraGroupActions } from "../../src/common/types/iam.js"; +import { randomUUID } from "crypto"; +import { mockClient } from "aws-sdk-client-mock"; +import { V } from "vitest/dist/chunks/reporters.d.79o4mouw.js"; const app = await init(); describe("Test Modify Group and List Group Routes", () => { beforeEach(() => { (app as any).nodeCache.flushAll(); + sqsMock.reset(); vi.clearAllMocks(); }); test("Modify group: Add and remove members", async () => { + const queueId = randomUUID(); + sqsMock + .on(SendMessageBatchCommand) + .resolves({ $metadata: { requestId: queueId } }); const testJwt = createJwt(); await app.ready(); @@ -50,7 +68,7 @@ describe("Test Modify Group and List Group Routes", () => { add: ["validuser1@illinois.edu"], remove: ["validuser2@illinois.edu"], }); - + sqsMock.on(SendMessageCommand).resolves({}); expect(response.statusCode).toBe(202); expect(modifyGroup).toHaveBeenCalledTimes(2); expect(modifyGroup).toHaveBeenNthCalledWith( @@ -75,12 +93,13 @@ describe("Test Modify Group and List Group Routes", () => { { email: "validuser2@illinois.edu" }, ]); expect(response.body.failure).toEqual([]); + expect(sqsMock.calls()).toHaveLength(2); }); test("Modify group: Fail for invalid email domain", async () => { const testJwt = createJwt(); await app.ready(); - + sqsMock.on(SendMessageBatchCommand).rejects(); const response = await supertest(app.server) .patch("/api/v1/iam/groups/test-group-id") .set("authorization", `Bearer ${testJwt}`) @@ -99,6 +118,7 @@ describe("Test Modify Group and List Group Routes", () => { "User's domain must be illinois.edu to be added or removed from the group.", }, ]); + expect(sqsMock.calls()).toHaveLength(0); }); test("List group members: Happy path", async () => { diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index 30045a1..2a4193d 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ thresholds: { statements: 55, functions: 66, - lines: 54, + lines: 55, }, }, },