Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 11 additions & 19 deletions lib/cross-account/xa-construct.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
}

Expand All @@ -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],
}),
Expand All @@ -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: {
Expand Down
6 changes: 5 additions & 1 deletion lib/cross-account/xa-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface CrossAccountManagerProps {
managerTimeout?: number;
callerTimeout?: number;
subclassDir: string;
autoRefresh?: boolean;
}

const hashAccessors = (accessors: { [accessor: string]: string[] }) => {
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -131,7 +133,9 @@ export abstract class CrossAccountManager extends Construct {
// Util factory to get an AwsSdkCall for the AwsCustomResource
const callFor = (operation: string) => {
return {
physicalResourceId: PhysicalResourceId.of(cloudfrontAccessorsHash),
physicalResourceId: PhysicalResourceId.of(
autoRefresh ? Date.now().toString() : cloudfrontAccessorsHash,
),
service: "Lambda",
action: "Invoke",
parameters: {
Expand Down
7 changes: 5 additions & 2 deletions lib/kms/index.ts
Original file line number Diff line number Diff line change
@@ -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";
51 changes: 46 additions & 5 deletions lib/kms/key.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand All @@ -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",
});
}
}
7 changes: 7 additions & 0 deletions lib/kms/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CrossAccountKmsKeyManagerProps {
xaAwsId: string;
managerTimeout?: number;
callerTimeout?: number;
autoRefresh?: boolean;
}

/**
Expand All @@ -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)
Expand All @@ -44,13 +49,15 @@ export class CrossAccountKmsKeyManager extends CrossAccountManager {
xaAwsId,
managerTimeout,
callerTimeout, // default timeout of 3 seconds is awful short/fragile
autoRefresh,
} = props;
super(scope, id, {
resourceIdentifier: xaKeyId,
xaAwsId,
managerTimeout,
callerTimeout,
subclassDir: path.join(__dirname, "../../lambda-code/kms"),
autoRefresh,
});
}

Expand Down
69 changes: 62 additions & 7 deletions lib/s3/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
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.
* 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
Expand All @@ -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
// so let's ensure the bucket name is 52 characters or less
if ((bucketProps.bucketName?.length ?? 0) > 52) {
throw new Error(
`Bucket name "${bucketProps.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",
Expand All @@ -50,3 +55,53 @@ 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 wrapped 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 > 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",
});
}
}
7 changes: 5 additions & 2 deletions lib/s3/index.ts
Original file line number Diff line number Diff line change
@@ -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";
14 changes: 13 additions & 1 deletion lib/s3/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CrossAccountS3BucketManagerProps {
xaAwsId: string;
managerTimeout?: number;
callerTimeout?: number;
autoRefresh?: boolean;
}

/**
Expand All @@ -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)
Expand All @@ -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,
});
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading