Skip to content

Commit 63cfffd

Browse files
committed
add group modification route with protected groups support
1 parent 97ed349 commit 63cfffd

File tree

5 files changed

+284
-20
lines changed

5 files changed

+284
-20
lines changed

src/api/functions/entraId.ts

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
import { genericConfig } from "../../common/config.js";
22
import {
3+
EntraGroupError,
34
EntraInvitationError,
45
InternalServerError,
56
} from "../../common/errors/index.js";
67
import { getSecretValue } from "../plugins/auth.js";
78
import { ConfidentialClientApplication } from "@azure/msal-node";
89
import { getItemFromCache, insertItemIntoCache } from "./cache.js";
10+
import {
11+
EntraGroupActions,
12+
EntraInvitationResponse,
13+
} from "../../common/types/iam.js";
914

10-
interface EntraInvitationResponse {
11-
status: number;
12-
data?: Record<string, string>;
13-
error?: {
14-
message: string;
15-
code?: string;
16-
};
17-
}
1815
export async function getEntraIdToken(
1916
clientId: string,
2017
scopes: string[] = ["https://graph.microsoft.com/.default"],
@@ -76,6 +73,7 @@ export async function getEntraIdToken(
7673

7774
/**
7875
* Adds a user to the tenant by sending an invitation to their email
76+
* @param token - Entra ID token authorized to take this action.
7977
* @param email - The email address of the user to invite
8078
* @throws {InternalServerError} If the invitation fails
8179
* @returns {Promise<boolean>} True if the invitation was successful
@@ -123,3 +121,115 @@ export async function addToTenant(token: string, email: string) {
123121
});
124122
}
125123
}
124+
125+
/**
126+
* Resolves an email address to an OID using Microsoft Graph API.
127+
* @param token - Entra ID token authorized to perform this action.
128+
* @param email - The email address to resolve.
129+
* @throws {Error} If the resolution fails.
130+
* @returns {Promise<string>} The OID of the user.
131+
*/
132+
export async function resolveEmailToOid(
133+
token: string,
134+
email: string,
135+
): Promise<string> {
136+
email = email.toLowerCase().replace(/\s/g, "");
137+
138+
const url = `https://graph.microsoft.com/v1.0/users?$filter=mail eq '${email}'`;
139+
140+
const response = await fetch(url, {
141+
method: "GET",
142+
headers: {
143+
Authorization: `Bearer ${token}`,
144+
"Content-Type": "application/json",
145+
},
146+
});
147+
148+
if (!response.ok) {
149+
const errorData = (await response.json()) as {
150+
error?: { message?: string };
151+
};
152+
throw new Error(errorData?.error?.message ?? response.statusText);
153+
}
154+
155+
const data = (await response.json()) as {
156+
value: { id: string }[];
157+
};
158+
159+
if (!data.value || data.value.length === 0) {
160+
throw new Error(`No user found with email: ${email}`);
161+
}
162+
163+
return data.value[0].id;
164+
}
165+
166+
/**
167+
* Adds or removes a user from an Entra ID group.
168+
* @param token - Entra ID token authorized to take this action.
169+
* @param email - The email address of the user to add or remove.
170+
* @param group - The group ID to take action on.
171+
* @param action - Whether to add or remove the user from the group.
172+
* @throws {EntraGroupError} If the group action fails.
173+
* @returns {Promise<boolean>} True if the action was successful.
174+
*/
175+
export async function modifyGroup(
176+
token: string,
177+
email: string,
178+
group: string,
179+
action: EntraGroupActions,
180+
): Promise<boolean> {
181+
email = email.toLowerCase().replace(/\s/g, "");
182+
if (!email.endsWith("@illinois.edu")) {
183+
throw new EntraGroupError({
184+
group,
185+
message: "User's domain must be illinois.edu to be added to the group.",
186+
});
187+
}
188+
189+
try {
190+
const oid = await resolveEmailToOid(token, email);
191+
const methodMapper = {
192+
[EntraGroupActions.ADD]: "POST",
193+
[EntraGroupActions.REMOVE]: "DELETE",
194+
};
195+
196+
const urlMapper = {
197+
[EntraGroupActions.ADD]: `https://graph.microsoft.com/v1.0/groups/${group}/members/$ref`,
198+
[EntraGroupActions.REMOVE]: `https://graph.microsoft.com/v1.0/groups/${group}/members/${oid}/$ref`,
199+
};
200+
const url = urlMapper[action];
201+
const body = {
202+
"@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${oid}`,
203+
};
204+
205+
const response = await fetch(url, {
206+
method: methodMapper[action],
207+
headers: {
208+
Authorization: `Bearer ${token}`,
209+
"Content-Type": "application/json",
210+
},
211+
body: JSON.stringify(body),
212+
});
213+
214+
if (!response.ok) {
215+
const errorData = (await response.json()) as {
216+
error?: { message?: string };
217+
};
218+
throw new EntraGroupError({
219+
message: errorData?.error?.message ?? response.statusText,
220+
group,
221+
});
222+
}
223+
224+
return true;
225+
} catch (error) {
226+
if (error instanceof EntraGroupError) {
227+
throw error;
228+
}
229+
230+
throw new EntraGroupError({
231+
message: error instanceof Error ? error.message : String(error),
232+
group,
233+
});
234+
}
235+
}

src/api/routes/iam.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { FastifyPluginAsync } from "fastify";
22
import { AppRoles } from "../../common/roles.js";
33
import { zodToJsonSchema } from "zod-to-json-schema";
4-
import { addToTenant, getEntraIdToken } from "../functions/entraId.js";
4+
import {
5+
addToTenant,
6+
getEntraIdToken,
7+
modifyGroup,
8+
} from "../functions/entraId.js";
59
import {
610
BaseError,
711
DatabaseFetchError,
812
DatabaseInsertError,
13+
EntraGroupError,
914
EntraInvitationError,
1015
InternalServerError,
1116
NotFoundError,
@@ -22,7 +27,10 @@ import {
2227
invitePostRequestSchema,
2328
GroupMappingCreatePostRequest,
2429
groupMappingCreatePostSchema,
25-
invitePostResponseSchema,
30+
entraActionResponseSchema,
31+
groupModificationPatchSchema,
32+
GroupModificationPatchRequest,
33+
EntraGroupActions,
2634
} from "../../common/types/iam.js";
2735

2836
const dynamoClient = new DynamoDBClient({
@@ -134,7 +142,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
134142
"/inviteUsers",
135143
{
136144
schema: {
137-
response: { 200: zodToJsonSchema(invitePostResponseSchema) },
145+
response: { 200: zodToJsonSchema(entraActionResponseSchema) },
138146
},
139147
preValidation: async (request, reply) => {
140148
await fastify.zodValidateBody(request, reply, invitePostRequestSchema);
@@ -176,6 +184,104 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
176184
reply.status(202).send(response);
177185
},
178186
);
187+
fastify.patch<{
188+
Body: GroupModificationPatchRequest;
189+
Querystring: { groupId: string };
190+
}>(
191+
"/groupMembership/:groupId",
192+
{
193+
schema: {
194+
querystring: {
195+
type: "object",
196+
properties: {
197+
groupId: {
198+
type: "string",
199+
},
200+
},
201+
},
202+
},
203+
preValidation: async (request, reply) => {
204+
await fastify.zodValidateBody(
205+
request,
206+
reply,
207+
groupModificationPatchSchema,
208+
);
209+
},
210+
onRequest: async (request, reply) => {
211+
await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]);
212+
},
213+
},
214+
async (request, reply) => {
215+
const groupId = (request.params as Record<string, string>).groupId;
216+
if (!groupId || groupId === "") {
217+
throw new NotFoundError({
218+
endpointName: request.url,
219+
});
220+
}
221+
if (genericConfig.ProtectedEntraIDGroups.includes(groupId)) {
222+
throw new EntraGroupError({
223+
code: 403,
224+
message:
225+
"This group is protected and may not be modified by this service. You must log into Entra ID directly to modify this group.",
226+
group: groupId,
227+
});
228+
}
229+
const entraIdToken = await getEntraIdToken(
230+
fastify.environmentConfig.AadValidClientId,
231+
);
232+
const addResults = await Promise.allSettled(
233+
request.body.add.map((email) =>
234+
modifyGroup(entraIdToken, email, groupId, EntraGroupActions.ADD),
235+
),
236+
);
237+
const removeResults = await Promise.allSettled(
238+
request.body.remove.map((email) =>
239+
modifyGroup(entraIdToken, email, groupId, EntraGroupActions.REMOVE),
240+
),
241+
);
242+
const response: Record<string, Record<string, string>[]> = {
243+
success: [],
244+
failure: [],
245+
};
246+
for (let i = 0; i < addResults.length; i++) {
247+
const result = addResults[i];
248+
if (result.status === "fulfilled") {
249+
response.success.push({ email: request.body.add[i] });
250+
} else {
251+
if (result.reason instanceof EntraGroupError) {
252+
response.failure.push({
253+
email: request.body.add[i],
254+
message: result.reason.message,
255+
});
256+
} else {
257+
response.failure.push({
258+
email: request.body.add[i],
259+
message: "An unknown error occurred.",
260+
});
261+
}
262+
}
263+
}
264+
for (let i = 0; i < removeResults.length; i++) {
265+
const result = removeResults[i];
266+
if (result.status === "fulfilled") {
267+
response.success.push({ email: request.body.remove[i] });
268+
} else {
269+
if (result.reason instanceof EntraGroupError) {
270+
response.failure.push({
271+
email: request.body.add[i],
272+
message: result.reason.message,
273+
});
274+
} else {
275+
response.failure.push({
276+
email: request.body.add[i],
277+
message: "An unknown error occurred.",
278+
});
279+
}
280+
}
281+
}
282+
reply.status(202).send(response);
283+
},
284+
);
179285
};
180286

181287
export default iamRoutes;

src/common/config.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,17 @@ type GenericConfigType = {
3030
TicketMetadataTableName: string;
3131
MerchStoreMetadataTableName: string;
3232
IAMTablePrefix: string;
33+
ProtectedEntraIDGroups: string[]; // these groups are too privileged to be modified via this portal and must be modified directly in Entra ID.
3334
};
3435

3536
type EnvironmentConfigType = {
3637
[env in RunEnvironment]: ConfigType;
3738
};
3839

40+
export const infraChairsGroupId = "48591dbc-cdcb-4544-9f63-e6b92b067e33";
41+
export const officersGroupId = "ff49e948-4587-416b-8224-65147540d5fc";
42+
export const execCouncilGroupId = "ad81254b-4eeb-4c96-8191-3acdce9194b1";
43+
3944
const genericConfig: GenericConfigType = {
4045
EventsDynamoTableName: "infra-core-api-events",
4146
CacheDynamoTableName: "infra-core-api-cache",
@@ -48,12 +53,13 @@ const genericConfig: GenericConfigType = {
4853
TicketPurchasesTableName: "infra-events-tickets",
4954
TicketMetadataTableName: "infra-events-ticketing-metadata",
5055
IAMTablePrefix: "infra-core-api-iam",
56+
ProtectedEntraIDGroups: [infraChairsGroupId, officersGroupId],
5157
} as const;
5258

5359
const environmentConfig: EnvironmentConfigType = {
5460
dev: {
5561
GroupRoleMapping: {
56-
"48591dbc-cdcb-4544-9f63-e6b92b067e33": allAppRoles, // Infra Chairs
62+
[infraChairsGroupId]: allAppRoles, // Infra Chairs
5763
"940e4f9e-6891-4e28-9e29-148798495cdb": allAppRoles, // ACM Infra Team
5864
"f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6": allAppRoles, // Infra Leads
5965
"0": allAppRoles, // Dummy Group for development only
@@ -76,12 +82,9 @@ const environmentConfig: EnvironmentConfigType = {
7682
},
7783
prod: {
7884
GroupRoleMapping: {
79-
"48591dbc-cdcb-4544-9f63-e6b92b067e33": allAppRoles, // Infra Chairs
80-
"ff49e948-4587-416b-8224-65147540d5fc": allAppRoles, // Officers
81-
"ad81254b-4eeb-4c96-8191-3acdce9194b1": [
82-
AppRoles.EVENTS_MANAGER,
83-
AppRoles.IAM_INVITE_ONLY,
84-
], // Exec
85+
[infraChairsGroupId]: allAppRoles, // Infra Chairs
86+
[officersGroupId]: allAppRoles, // Officers
87+
[execCouncilGroupId]: [AppRoles.EVENTS_MANAGER, AppRoles.IAM_INVITE_ONLY], // Exec
8588
},
8689
UserRoleMapping: {
8790
"[email protected]": allAppRoles,

src/common/errors/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,25 @@ export class NotSupportedError extends BaseError<"NotSupportedError"> {
179179
});
180180
}
181181
}
182+
183+
export class EntraGroupError extends BaseError<"EntraGroupError"> {
184+
group: string;
185+
constructor({
186+
code,
187+
message,
188+
group,
189+
}: {
190+
code?: number;
191+
message?: string;
192+
group: string;
193+
}) {
194+
super({
195+
name: "EntraGroupError",
196+
id: 308,
197+
message:
198+
message || `Could not modify the group membership for group ${group}.`,
199+
httpStatusCode: code || 500,
200+
});
201+
this.group = group;
202+
}
203+
}

0 commit comments

Comments
 (0)