Skip to content

Commit 4e9780b

Browse files
committed
get editable groups dynamically
1 parent 10830a4 commit 4e9780b

File tree

6 files changed

+158
-81
lines changed

6 files changed

+158
-81
lines changed

src/api/functions/entraId.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export async function getEntraIdToken(
5858
).toString("utf8");
5959
const cachedToken = await getItemFromCache(
6060
clients.dynamoClient,
61-
`entra_id_access_token_${localSecretName}`,
61+
`entra_id_access_token_${localSecretName}_${clientId}`,
6262
);
6363
if (cachedToken) {
6464
return cachedToken.token as string;
@@ -508,6 +508,52 @@ export async function isUserInGroup(
508508
}
509509
}
510510

511+
/**
512+
* Fetches the ID and display name of groups owned by a specific service principal.
513+
* @param token - An Entra ID token authorized to read service principal information.
514+
*/
515+
export async function getServicePrincipalOwnedGroups(
516+
token: string,
517+
servicePrincipal: string,
518+
): Promise<{ id: string; displayName: string }[]> {
519+
try {
520+
// Selects only group objects and retrieves just their id and displayName
521+
const url = `https://graph.microsoft.com/v1.0/servicePrincipals/${servicePrincipal}/ownedObjects/microsoft.graph.group?$select=id,displayName`;
522+
523+
const response = await fetch(url, {
524+
method: "GET",
525+
headers: {
526+
Authorization: `Bearer ${token}`,
527+
"Content-Type": "application/json",
528+
},
529+
});
530+
531+
if (response.ok) {
532+
const data = (await response.json()) as {
533+
value: { id: string; displayName: string }[];
534+
};
535+
return data.value;
536+
}
537+
538+
const errorData = (await response.json()) as {
539+
error?: { message?: string };
540+
};
541+
throw new EntraFetchError({
542+
message: errorData?.error?.message ?? response.statusText,
543+
email: `sp:${servicePrincipal}`,
544+
});
545+
} catch (error) {
546+
if (error instanceof BaseError) {
547+
throw error;
548+
}
549+
const message = error instanceof Error ? error.message : String(error);
550+
throw new EntraFetchError({
551+
message,
552+
email: `sp:${servicePrincipal}`,
553+
});
554+
}
555+
}
556+
511557
export async function listGroupIDsByEmail(
512558
token: string,
513559
email: string,

src/api/routes/iam.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
addToTenant,
55
getEntraIdToken,
66
getGroupMetadata,
7+
getServicePrincipalOwnedGroups,
78
listGroupMembers,
89
modifyGroup,
910
patchUserProfile,
@@ -560,6 +561,34 @@ No action is required from you at this time.
560561
reply.status(200).send(response);
561562
},
562563
);
564+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
565+
"/groups",
566+
{
567+
schema: withRoles(
568+
[AppRoles.IAM_ADMIN],
569+
withTags(["IAM"], {
570+
summary: "Get all manageable groups.", // This is all groups where the Core API service principal is an owner.
571+
}),
572+
),
573+
onRequest: fastify.authorizeFromSchema,
574+
},
575+
async (_request, reply) => {
576+
const entraIdToken = await getEntraIdToken(
577+
await getAuthorizedClients(),
578+
fastify.environmentConfig.AadValidClientId,
579+
undefined,
580+
genericConfig.EntraSecretName,
581+
);
582+
return reply
583+
.status(200)
584+
.send(
585+
await getServicePrincipalOwnedGroups(
586+
entraIdToken,
587+
fastify.environmentConfig.EntraServicePrincipalId,
588+
),
589+
);
590+
},
591+
);
563592
};
564593

565594
export default iamRoutes;

