Skip to content

Commit 16f1677

Browse files
committed
send emails on sensitive actions
1 parent a5fd78b commit 16f1677

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

src/api/functions/entraId.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ConfidentialClientApplication } from "@azure/msal-node";
2121
import { getItemFromCache, insertItemIntoCache } from "./cache.js";
2222
import {
2323
EntraGroupActions,
24+
EntraGroupMetadata,
2425
EntraInvitationResponse,
2526
ProfilePatchRequest,
2627
} from "../../common/types/iam.js";
@@ -553,3 +554,54 @@ export async function listGroupIDsByEmail(
553554
});
554555
}
555556
}
557+
558+
/**
559+
* Retrieves metadata for a specific Entra ID group.
560+
* @param token - Entra ID token authorized to take this action.
561+
* @param groupId - The group ID to fetch metadata for.
562+
* @throws {EntraGroupError} If fetching the group metadata fails.
563+
* @returns {Promise<EntraGroupMetadata>} The group's metadata.
564+
*/
565+
export async function getGroupMetadata(
566+
token: string,
567+
groupId: string,
568+
): Promise<EntraGroupMetadata> {
569+
if (!validateGroupId(groupId)) {
570+
throw new EntraGroupError({
571+
message: "Invalid group ID format",
572+
group: groupId,
573+
});
574+
}
575+
try {
576+
const url = `https://graph.microsoft.com/v1.0/groups/${groupId}?$select=id,displayName,mail,description`;
577+
const response = await fetch(url, {
578+
method: "GET",
579+
headers: {
580+
Authorization: `Bearer ${token}`,
581+
"Content-Type": "application/json",
582+
},
583+
});
584+
585+
if (!response.ok) {
586+
const errorData = (await response.json()) as {
587+
error?: { message?: string };
588+
};
589+
throw new EntraGroupError({
590+
message: errorData?.error?.message ?? response.statusText,
591+
group: groupId,
592+
});
593+
}
594+
595+
const data = (await response.json()) as EntraGroupMetadata;
596+
return data;
597+
} catch (error) {
598+
if (error instanceof EntraGroupError) {
599+
throw error;
600+
}
601+
602+
throw new EntraGroupError({
603+
message: error instanceof Error ? error.message : String(error),
604+
group: groupId,
605+
});
606+
}
607+
}

src/api/routes/apiKey.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
ValidationError,
2323
} from "common/errors/index.js";
2424
import { z } from "zod";
25+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
26+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
2527

2628
const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
2729
await fastify.register(rateLimiter, {
@@ -86,6 +88,47 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
8688
message: "Could not create API key.",
8789
});
8890
}
91+
request.log.debug("Constructing SQS payload to send email notification.");
92+
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> = {
93+
function: AvailableSQSFunctions.EmailNotifications,
94+
metadata: {
95+
initiator: request.username!,
96+
reqId: request.id,
97+
},
98+
payload: {
99+
to: [request.username!],
100+
subject: "Important: ACM @ UIUC API Key Created",
101+
content: `
102+
This email confirms that an API key for the ACM @ UIUC API has been generated from your account.
103+
104+
Key ID: acmuiuc_${keyId}
105+
106+
IP address: ${request.ip}.
107+
108+
Roles: ${roles.join(", ")}.
109+
110+
If you did not create this API key, please secure your account and notify the ACM Infrastructure team.
111+
`,
112+
callToActionButton: {
113+
name: "View API Keys",
114+
url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`,
115+
},
116+
},
117+
};
118+
if (!fastify.sqsClient) {
119+
fastify.sqsClient = new SQSClient({
120+
region: genericConfig.AwsRegion,
121+
});
122+
}
123+
const result = await fastify.sqsClient.send(
124+
new SendMessageCommand({
125+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
126+
MessageBody: JSON.stringify(sqsPayload),
127+
}),
128+
);
129+
if (result.MessageId) {
130+
request.log.info(`Queued notification with ID ${result.MessageId}.`);
131+
}
89132
return reply.status(201).send({
90133
apiKey,
91134
expiresAt,
@@ -149,6 +192,45 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
149192
message: "Could not delete API key.",
150193
});
151194
}
195+
request.log.debug("Constructing SQS payload to send email notification.");
196+
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> = {
197+
function: AvailableSQSFunctions.EmailNotifications,
198+
metadata: {
199+
initiator: request.username!,
200+
reqId: request.id,
201+
},
202+
payload: {
203+
to: [request.username!],
204+
subject: "Important: ACM @ UIUC API Key Deleted",
205+
content: `
206+
This email confirms that an API key for the ACM @ UIUC API has been deleted from your account.
207+
208+
Key ID: acmuiuc_${keyId}
209+
210+
IP address: ${request.ip}.
211+
212+
If you did not delete this API key, please secure your account and notify the ACM Infrastructure team.
213+
`,
214+
callToActionButton: {
215+
name: "View API Keys",
216+
url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`,
217+
},
218+
},
219+
};
220+
if (!fastify.sqsClient) {
221+
fastify.sqsClient = new SQSClient({
222+
region: genericConfig.AwsRegion,
223+
});
224+
}
225+
const result = await fastify.sqsClient.send(
226+
new SendMessageCommand({
227+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
228+
MessageBody: JSON.stringify(sqsPayload),
229+
}),
230+
);
231+
if (result.MessageId) {
232+
request.log.info(`Queued notification with ID ${result.MessageId}.`);
233+
}
152234
return reply.status(204).send();
153235
},
154236
);

