Skip to content

Commit abd486d

Browse files
committed
Automatically create Entra ID groups for each org lead if it doesn't exist
1 parent 87f4163 commit abd486d

File tree

4 files changed

+313
-57
lines changed

4 files changed

+313
-57
lines changed

src/api/functions/entraId.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
commChairsGroupId,
33
commChairsTestingGroupId,
4+
environmentConfig,
45
execCouncilGroupId,
56
execCouncilTestingGroupId,
67
genericConfig,
@@ -34,6 +35,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3435
import { checkPaidMembershipFromTable } from "./membership.js";
3536
import type pino from "pino";
3637
import { type FastifyBaseLogger } from "fastify";
38+
import { RunEnvironment } from "common/roles.js";
3739

3840
function validateGroupId(groupId: string): boolean {
3941
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
@@ -212,6 +214,40 @@ export async function resolveEmailToOid(
212214
return data.value[0].id;
213215
}
214216

217+
export async function resolveUpnToOid(
218+
token: string,
219+
upn: string,
220+
): Promise<string> {
221+
const safeUpn = upn.toLowerCase().replace(/\s/g, "");
222+
223+
const url = `https://graph.microsoft.com/v1.0/users?$filter=userPrincipalName eq '${safeUpn}'`;
224+
225+
const response = await fetch(url, {
226+
method: "GET",
227+
headers: {
228+
Authorization: `Bearer ${token}`,
229+
"Content-Type": "application/json",
230+
},
231+
});
232+
233+
if (!response.ok) {
234+
const errorData = (await response.json()) as {
235+
error?: { message?: string };
236+
};
237+
throw new Error(errorData?.error?.message ?? response.statusText);
238+
}
239+
240+
const data = (await response.json()) as {
241+
value: { id: string }[];
242+
};
243+
244+
if (!data.value || data.value.length === 0) {
245+
throw new Error(`No user found with UPN: ${safeUpn}`);
246+
}
247+
248+
return data.value[0].id;
249+
}
250+
215251
/**
216252
* Adds or removes a user from an Entra ID group.
217253
* @param token - Entra ID token authorized to take this action.
@@ -674,3 +710,191 @@ export async function getGroupMetadata(
674710
});
675711
}
676712
}
713+
714+
/**
715+
* Creates a Microsoft 365 group with the given name, email, initial members, and sets the service principal as owner.
716+
* @param token - Entra ID token authorized to take this action.
717+
* @param displayName - The display name for the group.
718+
* @param mailNickname - The mail nickname (email prefix) for the group.
719+
* @param memberUpns - Array of user principal names (emails) to add as initial members.
720+
* @throws {EntraGroupError} If the group creation fails or if a group with the same name exists as a different type.
721+
* @returns {Promise<string>} The ID of the created or existing Microsoft 365 group.
722+
*/
723+
export async function createM365Group(
724+
token: string,
725+
displayName: string,
726+
mailNickname: string,
727+
memberUpns: string[] = [],
728+
runEnvironment: RunEnvironment,
729+
): Promise<string> {
730+
const groupSuffix = environmentConfig[runEnvironment].GroupSuffix;
731+
const groupEmailSuffix = environmentConfig[runEnvironment].GroupEmailSuffix;
732+
const [localPart, _] = groupEmailSuffix.split("@");
733+
const safeMailNickname = `${mailNickname.toLowerCase().replace(/\s/g, "")}-${localPart}`;
734+
const groupName = `${displayName} ${groupSuffix}`;
735+
736+
try {
737+
// First, check if a group with this mail nickname already exists
738+
const checkUrl = `https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '${encodeURIComponent(groupName)}'`;
739+
const checkResponse = await fetch(checkUrl, {
740+
method: "GET",
741+
headers: {
742+
Authorization: `Bearer ${token}`,
743+
"Content-Type": "application/json",
744+
},
745+
});
746+
747+
if (!checkResponse.ok) {
748+
const errorData = (await checkResponse.json()) as {
749+
error?: { message?: string };
750+
};
751+
throw new EntraGroupError({
752+
message: errorData?.error?.message ?? checkResponse.statusText,
753+
group: groupName,
754+
});
755+
}
756+
757+
const checkData = (await checkResponse.json()) as {
758+
value: Array<{ id: string; groupTypes: string[] }>;
759+
};
760+
761+
if (checkData.value && checkData.value.length > 0) {
762+
const existingGroup = checkData.value[0];
763+
const isM365Group = existingGroup.groupTypes?.includes("Unified");
764+
765+
if (isM365Group) {
766+
return existingGroup.id;
767+
}
768+
throw new EntraGroupError({
769+
message: `A group with name '${groupName}' already exists but is not a Microsoft 365 group.`,
770+
group: groupName,
771+
});
772+
}
773+
774+
// Extract principal ID from token
775+
const tokenParts = token.split(".");
776+
if (tokenParts.length !== 3) {
777+
throw new EntraGroupError({
778+
message: "Invalid token format",
779+
group: safeMailNickname,
780+
});
781+
}
782+
783+
const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString());
784+
const currentPrincipalId = payload.oid;
785+
if (!currentPrincipalId) {
786+
throw new EntraGroupError({
787+
message: "Could not extract object ID from token",
788+
group: safeMailNickname,
789+
});
790+
}
791+
792+
// Resolve member UPNs to OIDs
793+
const memberOidPromises = memberUpns.map(async (upn) => {
794+
const safeUpn = upn.toLowerCase().replace(/\s/g, "");
795+
try {
796+
return await resolveUpnToOid(token, safeUpn);
797+
} catch (error) {
798+
const message = error instanceof Error ? error.message : String(error);
799+
throw new EntraGroupError({
800+
message: `Failed to resolve member UPN ${safeUpn}: ${message}`,
801+
group: safeMailNickname,
802+
});
803+
}
804+
});
805+
806+
const memberOids = await Promise.all(memberOidPromises);
807+
808+
// Create the group body - service principals cannot be set as owners during creation
809+
const createUrl = "https://graph.microsoft.com/v1.0/groups";
810+
const body: {
811+
displayName: string;
812+
mailNickname: string;
813+
mailEnabled: boolean;
814+
securityEnabled: boolean;
815+
groupTypes: string[];
816+
"[email protected]"?: string[];
817+
} = {
818+
displayName: groupName,
819+
mailNickname: safeMailNickname,
820+
mailEnabled: true,
821+
securityEnabled: false,
822+
groupTypes: ["Unified"],
823+
};
824+
825+
// Add members if provided
826+
if (memberOids.length > 0) {
827+
body["[email protected]"] = memberOids.map(
828+
(oid) => `https://graph.microsoft.com/v1.0/users/${oid}`,
829+
);
830+
}
831+
832+
console.log(createUrl, body);
833+
const createResponse = await fetch(createUrl, {
834+
method: "POST",
835+
headers: {
836+
Authorization: `Bearer ${token}`,
837+
"Content-Type": "application/json",
838+
},
839+
body: JSON.stringify(body),
840+
});
841+
842+
if (!createResponse.ok) {
843+
const errorData = (await createResponse.json()) as {
844+
error?: { message?: string };
845+
};
846+
throw new EntraGroupError({
847+
message: errorData?.error?.message ?? createResponse.statusText,
848+
group: safeMailNickname,
849+
});
850+
}
851+
852+
const createData = (await createResponse.json()) as { id: string };
853+
const groupId = createData.id;
854+
855+
// Add the service principal as an owner after group creation
856+
try {
857+
const addOwnerUrl = `https://graph.microsoft.com/v1.0/groups/${groupId}/owners/$ref`;
858+
const addOwnerResponse = await fetch(addOwnerUrl, {
859+
method: "POST",
860+
headers: {
861+
Authorization: `Bearer ${token}`,
862+
"Content-Type": "application/json",
863+
},
864+
body: JSON.stringify({
865+
"@odata.id": `https://graph.microsoft.com/v1.0/servicePrincipals/${currentPrincipalId}`,
866+
}),
867+
});
868+
869+
if (!addOwnerResponse.ok) {
870+
const errorData = (await addOwnerResponse.json()) as {
871+
error?: { message?: string };
872+
};
873+
console.warn(
874+
`Failed to add service principal as owner: ${errorData?.error?.message ?? addOwnerResponse.statusText}`,
875+
);
876+
// Don't throw here - group was created successfully, owner addition is non-critical
877+
}
878+
} catch (ownerError) {
879+
console.warn(`Failed to add service principal as owner:`, ownerError);
880+
// Don't throw - group was created successfully
881+
}
882+
883+
return groupId;
884+
} catch (error) {
885+
if (error instanceof EntraGroupError) {
886+
throw error;
887+
}
888+
const message = error instanceof Error ? error.message : String(error);
889+
if (message) {
890+
throw new EntraGroupError({
891+
message,
892+
group: safeMailNickname,
893+
});
894+
}
895+
throw new EntraGroupError({
896+
message: "Unknown error occurred while creating Microsoft 365 group",
897+
group: safeMailNickname,
898+
});
899+
}
900+
}

