Skip to content

Commit 64a35e7

Browse files
authored
Get manageable groups dynamically (#180)
Allow the Core UI to manage any groups which the Core API Service Principal is an owner of.
1 parent 65325ca commit 64a35e7

File tree

9 files changed

+243
-86
lines changed

9 files changed

+243
-86
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/functions/redisCache.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type Redis } from "ioredis";
2+
3+
export async function getRedisKey<T>({
4+
redisClient,
5+
key,
6+
parseJson = false,
7+
}: {
8+
redisClient: Redis;
9+
key: string;
10+
parseJson?: boolean;
11+
}) {
12+
const resp = await redisClient.get(key);
13+
if (!resp) {
14+
return null;
15+
}
16+
return parseJson ? (JSON.parse(resp) as T) : (resp as string);
17+
}
18+
19+
export async function setRedisKey({
20+
redisClient,
21+
key,
22+
value,
23+
expiresSec,
24+
}: {
25+
redisClient: Redis;
26+
key: string;
27+
value: string;
28+
expiresSec?: number;
29+
}) {
30+
if (expiresSec) {
31+
return await redisClient.set(key, value, "EX", expiresSec);
32+
}
33+
return await redisClient.set(key, value);
34+
}

src/api/routes/iam.ts

Lines changed: 53 additions & 3 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,
@@ -18,15 +19,17 @@ import {
1819
NotFoundError,
1920
} from "../../common/errors/index.js";
2021
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
21-
import { genericConfig, roleArns } from "../../common/config.js";
22+
import {
23+
GENERIC_CACHE_SECONDS,
24+
genericConfig,
25+
roleArns,
26+
} from "../../common/config.js";
2227
import { marshall } from "@aws-sdk/util-dynamodb";
2328
import {
2429
invitePostRequestSchema,
2530
groupMappingCreatePostSchema,
26-
entraActionResponseSchema,
2731
groupModificationPatchSchema,
2832
EntraGroupActions,
29-
entraGroupMembershipListResponse,
3033
entraProfilePatchRequest,
3134
} from "../../common/types/iam.js";
3235
import {
@@ -44,6 +47,7 @@ import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
4447
import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs";
4548
import { v4 as uuidv4 } from "uuid";
4649
import { randomUUID } from "crypto";
50+
import { getRedisKey, setRedisKey } from "api/functions/redisCache.js";
4751

4852
const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
4953
const getAuthorizedClients = async () => {
@@ -560,6 +564,52 @@ No action is required from you at this time.
560564
reply.status(200).send(response);
561565
},
562566
);
567+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
568+
"/groups",
569+
{
570+
schema: withRoles(
571+
[AppRoles.IAM_ADMIN],
572+
withTags(["IAM"], {
573+
summary: "Get all manageable groups.", // This is all groups where the Core API service principal is an owner.
574+
}),
575+
),
576+
onRequest: fastify.authorizeFromSchema,
577+
},
578+
async (request, reply) => {
579+
const entraIdToken = await getEntraIdToken(
580+
await getAuthorizedClients(),
581+
fastify.environmentConfig.AadValidClientId,
582+
undefined,
583+
genericConfig.EntraSecretName,
584+
);
585+
const { redisClient } = fastify;
586+
const key = `entra_manageable_groups_${fastify.environmentConfig.EntraServicePrincipalId}`;
587+
const redisResponse = await getRedisKey<
588+
{ displayName: string; id: string }[]
589+
>({ redisClient, key, parseJson: true });
590+
if (redisResponse) {
591+
request.log.debug("Got manageable groups from Redis cache.");
592+
return reply.status(200).send(redisResponse);
593+
}
594+
// get groups, but don't show protected groups as manageable
595+
const freshData = (
596+
await getServicePrincipalOwnedGroups(
597+
entraIdToken,
598+
fastify.environmentConfig.EntraServicePrincipalId,
599+
)
600+
).filter((x) => !genericConfig.ProtectedEntraIDGroups.includes(x.id));
601+
request.log.debug(
602+
"Got manageable groups from Entra ID, setting to cache.",
603+
);
604+
await setRedisKey({
605+
redisClient,
606+
key,
607+
value: JSON.stringify(freshData),
608+
expiresSec: GENERIC_CACHE_SECONDS,
609+
});
610+
return reply.status(200).send(freshData);
611+
},
612+
);
563613
};
564614

565615
export default iamRoutes;

src/common/config.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;
88

99
type AzureRoleMapping = Record<string, readonly AppRoles[]>;
1010

11+
export const GENERIC_CACHE_SECONDS = 120;
12+
1113
export type ConfigType = {
1214
UserFacingUrl: string;
1315
AzureRoleMapping: AzureRoleMapping;
1416
ValidCorsOrigins: ValueOrArray<OriginType> | OriginFunction;
1517
AadValidClientId: string;
18+
EntraServicePrincipalId: string;
1619
LinkryBaseUrl: string
1720
PasskitIdentifier: string;
1821
PasskitSerialNumber: string;
@@ -64,7 +67,6 @@ export const execCouncilGroupId = "ad81254b-4eeb-4c96-8191-3acdce9194b1";
6467
export const execCouncilTestingGroupId = "dbe18eb2-9675-46c4-b1ef-749a6db4fedd";
6568
export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3";
6669
export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507";
67-
export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46";
6870

6971
const genericConfig: GenericConfigType = {
7072
EventsDynamoTableName: "infra-core-api-events",
@@ -116,7 +118,8 @@ const environmentConfig: EnvironmentConfigType = {
116118
PaidMemberPriceId: "price_1R4TcTDGHrJxx3mKI6XF9cNG",
117119
AadValidReadOnlyClientId: "2c6a0057-5acc-496c-a4e5-4adbf88387ba",
118120
LinkryCloudfrontKvArn: "arn:aws:cloudfront::427040638965:key-value-store/0c2c02fd-7c47-4029-975d-bc5d0376bba1",
119-
DiscordGuildId: "1278798685706391664"
121+
DiscordGuildId: "1278798685706391664",
122+
EntraServicePrincipalId: "8c26ff11-fb86-42f2-858b-9011c9f0708d"
120123
},
121124
prod: {
122125
UserFacingUrl: "https://core.acm.illinois.edu",
@@ -140,7 +143,8 @@ const environmentConfig: EnvironmentConfigType = {
140143
PaidMemberGroupId: "172fd9ee-69f0-4384-9786-41ff1a43cf8e",
141144
PaidMemberPriceId: "price_1MUGIRDiGOXU9RuSChPYK6wZ",
142145
AadValidReadOnlyClientId: "2c6a0057-5acc-496c-a4e5-4adbf88387ba",
143-
DiscordGuildId: "718945436332720229"
146+
DiscordGuildId: "718945436332720229",
147+
EntraServicePrincipalId: "88c76504-9856-4325-bb0a-99f977e3607f"
144148
},
145149
};
146150

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

0 commit comments

Comments
 (0)