@@ -28,8 +28,9 @@ import { getEntraIdToken } from "api/functions/entraId.js";
2828import { genericConfig , roleArns } from "common/config.js" ;
2929import { getRoleCredentials } from "api/functions/sts.js" ;
3030import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager" ;
31- import { DynamoDBClient } from "@aws-sdk/client-dynamodb" ;
31+ import { BatchGetItemCommand , DynamoDBClient } from "@aws-sdk/client-dynamodb" ;
3232import { AppRoles } from "common/roles.js" ;
33+ import { marshall , unmarshall } from "@aws-sdk/util-dynamodb" ;
3334
3435const membershipV2Plugin : FastifyPluginAsync = async ( fastify , _options ) => {
3536 const getAuthorizedClients = async ( ) => {
@@ -160,6 +161,213 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
160161 ) ;
161162 } ,
162163 ) ;
164+ fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . post (
165+ "/verifyBatchOfMembers" ,
166+ {
167+ schema : withRoles (
168+ [
169+ AppRoles . VIEW_INTERNAL_MEMBERSHIP_LIST ,
170+ AppRoles . VIEW_EXTERNAL_MEMBERSHIP_LIST ,
171+ ] ,
172+ withTags ( [ "Membership" ] , {
173+ body : z . array ( illinoisNetId ) . nonempty ( ) . max ( 500 ) ,
174+ querystring : z . object ( {
175+ list : z . string ( ) . min ( 1 ) . optional ( ) . meta ( {
176+ example : "built" ,
177+ description :
178+ "Membership list to check from (defaults to ACM Paid Member list)." ,
179+ } ) ,
180+ } ) ,
181+ summary :
182+ "Check a batch of NetIDs for ACM @ UIUC paid membership (or partner organization membership) status." ,
183+ response : {
184+ 200 : {
185+ description : "List membership status." ,
186+ content : {
187+ "application/json" : {
188+ schema : z
189+ . object ( {
190+ members : z . array ( illinoisNetId ) ,
191+ notMembers : z . array ( illinoisNetId ) ,
192+ list : z . optional ( z . string ( ) . min ( 1 ) ) ,
193+ } )
194+ . meta ( {
195+ example : {
196+ members : [ "rjjones" ] ,
197+ notMembers : [ "isbell" ] ,
198+ list : "built" ,
199+ } ,
200+ } ) ,
201+ } ,
202+ } ,
203+ } ,
204+ } ,
205+ } ) ,
206+ ) ,
207+ onRequest : async ( request , reply ) => {
208+ await fastify . authorizeFromSchema ( request , reply ) ;
209+ if ( ! request . userRoles ) {
210+ throw new InternalServerError ( { } ) ;
211+ }
212+ const list = request . query . list || "acmpaid" ;
213+ if (
214+ list === "acmpaid" &&
215+ ! request . userRoles . has ( AppRoles . VIEW_INTERNAL_MEMBERSHIP_LIST )
216+ ) {
217+ throw new UnauthorizedError ( { } ) ;
218+ }
219+ if (
220+ list !== "acmpaid" &&
221+ ! request . userRoles . has ( AppRoles . VIEW_EXTERNAL_MEMBERSHIP_LIST )
222+ ) {
223+ throw new UnauthorizedError ( { } ) ;
224+ }
225+ } ,
226+ } ,
227+ async ( request , reply ) => {
228+ const list = request . query . list || "acmpaid" ;
229+ let netIdsToCheck = [
230+ ...new Set ( request . body . map ( ( id ) => id . toLowerCase ( ) ) ) ,
231+ ] ;
232+
233+ const members = new Set < string > ( ) ;
234+ const notMembers = new Set < string > ( ) ;
235+
236+ const cacheKeys = netIdsToCheck . map ( ( id ) => `membership:${ id } :${ list } ` ) ;
237+ if ( cacheKeys . length > 0 ) {
238+ const cachedResults = await fastify . redisClient . mget ( cacheKeys ) ;
239+ const remainingNetIds : string [ ] = [ ] ;
240+ cachedResults . forEach ( ( result , index ) => {
241+ const netId = netIdsToCheck [ index ] ;
242+ if ( result ) {
243+ const { isMember } = JSON . parse ( result ) as { isMember : boolean } ;
244+ if ( isMember ) {
245+ members . add ( netId ) ;
246+ } else {
247+ notMembers . add ( netId ) ;
248+ }
249+ } else {
250+ remainingNetIds . push ( netId ) ;
251+ }
252+ } ) ;
253+ netIdsToCheck = remainingNetIds ;
254+ }
255+
256+ if ( netIdsToCheck . length === 0 ) {
257+ return reply . send ( {
258+ members : [ ...members ] . sort ( ) ,
259+ notMembers : [ ...notMembers ] . sort ( ) ,
260+ list : list === "acmpaid" ? undefined : list ,
261+ } ) ;
262+ }
263+
264+ const cachePipeline = fastify . redisClient . pipeline ( ) ;
265+
266+ if ( list !== "acmpaid" ) {
267+ // can't do batch get on an index.
268+ const checkPromises = netIdsToCheck . map ( async ( netId ) => {
269+ const isMember = await checkExternalMembership (
270+ netId ,
271+ list ,
272+ fastify . dynamoClient ,
273+ ) ;
274+ if ( isMember ) {
275+ members . add ( netId ) ;
276+ } else {
277+ notMembers . add ( netId ) ;
278+ }
279+ cachePipeline . set (
280+ `membership:${ netId } :${ list } ` ,
281+ JSON . stringify ( { isMember } ) ,
282+ "EX" ,
283+ MEMBER_CACHE_SECONDS ,
284+ ) ;
285+ } ) ;
286+ await Promise . all ( checkPromises ) ;
287+ } else {
288+ const BATCH_SIZE = 100 ;
289+ const foundInDynamo = new Set < string > ( ) ;
290+ for ( let i = 0 ; i < netIdsToCheck . length ; i += BATCH_SIZE ) {
291+ const batch = netIdsToCheck . slice ( i , i + BATCH_SIZE ) ;
292+ const command = new BatchGetItemCommand ( {
293+ RequestItems : {
294+ [ genericConfig . MembershipTableName ] : {
295+ Keys : batch . map ( ( netId ) =>
296+ marshall ( { email : `${ netId } @illinois.edu` } ) ,
297+ ) ,
298+ } ,
299+ } ,
300+ } ) ;
301+ const { Responses } = await fastify . dynamoClient . send ( command ) ;
302+ const items = Responses ?. [ genericConfig . MembershipTableName ] ?? [ ] ;
303+ for ( const item of items ) {
304+ const { email } = unmarshall ( item ) ;
305+ const netId = email . split ( "@" ) [ 0 ] ;
306+ members . add ( netId ) ;
307+ foundInDynamo . add ( netId ) ;
308+ cachePipeline . set (
309+ `membership:${ netId } :${ list } ` ,
310+ JSON . stringify ( { isMember : true } ) ,
311+ "EX" ,
312+ MEMBER_CACHE_SECONDS ,
313+ ) ;
314+ }
315+ }
316+
317+ // 3. Fallback to Entra ID for remaining paid members
318+ const netIdsForEntra = netIdsToCheck . filter (
319+ ( id ) => ! foundInDynamo . has ( id ) ,
320+ ) ;
321+ if ( netIdsForEntra . length > 0 ) {
322+ const entraIdToken = await getEntraIdToken ( {
323+ clients : await getAuthorizedClients ( ) ,
324+ clientId : fastify . environmentConfig . AadValidClientId ,
325+ secretName : genericConfig . EntraSecretName ,
326+ logger : request . log ,
327+ } ) ;
328+ const paidMemberGroup = fastify . environmentConfig . PaidMemberGroupId ;
329+ const entraCheckPromises = netIdsForEntra . map ( async ( netId ) => {
330+ const isMember = await checkPaidMembershipFromEntra (
331+ netId ,
332+ entraIdToken ,
333+ paidMemberGroup ,
334+ ) ;
335+ if ( isMember ) {
336+ members . add ( netId ) ;
337+ // Fire-and-forget writeback to DynamoDB to warm it up
338+ setPaidMembershipInTable ( netId , fastify . dynamoClient ) . catch (
339+ ( err ) =>
340+ request . log . error (
341+ err ,
342+ `Failed to write back Entra membership for ${ netId } ` ,
343+ ) ,
344+ ) ;
345+ } else {
346+ notMembers . add ( netId ) ;
347+ }
348+ cachePipeline . set (
349+ `membership:${ netId } :${ list } ` ,
350+ JSON . stringify ( { isMember } ) ,
351+ "EX" ,
352+ MEMBER_CACHE_SECONDS ,
353+ ) ;
354+ } ) ;
355+ await Promise . all ( entraCheckPromises ) ;
356+ }
357+ }
358+
359+ if ( cachePipeline . length > 0 ) {
360+ await cachePipeline . exec ( ) ;
361+ }
362+
363+ return reply . send ( {
364+ members : [ ...members ] . sort ( ) ,
365+ notMembers : [ ...notMembers ] . sort ( ) ,
366+ list : list === "acmpaid" ? undefined : list ,
367+ } ) ;
368+ } ,
369+ ) ;
370+
163371 fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . get (
164372 "/:netId" ,
165373 {
0 commit comments