11import {
2+ BatchWriteItemCommand ,
23 ConditionalCheckFailedException ,
34 DynamoDBClient ,
45 PutItemCommand ,
56 QueryCommand ,
67} from "@aws-sdk/client-dynamodb" ;
7- import { marshall } from "@aws-sdk/util-dynamodb" ;
8+ import { marshall , unmarshall } from "@aws-sdk/util-dynamodb" ;
89import { genericConfig } from "common/config.js" ;
910import {
1011 addToTenant ,
@@ -13,15 +14,161 @@ import {
1314 patchUserProfile ,
1415 resolveEmailToOid ,
1516} from "./entraId.js" ;
16- import { EntraGroupError } from "common/errors/index.js" ;
17+ import { EntraGroupError , ValidationError } from "common/errors/index.js" ;
1718import { EntraGroupActions } from "common/types/iam.js" ;
1819import { pollUntilNoError } from "./general.js" ;
1920import Redis from "ioredis" ;
20- import { getKey } from "./redisCache.js" ;
21+ import { getKey , setKey } from "./redisCache.js" ;
2122import { FastifyBaseLogger } from "fastify" ;
23+ import type pino from "pino" ;
24+ import { createAuditLogEntry } from "./auditLog.js" ;
25+ import { Modules } from "common/modules.js" ;
2226
2327export const MEMBER_CACHE_SECONDS = 43200 ; // 12 hours
2428
29+ export async function patchExternalMemberList ( {
30+ listId : oldListId ,
31+ add : oldAdd ,
32+ remove : oldRemove ,
33+ clients : { dynamoClient, redisClient } ,
34+ logger,
35+ auditLogData : { actor, requestId } ,
36+ } : {
37+ listId : string ;
38+ add : string [ ] ;
39+ remove : string [ ] ;
40+ clients : { dynamoClient : DynamoDBClient ; redisClient : Redis . default } ;
41+ logger : pino . Logger | FastifyBaseLogger ;
42+ auditLogData : { actor : string ; requestId : string } ;
43+ } ) {
44+ const listId = oldListId . toLowerCase ( ) ;
45+ const add = oldAdd . map ( ( x ) => x . toLowerCase ( ) ) ;
46+ const remove = oldRemove . map ( ( x ) => x . toLowerCase ( ) ) ;
47+ if ( add . length === 0 && remove . length === 0 ) {
48+ return ;
49+ }
50+ const addSet = new Set ( add ) ;
51+
52+ const conflictingNetId = remove . find ( ( netId ) => addSet . has ( netId ) ) ;
53+
54+ if ( conflictingNetId ) {
55+ throw new ValidationError ( {
56+ message : `The netId '${ conflictingNetId } ' cannot be in both the 'add' and 'remove' lists simultaneously.` ,
57+ } ) ;
58+ }
59+ const writeRequests = [ ] ;
60+ // Create PutRequest objects for each member to be added.
61+ for ( const netId of add ) {
62+ writeRequests . push ( {
63+ PutRequest : {
64+ Item : {
65+ memberList : { S : listId } ,
66+ netId : { S : netId } ,
67+ } ,
68+ } ,
69+ } ) ;
70+ }
71+ // Create DeleteRequest objects for each member to be removed.
72+ for ( const netId of remove ) {
73+ writeRequests . push ( {
74+ DeleteRequest : {
75+ Key : {
76+ memberList : { S : listId } ,
77+ netId : { S : netId } ,
78+ } ,
79+ } ,
80+ } ) ;
81+ }
82+ const BATCH_SIZE = 25 ;
83+ const batchPromises = [ ] ;
84+ for ( let i = 0 ; i < writeRequests . length ; i += BATCH_SIZE ) {
85+ const batch = writeRequests . slice ( i , i + BATCH_SIZE ) ;
86+ const command = new BatchWriteItemCommand ( {
87+ RequestItems : {
88+ [ genericConfig . ExternalMembershipTableName ] : batch ,
89+ } ,
90+ } ) ;
91+ batchPromises . push ( dynamoClient . send ( command ) ) ;
92+ }
93+ const removeCacheInvalidation = remove . map ( ( x ) =>
94+ setKey ( {
95+ redisClient,
96+ key : `membership:${ x } :${ listId } ` ,
97+ data : JSON . stringify ( { isMember : false } ) ,
98+ expiresIn : MEMBER_CACHE_SECONDS ,
99+ logger,
100+ } ) ,
101+ ) ;
102+ const addCacheInvalidation = add . map ( ( x ) =>
103+ setKey ( {
104+ redisClient,
105+ key : `membership:${ x } :${ listId } ` ,
106+ data : JSON . stringify ( { isMember : true } ) ,
107+ expiresIn : MEMBER_CACHE_SECONDS ,
108+ logger,
109+ } ) ,
110+ ) ;
111+ const auditLogPromises = [ ] ;
112+ if ( add . length > 0 ) {
113+ auditLogPromises . push (
114+ createAuditLogEntry ( {
115+ dynamoClient,
116+ entry : {
117+ module : Modules . EXTERNAL_MEMBERSHIP ,
118+ actor,
119+ requestId,
120+ message : `Added ${ add . length } member(s) to target list.` ,
121+ target : listId ,
122+ } ,
123+ } ) ,
124+ ) ;
125+ }
126+ if ( remove . length > 0 ) {
127+ auditLogPromises . push (
128+ createAuditLogEntry ( {
129+ dynamoClient,
130+ entry : {
131+ module : Modules . EXTERNAL_MEMBERSHIP ,
132+ actor,
133+ requestId,
134+ message : `Removed ${ remove . length } member(s) from target list.` ,
135+ target : listId ,
136+ } ,
137+ } ) ,
138+ ) ;
139+ }
140+ await Promise . all ( [
141+ ...removeCacheInvalidation ,
142+ ...addCacheInvalidation ,
143+ ...batchPromises ,
144+ ] ) ;
145+ await Promise . all ( auditLogPromises ) ;
146+ }
147+ export async function getExternalMemberList (
148+ list : string ,
149+ dynamoClient : DynamoDBClient ,
150+ ) : Promise < string [ ] > {
151+ const { Items } = await dynamoClient . send (
152+ new QueryCommand ( {
153+ TableName : genericConfig . ExternalMembershipTableName ,
154+ KeyConditionExpression : "#pk = :pk" ,
155+ ExpressionAttributeNames : {
156+ "#pk" : "memberList" ,
157+ } ,
158+ ExpressionAttributeValues : marshall ( {
159+ ":pk" : list ,
160+ } ) ,
161+ } ) ,
162+ ) ;
163+ if ( ! Items || Items . length === 0 ) {
164+ return [ ] ;
165+ }
166+ return Items . map ( ( x ) => unmarshall ( x ) )
167+ . filter ( ( x ) => ! ! x )
168+ . map ( ( x ) => x . netId )
169+ . sort ( ) ;
170+ }
171+
25172export async function checkExternalMembership (
26173 netId : string ,
27174 list : string ,
@@ -30,12 +177,15 @@ export async function checkExternalMembership(
30177 const { Items } = await dynamoClient . send (
31178 new QueryCommand ( {
32179 TableName : genericConfig . ExternalMembershipTableName ,
33- KeyConditionExpression : "#pk = :pk" ,
180+ KeyConditionExpression : "#pk = :pk and #sk = :sk" ,
181+ IndexName : "invertedIndex" ,
34182 ExpressionAttributeNames : {
35- "#pk" : "netid_list" ,
183+ "#pk" : "netId" ,
184+ "#sk" : "memberList" ,
36185 } ,
37186 ExpressionAttributeValues : marshall ( {
38- ":pk" : `${ netId } _${ list } ` ,
187+ ":pk" : netId ,
188+ ":sk" : list ,
39189 } ) ,
40190 } ) ,
41191 ) ;
0 commit comments