Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 92 additions & 90 deletions cloudformation/iam.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
AWSTemplateFormatVersion: '2010-09-09'
AWSTemplateFormatVersion: "2010-09-09"
Description: Stack IAM Roles
Transform: AWS::Serverless-2016-10-31
Parameters:
RunEnvironment:
Type: String
AllowedValues:
- dev
- prod
- dev
- prod
LambdaFunctionName:
Type: String
AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$
Expand All @@ -21,99 +21,101 @@ Resources:
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Version: "2012-10-17"
Statement:
- Action:
- sts:AssumeRole
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Policies:
- PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- ses:SendEmail
- ses:SendRawEmail
Effect: Allow
Resource: "*"
Condition:
StringEquals:
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
ForAllValues:StringLike:
ses:Recipients:
- "*@illinois.edu"
PolicyName: ses-membership
- PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- sqs:SendMessage
- sts:AssumeRole
Effect: Allow
Resource: !Ref SqsQueueArn
PolicyName: lambda-sqs
- PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Effect: Allow
Resource:
- Fn::Sub: arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunctionName}:*
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
- ec2:DescribeSubnets
- ec2:DeleteNetworkInterface
- ec2:AssignPrivateIpAddresses
- ec2:UnassignPrivateIpAddresses
Resource: '*'
PolicyName: lambda
- PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- secretsmanager:GetSecretValue
Effect: Allow
Resource:
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:infra-core-api-config*
PolicyName: lambda-db-secrets
- PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- dynamodb:*
Effect: Allow
Resource:
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/*
Principal:
Service:
- lambda.amazonaws.com
Policies:
- PolicyDocument:
Version: "2012-10-17"
Statement:
- Action:
- ses:SendEmail
- ses:SendRawEmail
Effect: Allow
Resource: "*"
Condition:
StringEquals:
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
ForAllValues:StringLike:
ses:Recipients:
- "*@illinois.edu"
PolicyName: ses-membership
- PolicyDocument:
Version: "2012-10-17"
Statement:
- Action:
- sqs:SendMessage
Effect: Allow
Resource: !Ref SqsQueueArn
PolicyName: lambda-sqs
- PolicyDocument:
Version: "2012-10-17"
Statement:
- Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Effect: Allow
Resource:
- Fn::Sub: arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunctionName}:*
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
- ec2:DescribeSubnets
- ec2:DeleteNetworkInterface
- ec2:AssignPrivateIpAddresses
- ec2:UnassignPrivateIpAddresses
Resource: "*"
PolicyName: lambda
- PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- secretsmanager:GetSecretValue
Effect: Allow
Resource:
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:infra-core-api-config*
PolicyName: lambda-db-secrets
- PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- dynamodb:*
Effect: Allow
Resource:
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-purchase-history
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-tickets/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning/*

PolicyName: lambda-dynamo
PolicyName: lambda-dynamo
Outputs:
MainFunctionRoleArn:
Description: Main API IAM role ARN
Value:
Fn::GetAtt:
- ApiLambdaIAMRole
- Arn
- ApiLambdaIAMRole
- Arn
17 changes: 17 additions & 0 deletions cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,23 @@ Resources:
FunctionResponseTypes:
- ReportBatchItemFailures

MembershipRecordsTable:
Type: "AWS::DynamoDB::Table"
DeletionPolicy: "Retain"
UpdateReplacePolicy: "Retain"
Properties:
BillingMode: "PAY_PER_REQUEST"
TableName: infra-core-api-membership-provisioning
DeletionProtectionEnabled: true
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: !If [IsProd, true, false]
AttributeDefinitions:
- AttributeName: email
AttributeType: S
KeySchema:
- AttributeName: email
KeyType: HASH

IamGroupRolesTable:
Type: "AWS::DynamoDB::Table"
DeletionPolicy: "Retain"
Expand Down
57 changes: 57 additions & 0 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,60 @@ export async function patchUserProfile(
});
}
}

/**
* Checks if a user is a member of an Entra ID group.
* @param token - Entra ID token authorized to take this action.
* @param email - The email address of the user to check.
* @param group - The group ID to check membership in.
* @throws {EntraGroupError} If the membership check fails.
* @returns {Promise<boolean>} True if the user is a member of the group, false otherwise.
*/
export async function isUserInGroup(
token: string,
email: string,
group: string,
): Promise<boolean> {
email = email.toLowerCase().replace(/\s/g, "");
if (!email.endsWith("@illinois.edu")) {
throw new EntraGroupError({
group,
message: "User's domain must be illinois.edu to check group membership.",
});
}
try {
const oid = await resolveEmailToOid(token, email);
const url = `https://graph.microsoft.com/v1.0/groups/${group}/members/${oid}`;

const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});

