diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 6493af73..fbeb9e3a 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -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]+$ @@ -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 diff --git a/cloudformation/main.yml b/cloudformation/main.yml index ea770019..c1ca621d 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -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" diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 00e3dd76..270f1b9c 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -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} True if the user is a member of the group, false otherwise. + */ +export async function isUserInGroup( + token: string, + email: string, + group: string, +): Promise { + 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, + }); + } +} diff --git a/src/api/functions/membership.ts b/src/api/functions/membership.ts index 7f446240..50c91ab3 100644 --- a/src/api/functions/membership.ts +++ b/src/api/functions/membership.ts @@ -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, @@ -22,3 +30,51 @@ export async function checkPaidMembership( throw e; } } + +export async function checkPaidMembershipFromTable( + netId: string, + dynamoClient: DynamoDBClient, +): Promise { + 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 { + return isUserInGroup(entraToken, `${netId}@illinois.edu`, paidMemberGroup); +} + +export async function setPaidMembershipInTable( + netId: string, + dynamoClient: DynamoDBClient, +): Promise { + 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), + }), + ); +} diff --git a/src/api/functions/validation.ts b/src/api/functions/validation.ts index b9f241f8..4c785c9d 100644 --- a/src/api/functions/validation.ts +++ b/src/api/functions/validation.ts @@ -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); +} diff --git a/src/api/index.ts b/src/api/index.ts index d049bd6a..b2ff8eff 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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(); @@ -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" }); diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts new file mode 100644 index 00000000..63c25973 --- /dev/null +++ b/src/api/routes/membership.ts @@ -0,0 +1,74 @@ +import { + checkPaidMembershipFromEntra, + checkPaidMembershipFromTable, + setPaidMembershipInTable, +} from "api/functions/membership.js"; +import { validateNetId } from "api/functions/validation.js"; +import { FastifyPluginAsync } from "fastify"; +import { ValidationError } from "common/errors/index.js"; +import { getEntraIdToken } from "api/functions/entraId.js"; + +const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { + fastify.get<{ + Body: undefined; + Querystring: { netId: string }; + }>( + "/:netId", + { + schema: { + querystring: { + type: "object", + properties: { + netId: { + type: "string", + }, + }, + }, + }, + }, + async (request, reply) => { + const netId = (request.params as Record).netId; + if (!validateNetId(netId)) { + throw new ValidationError({ + message: `${netId} is not a valid Illinois NetID!`, + }); + } + const isDynamoMember = await checkPaidMembershipFromTable( + netId, + fastify.dynamoClient, + ); + // check Dynamo cache first + if (isDynamoMember) { + return reply + .header("X-ACM-Data-Source", "dynamo") + .send({ netId, isPaidMember: true }); + } + // check AAD + const entraIdToken = await getEntraIdToken( + { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + }, + fastify.environmentConfig.AadValidClientId, + ); + const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId; + const isAadMember = await checkPaidMembershipFromEntra( + netId, + entraIdToken, + paidMemberGroup, + ); + if (isAadMember) { + reply + .header("X-ACM-Data-Source", "aad") + .send({ netId, isPaidMember: true }); + await setPaidMembershipInTable(netId, fastify.dynamoClient); + return; + } + return reply + .header("X-ACM-Data-Source", "aad") + .send({ netId, isPaidMember: false }); + }, + ); +}; + +export default membershipPlugin; diff --git a/src/api/routes/mobileWallet.ts b/src/api/routes/mobileWallet.ts index 343563d5..c1f570ae 100644 --- a/src/api/routes/mobileWallet.ts +++ b/src/api/routes/mobileWallet.ts @@ -53,11 +53,14 @@ const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => { message: "Email query parameter is not a valid email", }); } - const isPaidMember = await checkPaidMembership( - fastify.environmentConfig.MembershipApiEndpoint, - request.log, - request.query.email.replace("@illinois.edu", ""), - ); + const isPaidMember = + (fastify.runEnvironment === "dev" && + request.query.email === "testinguser@illinois.edu") || + (await checkPaidMembership( + fastify.environmentConfig.MembershipApiEndpoint, + request.log, + request.query.email.replace("@illinois.edu", ""), + )); if (!isPaidMember) { throw new UnauthenticatedError({ message: `${request.query.email} is not a paid member.`, diff --git a/src/common/config.ts b/src/common/config.ts index 51e27c67..6c31cbd4 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -17,6 +17,7 @@ export type ConfigType = { MembershipApiEndpoint: string; EmailDomain: string; SqsQueueUrl: string; + PaidMemberGroupId: string; }; export type GenericConfigType = { @@ -30,6 +31,7 @@ export type GenericConfigType = { MerchStorePurchasesTableName: string; TicketPurchasesTableName: string; TicketMetadataTableName: string; + MembershipTableName: string; MerchStoreMetadataTableName: string; IAMTablePrefix: string; ProtectedEntraIDGroups: string[]; // these groups are too privileged to be modified via this portal and must be modified directly in Entra ID. @@ -62,6 +64,7 @@ const genericConfig: GenericConfigType = { TicketMetadataTableName: "infra-events-ticketing-metadata", IAMTablePrefix: "infra-core-api-iam", ProtectedEntraIDGroups: [infraChairsGroupId, officersGroupId], + MembershipTableName: "infra-core-api-membership-provisioning", } as const; const environmentConfig: EnvironmentConfigType = { @@ -83,6 +86,7 @@ const environmentConfig: EnvironmentConfigType = { EmailDomain: "aws.qa.acmuiuc.org", SqsQueueUrl: "https://sqs.us-east-1.amazonaws.com/427040638965/infra-core-api-sqs", + PaidMemberGroupId: "9222451f-b354-4e64-ba28-c0f367a277c2", }, prod: { AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, @@ -100,6 +104,7 @@ const environmentConfig: EnvironmentConfigType = { EmailDomain: "acm.illinois.edu", SqsQueueUrl: "https://sqs.us-east-1.amazonaws.com/298118738376/infra-core-api-sqs", + PaidMemberGroupId: "172fd9ee-69f0-4384-9786-41ff1a43cf8e", }, }; diff --git a/src/common/errors/index.ts b/src/common/errors/index.ts index de495a22..2204f2b6 100644 --- a/src/common/errors/index.ts +++ b/src/common/errors/index.ts @@ -215,7 +215,6 @@ export class EntraFetchError extends BaseError<"EntraFetchError"> { } } - export class EntraPatchError extends BaseError<"EntraPatchError"> { email: string; constructor({ message, email }: { message?: string; email: string }) {