src/common/config.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type ConfigType = {
1313
AzureRoleMapping: AzureRoleMapping;
1414
ValidCorsOrigins: ValueOrArray<OriginType> | OriginFunction;
1515
AadValidClientId: string;
16+
EntraServicePrincipalId: string;
1617
LinkryBaseUrl: string
1718
PasskitIdentifier: string;
1819
PasskitSerialNumber: string;
@@ -64,7 +65,6 @@ export const execCouncilGroupId = "ad81254b-4eeb-4c96-8191-3acdce9194b1";
6465
export const execCouncilTestingGroupId = "dbe18eb2-9675-46c4-b1ef-749a6db4fedd";
6566
export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3";
6667
export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507";
67-
export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46";
6868

6969
const genericConfig: GenericConfigType = {
7070
EventsDynamoTableName: "infra-core-api-events",
@@ -116,7 +116,8 @@ const environmentConfig: EnvironmentConfigType = {
116116
PaidMemberPriceId: "price_1R4TcTDGHrJxx3mKI6XF9cNG",
117117
AadValidReadOnlyClientId: "2c6a0057-5acc-496c-a4e5-4adbf88387ba",
118118
LinkryCloudfrontKvArn: "arn:aws:cloudfront::427040638965:key-value-store/0c2c02fd-7c47-4029-975d-bc5d0376bba1",
119-
DiscordGuildId: "1278798685706391664"
119+
DiscordGuildId: "1278798685706391664",
120+
EntraServicePrincipalId: "8c26ff11-fb86-42f2-858b-9011c9f0708d"
120121
},
121122
prod: {
122123
UserFacingUrl: "https://core.acm.illinois.edu",
@@ -140,7 +141,8 @@ const environmentConfig: EnvironmentConfigType = {
140141
PaidMemberGroupId: "172fd9ee-69f0-4384-9786-41ff1a43cf8e",
141142
PaidMemberPriceId: "price_1MUGIRDiGOXU9RuSChPYK6wZ",
142143
AadValidReadOnlyClientId: "2c6a0057-5acc-496c-a4e5-4adbf88387ba",
143-
DiscordGuildId: "718945436332720229"
144+
DiscordGuildId: "718945436332720229",
145+
EntraServicePrincipalId: "88c76504-9856-4325-bb0a-99f977e3607f"
144146
},
145147
};
146148

src/common/types/iam.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export const entraActionResponseSchema = z.object({
5353

5454
export type EntraActionResponse = z.infer<typeof entraActionResponseSchema>;
5555

56+
export type GroupGetResponse = { id: string, displayName: string }[]
57+
5658
export const groupModificationPatchSchema = z.object({
5759
add: z.array(z.string()),
5860
remove: z.array(z.string()),

src/ui/config.ts

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
commChairsTestingGroupId,
44
execCouncilGroupId,
55
execCouncilTestingGroupId,
6-
miscTestingGroupId,
76
} from "@common/config";
87

98
export const runEnvironments = ["dev", "prod", "local-dev"] as const;
@@ -14,19 +13,10 @@ export type RunEnvironment = (typeof runEnvironments)[number];
1413
export type ValidServices = (typeof services)[number];
1514
export type ValidService = ValidServices;
1615

17-
export type KnownGroups = {
18-
Exec: string;
19-
CommChairs: string;
20-
StripeLinkCreators: string;
21-
InfraTeam: string;
22-
InfraLeads: string;
23-
};
24-
2516
export type ConfigType = {
2617
AadValidClientId: string;
2718
LinkryPublicUrl: string;
2819
ServiceConfiguration: Record<ValidServices, ServiceConfiguration>;
29-
KnownGroupMappings: KnownGroups;
3020
};
3121

3222
export type ServiceConfiguration = {
@@ -71,13 +61,6 @@ const environmentConfig: EnvironmentConfigType = {
7161
apiId: "https://graph.microsoft.com",
7262
},
7363
},
74-
KnownGroupMappings: {
75-
Exec: execCouncilTestingGroupId,
76-
CommChairs: commChairsTestingGroupId,
77-
StripeLinkCreators: miscTestingGroupId,
78-
InfraTeam: miscTestingGroupId,
79-
InfraLeads: miscTestingGroupId,
80-
},
8164
},
8265
dev: {
8366
AadValidClientId: "d1978c23-6455-426a-be4d-528b2d2e4026",
@@ -106,13 +89,6 @@ const environmentConfig: EnvironmentConfigType = {
10689
apiId: "https://graph.microsoft.com",
10790
},
10891
},
109-
KnownGroupMappings: {
110-
Exec: execCouncilTestingGroupId,
111-
CommChairs: commChairsTestingGroupId,
112-
StripeLinkCreators: miscTestingGroupId,
113-
InfraTeam: miscTestingGroupId,
114-
InfraLeads: miscTestingGroupId,
115-
},
11692
},
11793
prod: {
11894
AadValidClientId: "43fee67e-e383-4071-9233-ef33110e9386",
@@ -141,13 +117,6 @@ const environmentConfig: EnvironmentConfigType = {
141117
apiId: "https://graph.microsoft.com",
142118
},
143119
},
144-
KnownGroupMappings: {
145-
Exec: execCouncilGroupId,
146-
CommChairs: commChairsGroupId,
147-
StripeLinkCreators: "675203eb-fbb9-4789-af2f-e87a3243f8e6",
148-
InfraTeam: "940e4f9e-6891-4e28-9e29-148798495cdb",
149-
InfraLeads: "f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6",
150-
},
151120
},
152121
} as const;
153122

src/ui/pages/iam/ManageIam.page.tsx

Lines changed: 75 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,51 @@
1-
import { useState } from "react";
1+
import { useState, useEffect } from "react";
22
import { Title, SimpleGrid, Select, Stack, Text } from "@mantine/core";
33
import { AuthGuard } from "@ui/components/AuthGuard";
44
import { useApi } from "@ui/util/api";
55
import { AppRoles } from "@common/roles";
66
import UserInvitePanel from "./UserInvitePanel";
77
import GroupMemberManagement from "./GroupMemberManagement";
8-
import { EntraActionResponse, GroupMemberGetResponse } from "@common/types/iam";
8+
import {
9+
EntraActionResponse,
10+
GroupMemberGetResponse,
11+
GroupGetResponse,
12+
} from "@common/types/iam";
913
import { transformCommaSeperatedName } from "@common/utils";
10-
import { getRunEnvironmentConfig, KnownGroups } from "@ui/config";
11-
12-
const userGroupMappings: KnownGroups = {
13-
Exec: "Executive Council",
14-
CommChairs: "Committee Chairs",
15-
StripeLinkCreators: "Stripe Link Creators",
16-
InfraTeam: "Infrastructure Team",
17-
InfraLeads: "Infrastructure Leads",
18-
};
14+
import { notifications } from "@mantine/notifications";
15+
import { IconAlertCircle } from "@tabler/icons-react";
1916

2017
export const ManageIamPage = () => {
2118
const api = useApi("core");
22-
const groupMappings = getRunEnvironmentConfig().KnownGroupMappings;
23-
const groupOptions = Object.entries(groupMappings).map(([key, value]) => ({
24-
label: userGroupMappings[key as keyof KnownGroups] || key,
25-
value: `${key}_${value}`, // to ensure that the same group for multiple roles still renders
26-
}));
19+
const [groupOptions, setGroupOptions] = useState<
20+
{ label: string; value: string }[]
21+
>([]);
22+
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
2723

28-
const [selectedGroup, setSelectedGroup] = useState(
29-
groupOptions[0]?.value || "",
30-
);
24+
// Fetch groups from the API on component mount
25+
useEffect(() => {
26+
const fetchGroups = async () => {
27+
try {
28+
const response = await api.get<GroupGetResponse>("/api/v1/iam/groups");
29+
const options = response.data
30+
.map(({ id, displayName }) => ({
31+
label: displayName,
32+
value: id,
33+
}))
34+
.sort((a, b) => a.label.localeCompare(b.label)); // Sort alphabetically
35+
setGroupOptions(options);
36+
} catch (error) {
37+
console.error("Failed to fetch groups:", error);
38+
notifications.show({
39+
title: "Failed to get groups.",
40+
message: "Please try again or contact support.",
41+
color: "red",
42+
icon: <IconAlertCircle size={16} />,
43+
});
44+
}
45+
};
46+
47+
fetchGroups();
48+
}, [api]); // Dependency array ensures this runs once
3149

3250
const handleInviteSubmit = async (emailList: string[]) => {
3351
try {
@@ -47,44 +65,49 @@ export const ManageIamPage = () => {
4765
}
4866
};
4967

50-
const getGroupMembers = async (selectedGroup: string) => {
68+
const getGroupMembers = async (groupId: string | null) => {
69+
if (!groupId) {
70+
return [];
71+
} // Do not fetch if no group is selected
5172
try {
52-
const response = await api.get(
53-
`/api/v1/iam/groups/${selectedGroup.split("_")[1]}`,
54-
);
73+
const response = await api.get(`/api/v1/iam/groups/${groupId}`);
5574
const data = response.data as GroupMemberGetResponse;
56-
const responseMapped = data
75+
return data
5776
.map((x) => ({
5877
...x,
5978
name: transformCommaSeperatedName(x.name),
6079
}))
6180
.sort((a, b) => (a.name > b.name ? 1 : a.name < b.name ? -1 : 0));
62-
return responseMapped;
6381
} catch (error) {
6482
console.error("Failed to get users:", error);
6583
return [];
6684
}
6785
};
6886

6987
const updateGroupMembers = async (toAdd: string[], toRemove: string[]) => {
70-
const allMembers = [...toAdd, ...toRemove];
88+
if (!selectedGroup) {
89+
const errorMessage = "No group selected for update.";
90+
console.error(errorMessage);
91+
return {
92+
success: [],
93+
failure: [...toAdd, ...toRemove].map((email) => ({
94+
email,
95+
message: errorMessage,
96+
})),
97+
};
98+
}
99+
71100
try {
72-
const response = await api.patch(
73-
`/api/v1/iam/groups/${selectedGroup.split("_")[1]}`,
74-
{
75-
remove: toRemove,
76-
add: toAdd,
77-
},
78-
);
101+
const response = await api.patch(`/api/v1/iam/groups/${selectedGroup}`, {
102+
remove: toRemove,
103+
add: toAdd,
104+
});
79105
return response.data;
80-
} catch (error) {
81-
if (!(error instanceof Error)) {
82-
throw error;
83-
}
106+
} catch (error: any) {
84107
console.error("Failed to modify group members:", error);
85108
return {
86109
success: [],
87-
failure: allMembers.map((email) => ({
110+
failure: [...toAdd, ...toRemove].map((email) => ({
88111
email,
89112
message: error.message || "Failed to modify group member",
90113
})),
@@ -115,15 +138,21 @@ export const ManageIamPage = () => {
115138
data={groupOptions}
116139
value={selectedGroup}
117140
clearable={false}
118-
onChange={(value) => value && setSelectedGroup(value)}
119-
placeholder="Choose a group to manage"
120-
/>
121-
<GroupMemberManagement
122-
fetchMembers={() => {
123-
return getGroupMembers(selectedGroup);
124-
}}
125-
updateMembers={updateGroupMembers}
141+
onChange={(value) => setSelectedGroup(value)}
142+
placeholder={
143+
groupOptions.length > 0
144+
? "Choose a group to manage"
145+
: "Loading groups..."
146+
}
147+
disabled={groupOptions.length === 0}
126148
/>
149+
{selectedGroup && (
150+
<GroupMemberManagement
151+
key={selectedGroup} // Re-mounts component on group change to trigger fetch
152+
fetchMembers={() => getGroupMembers(selectedGroup)}
153+
updateMembers={updateGroupMembers}
154+
/>
155+
)}
127156
</Stack>
128157
</AuthGuard>
129158
</SimpleGrid>

0 commit comments

Comments
 (0)