Skip to content

Commit 6361848

Browse files
committed
add membership checking to core
1 parent 64dce2c commit 6361848

File tree

6 files changed

+160
-7
lines changed

6 files changed

+160
-7
lines changed

src/api/functions/entraId.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,60 @@ export async function patchUserProfile(
447447
});
448448
}
449449
}
450+
451+
/**
452+
* Checks if a user is a member of an Entra ID group.
453+
* @param token - Entra ID token authorized to take this action.
454+
* @param email - The email address of the user to check.
455+
* @param group - The group ID to check membership in.
456+
* @throws {EntraGroupError} If the membership check fails.
457+
* @returns {Promise<boolean>} True if the user is a member of the group, false otherwise.
458+
*/
459+
export async function isUserInGroup(
460+
token: string,
461+
email: string,
462+
group: string,
463+
): Promise<boolean> {
464+
email = email.toLowerCase().replace(/\s/g, "");
465+
if (!email.endsWith("@illinois.edu")) {
466+
throw new EntraGroupError({
467+
group,
468+
message: "User's domain must be illinois.edu to check group membership.",
469+
});
470+
}
471+
try {
472+
const oid = await resolveEmailToOid(token, email);
473+
const url = `https://graph.microsoft.com/v1.0/groups/${group}/members/${oid}`;
474+
475+
const response = await fetch(url, {
476+
method: "GET",
477+
headers: {
478+
Authorization: `Bearer ${token}`,
479+
"Content-Type": "application/json",
480+
},
481+
});
482+
483+
if (response.ok) {
484+
return true; // User is in the group
485+
} else if (response.status === 404) {
486+
return false; // User is not in the group
487+
}
488+
489+
const errorData = (await response.json()) as {
490+
error?: { message?: string };
491+
};
492+
throw new EntraGroupError({
493+
message: errorData?.error?.message ?? response.statusText,
494+
group,
495+
});
496+
} catch (error) {
497+
if (error instanceof EntraGroupError) {
498+
throw error;
499+
}
500+
const message = error instanceof Error ? error.message : String(error);
501+
throw new EntraGroupError({
502+
message,
503+
group,
504+
});
505+
}
506+
}

src/api/functions/membership.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
import {
2+
DynamoDBClient,
3+
PutItemCommand,
4+
QueryCommand,
5+
} from "@aws-sdk/client-dynamodb";
6+
import { marshall } from "@aws-sdk/util-dynamodb";
7+
import { genericConfig } from "common/config.js";
18
import { FastifyBaseLogger } from "fastify";
9+
import { isUserInGroup } from "./entraId.js";
210

311
export async function checkPaidMembership(
412
endpoint: string,
@@ -22,3 +30,51 @@ export async function checkPaidMembership(
2230
throw e;
2331
}
2432
}
33+
34+
export async function checkPaidMembershipFromTable(
35+
netId: string,
36+
dynamoClient: DynamoDBClient,
37+
): Promise<boolean> {
38+
const { Items } = await dynamoClient.send(
39+
new QueryCommand({
40+
TableName: genericConfig.MembershipTableName,
41+
KeyConditionExpression: "#pk = :pk",
42+
ExpressionAttributeNames: {
43+
"#pk": "email",
44+
},
45+
ExpressionAttributeValues: marshall({
46+
":pk": `${netId}@illinois.edu`,
47+
}),
48+
}),
49+
);
50+
if (!Items || Items.length == 0) {
51+
return false;
52+
}
53+
return true;
54+
}
55+
56+
export async function checkPaidMembershipFromEntra(
57+
netId: string,
58+
entraToken: string,
59+
paidMemberGroup: string,
60+
): Promise<boolean> {
61+
return isUserInGroup(entraToken, `${netId}@illinois.edu`, paidMemberGroup);
62+
}
63+
64+
export async function setPaidMembershipInTable(
65+
netId: string,
66+
dynamoClient: DynamoDBClient,
67+
): Promise<void> {
68+
const obj = {
69+
email: `${netId}@illinois.edu`,
70+
inserted_at: new Date().toISOString(),
71+
inserted_by: "membership-api-queried",
72+
};
73+
74+
await dynamoClient.send(
75+
new PutItemCommand({
76+
TableName: genericConfig.MembershipTableName,
77+
Item: marshall(obj),
78+
}),
79+
);
80+
}