src/api/functions/organizations.ts

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ export const addLead = async ({
181181
entraIdToken,
182182
dynamoClient,
183183
logger,
184-
execGroupId,
185184
officersEmail,
186185
}: {
187186
user: z.infer<typeof enforcedOrgLeadEntry>;
@@ -192,7 +191,6 @@ export const addLead = async ({
192191
entraIdToken: string;
193192
dynamoClient: DynamoDBClient;
194193
logger: FastifyBaseLogger;
195-
execGroupId: string;
196194
officersEmail: string;
197195
}): Promise<SQSMessage | null> => {
198196
const { username } = user;
@@ -250,32 +248,14 @@ export const addLead = async ({
250248
`Successfully added ${username} as lead for ${orgId} in DynamoDB.`,
251249
);
252250

253-
const promises = [
254-
modifyGroup(
251+
if (entraGroupId) {
252+
await modifyGroup(
255253
entraIdToken,
256254
username,
257-
execGroupId,
255+
entraGroupId,
258256
EntraGroupActions.ADD,
259257
dynamoClient,
260-
),
261-
];
262-
263-
if (entraGroupId) {
264-
promises.push(
265-
modifyGroup(
266-
entraIdToken,
267-
username,
268-
entraGroupId,
269-
EntraGroupActions.ADD,
270-
dynamoClient,
271-
),
272258
);
273-
}
274-
275-
await Promise.all(promises);
276-
277-
logger.info(`Successfully added ${username} to ACM Exec Entra group.`);
278-
if (entraGroupId) {
279259
logger.info(`Successfully added ${username} to Entra group for ${orgId}.`);
280260
}
281261

@@ -300,7 +280,6 @@ export const removeLead = async ({
300280
entraIdToken,
301281
dynamoClient,
302282
logger,
303-
execGroupId,
304283
officersEmail,
305284
}: {
306285
username: string;
@@ -311,7 +290,6 @@ export const removeLead = async ({
311290
entraIdToken: string;
312291
dynamoClient: DynamoDBClient;
313292
logger: FastifyBaseLogger;
314-
execGroupId: string;
315293
officersEmail: string;
316294
}): Promise<SQSMessage | null> => {
317295
const removeOperation = async () => {
@@ -378,27 +356,6 @@ export const removeLead = async ({
378356
);
379357
}
380358

381-
// Use consistent read to check if user has other lead roles
382-
const userRoles = await getUserOrgRoles({ username, dynamoClient, logger });
383-
const otherLeadRoles = userRoles
384-
.filter((x) => x.role === "LEAD")
385-
.filter((x) => x.org !== orgId);
386-
387-
if (otherLeadRoles.length === 0) {
388-
await modifyGroup(
389-
entraIdToken,
390-
username,
391-
execGroupId,
392-
EntraGroupActions.REMOVE,
393-
dynamoClient,
394-
);
395-
logger.info(`Successfully removed ${username} from ACM Exec Entra group.`);
396-
} else {
397-
logger.info(
398-
`Keeping ${username} in ACM Exec Entra group as they lead: ${JSON.stringify(otherLeadRoles.map((x) => x.org))}.`,
399-
);
400-
}
401-
402359
return {
403360
function: AvailableSQSFunctions.EmailNotifications,
404361
metadata: { initiator: actorUsername, reqId },

0 commit comments

Comments
 (0)