src/api/routes/iam.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AppRoles } from "../../common/roles.js";
33
import {
44
addToTenant,
55
getEntraIdToken,
6+
getGroupMetadata,
67
listGroupMembers,
78
modifyGroup,
89
patchUserProfile,
@@ -39,6 +40,9 @@ import { Modules } from "common/modules.js";
3940
import { groupId, withRoles, withTags } from "api/components/index.js";
4041
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
4142
import { z } from "zod";
43+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
44+
import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs";
45+
import { v4 as uuidv4 } from "uuid";
4246

4347
const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
4448
const getAuthorizedClients = async () => {
@@ -305,6 +309,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
305309
await getAuthorizedClients(),
306310
fastify.environmentConfig.AadValidClientId,
307311
);
312+
const groupMetadataPromise = getGroupMetadata(entraIdToken, groupId);
308313
const addResults = await Promise.allSettled(
309314
request.body.add.map((email) =>
310315
modifyGroup(
@@ -327,15 +332,19 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
327332
),
328333
),
329334
);
335+
const groupMetadata = await groupMetadataPromise;
330336
const response: Record<string, Record<string, string>[]> = {
331337
success: [],
332338
failure: [],
333339
};
334340
const logPromises = [];
341+
const addedEmails = [];
342+
const removedEmails = [];
335343
for (let i = 0; i < addResults.length; i++) {
336344
const result = addResults[i];
337345
if (result.status === "fulfilled") {
338346
response.success.push({ email: request.body.add[i] });
347+
addedEmails.push(request.body.add[i]);
339348
logPromises.push(
340349
createAuditLogEntry({
341350
dynamoClient: fastify.dynamoClient,
@@ -378,6 +387,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
378387
const result = removeResults[i];
379388
if (result.status === "fulfilled") {
380389
response.success.push({ email: request.body.remove[i] });
390+
removedEmails.push(request.body.remove[i]);
381391
logPromises.push(
382392
createAuditLogEntry({
383393
dynamoClient: fastify.dynamoClient,
@@ -416,6 +426,81 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
416426
}
417427
}
418428
}
429+
const sqsAddedPayloads = addedEmails.map((x) => {
430+
return {
431+
function: AvailableSQSFunctions.EmailNotifications,
432+
metadata: {
433+
initiator: request.username!,
434+
reqId: request.id,
435+
},
436+
payload: {
437+
to: [x],
438+
subject: "You have been added to an access group",
439+
content: `
440+
Hello,
441+
442+
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.
443+
444+
No action is required from you at this time.
445+
`,
446+
},
447+
};
448+
});
449+
const sqsRemovedPayloads = removedEmails.map((x) => {
450+
return {
451+
function: AvailableSQSFunctions.EmailNotifications,
452+
metadata: {
453+
initiator: request.username!,
454+
reqId: request.id,
455+
},
456+
payload: {
457+
to: [x],
458+
subject: "You have been removed from an access group",
459+
content: `
460+
Hello,
461+
462+
We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}.
463+
464+
No action is required from you at this time.
465+
`,
466+
},
467+
};
468+
});
469+
if (!fastify.sqsClient) {
470+
fastify.sqsClient = new SQSClient({
471+
region: genericConfig.AwsRegion,
472+
});
473+
}
474+
if (sqsAddedPayloads.length > 0) {
475+
request.log.debug("Sending added emails");
476+
const addedQueued = await fastify.sqsClient.send(
477+
new SendMessageBatchCommand({
478+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
479+
Entries: sqsAddedPayloads.map((x) => ({
480+
Id: uuidv4(),
481+
MessageBody: JSON.stringify(x),
482+
})),
483+
}),
484+
);
485+
request.log.info(
486+
`Sent added emails, queue ID ${addedQueued.$metadata.requestId}`,
487+
);
488+
}
489+
if (sqsRemovedPayloads.length > 0) {
490+
request.log.debug("Sending removed emails");
491+
const removedQueued = await fastify.sqsClient.send(
492+
new SendMessageBatchCommand({
493+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
494+
Entries: sqsRemovedPayloads.map((x) => ({
495+
Id: uuidv4(),
496+
MessageBody: JSON.stringify(x),
497+
})),
498+
}),
499+
);
500+
request.log.info(
501+
`Sent removed emails, queue ID ${removedQueued.$metadata.requestId}`,
502+
);
503+
}
419504
await Promise.allSettled(logPromises);
420505
reply.status(202).send(response);
421506
},

src/common/types/iam.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ export enum EntraGroupActions {
66
REMOVE,
77
}
88

9+
export interface EntraGroupMetadata {
10+
id: string;
11+
displayName: string;
12+
mail: string | null;
13+
description: string | null;
14+
}
15+
916
export interface EntraInvitationResponse {
1017
status: number;
1118
data?: Record<string, string>;

0 commit comments

Comments
 (0)