Skip to content

Commit d41b4a7

Browse files
authored
Add membership checking to Core API (#69)
* port code from old branch * update netid validation regex * add membership checking to core * revert mobilewallet * fix * fix test case handling
1 parent b1bdb7e commit d41b4a7

File tree

10 files changed

+316
-96
lines changed

10 files changed

+316
-96
lines changed

cloudformation/iam.yml

Lines changed: 92 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
AWSTemplateFormatVersion: '2010-09-09'
1+
AWSTemplateFormatVersion: "2010-09-09"
22
Description: Stack IAM Roles
33
Transform: AWS::Serverless-2016-10-31
44
Parameters:
55
RunEnvironment:
66
Type: String
77
AllowedValues:
8-
- dev
9-
- prod
8+
- dev
9+
- prod
1010
LambdaFunctionName:
1111
Type: String
1212
AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$
@@ -21,99 +21,101 @@ Resources:
2121
ManagedPolicyArns:
2222
- arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole
2323
AssumeRolePolicyDocument:
24-
Version: '2012-10-17'
24+
Version: "2012-10-17"
2525
Statement:
26-
- Action:
27-
- sts:AssumeRole
28-
Effect: Allow
29-
Principal:
30-
Service:
31-
- lambda.amazonaws.com
32-
Policies:
33-
- PolicyDocument:
34-
Version: '2012-10-17'
35-
Statement:
36-
- Action:
37-
- ses:SendEmail
38-
- ses:SendRawEmail
39-
Effect: Allow
40-
Resource: "*"
41-
Condition:
42-
StringEquals:
43-
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
44-
ForAllValues:StringLike:
45-
ses:Recipients:
46-
- "*@illinois.edu"
47-
PolicyName: ses-membership
48-
- PolicyDocument:
49-
Version: '2012-10-17'
50-
Statement:
5126
- Action:
52-
- sqs:SendMessage
27+
- sts:AssumeRole
5328
Effect: Allow
54-
Resource: !Ref SqsQueueArn
55-
PolicyName: lambda-sqs
56-
- PolicyDocument:
57-
Version: '2012-10-17'
58-
Statement:
59-
- Action:
60-
- logs:CreateLogGroup
61-
- logs:CreateLogStream
62-
- logs:PutLogEvents
63-
Effect: Allow
64-
Resource:
65-
- Fn::Sub: arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunctionName}:*
66-
- Effect: Allow
67-
Action:
68-
- ec2:CreateNetworkInterface
69-
- ec2:DescribeNetworkInterfaces
70-
- ec2:DeleteNetworkInterface
71-
- ec2:DescribeSubnets
72-
- ec2:DeleteNetworkInterface
73-
- ec2:AssignPrivateIpAddresses
74-
- ec2:UnassignPrivateIpAddresses
75-
Resource: '*'
76-
PolicyName: lambda
77-
- PolicyDocument:
78-
Version: 2012-10-17
79-
Statement:
80-
- Action:
81-
- secretsmanager:GetSecretValue
82-
Effect: Allow
83-
Resource:
84-
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:infra-core-api-config*
85-
PolicyName: lambda-db-secrets
86-
- PolicyDocument:
87-
Version: 2012-10-17
88-
Statement:
89-
- Action:
90-
- dynamodb:*
91-
Effect: Allow
92-
Resource:
93-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/*
94-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events
95-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
96-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache/*
97-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history/*
98-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history
99-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets
100-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets/*
101-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata/*
102-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata
103-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata/*
104-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata
105-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles
106-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles/*
107-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles
108-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles/*
109-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links
110-
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/*
29+
Principal:
30+
Service:
31+
- lambda.amazonaws.com
32+
Policies:
33+
- PolicyDocument:
34+
Version: "2012-10-17"
35+
Statement:
36+
- Action:
37+
- ses:SendEmail
38+
- ses:SendRawEmail
39+
Effect: Allow
40+
Resource: "*"
41+
Condition:
42+
StringEquals:
43+
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
44+
ForAllValues:StringLike:
45+
ses:Recipients:
46+
- "*@illinois.edu"
47+
PolicyName: ses-membership
48+
- PolicyDocument:
49+
Version: "2012-10-17"
50+
Statement:
51+
- Action:
52+
- sqs:SendMessage
53+
Effect: Allow
54+
Resource: !Ref SqsQueueArn
55+
PolicyName: lambda-sqs
56+
- PolicyDocument:
57+
Version: "2012-10-17"
58+
Statement:
59+
- Action:
60+
- logs:CreateLogGroup
61+
- logs:CreateLogStream
62+
- logs:PutLogEvents
63+
Effect: Allow
64+
Resource:
65+
- Fn::Sub: arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunctionName}:*
66+
- Effect: Allow
67+
Action:
68+
- ec2:CreateNetworkInterface
69+
- ec2:DescribeNetworkInterfaces
70+
- ec2:DeleteNetworkInterface
71+
- ec2:DescribeSubnets
72+
- ec2:DeleteNetworkInterface
73+
- ec2:AssignPrivateIpAddresses
74+
- ec2:UnassignPrivateIpAddresses
75+
Resource: "*"
76+
PolicyName: lambda
77+
- PolicyDocument:
78+
Version: 2012-10-17
79+
Statement:
80+
- Action:
81+
- secretsmanager:GetSecretValue
82+
Effect: Allow
83+
Resource:
84+
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:infra-core-api-config*
85+
PolicyName: lambda-db-secrets
86+
- PolicyDocument:
87+
Version: 2012-10-17
88+
Statement:
89+
- Action:
90+
- dynamodb:*
91+
Effect: Allow
92+
Resource:
93+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/*
94+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events
95+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
96+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache/*
97+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history/*
98+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history
99+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets
100+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets/*
101+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata/*
102+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata
103+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata/*
104+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata
105+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles
106+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles/*
107+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles
108+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles/*
109+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links
110+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/*
111+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning
112+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning/*
111113

112-
PolicyName: lambda-dynamo
114+
PolicyName: lambda-dynamo
113115
Outputs:
114116
MainFunctionRoleArn:
115117
Description: Main API IAM role ARN
116118
Value:
117119
Fn::GetAtt:
118-
- ApiLambdaIAMRole
119-
- Arn
120+
- ApiLambdaIAMRole
121+
- Arn

cloudformation/main.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,23 @@ Resources:
253253
FunctionResponseTypes:
254254
- ReportBatchItemFailures
255255

256+
MembershipRecordsTable:
257+
Type: "AWS::DynamoDB::Table"
258+
DeletionPolicy: "Retain"
259+
UpdateReplacePolicy: "Retain"
260+
Properties:
261+
BillingMode: "PAY_PER_REQUEST"
262+
TableName: infra-core-api-membership-provisioning
263+
DeletionProtectionEnabled: true
264+
PointInTimeRecoverySpecification:
265+
PointInTimeRecoveryEnabled: !If [IsProd, true, false]
266+
AttributeDefinitions:
267+
- AttributeName: email
268+
AttributeType: S
269+
KeySchema:
270+
- AttributeName: email
271+
KeyType: HASH
272+
256273
IamGroupRolesTable:
257274
Type: "AWS::DynamoDB::Table"
258275
DeletionPolicy: "Retain"

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/functions/validation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ export function validateEmail(email: string): boolean {
55
const result = emailSchema.safeParse(email);
66
return result.success;
77
}
8+
9+
export function validateNetId(netId: string): boolean {
10+
const regex = /^[a-zA-Z]{2}[a-zA-Z\-]*(?:[2-9]|[1-9][0-9]{1,2})?$/;
11+
return regex.test(netId);
12+
}

src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2323
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
2424
import mobileWalletRoute from "./routes/mobileWallet.js";
2525
import stripeRoutes from "./routes/stripe.js";
26+
import membershipPlugin from "./routes/membership.js";
2627

2728
dotenv.config();
2829

@@ -110,6 +111,7 @@ async function init() {
110111
api.register(protectedRoute, { prefix: "/protected" });
111112
api.register(eventsPlugin, { prefix: "/events" });
112113
api.register(organizationsPlugin, { prefix: "/organizations" });
114+
api.register(membershipPlugin, { prefix: "/membership" });
113115
api.register(icalPlugin, { prefix: "/ical" });
114116
api.register(iamRoutes, { prefix: "/iam" });
115117
api.register(ticketsPlugin, { prefix: "/tickets" });

0 commit comments

Comments
 (0)