diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 098673dc..eb3a10a9 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -58,7 +58,7 @@ export async function getEntraIdToken( ).toString("utf8"); const cachedToken = await getItemFromCache( clients.dynamoClient, - `entra_id_access_token_${localSecretName}`, + `entra_id_access_token_${localSecretName}_${clientId}`, ); if (cachedToken) { return cachedToken.token as string; @@ -508,6 +508,52 @@ export async function isUserInGroup( } } +/** + * Fetches the ID and display name of groups owned by a specific service principal. + * @param token - An Entra ID token authorized to read service principal information. + */ +export async function getServicePrincipalOwnedGroups( + token: string, + servicePrincipal: string, +): Promise<{ id: string; displayName: string }[]> { + try { + // Selects only group objects and retrieves just their id and displayName + const url = `https://graph.microsoft.com/v1.0/servicePrincipals/${servicePrincipal}/ownedObjects/microsoft.graph.group?$select=id,displayName`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = (await response.json()) as { + value: { id: string; displayName: string }[]; + }; + return data.value; + } + + const errorData = (await response.json()) as { + error?: { message?: string }; + }; + throw new EntraFetchError({ + message: errorData?.error?.message ?? response.statusText, + email: `sp:${servicePrincipal}`, + }); + } catch (error) { + if (error instanceof BaseError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new EntraFetchError({ + message, + email: `sp:${servicePrincipal}`, + }); + } +} + export async function listGroupIDsByEmail( token: string, email: string, diff --git a/src/api/functions/redisCache.ts b/src/api/functions/redisCache.ts new file mode 100644 index 00000000..bf97786d --- /dev/null +++ b/src/api/functions/redisCache.ts @@ -0,0 +1,34 @@ +import { type Redis } from "ioredis"; + +export async function getRedisKey({ + redisClient, + key, + parseJson = false, +}: { + redisClient: Redis; + key: string; + parseJson?: boolean; +}) { + const resp = await redisClient.get(key); + if (!resp) { + return null; + } + return parseJson ? (JSON.parse(resp) as T) : (resp as string); +} + +export async function setRedisKey({ + redisClient, + key, + value, + expiresSec, +}: { + redisClient: Redis; + key: string; + value: string; + expiresSec?: number; +}) { + if (expiresSec) { + return await redisClient.set(key, value, "EX", expiresSec); + } + return await redisClient.set(key, value); +} diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index fc14eb38..241a050e 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -4,6 +4,7 @@ import { addToTenant, getEntraIdToken, getGroupMetadata, + getServicePrincipalOwnedGroups, listGroupMembers, modifyGroup, patchUserProfile, @@ -18,15 +19,17 @@ import { NotFoundError, } from "../../common/errors/index.js"; import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb"; -import { genericConfig, roleArns } from "../../common/config.js"; +import { + GENERIC_CACHE_SECONDS, + genericConfig, + roleArns, +} from "../../common/config.js"; import { marshall } from "@aws-sdk/util-dynamodb"; import { invitePostRequestSchema, groupMappingCreatePostSchema, - entraActionResponseSchema, groupModificationPatchSchema, EntraGroupActions, - entraGroupMembershipListResponse, entraProfilePatchRequest, } from "../../common/types/iam.js"; import { @@ -44,6 +47,7 @@ import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs"; import { v4 as uuidv4 } from "uuid"; import { randomUUID } from "crypto"; +import { getRedisKey, setRedisKey } from "api/functions/redisCache.js"; const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -560,6 +564,52 @@ No action is required from you at this time. reply.status(200).send(response); }, ); + fastify.withTypeProvider().get( + "/groups", + { + schema: withRoles( + [AppRoles.IAM_ADMIN], + withTags(["IAM"], { + summary: "Get all manageable groups.", // This is all groups where the Core API service principal is an owner. + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + const entraIdToken = await getEntraIdToken( + await getAuthorizedClients(), + fastify.environmentConfig.AadValidClientId, + undefined, + genericConfig.EntraSecretName, + ); + const { redisClient } = fastify; + const key = `entra_manageable_groups_${fastify.environmentConfig.EntraServicePrincipalId}`; + const redisResponse = await getRedisKey< + { displayName: string; id: string }[] + >({ redisClient, key, parseJson: true }); + if (redisResponse) { + request.log.debug("Got manageable groups from Redis cache."); + return reply.status(200).send(redisResponse); + } + // get groups, but don't show protected groups as manageable + const freshData = ( + await getServicePrincipalOwnedGroups( + entraIdToken, + fastify.environmentConfig.EntraServicePrincipalId, + ) + ).filter((x) => !genericConfig.ProtectedEntraIDGroups.includes(x.id)); + request.log.debug( + "Got manageable groups from Entra ID, setting to cache.", + ); + await setRedisKey({ + redisClient, + key, + value: JSON.stringify(freshData), + expiresSec: GENERIC_CACHE_SECONDS, + }); + return reply.status(200).send(freshData); + }, + ); }; export default iamRoutes; diff --git a/src/common/config.ts b/src/common/config.ts index c8c5474f..859441cd 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -8,11 +8,14 @@ type ValueOrArray = T | ArrayOfValueOrArray; type AzureRoleMapping = Record; +export const GENERIC_CACHE_SECONDS = 120; + export type ConfigType = { UserFacingUrl: string; AzureRoleMapping: AzureRoleMapping; ValidCorsOrigins: ValueOrArray | OriginFunction; AadValidClientId: string; + EntraServicePrincipalId: string; LinkryBaseUrl: string PasskitIdentifier: string; PasskitSerialNumber: string; @@ -64,7 +67,6 @@ export const execCouncilGroupId = "ad81254b-4eeb-4c96-8191-3acdce9194b1"; export const execCouncilTestingGroupId = "dbe18eb2-9675-46c4-b1ef-749a6db4fedd"; export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3"; export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; -export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; const genericConfig: GenericConfigType = { EventsDynamoTableName: "infra-core-api-events", @@ -116,7 +118,8 @@ const environmentConfig: EnvironmentConfigType = { PaidMemberPriceId: "price_1R4TcTDGHrJxx3mKI6XF9cNG", AadValidReadOnlyClientId: "2c6a0057-5acc-496c-a4e5-4adbf88387ba", LinkryCloudfrontKvArn: "arn:aws:cloudfront::427040638965:key-value-store/0c2c02fd-7c47-4029-975d-bc5d0376bba1", - DiscordGuildId: "1278798685706391664" + DiscordGuildId: "1278798685706391664", + EntraServicePrincipalId: "8c26ff11-fb86-42f2-858b-9011c9f0708d" }, prod: { UserFacingUrl: "https://core.acm.illinois.edu", @@ -140,7 +143,8 @@ const environmentConfig: EnvironmentConfigType = { PaidMemberGroupId: "172fd9ee-69f0-4384-9786-41ff1a43cf8e", PaidMemberPriceId: "price_1MUGIRDiGOXU9RuSChPYK6wZ", AadValidReadOnlyClientId: "2c6a0057-5acc-496c-a4e5-4adbf88387ba", - DiscordGuildId: "718945436332720229" + DiscordGuildId: "718945436332720229", + EntraServicePrincipalId: "88c76504-9856-4325-bb0a-99f977e3607f" }, }; diff --git a/src/common/types/iam.ts b/src/common/types/iam.ts index 331ffa9e..c28bb301 100644 --- a/src/common/types/iam.ts +++ b/src/common/types/iam.ts @@ -53,6 +53,8 @@ export const entraActionResponseSchema = z.object({ export type EntraActionResponse = z.infer; +export type GroupGetResponse = { id: string, displayName: string }[] + export const groupModificationPatchSchema = z.object({ add: z.array(z.string()), remove: z.array(z.string()), diff --git a/src/ui/config.ts b/src/ui/config.ts index 6b9a5e86..10578723 100644 --- a/src/ui/config.ts +++ b/src/ui/config.ts @@ -3,7 +3,6 @@ import { commChairsTestingGroupId, execCouncilGroupId, execCouncilTestingGroupId, - miscTestingGroupId, } from "@common/config"; export const runEnvironments = ["dev", "prod", "local-dev"] as const; @@ -14,19 +13,10 @@ export type RunEnvironment = (typeof runEnvironments)[number]; export type ValidServices = (typeof services)[number]; export type ValidService = ValidServices; -export type KnownGroups = { - Exec: string; - CommChairs: string; - StripeLinkCreators: string; - InfraTeam: string; - InfraLeads: string; -}; - export type ConfigType = { AadValidClientId: string; LinkryPublicUrl: string; ServiceConfiguration: Record; - KnownGroupMappings: KnownGroups; }; export type ServiceConfiguration = { @@ -71,13 +61,6 @@ const environmentConfig: EnvironmentConfigType = { apiId: "https://graph.microsoft.com", }, }, - KnownGroupMappings: { - Exec: execCouncilTestingGroupId, - CommChairs: commChairsTestingGroupId, - StripeLinkCreators: miscTestingGroupId, - InfraTeam: miscTestingGroupId, - InfraLeads: miscTestingGroupId, - }, }, dev: { AadValidClientId: "d1978c23-6455-426a-be4d-528b2d2e4026", @@ -106,13 +89,6 @@ const environmentConfig: EnvironmentConfigType = { apiId: "https://graph.microsoft.com", }, }, - KnownGroupMappings: { - Exec: execCouncilTestingGroupId, - CommChairs: commChairsTestingGroupId, - StripeLinkCreators: miscTestingGroupId, - InfraTeam: miscTestingGroupId, - InfraLeads: miscTestingGroupId, - }, }, prod: { AadValidClientId: "43fee67e-e383-4071-9233-ef33110e9386", @@ -141,13 +117,6 @@ const environmentConfig: EnvironmentConfigType = { apiId: "https://graph.microsoft.com", }, }, - KnownGroupMappings: { - Exec: execCouncilGroupId, - CommChairs: commChairsGroupId, - StripeLinkCreators: "675203eb-fbb9-4789-af2f-e87a3243f8e6", - InfraTeam: "940e4f9e-6891-4e28-9e29-148798495cdb", - InfraLeads: "f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6", - }, }, } as const; diff --git a/src/ui/pages/iam/ManageIam.page.tsx b/src/ui/pages/iam/ManageIam.page.tsx index 98fbdcf1..7290cfac 100644 --- a/src/ui/pages/iam/ManageIam.page.tsx +++ b/src/ui/pages/iam/ManageIam.page.tsx @@ -1,33 +1,51 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Title, SimpleGrid, Select, Stack, Text } from "@mantine/core"; import { AuthGuard } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; import UserInvitePanel from "./UserInvitePanel"; import GroupMemberManagement from "./GroupMemberManagement"; -import { EntraActionResponse, GroupMemberGetResponse } from "@common/types/iam"; +import { + EntraActionResponse, + GroupMemberGetResponse, + GroupGetResponse, +} from "@common/types/iam"; import { transformCommaSeperatedName } from "@common/utils"; -import { getRunEnvironmentConfig, KnownGroups } from "@ui/config"; - -const userGroupMappings: KnownGroups = { - Exec: "Executive Council", - CommChairs: "Committee Chairs", - StripeLinkCreators: "Stripe Link Creators", - InfraTeam: "Infrastructure Team", - InfraLeads: "Infrastructure Leads", -}; +import { notifications } from "@mantine/notifications"; +import { IconAlertCircle } from "@tabler/icons-react"; export const ManageIamPage = () => { const api = useApi("core"); - const groupMappings = getRunEnvironmentConfig().KnownGroupMappings; - const groupOptions = Object.entries(groupMappings).map(([key, value]) => ({ - label: userGroupMappings[key as keyof KnownGroups] || key, - value: `${key}_${value}`, // to ensure that the same group for multiple roles still renders - })); + const [groupOptions, setGroupOptions] = useState< + { label: string; value: string }[] + >([]); + const [selectedGroup, setSelectedGroup] = useState(null); - const [selectedGroup, setSelectedGroup] = useState( - groupOptions[0]?.value || "", - ); + // Fetch groups from the API on component mount + useEffect(() => { + const fetchGroups = async () => { + try { + const response = await api.get("/api/v1/iam/groups"); + const options = response.data + .map(({ id, displayName }) => ({ + label: displayName, + value: id, + })) + .sort((a, b) => a.label.localeCompare(b.label)); // Sort alphabetically + setGroupOptions(options); + } catch (error) { + console.error("Failed to fetch groups:", error); + notifications.show({ + title: "Failed to get groups.", + message: "Please try again or contact support.", + color: "red", + icon: , + }); + } + }; + + fetchGroups(); + }, [api]); // Dependency array ensures this runs once const handleInviteSubmit = async (emailList: string[]) => { try { @@ -47,19 +65,19 @@ export const ManageIamPage = () => { } }; - const getGroupMembers = async (selectedGroup: string) => { + const getGroupMembers = async (groupId: string | null) => { + if (!groupId) { + return []; + } // Do not fetch if no group is selected try { - const response = await api.get( - `/api/v1/iam/groups/${selectedGroup.split("_")[1]}`, - ); + const response = await api.get(`/api/v1/iam/groups/${groupId}`); const data = response.data as GroupMemberGetResponse; - const responseMapped = data + return data .map((x) => ({ ...x, name: transformCommaSeperatedName(x.name), })) .sort((a, b) => (a.name > b.name ? 1 : a.name < b.name ? -1 : 0)); - return responseMapped; } catch (error) { console.error("Failed to get users:", error); return []; @@ -67,24 +85,29 @@ export const ManageIamPage = () => { }; const updateGroupMembers = async (toAdd: string[], toRemove: string[]) => { - const allMembers = [...toAdd, ...toRemove]; + if (!selectedGroup) { + const errorMessage = "No group selected for update."; + console.error(errorMessage); + return { + success: [], + failure: [...toAdd, ...toRemove].map((email) => ({ + email, + message: errorMessage, + })), + }; + } + try { - const response = await api.patch( - `/api/v1/iam/groups/${selectedGroup.split("_")[1]}`, - { - remove: toRemove, - add: toAdd, - }, - ); + const response = await api.patch(`/api/v1/iam/groups/${selectedGroup}`, { + remove: toRemove, + add: toAdd, + }); return response.data; - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } + } catch (error: any) { console.error("Failed to modify group members:", error); return { success: [], - failure: allMembers.map((email) => ({ + failure: [...toAdd, ...toRemove].map((email) => ({ email, message: error.message || "Failed to modify group member", })), @@ -115,15 +138,21 @@ export const ManageIamPage = () => { data={groupOptions} value={selectedGroup} clearable={false} - onChange={(value) => value && setSelectedGroup(value)} - placeholder="Choose a group to manage" - /> - { - return getGroupMembers(selectedGroup); - }} - updateMembers={updateGroupMembers} + onChange={(value) => setSelectedGroup(value)} + placeholder={ + groupOptions.length > 0 + ? "Choose a group to manage" + : "Loading groups..." + } + disabled={groupOptions.length === 0} /> + {selectedGroup && ( + getGroupMembers(selectedGroup)} + updateMembers={updateGroupMembers} + /> + )} diff --git a/tests/live/iam.test.ts b/tests/live/iam.test.ts index b9bde3f3..1eeaec2b 100644 --- a/tests/live/iam.test.ts +++ b/tests/live/iam.test.ts @@ -2,12 +2,35 @@ import { expect, test } from "vitest"; import { createJwt } from "./utils.js"; import { EntraActionResponse, + GroupGetResponse, GroupMemberGetResponse, } from "../../src/common/types/iam.js"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; import { getBaseEndpoint } from "./utils.js"; +import { genericConfig } from "../../src/common/config.js"; const baseEndpoint = getBaseEndpoint(); +test("getting groups", async () => { + const token = await createJwt(); + const response = await fetch(`${baseEndpoint}/api/v1/iam/groups`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + expect(response.status).toBe(200); + const responseJson = (await response.json()) as GroupGetResponse; + expect(responseJson.length).greaterThan(0); + for (const item of responseJson) { + expect(item).toHaveProperty("displayName"); + expect(item).toHaveProperty("id"); + expect(item["displayName"].length).greaterThan(0); + expect(item["id"].length).greaterThan(0); + expect(genericConfig.ProtectedEntraIDGroups).not.toContain(item["id"]); + } +}); + test("getting members of a group", async () => { const token = await createJwt(); const response = await fetch( diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index 2a4193da..4c6acd22 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -13,9 +13,9 @@ export default defineConfig({ include: ["src/api/**/*.ts", "src/common/**/*.ts"], exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"], thresholds: { - statements: 55, + statements: 50, functions: 66, - lines: 55, + lines: 50, }, }, },