src/api/routes/membership.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import {
2+
checkPaidMembershipFromEntra,
3+
checkPaidMembershipFromTable,
4+
setPaidMembershipInTable,
5+
} from "api/functions/membership.js";
16
import { validateNetId } from "api/functions/validation.js";
2-
import { NotImplementedError } from "common/errors/index.js";
37
import { FastifyPluginAsync } from "fastify";
4-
import { ValidationError } from "zod-validation-error";
8+
import { ValidationError } from "common/errors/index.js";
9+
import { getEntraIdToken } from "api/functions/entraId.js";
510

611
const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
712
fastify.get<{
@@ -24,9 +29,44 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
2429
async (request, reply) => {
2530
const netId = (request.params as Record<string, string>).netId;
2631
if (!validateNetId(netId)) {
27-
throw new ValidationError(`${netId} is not a valid Illinois NetID!`);
32+
throw new ValidationError({
33+
message: `${netId} is not a valid Illinois NetID!`,
34+
});
2835
}
29-
throw new NotImplementedError({});
36+
const isDynamoMember = await checkPaidMembershipFromTable(
37+
netId,
38+
fastify.dynamoClient,
39+
);
40+
// check Dynamo cache first
41+
if (isDynamoMember) {
42+
return reply
43+
.header("X-ACM-Data-Source", "dynamo")
44+
.send({ netId, isPaidMember: true });
45+
}
46+
// check AAD
47+
const entraIdToken = await getEntraIdToken(
48+
{
49+
smClient: fastify.secretsManagerClient,
50+
dynamoClient: fastify.dynamoClient,
51+
},
52+
fastify.environmentConfig.AadValidClientId,
53+
);
54+
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
55+
const isAadMember = await checkPaidMembershipFromEntra(
56+
netId,
57+
entraIdToken,
58+
paidMemberGroup,
59+
);
60+
if (isAadMember) {
61+
reply
62+
.header("X-ACM-Data-Source", "aad")
63+
.send({ netId, isPaidMember: true });
64+
await setPaidMembershipInTable(netId, fastify.dynamoClient);
65+
return;
66+
}
67+
return reply
68+
.header("X-ACM-Data-Source", "aad")
69+
.send({ netId, isPaidMember: false });
3070
},
3171
);
3272
};

src/api/routes/mobileWallet.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,8 @@ const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => {
5454
});
5555
}
5656
const isPaidMember = await checkPaidMembership(
57-
fastify.environmentConfig.MembershipApiEndpoint,
58-
request.log,
5957
request.query.email.replace("@illinois.edu", ""),
58+
fastify.dynamoClient,
6059
);
6160
if (!isPaidMember) {
6261
throw new UnauthenticatedError({

src/common/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type GenericConfigType = {
3131
MerchStorePurchasesTableName: string;
3232
TicketPurchasesTableName: string;
3333
TicketMetadataTableName: string;
34+
MembershipTableName: string;
3435
MerchStoreMetadataTableName: string;
3536
IAMTablePrefix: string;
3637
ProtectedEntraIDGroups: string[]; // these groups are too privileged to be modified via this portal and must be modified directly in Entra ID.
@@ -63,6 +64,7 @@ const genericConfig: GenericConfigType = {
6364
TicketMetadataTableName: "infra-events-ticketing-metadata",
6465
IAMTablePrefix: "infra-core-api-iam",
6566
ProtectedEntraIDGroups: [infraChairsGroupId, officersGroupId],
67+
MembershipTableName: "infra-core-api-membership-provisioning",
6668
} as const;
6769

6870
const environmentConfig: EnvironmentConfigType = {

src/common/errors/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,6 @@ export class EntraFetchError extends BaseError<"EntraFetchError"> {
215215
}
216216
}
217217

218-
219218
export class EntraPatchError extends BaseError<"EntraPatchError"> {
220219
email: string;
221220
constructor({ message, email }: { message?: string; email: string }) {

0 commit comments

Comments
 (0)