|
1 | 1 | import { |
2 | 2 | commChairsGroupId, |
3 | 3 | commChairsTestingGroupId, |
| 4 | + environmentConfig, |
4 | 5 | execCouncilGroupId, |
5 | 6 | execCouncilTestingGroupId, |
6 | 7 | genericConfig, |
@@ -34,6 +35,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; |
34 | 35 | import { checkPaidMembershipFromTable } from "./membership.js"; |
35 | 36 | import type pino from "pino"; |
36 | 37 | import { type FastifyBaseLogger } from "fastify"; |
| 38 | +import { RunEnvironment } from "common/roles.js"; |
37 | 39 |
|
38 | 40 | function validateGroupId(groupId: string): boolean { |
39 | 41 | const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed |
@@ -212,6 +214,40 @@ export async function resolveEmailToOid( |
212 | 214 | return data.value[0].id; |
213 | 215 | } |
214 | 216 |
|
| 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 | + |
215 | 251 | /** |
216 | 252 | * Adds or removes a user from an Entra ID group. |
217 | 253 | * @param token - Entra ID token authorized to take this action. |
@@ -674,3 +710,191 @@ export async function getGroupMetadata( |
674 | 710 | }); |
675 | 711 | } |
676 | 712 | } |
| 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 | + |
| 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 | +} |
0 commit comments