66 * Reads the output of check-replication-permissions.js and creates IAM policies
77 * with s3:ReplicateObject for roles that are missing it, then attaches them.
88 *
9+ * TBD: This script does not re-check whether the permission is still missing
10+ * before applying the fix. This means:
11+ * - If someone manually added s3:ReplicateObject between check and fix,
12+ * a redundant (but harmless) policy is created.
13+ * - If the fix policy is later modified externally, re-running won't
14+ * detect or correct it (EntityAlreadyExists skips the policy).
15+ * We keep it simple today: the intended workflow is check → fix → re-check.
16+ * Re-run check-replication-permissions.js after fixing to verify the result.
17+ *
918 * Usage: node fix-missing-replication-permissions.js <input-file> <vault-host> <admin-config> [output-file] [--iam-port <port>] [--https] [--dry-run]
1019 *
1120 * Requires: vaultclient, @aws-sdk/client-iam (both in s3utils dependencies)
@@ -83,45 +92,15 @@ function parseRoleArn(arn) {
8392 return { accountId : match [ 1 ] , roleName : match [ 2 ] } ;
8493}
8594
86- /**
87- * Group missing entries by role, collecting all affected buckets per role.
88- *
89- * Input (results from check-replication-permissions.js):
90- * [
91- * { bucket: "bucket-old-1", ownerDisplayName: "testaccount", sourceRole: "arn:aws:iam::123:role/crr-role" },
92- * { bucket: "bucket-old-2", ownerDisplayName: "testaccount", sourceRole: "arn:aws:iam::123:role/crr-role" },
93- * ]
94- *
95- * Output:
96- * [
97- * { accountId: "123", accountName: "testaccount", roleName: "crr-role",
98- * roleArn: "arn:aws:iam::123:role/crr-role", buckets: ["bucket-old-1", "bucket-old-2"] }
99- * ]
100- */
101- function groupByRole ( results ) {
102- const roles = Object . groupBy ( results , entry => entry . sourceRole ) ;
103-
104- return Object . entries ( roles ) . map ( ( [ roleArn , entries ] ) => {
105- const { accountId, roleName } = parseRoleArn ( roleArn ) ;
106- return {
107- accountId,
108- accountName : entries [ 0 ] . ownerDisplayName ,
109- roleName,
110- roleArn,
111- buckets : entries . map ( e => e . bucket ) ,
112- } ;
113- } ) ;
114- }
115-
116- /** Build the IAM policy document */
117- function buildPolicyDocument ( buckets ) {
95+ /** Build the IAM policy document for a single bucket */
96+ function buildPolicyDocument ( bucket ) {
11897 return {
11998 Version : '2012-10-17' ,
12099 Statement : [ {
121100 Sid : STATEMENT_ID ,
122101 Effect : 'Allow' ,
123102 Action : 's3:ReplicateObject' ,
124- Resource : buckets . map ( b => `arn:aws:s3:::${ b } /*` ) ,
103+ Resource : `arn:aws:s3:::${ bucket } /*` ,
125104 } ] ,
126105 } ;
127106}
@@ -190,13 +169,11 @@ async function main() {
190169 process . exit ( 1 ) ;
191170 }
192171
193- // Group by role, sorted by account so roles in the same account are
194- // processed consecutively — reduces the chance of cached credentials
195- // expiring while other accounts are being processed.
196- const roles = groupByRole ( entries )
197- . sort ( ( a , b ) => ( a . accountId < b . accountId ? - 1 : 1 ) ) ;
172+ // Sort by sourceRole so entries in the same account are processed
173+ // consecutively, maximising credential cache hits.
174+ entries . sort ( ( a , b ) => ( a . sourceRole < b . sourceRole ? - 1 : 1 ) ) ;
198175
199- log ( `Processing ${ roles . length } role (s)` ) ;
176+ log ( `Processing ${ entries . length } bucket (s)` ) ;
200177 log ( '' ) ;
201178
202179 // Read admin credentials
@@ -223,7 +200,7 @@ async function main() {
223200 inputFile : config . inputFile ,
224201 dryRun : config . dryRun ,
225202 counts : {
226- totalRolesProcessed : roles . length ,
203+ totalBucketsProcessed : entries . length ,
227204 totalBucketsFixed : 0 ,
228205 policiesCreated : 0 ,
229206 policiesAttached : 0 ,
@@ -236,33 +213,34 @@ async function main() {
236213 errors : [ ] ,
237214 } ;
238215
239- // Cache IAM client per account to reuse connections across roles
216+ // Cache IAM client per account to reuse connections across buckets
240217 // and for cleanup (key deletion).
241218 // Map<accountId, { accountName, accessKeyId, iamClient }>
242219 const accountCache = new Map ( ) ;
243220
244- for ( let i = 0 ; i < roles . length ; i ++ ) {
245- const { accountId, accountName, roleName, roleArn, buckets } = roles [ i ] ;
246- const policyName = `${ POLICY_PREFIX } -${ roleName } ` ;
247- const policyDocument = buildPolicyDocument ( buckets ) ;
221+ for ( let i = 0 ; i < entries . length ; i ++ ) {
222+ const entry = entries [ i ] ;
223+ const { accountId, roleName } = parseRoleArn ( entry . sourceRole ) ;
224+ const { bucket, ownerDisplayName : accountName } = entry ;
225+ const policyName = `${ POLICY_PREFIX } -${ bucket } ` ;
226+ const policyDocument = buildPolicyDocument ( bucket ) ;
248227
249- log ( `[${ i + 1 } /${ roles . length } ] Role "${ roleName } " — account "${ accountName } " ( ${ buckets . length } bucket(s)) ` ) ;
228+ log ( `[${ i + 1 } /${ entries . length } ] Bucket "${ bucket } " — role "${ roleName } " — account " ${ accountName } " ` ) ;
250229
251230 const fix = {
252231 accountId,
253232 accountName,
254233 roleName,
255- roleArn,
234+ roleArn : entry . sourceRole ,
256235 policyName,
257- buckets ,
236+ bucket ,
258237 status : 'pending' ,
259238 } ;
260239
261240 if ( config . dryRun ) {
262241 fix . status = 'dry-run' ;
263242 log ( ` [DRY-RUN] Would create policy "${ policyName } "` ) ;
264243 log ( ` [DRY-RUN] Would attach to role "${ roleName } "` ) ;
265- log ( ` Buckets: ${ buckets . join ( ', ' ) } ` ) ;
266244 outcome . fixes . push ( fix ) ;
267245 continue ;
268246 }
@@ -284,9 +262,10 @@ async function main() {
284262
285263 const { iamClient } = accountCache . get ( accountId ) ;
286264
287- // Idempotent/safe to re-run: CreatePolicy reuses an existing policy
288- // with the same name, and AttachRolePolicy is a no-op if
289- // the policy is already attached to the role.
265+ // Idempotent/safe to re-run: each bucket has its own policy,
266+ // so EntityAlreadyExists means the exact same policy document
267+ // already exists — a true no-op. AttachRolePolicy is also
268+ // a no-op if the policy is already attached to the role.
290269 let policyArn ;
291270 try {
292271 const resp = await iamClient . send ( new CreatePolicyCommand ( {
@@ -300,7 +279,7 @@ async function main() {
300279 if ( err . name === 'EntityAlreadyExistsException'
301280 || err . Code === 'EntityAlreadyExists' ) {
302281 policyArn = `arn:aws:iam::${ accountId } :policy/${ policyName } ` ;
303- log ( ` Policy "${ policyName } " already exists, reusing ` ) ;
282+ log ( ` Policy "${ policyName } " already exists, skipping ` ) ;
304283 } else {
305284 throw err ;
306285 }
@@ -316,7 +295,7 @@ async function main() {
316295 log ( ` Attached policy to role "${ roleName } "` ) ;
317296
318297 fix . status = 'success' ;
319- outcome . metadata . counts . totalBucketsFixed += buckets . length ;
298+ outcome . metadata . counts . totalBucketsFixed ++ ;
320299 } catch ( err ) {
321300 fix . status = 'error' ;
322301 fix . error = err . message ;
@@ -325,6 +304,7 @@ async function main() {
325304 accountId,
326305 accountName,
327306 roleName,
307+ bucket,
328308 policyName,
329309 message : err . message ,
330310 } ) ;
@@ -356,7 +336,7 @@ async function main() {
356336
357337 // Print summary
358338 log ( '\n=== Summary ===' ) ;
359- log ( `Roles processed: ${ outcome . metadata . counts . totalRolesProcessed } ` ) ;
339+ log ( `Buckets processed: ${ outcome . metadata . counts . totalBucketsProcessed } ` ) ;
360340 log ( `Buckets fixed: ${ outcome . metadata . counts . totalBucketsFixed } ` ) ;
361341 log ( `Policies created: ${ outcome . metadata . counts . policiesCreated } ` ) ;
362342 log ( `Policies attached: ${ outcome . metadata . counts . policiesAttached } ` ) ;
0 commit comments