@@ -28,8 +28,9 @@ import { getEntraIdToken } from "api/functions/entraId.js";
28
28
import { genericConfig , roleArns } from "common/config.js" ;
29
29
import { getRoleCredentials } from "api/functions/sts.js" ;
30
30
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager" ;
31
- import { DynamoDBClient } from "@aws-sdk/client-dynamodb" ;
31
+ import { BatchGetItemCommand , DynamoDBClient } from "@aws-sdk/client-dynamodb" ;
32
32
import { AppRoles } from "common/roles.js" ;
33
+ import { marshall , unmarshall } from "@aws-sdk/util-dynamodb" ;
33
34
34
35
const membershipV2Plugin : FastifyPluginAsync = async ( fastify , _options ) => {
35
36
const getAuthorizedClients = async ( ) => {
@@ -160,6 +161,213 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
160
161
) ;
161
162
} ,
162
163
) ;
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
+
163
371
fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . get (
164
372
"/:netId" ,
165
373
{
0 commit comments