Skip to content

Commit 871f932

Browse files
authored
Derive Exec Group with Leads Group IDs (#311)
1 parent 1eb93ee commit 871f932

File tree

8 files changed

+179
-23
lines changed

8 files changed

+179
-23
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"test:e2e-ui": "playwright test --ui"
3333
},
3434
"dependencies": {
35-
"@acm-uiuc/js-shared": "^2.3.0"
35+
"@acm-uiuc/js-shared": "^2.4.0"
3636
},
3737
"devDependencies": {
3838
"@eslint/compat": "^1.3.2",

src/api/functions/entraId.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
} from "../../common/config.js";
1111
import {
1212
BaseError,
13-
DecryptionError,
1413
EntraFetchError,
1514
EntraGroupError,
1615
EntraGroupsFromEmailError,
@@ -574,10 +573,15 @@ export async function isUserInGroup(
574573
export async function getServicePrincipalOwnedGroups(
575574
token: string,
576575
servicePrincipal: string,
576+
includeDynamicGroups: boolean,
577577
): Promise<{ id: string; displayName: string }[]> {
578578
try {
579-
// Selects only group objects and retrieves just their id and displayName
580-
const url = `https://graph.microsoft.com/v1.0/servicePrincipals/${servicePrincipal}/ownedObjects/microsoft.graph.group?$select=id,displayName`;
579+
// Include groupTypes in selection to filter dynamic groups if needed
580+
const selectFields = includeDynamicGroups
581+
? "id,displayName,description"
582+
: "id,displayName,description,groupTypes";
583+
584+
const url = `https://graph.microsoft.com/v1.0/servicePrincipals/${servicePrincipal}/ownedObjects/microsoft.graph.group?$select=${selectFields}`;
581585

582586
const response = await fetch(url, {
583587
method: "GET",
@@ -589,9 +593,26 @@ export async function getServicePrincipalOwnedGroups(
589593

590594
if (response.ok) {
591595
const data = (await response.json()) as {
592-
value: { id: string; displayName: string }[];
596+
value: {
597+
id: string;
598+
displayName: string;
599+
groupTypes?: string[];
600+
description?: string;
601+
}[];
593602
};
594-
return data.value;
603+
604+
// Filter out dynamic groups and admin lists if includeDynamicGroups is false
605+
const groups = includeDynamicGroups
606+
? data.value
607+
: data.value
608+
.filter((group) => !group.groupTypes?.includes("DynamicMembership"))
609+
.filter(
610+
(group) =>
611+
!group.description?.startsWith("[Managed by Core API]"),
612+
);
613+
614+
// Return only id and displayName (strip groupTypes if it was included)
615+
return groups.map(({ id, displayName }) => ({ id, displayName }));
595616
}
596617

597618
const errorData = (await response.json()) as {
@@ -813,9 +834,11 @@ export async function createM365Group(
813834
mailEnabled: boolean;
814835
securityEnabled: boolean;
815836
groupTypes: string[];
837+
description: string;
816838
"[email protected]"?: string[];
817839
} = {
818840
displayName: groupName,
841+
description: "[Managed by Core API]",
819842
mailNickname: safeMailNickname,
820843
mailEnabled: true,
821844
securityEnabled: false,
@@ -898,3 +921,66 @@ export async function createM365Group(
898921
});
899922
}
900923
}
924+
925+
/**
926+
* Sets the dynamic membership rule for an Entra ID group.
927+
* @param token - Entra ID token authorized to take this action.
928+
* @param groupId - The group ID to update.
929+
* @param membershipRule - The dynamic membership rule expression.
930+
* @throws {EntraGroupError} If setting the membership rule fails.
931+
* @returns {Promise<void>}
932+
*/
933+
export async function setGroupMembershipRule(
934+
token: string,
935+
groupId: string,
936+
membershipRule: string,
937+
): Promise<void> {
938+
if (!validateGroupId(groupId)) {
939+
throw new EntraGroupError({
940+
message: "Invalid group ID format",
941+
group: groupId,
942+
});
943+
}
944+
945+
if (!membershipRule || membershipRule.trim().length === 0) {
946+
throw new EntraGroupError({
947+
message: "Membership rule cannot be empty",
948+
group: groupId,
949+
});
950+
}
951+
952+
try {
953+
const url = `https://graph.microsoft.com/v1.0/groups/${groupId}`;
954+
const response = await fetch(url, {
955+
method: "PATCH",
956+
headers: {
957+
Authorization: `Bearer ${token}`,
958+
"Content-Type": "application/json",
959+
},
960+
body: JSON.stringify({
961+
membershipRule,
962+
membershipRuleProcessingState: "On",
963+
groupTypes: ["DynamicMembership"],
964+
}),
965+
});
966+
967+
if (!response.ok) {
968+
const errorData = (await response.json()) as {
969+
error?: { message?: string };
970+
};
971+
throw new EntraGroupError({
972+
message: errorData?.error?.message ?? response.statusText,
973+
group: groupId,
974+
});
975+
}
976+
} catch (error) {
977+
if (error instanceof EntraGroupError) {
978+
throw error;
979+
}
980+
981+
throw new EntraGroupError({
982+
message: error instanceof Error ? error.message : String(error),
983+
group: groupId,
984+
});
985+
}
986+
}

src/api/functions/organizations.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AllOrganizationList } from "@acm-uiuc/js-shared";
22
import {
33
QueryCommand,
4+
ScanCommand,
45
TransactWriteItemsCommand,
56
type DynamoDBClient,
67
} from "@aws-sdk/client-dynamodb";
@@ -367,3 +368,44 @@ export const removeLead = async ({
367368
},
368369
};
369370
};
371+
372+
/**
373+
* Returns the Microsoft 365 Dynamic User query to return all members of all lead groups.
374+
* Currently used to setup the Exec member list.
375+
* @param dynamoClient A DynamoDB client.
376+
* @param includeGroupIds Used to ensure that a specific group ID is included (Scan could be eventually consistent.)
377+
*/
378+
export async function getLeadsM365DynamicQuery({
379+
dynamoClient,
380+
includeGroupIds,
381+
}: {
382+
dynamoClient: DynamoDBClient;
383+
includeGroupIds?: string[];
384+
}): Promise<string | null> {
385+
const command = new ScanCommand({
386+
TableName: genericConfig.SigInfoTableName,
387+
IndexName: "LeadsGroupIdIndex",
388+
});
389+
const results = await dynamoClient.send(command);
390+
if (!results || !results.Items || results.Items.length === 0) {
391+
return null;
392+
}
393+
const entries = results.Items.map((x) => unmarshall(x)) as {
394+
primaryKey: string;
395+
leadsEntraGroupId: string;
396+
}[];
397+
const groupIds = entries
398+
.filter((x) => x.primaryKey.startsWith("DEFINE#"))
399+
.map((x) => x.leadsEntraGroupId);
400+
401+
if (groupIds.length === 0) {
402+
return null;
403+
}
404+
405+
const formattedGroupIds = [
406+
...new Set([...(includeGroupIds || []), ...groupIds]),
407+
]
408+
.map((id) => `'${id}'`)
409+
.join(", ");
410+
return `user.memberOf -any (group.objectId -in [${formattedGroupIds}])`;
411+
}

src/api/routes/iam.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,11 +663,12 @@ No action is required from you at this time.
663663
secretName: genericConfig.EntraSecretName,
664664
logger: request.log,
665665
});
666-
// get groups, but don't show protected groups as manageable
666+
// get groups, but don't show protected groups and app managed groups manageable
667667
const freshData = (
668668
await getServicePrincipalOwnedGroups(
669669
entraIdToken,
670670
fastify.environmentConfig.EntraServicePrincipalId,
671+
false,
671672
)
672673
).filter(
673674
(x) =>

src/api/routes/organizations.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { FastifyPluginAsync } from "fastify";
2-
import { AllOrganizationList } from "@acm-uiuc/js-shared";
2+
import {
3+
ACMOrganization,
4+
AllOrganizationList,
5+
OrganizationShortIdentifierMapping,
6+
} from "@acm-uiuc/js-shared";
37
import rateLimiter from "api/plugins/rateLimiter.js";
48
import { withRoles, withTags } from "api/components/index.js";
59
import { z } from "zod/v4";
@@ -19,6 +23,7 @@ import {
1923
} from "common/errors/index.js";
2024
import {
2125
addLead,
26+
getLeadsM365DynamicQuery,
2227
getOrgInfo,
2328
removeLead,
2429
SQSMessage,
@@ -41,7 +46,11 @@ import { buildAuditLogTransactPut } from "api/functions/auditLog.js";
4146
import { Modules } from "common/modules.js";
4247
import { authorizeByOrgRoleOrSchema } from "api/functions/authorization.js";
4348
import { checkPaidMembership } from "api/functions/membership.js";
44-
import { createM365Group, getEntraIdToken } from "api/functions/entraId.js";
49+
import {
50+
createM365Group,
51+
getEntraIdToken,
52+
setGroupMembershipRule,
53+
} from "api/functions/entraId.js";
4554
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
4655
import { getRoleCredentials } from "api/functions/sts.js";
4756
import { SQSClient } from "@aws-sdk/client-sqs";
@@ -402,14 +411,15 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
402411
});
403412

404413
// Create Entra group if it doesn't exist and we're adding leads
405-
if (!entraGroupId && add.length > 0) {
414+
const shouldCreateNewEntraGroup = !entraGroupId && add.length > 0;
415+
if (shouldCreateNewEntraGroup) {
406416
request.log.info(
407417
`No Entra group exists for ${request.params.orgId}. Creating new group...`,
408418
);
409419

410420
try {
411-
const displayName = `${request.params.orgId} Admin List`;
412-
const mailNickname = `${request.params.orgId.toLowerCase()}-adm`;
421+
const displayName = `${request.params.orgId} Admin`;
422+
const mailNickname = `${OrganizationShortIdentifierMapping[request.params.orgId as keyof typeof OrganizationShortIdentifierMapping]}-adm`;
413423
const memberUpns = add.map((u) =>
414424
u.username.replace("@illinois.edu", "@acm.illinois.edu"),
415425
);
@@ -471,6 +481,20 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
471481
message: "Failed to create Entra group for organization leads.",
472482
});
473483
}
484+
// get the new dynamic membership query
485+
const newQuery = await getLeadsM365DynamicQuery({
486+
dynamoClient: fastify.dynamoClient,
487+
includeGroupIds: [entraGroupId],
488+
});
489+
if (newQuery) {
490+
const groupToUpdate =
491+
fastify.runEnvironment === "prod"
492+
? execCouncilGroupId
493+
: execCouncilTestingGroupId;
494+
request.log.info("Changing Exec group membership dynamic query...");
495+
await setGroupMembershipRule(entraIdToken, groupToUpdate, newQuery);
496+
request.log.info("Changed Exec group membership dynamic query!");
497+
}
474498
}
475499

476500
const commonArgs = {

src/ui/config.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
import {
2-
commChairsGroupId,
3-
commChairsTestingGroupId,
4-
execCouncilGroupId,
5-
execCouncilTestingGroupId,
6-
} from "@common/config";
7-
81
export const runEnvironments = ["dev", "prod", "local-dev"] as const;
92
// local dev should be used when you want to test against a local instance of the API
103

terraform/modules/dynamo/main.tf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ resource "aws_dynamodb_table" "sig_info" {
318318
name = "entryId"
319319
type = "S"
320320
}
321+
attribute {
322+
name = "leadsEntraGroupId"
323+
type = "S"
324+
}
321325
attribute {
322326
name = "username"
323327
type = "S"
@@ -328,4 +332,10 @@ resource "aws_dynamodb_table" "sig_info" {
328332
range_key = "primaryKey"
329333
projection_type = "KEYS_ONLY"
330334
}
335+
global_secondary_index {
336+
name = "LeadsGroupIdIndex"
337+
hash_key = "leadsEntraGroupId"
338+
range_key = "primaryKey"
339+
projection_type = "KEYS_ONLY"
340+
}
331341
}

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
# yarn lockfile v1
33

44

5-
"@acm-uiuc/js-shared@^2.3.0":
6-
version "2.3.0"
7-
resolved "https://registry.yarnpkg.com/@acm-uiuc/js-shared/-/js-shared-2.3.0.tgz#5cc865e60218749ec71afaa220647e2bf6869dc0"
8-
integrity sha512-9t3033IegfdPo2DmQfMQT0nk2GfwJfFdUA8U/uS3+uy9q1tvTF2OcVjaUJt97ZjJYMV/6Iic2qP5Rcs40o18Uw==
5+
"@acm-uiuc/js-shared@^2.4.0":
6+
version "2.4.0"
7+
resolved "https://registry.yarnpkg.com/@acm-uiuc/js-shared/-/js-shared-2.4.0.tgz#c8a28c8189c7a3847d8a06cb918b9f4ce256f8de"
8+
integrity sha512-hAjns2jT0MXUdtYuxVEn3ob/sTPnCsOeBeJXt+PgRQqhg+Cu09E82Y/68FNbTPlKi/YSzhQEToDXRuQtGM+VRA==
99

1010
"@adobe/css-tools@^4.4.0":
1111
version "4.4.3"

0 commit comments

Comments
 (0)