From d4e1f72d142f9b29da82ab6feb1657dacc65f74b Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 11:24:51 +0900 Subject: [PATCH 01/12] Use CDK-idiomatic namespacing in xa-construct.ts --- lib/cross-account/xa-construct.ts | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/lib/cross-account/xa-construct.ts b/lib/cross-account/xa-construct.ts index c2e1333..8ab0e4b 100644 --- a/lib/cross-account/xa-construct.ts +++ b/lib/cross-account/xa-construct.ts @@ -1,14 +1,6 @@ import { Construct } from "constructs"; import { CfnOutput } from "aws-cdk-lib"; -import { - AccountPrincipal, - AccountRootPrincipal, - ArnPrincipal, - Effect, - PolicyDocument, - PolicyStatement, - Role, -} from "aws-cdk-lib/aws-iam"; +import * as iam from "aws-cdk-lib/aws-iam"; /** * Properties for CrossAccountConstruct. @@ -33,10 +25,10 @@ export interface CrossAccountConstructProps { */ export abstract class CrossAccountConstruct extends Construct { /** The IAM role used for cross-account policy management */ - private mgmtRole: Role; + private mgmtRole: iam.Role; /** Getter for the cross-account management role */ - public get role(): Role { + public get role(): iam.Role { return this.mgmtRole; } @@ -54,15 +46,15 @@ export abstract class CrossAccountConstruct extends Construct { policyTarget: string, policyActions: string[], ) { - this.mgmtRole = new Role(this, "XaMgmtRole", { - assumedBy: new AccountRootPrincipal(), // placeholder + this.mgmtRole = new iam.Role(this, "XaMgmtRole", { + assumedBy: new iam.AccountRootPrincipal(), // placeholder description: `IAM role to enable cross-account management of policy for ${resourceIdentifier}`, roleName: `${resourceIdentifier}-xa-mgmt`, inlinePolicies: { - UpdateResourcePolicy: new PolicyDocument({ + UpdateResourcePolicy: new iam.PolicyDocument({ statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, actions: policyActions, resources: [policyTarget], }), @@ -73,9 +65,9 @@ export abstract class CrossAccountConstruct extends Construct { const accessorRoleName = `${resourceIdentifier}-xa-mgmt-ex`; this.role.assumeRolePolicy?.addStatements( - new PolicyStatement({ - effect: Effect.ALLOW, - principals: this.xaAwsIds.map((id) => new AccountPrincipal(id)), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + principals: this.xaAwsIds.map((id) => new iam.AccountPrincipal(id)), actions: ["sts:AssumeRole"], conditions: { StringLike: { From 83a53b000874bcefafdb151e0f42ed18edca953a Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:10:28 +0900 Subject: [PATCH 02/12] Add existing s3 bucket wrapper resource --- lib/s3/bucket.ts | 60 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/lib/s3/bucket.ts b/lib/s3/bucket.ts index 580e4eb..962a2ff 100644 --- a/lib/s3/bucket.ts +++ b/lib/s3/bucket.ts @@ -1,15 +1,20 @@ import { Construct } from "constructs"; -import { CfnOutput } from "aws-cdk-lib"; -import { Bucket, BucketProps } from "aws-cdk-lib/aws-s3"; +import { CfnOutput, Token } from "aws-cdk-lib"; +import * as s3 from "aws-cdk-lib/aws-s3"; import { CrossAccountConstruct, CrossAccountConstructProps, } from "../cross-account"; export interface CrossAccountS3BucketProps - extends BucketProps, + extends s3.BucketProps, CrossAccountConstructProps {} +export interface CrossAccountS3BucketWrapperProps + extends CrossAccountConstructProps { + existing: s3.IBucket; +} + /** * CrossAccountS3Bucket creates an S3 bucket with a corresponding IAM role * that can be assumed by specific cross-account roles to update the bucket policy. @@ -22,21 +27,21 @@ export interface CrossAccountS3BucketProps */ export class CrossAccountS3Bucket extends CrossAccountConstruct { /** The managed S3 bucket */ - public readonly bucket: Bucket; + public readonly bucket: s3.Bucket; constructor(scope: Construct, id: string, props: CrossAccountS3BucketProps) { const { xaAwsIds, ...bucketProps } = props; super(scope, id, { xaAwsIds }); // The updater Lambda's execution role will have 11 characters appended to it - // so let's keep the bucket name to 52 characters or less - if ((bucketProps.bucketName?.length ?? 0) > 52) { + // so let's ensure the bucket name is 52 characters or less + if ((this.bucket.bucketName.length ?? 0) > 52) { throw new Error( - `Bucket name "${bucketProps.bucketName}" must be 52 characters or less.`, + `Bucket name "${this.bucket.bucketName}" must be 52 characters or less.`, ); } - this.bucket = new Bucket(this, "XaBucket", bucketProps); + this.bucket = new s3.Bucket(this, "XaBucket", bucketProps); this.createManagementRole(this.bucket.bucketName, this.bucket.bucketArn, [ "s3:GetBucketPolicy", @@ -50,3 +55,42 @@ export class CrossAccountS3Bucket extends CrossAccountConstruct { }); } } + +export class CrossAccountS3BucketWrapper extends CrossAccountConstruct { + /** The managed S3 bucket */ + public readonly wrappedBucket: s3.IBucket; + + constructor( + scope: Construct, + id: string, + props: CrossAccountS3BucketWrapperProps, + ) { + const { xaAwsIds, existing } = props; + super(scope, id, { xaAwsIds }); + + // The updater Lambda's execution role will have 11 characters appended to it + // so let's try to ensure the bucket name is 52 characters or less + if (Token.isUnresolved(existing.bucketName)) { + console.log( + `WARNING: Cannot length check unresolved bucketName for CrossAccountS3Bucket ${id}.`, + ); + } else if ((existing.bucketName.length ?? 0) > 52) { + throw new Error( + `Bucket name "${existing.bucketName}" must be 52 characters or less.`, + ); + } + + this.wrappedBucket = existing; + + this.createManagementRole( + this.wrappedBucket.bucketName, + this.wrappedBucket.bucketArn, + ["s3:GetBucketPolicy", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy"], + ); + + new CfnOutput(this, "XaBucketName", { + value: this.wrappedBucket.bucketName, + description: "Name of the cross-account managed S3 bucket", + }); + } +} From 2a35f9827695873e4c5c37c3c171e53678ec15be Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:12:44 +0900 Subject: [PATCH 03/12] JSDocs --- lib/s3/bucket.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/s3/bucket.ts b/lib/s3/bucket.ts index 962a2ff..b2cd353 100644 --- a/lib/s3/bucket.ts +++ b/lib/s3/bucket.ts @@ -17,7 +17,7 @@ export interface CrossAccountS3BucketWrapperProps /** * CrossAccountS3Bucket creates an S3 bucket with a corresponding IAM role - * that can be assumed by specific cross-account roles to update the bucket policy. + * that can be assumed by specific cross-account resources to update the bucket policy. * * @remarks * - `xaAwsIds` should be the list of AWS account IDs that are allowed to assume @@ -56,8 +56,19 @@ export class CrossAccountS3Bucket extends CrossAccountConstruct { } } +/** + * CrossAccountS3BucketWrapper takes an existing S3 bucket and creates a corresponding + * IAM role that can be assumed by specific cross-account resources to update its + * bucket policy. + * + * @remarks + * - `xaAwsIds` should be the list of AWS account IDs that are allowed to assume + * the management role. + * - The IAM role created is scoped to `s3:GetBucketPolicy` and `s3:PutBucketPolicy` + * on the bucket only. + */ export class CrossAccountS3BucketWrapper extends CrossAccountConstruct { - /** The managed S3 bucket */ + /** The wrapped S3 bucket */ public readonly wrappedBucket: s3.IBucket; constructor( From 17e76c944858c9df903a29ed628366d03c376fd7 Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:13:13 +0900 Subject: [PATCH 04/12] exports --- lib/s3/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/s3/index.ts b/lib/s3/index.ts index e604058..696391c 100644 --- a/lib/s3/index.ts +++ b/lib/s3/index.ts @@ -1,5 +1,8 @@ -export { CrossAccountS3Bucket } from "./bucket"; -export type { CrossAccountS3BucketProps } from "./bucket"; +export { CrossAccountS3Bucket, CrossAccountS3BucketWrapper } from "./bucket"; +export type { + CrossAccountS3BucketProps, + CrossAccountS3BucketWrapperProps, +} from "./bucket"; export { CrossAccountS3BucketManager } from "./manager"; export type { CrossAccountS3BucketManagerProps } from "./manager"; From 409b466c6ac6fffc5cb9155755f11ef57b36b1ca Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:17:28 +0900 Subject: [PATCH 05/12] Implement kms key wrapper --- lib/kms/index.ts | 7 +++++-- lib/kms/key.ts | 51 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/lib/kms/index.ts b/lib/kms/index.ts index 3223bba..56daab3 100644 --- a/lib/kms/index.ts +++ b/lib/kms/index.ts @@ -1,5 +1,8 @@ -export { CrossAccountKmsKey } from "./key"; -export type { CrossAccountKmsKeyProps } from "./key"; +export { CrossAccountKmsKey, CrossAccountKmsKeyWrapper } from "./key"; +export type { + CrossAccountKmsKeyProps, + CrossAccountKmsKeyWrapperProps, +} from "./key"; export { CrossAccountKmsKeyManager } from "./manager"; export type { CrossAccountKmsKeyManagerProps } from "./manager"; diff --git a/lib/kms/key.ts b/lib/kms/key.ts index e6e1f2b..d4057fc 100644 --- a/lib/kms/key.ts +++ b/lib/kms/key.ts @@ -1,18 +1,23 @@ import { Construct } from "constructs"; import { CfnOutput } from "aws-cdk-lib"; -import { Key, KeyProps } from "aws-cdk-lib/aws-kms"; +import * as kms from "aws-cdk-lib/aws-kms"; import { CrossAccountConstruct, CrossAccountConstructProps, } from "../cross-account"; export interface CrossAccountKmsKeyProps - extends KeyProps, + extends kms.KeyProps, CrossAccountConstructProps {} +export interface CrossAccountKmsKeyWrapperProps + extends CrossAccountConstructProps { + existing: kms.IKey; +} + /** * CrossAccountKmsKey creates a KMS key with a corresponding IAM role - * that can be assumed by specific cross-account roles to update the resource policy. + * that can be assumed by specific cross-account resources to update the resource policy. * * @remarks * - `xaAwsIds` should be the list of AWS account IDs that are allowed to assume @@ -22,13 +27,13 @@ export interface CrossAccountKmsKeyProps */ export class CrossAccountKmsKey extends CrossAccountConstruct { /** The managed KMS key */ - public readonly key: Key; + public readonly key: kms.Key; constructor(scope: Construct, id: string, props: CrossAccountKmsKeyProps) { const { xaAwsIds, ...keyProps } = props; super(scope, id, { xaAwsIds }); - this.key = new Key(this, "XaKey", keyProps); + this.key = new kms.Key(this, "XaKey", keyProps); this.createManagementRole(this.key.keyId, this.key.keyArn, [ "kms:GetKeyPolicy", @@ -41,3 +46,39 @@ export class CrossAccountKmsKey extends CrossAccountConstruct { }); } } + +/** + * CrossAccountKmsKeyWrapper takes an existing KMS key and creates a corresponding IAM + * role that can be assumed by specific cross-account resources to update its resource policy. + * + * @remarks + * - `xaAwsIds` should be the list of AWS account IDs that are allowed to assume + * the management role. + * - The IAM role created is scoped to `kms:GetKeyPolicy` and `kms:PutKeyPolicy` + * on this specific key only. + */ +export class CrossAccountKmsKeyWrapper extends CrossAccountConstruct { + /** The managed KMS key */ + public readonly wrappedKey: kms.IKey; + + constructor( + scope: Construct, + id: string, + props: CrossAccountKmsKeyWrapperProps, + ) { + const { xaAwsIds, existing } = props; + super(scope, id, { xaAwsIds }); + + this.wrappedKey = existing; + + this.createManagementRole(this.wrappedKey.keyId, this.wrappedKey.keyArn, [ + "kms:GetKeyPolicy", + "kms:PutKeyPolicy", + ]); + + new CfnOutput(this, "XaKeyArn", { + value: this.wrappedKey.keyArn, + description: "ARN of the cross-account managed KMS key", + }); + } +} From 80a2171862e8f6baf10c9a965aa7b65c90dcc9b0 Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:24:08 +0900 Subject: [PATCH 06/12] Implement KMS wrapper tests --- test/cross-account-kms-key.test.ts | 90 +++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/test/cross-account-kms-key.test.ts b/test/cross-account-kms-key.test.ts index 6eba9c4..fc7fcfc 100644 --- a/test/cross-account-kms-key.test.ts +++ b/test/cross-account-kms-key.test.ts @@ -1,7 +1,8 @@ import { App, Stack } from "aws-cdk-lib/core"; import { Template } from "aws-cdk-lib/assertions"; +import * as kms from "aws-cdk-lib/aws-kms"; -import { CrossAccountKmsKey } from "../lib/kms"; +import { CrossAccountKmsKey, CrossAccountKmsKeyWrapper } from "../lib/kms"; import { getAssumeRolePolicyMatcher, getRoleNameMatcher } from "./matchers"; test("Single Accessor XAKMS", () => { @@ -85,3 +86,90 @@ test("Several XAKMSes", () => { AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId2, xaAwsIds2), }); }); + +test("Single Accessor XAKMS Wrapper", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const keyId = "test-xakms-single-accessor"; + const alias = "test-xakms-s"; + const xaAwsIds = ["000000000000"]; + // WHEN + const existing = new kms.Key(stack, keyId, { alias }); + new CrossAccountKmsKeyWrapper(stack, `${keyId}Wrapper`, { + existing, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResource("AWS::KMS::Key", {}); + template.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${alias}`, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId, xaAwsIds), + }); +}); + +test("Multi Accessor XAKMS Wrapper", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const keyId = "test-xakms-multi-accessor"; + const alias = "test-xakms-m"; + const xaAwsIds = ["000000000000", "111111111111", "222222222222"]; + // WHEN + const existing = new kms.Key(stack, keyId, { alias }); + new CrossAccountKmsKeyWrapper(stack, `${keyId}Wrapper`, { + existing, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResource("AWS::KMS::Key", {}); + template.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${alias}`, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId, xaAwsIds), + }); +}); + +test("Several XAKMS Wrappers", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const keyId1 = "test-xakms-1"; + const alias1 = "test-xakms-alias-1"; + const xaAwsIds1 = ["000000000000"]; + const keyId2 = "test-xakms-2"; + const xaAwsIds2 = ["111111111111"]; + // WHEN + const existing1 = new kms.Key(stack, keyId1, { alias: alias1 }); + new CrossAccountKmsKeyWrapper(stack, `${keyId1}Wrapper`, { + existing: existing1, + xaAwsIds: xaAwsIds1, + }); + const existing2 = new kms.Key(stack, keyId2); + new CrossAccountKmsKeyWrapper(stack, `${keyId2}Wrapper`, { + existing: existing2, + xaAwsIds: xaAwsIds2, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResource("AWS::KMS::Key", {}); + template.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${alias1}`, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId1), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId1, xaAwsIds1), + }); + + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId2), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId2, xaAwsIds2), + }); +}); From dad9a23b5962b545104d34022cb7ce2d4a3e4a33 Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:24:23 +0900 Subject: [PATCH 07/12] Fix obvious bucketName length check bug --- lib/s3/bucket.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/s3/bucket.ts b/lib/s3/bucket.ts index b2cd353..5073063 100644 --- a/lib/s3/bucket.ts +++ b/lib/s3/bucket.ts @@ -35,9 +35,9 @@ export class CrossAccountS3Bucket extends CrossAccountConstruct { // The updater Lambda's execution role will have 11 characters appended to it // so let's ensure the bucket name is 52 characters or less - if ((this.bucket.bucketName.length ?? 0) > 52) { + if ((bucketProps.bucketName?.length ?? 0) > 52) { throw new Error( - `Bucket name "${this.bucket.bucketName}" must be 52 characters or less.`, + `Bucket name "${bucketProps.bucketName}" must be 52 characters or less.`, ); } @@ -85,7 +85,7 @@ export class CrossAccountS3BucketWrapper extends CrossAccountConstruct { console.log( `WARNING: Cannot length check unresolved bucketName for CrossAccountS3Bucket ${id}.`, ); - } else if ((existing.bucketName.length ?? 0) > 52) { + } else if (existing.bucketName.length > 52) { throw new Error( `Bucket name "${existing.bucketName}" must be 52 characters or less.`, ); From 48344ebb7ec1a382a8e50c47f3c9f3edea413e1b Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:34:58 +0900 Subject: [PATCH 08/12] Implement s3 wrapper tests (as expected cannot length check unresolved tokens) --- test/cross-account-s3-bucket.test.ts | 117 ++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/test/cross-account-s3-bucket.test.ts b/test/cross-account-s3-bucket.test.ts index 8a7aace..6d515d5 100644 --- a/test/cross-account-s3-bucket.test.ts +++ b/test/cross-account-s3-bucket.test.ts @@ -1,7 +1,8 @@ import { App, Stack } from "aws-cdk-lib/core"; import { Template } from "aws-cdk-lib/assertions"; +import * as s3 from "aws-cdk-lib/aws-s3"; -import { CrossAccountS3Bucket } from "../lib/s3"; +import { CrossAccountS3Bucket, CrossAccountS3BucketWrapper } from "../lib/s3"; import { getAssumeRolePolicyMatcher, getRoleNameMatcher } from "./matchers"; test("Single Accessor XAS3", () => { @@ -122,3 +123,117 @@ test("Several XAS3s", () => { AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketId2, xaAwsIds2), }); }); + +test("Single Accessor XAS3 Wrapper", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-single-accessor"; + const bucketName = "test-xas3-s"; + const xaAwsIds = ["000000000000"]; + // WHEN + const existing = new s3.Bucket(stack, bucketId, { bucketName }); + new CrossAccountS3BucketWrapper(stack, `${bucketId}Wrapper`, { + existing, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: "test-xas3-s", + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketName, xaAwsIds), + }); +}); + +test("Multi Accessor XAS3 Wrapper", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-multi-accessor"; + const bucketName = "test-xas3-m"; + const xaAwsIds = ["000000000000", "111111111111", "222222222222"]; + // WHEN + const existing = new s3.Bucket(stack, bucketId, { bucketName }); + new CrossAccountS3BucketWrapper(stack, `${bucketId}Wrapper`, { + existing, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: "test-xas3-m", + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketName, xaAwsIds), + }); +}); + +test("Longish XAS3 Wrapper", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-loooooooooooong"; + const looongBucketName = + "test-xas3-loooooooooooooooooooooooooooooooooooongish"; + // WHEN + const existing = new s3.Bucket(stack, bucketId, { + bucketName: looongBucketName, + }); + new CrossAccountS3BucketWrapper(stack, `${bucketId}Wrapper`, { + existing, + xaAwsIds: ["000000000000"], + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: looongBucketName, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId), + }); +}); + +test("Several XAS3 Wrappers", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId1 = "test-xas3-1"; + const xaAwsIds1 = ["000000000000"]; + const bucketId2 = "test-xas3-2"; + const xaAwsIds2 = ["111111111111"]; + // WHEN + const existing1 = new s3.Bucket(stack, bucketId1, { + bucketName: bucketId1, + }); + new CrossAccountS3BucketWrapper(stack, `${bucketId1}Wrapper`, { + existing: existing1, + xaAwsIds: xaAwsIds1, + }); + const existing2 = new s3.Bucket(stack, bucketId2, { + bucketName: bucketId2, + }); + new CrossAccountS3BucketWrapper(stack, `${bucketId2}Wrapper`, { + existing: existing2, + xaAwsIds: xaAwsIds2, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: bucketId1, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId1), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketId1, xaAwsIds1), + }); + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: bucketId2, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId2), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketId2, xaAwsIds2), + }); +}); From 78b3ad9e5b044257387387b2f35a7d3e25f51198 Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:37:44 +0900 Subject: [PATCH 09/12] Fix pre-push hook (again) --- .husky/pre-push | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.husky/pre-push b/.husky/pre-push index 1a6c6ed..0868bd5 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -8,7 +8,10 @@ else exit 0 fi +npm run test if git diff origin/main package.json | grep '"version":'; then + echo "Looks like version has been updated in package.json" +else echo "ERROR: You must update package.json version before pushing to main!" exit 1 fi From 2b2fb8563ab13567ea39a64fd60ba815e658b87c Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:38:04 +0900 Subject: [PATCH 10/12] Update version in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 24fe4ec..189eee6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@doceight/xa-cdk", - "version": "1.0.0", + "version": "1.1.0", "description": "CDK constructs for facilitating cross-account resource access", "repository": { "type": "git", From a0ea87bbb28a0ece186d7e3e4e04264525ada17a Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:48:53 +0900 Subject: [PATCH 11/12] Add auto-refresh option --- lib/cross-account/xa-manager.ts | 8 ++++++-- lib/kms/manager.ts | 7 +++++++ lib/s3/manager.ts | 14 +++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/cross-account/xa-manager.ts b/lib/cross-account/xa-manager.ts index a77f1d4..34d89b6 100644 --- a/lib/cross-account/xa-manager.ts +++ b/lib/cross-account/xa-manager.ts @@ -27,6 +27,7 @@ export interface CrossAccountManagerProps { managerTimeout?: number; callerTimeout?: number; subclassDir: string; + autoRefresh?: boolean; } const hashAccessors = (accessors: { [accessor: string]: string[] }) => { @@ -74,6 +75,7 @@ export abstract class CrossAccountManager extends Construct { managerTimeout = 30, // default timeout of 3 seconds is awful short/fragile callerTimeout = 30, // default timeout of 3 seconds is awful short/fragile subclassDir, + autoRefresh = true, } = props; const xaMgmtRoleArn = `arn:aws:iam::${xaAwsId}:role/${resourceIdentifier}-xa-mgmt`; @@ -126,12 +128,14 @@ export abstract class CrossAccountManager extends Construct { manager, targetIdentifier: resourceIdentifier, }); - const cloudfrontAccessorsHash = hashAccessors(cloudfrontAccessors); + const physId = autoRefresh + ? Date.now().toString() + : hashAccessors(cloudfrontAccessors); // Util factory to get an AwsSdkCall for the AwsCustomResource const callFor = (operation: string) => { return { - physicalResourceId: PhysicalResourceId.of(cloudfrontAccessorsHash), + physicalResourceId: PhysicalResourceId.of(physId), service: "Lambda", action: "Invoke", parameters: { diff --git a/lib/kms/manager.ts b/lib/kms/manager.ts index 7496d16..89f1f0b 100644 --- a/lib/kms/manager.ts +++ b/lib/kms/manager.ts @@ -14,6 +14,7 @@ export interface CrossAccountKmsKeyManagerProps { xaAwsId: string; managerTimeout?: number; callerTimeout?: number; + autoRefresh?: boolean; } /** @@ -29,6 +30,10 @@ export interface CrossAccountKmsKeyManagerProps { * Lambda Function's timeout (defaults to 30). * - `callerTimeout` is optional and specifies the number of seconds for the * AwsCustomResource's timeout (defaults to 30). + * - `autoRefresh` is optional and specifies whether or not to set the cross-account + * resource policy on every deployment. + * **pros**: if the policy has been overwritten this will auto-repair it + * **cons**: generates noise in CDK diff output * - To use this construct, first register the resources that need to access a given * KMS key using one of the following static registry functions: * - allowCloudfront(keyId, cloudfrontId) @@ -44,6 +49,7 @@ export class CrossAccountKmsKeyManager extends CrossAccountManager { xaAwsId, managerTimeout, callerTimeout, // default timeout of 3 seconds is awful short/fragile + autoRefresh, } = props; super(scope, id, { resourceIdentifier: xaKeyId, @@ -51,6 +57,7 @@ export class CrossAccountKmsKeyManager extends CrossAccountManager { managerTimeout, callerTimeout, subclassDir: path.join(__dirname, "../../lambda-code/kms"), + autoRefresh, }); } diff --git a/lib/s3/manager.ts b/lib/s3/manager.ts index 267153f..8356523 100644 --- a/lib/s3/manager.ts +++ b/lib/s3/manager.ts @@ -14,6 +14,7 @@ export interface CrossAccountS3BucketManagerProps { xaAwsId: string; managerTimeout?: number; callerTimeout?: number; + autoRefresh?: boolean; } /** @@ -29,6 +30,10 @@ export interface CrossAccountS3BucketManagerProps { * Lambda Function's timeout (defaults to 30). * - `callerTimeout` is optional and specifies the number of seconds for the * AwsCustomResource's timeout (defaults to 30). + * - `autoRefresh` is optional and specifies whether or not to set the cross-account + * resource policy on every deployment. + * **pros**: if the policy has been overwritten this will auto-repair it + * **cons**: generates noise in CDK diff output * - To use this construct, first register the resources that need to access a given * S3 Bucket using one of the following static registry functions: * - allowCloudfront(bucketName, cloudfrontId) @@ -39,13 +44,20 @@ export class CrossAccountS3BucketManager extends CrossAccountManager { id: string, props: CrossAccountS3BucketManagerProps, ) { - const { xaBucketName, xaAwsId, managerTimeout, callerTimeout } = props; + const { + xaBucketName, + xaAwsId, + managerTimeout, + callerTimeout, + autoRefresh, + } = props; super(scope, id, { resourceIdentifier: xaBucketName, xaAwsId, managerTimeout, callerTimeout, subclassDir: path.join(__dirname, "../../lambda-code/s3"), + autoRefresh, }); } From 234ecb5c65ded74846a91230ba2f0b0a405736ba Mon Sep 17 00:00:00 2001 From: DocEight Date: Sat, 15 Nov 2025 12:56:40 +0900 Subject: [PATCH 12/12] Always hash accessors (need to sort object for testing and I don't want to externalize this right now) --- lib/cross-account/xa-manager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/cross-account/xa-manager.ts b/lib/cross-account/xa-manager.ts index 34d89b6..ea6f648 100644 --- a/lib/cross-account/xa-manager.ts +++ b/lib/cross-account/xa-manager.ts @@ -128,14 +128,14 @@ export abstract class CrossAccountManager extends Construct { manager, targetIdentifier: resourceIdentifier, }); - const physId = autoRefresh - ? Date.now().toString() - : hashAccessors(cloudfrontAccessors); + const cloudfrontAccessorsHash = hashAccessors(cloudfrontAccessors); // Util factory to get an AwsSdkCall for the AwsCustomResource const callFor = (operation: string) => { return { - physicalResourceId: PhysicalResourceId.of(physId), + physicalResourceId: PhysicalResourceId.of( + autoRefresh ? Date.now().toString() : cloudfrontAccessorsHash, + ), service: "Lambda", action: "Invoke", parameters: {