if (response.ok) {
return true; // User is in the group
} else if (response.status === 404) {
return false; // User is not in the group
}

const errorData = (await response.json()) as {
error?: { message?: string };
};
throw new EntraGroupError({
message: errorData?.error?.message ?? response.statusText,
group,
});
} catch (error) {
if (error instanceof EntraGroupError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new EntraGroupError({
message,
group,
});
}
}
56 changes: 56 additions & 0 deletions src/api/functions/membership.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {
DynamoDBClient,
PutItemCommand,
QueryCommand,
} from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
import { genericConfig } from "common/config.js";
import { FastifyBaseLogger } from "fastify";
import { isUserInGroup } from "./entraId.js";

export async function checkPaidMembership(
endpoint: string,
Expand All @@ -22,3 +30,51 @@ export async function checkPaidMembership(
throw e;
}
}

export async function checkPaidMembershipFromTable(
netId: string,
dynamoClient: DynamoDBClient,
): Promise<boolean> {
const { Items } = await dynamoClient.send(
new QueryCommand({
TableName: genericConfig.MembershipTableName,
KeyConditionExpression: "#pk = :pk",
ExpressionAttributeNames: {
"#pk": "email",
},
ExpressionAttributeValues: marshall({
":pk": `${netId}@illinois.edu`,
}),
}),
);
if (!Items || Items.length == 0) {
return false;
}
return true;
}

export async function checkPaidMembershipFromEntra(
netId: string,
entraToken: string,
paidMemberGroup: string,
): Promise<boolean> {
return isUserInGroup(entraToken, `${netId}@illinois.edu`, paidMemberGroup);
}

export async function setPaidMembershipInTable(
netId: string,
dynamoClient: DynamoDBClient,
): Promise<void> {
const obj = {
email: `${netId}@illinois.edu`,
inserted_at: new Date().toISOString(),
inserted_by: "membership-api-queried",
};

await dynamoClient.send(
new PutItemCommand({
TableName: genericConfig.MembershipTableName,
Item: marshall(obj),
}),
);
}
5 changes: 5 additions & 0 deletions src/api/functions/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ export function validateEmail(email: string): boolean {
const result = emailSchema.safeParse(email);
return result.success;
}

export function validateNetId(netId: string): boolean {
const regex = /^[a-zA-Z]{2}[a-zA-Z\-]*(?:[2-9]|[1-9][0-9]{1,2})?$/;
return regex.test(netId);
}
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import mobileWalletRoute from "./routes/mobileWallet.js";
import stripeRoutes from "./routes/stripe.js";
import membershipPlugin from "./routes/membership.js";

dotenv.config();

Expand Down Expand Up @@ -110,6 +111,7 @@ async function init() {
api.register(protectedRoute, { prefix: "/protected" });
api.register(eventsPlugin, { prefix: "/events" });
api.register(organizationsPlugin, { prefix: "/organizations" });
api.register(membershipPlugin, { prefix: "/membership" });
api.register(icalPlugin, { prefix: "/ical" });
api.register(iamRoutes, { prefix: "/iam" });
api.register(ticketsPlugin, { prefix: "/tickets" });
Expand Down
Loading
Loading