diff --git a/src/constructs/iam/policies/describe-ec2.test.ts b/src/constructs/iam/policies/describe-ec2.test.ts index 243db41934..9da911a8be 100644 --- a/src/constructs/iam/policies/describe-ec2.test.ts +++ b/src/constructs/iam/policies/describe-ec2.test.ts @@ -3,15 +3,15 @@ import { attachPolicyToTestRole, simpleGuStackForTesting } from "../../../../tes import { GuDescribeEC2Policy } from "./describe-ec2"; describe("DescribeEC2Policy", () => { - it("can accept a custom policy name", () => { + it("creates the correct policy", () => { const stack = simpleGuStackForTesting(); - const policy = new GuDescribeEC2Policy(stack, "DescribeEC2Policy", { policyName: "my-awesome-policy" }); + const policy = GuDescribeEC2Policy.getInstance(stack); attachPolicyToTestRole(stack, policy); expect(stack).toHaveResource("AWS::IAM::Policy", { - PolicyName: "my-awesome-policy", + PolicyName: "describe-ec2-policy", PolicyDocument: { Version: "2012-10-17", Statement: [ diff --git a/src/constructs/iam/policies/describe-ec2.ts b/src/constructs/iam/policies/describe-ec2.ts index 273c11d78a..320cf42b3d 100644 --- a/src/constructs/iam/policies/describe-ec2.ts +++ b/src/constructs/iam/policies/describe-ec2.ts @@ -1,12 +1,13 @@ -import type { PolicyProps } from "@aws-cdk/aws-iam"; import { Effect, PolicyStatement } from "@aws-cdk/aws-iam"; import type { GuStack } from "../../core"; -import type { GuPolicyProps } from "./base-policy"; import { GuPolicy } from "./base-policy"; export class GuDescribeEC2Policy extends GuPolicy { - private static getDefaultProps(): PolicyProps { - return { + private static instance: GuPolicy | undefined; + + // eslint-disable-next-line custom-rules/valid-constructors -- TODO be better + private constructor(scope: GuStack) { + super(scope, "DescribeEC2Policy", { policyName: "describe-ec2-policy", statements: [ new PolicyStatement({ @@ -20,10 +21,18 @@ export class GuDescribeEC2Policy extends GuPolicy { resources: ["*"], }), ], - }; + }); } - constructor(scope: GuStack, id: string = "DescribeEC2Policy", props?: GuPolicyProps) { - super(scope, id, { ...GuDescribeEC2Policy.getDefaultProps(), ...props }); + public static getInstance(stack: GuStack): GuDescribeEC2Policy { + // Resources can only live in the same App so return a new instance where necessary. + // See https://github.com/aws/aws-cdk/blob/0ea4b19afd639541e5f1d7c1783032ee480c307e/packages/%40aws-cdk/core/lib/private/refs.ts#L47-L50 + const isSameStack = this.instance?.node.root === stack.node.root; + + if (!this.instance || !isSameStack) { + this.instance = new GuDescribeEC2Policy(stack); + } + + return this.instance; } } diff --git a/src/constructs/iam/policies/ssm.test.ts b/src/constructs/iam/policies/ssm.test.ts index 4592a9a30e..ad1c856c9b 100644 --- a/src/constructs/iam/policies/ssm.test.ts +++ b/src/constructs/iam/policies/ssm.test.ts @@ -6,7 +6,7 @@ describe("The GuSSMRunCommandPolicy class", () => { it("sets default props", () => { const stack = simpleGuStackForTesting(); - const ssmPolicy = new GuSSMRunCommandPolicy(stack); + const ssmPolicy = GuSSMRunCommandPolicy.getInstance(stack); attachPolicyToTestRole(stack, ssmPolicy); @@ -39,43 +39,4 @@ describe("The GuSSMRunCommandPolicy class", () => { }, }); }); - - it("merges defaults and passed in props", () => { - const stack = simpleGuStackForTesting(); - - const ssmPolicy = new GuSSMRunCommandPolicy(stack, "SSMRunCommandPolicy", { - policyName: "test", - }); - - attachPolicyToTestRole(stack, ssmPolicy); - - expect(stack).toHaveResource("AWS::IAM::Policy", { - PolicyName: "test", - PolicyDocument: { - Version: "2012-10-17", - Statement: [ - { - Effect: "Allow", - Resource: "*", - Action: [ - "ec2messages:AcknowledgeMessage", - "ec2messages:DeleteMessage", - "ec2messages:FailMessage", - "ec2messages:GetEndpoint", - "ec2messages:GetMessages", - "ec2messages:SendReply", - "ssm:UpdateInstanceInformation", - "ssm:ListInstanceAssociations", - "ssm:DescribeInstanceProperties", - "ssm:DescribeDocumentParameters", - "ssmmessages:CreateControlChannel", - "ssmmessages:CreateDataChannel", - "ssmmessages:OpenControlChannel", - "ssmmessages:OpenDataChannel", - ], - }, - ], - }, - }); - }); }); diff --git a/src/constructs/iam/policies/ssm.ts b/src/constructs/iam/policies/ssm.ts index 3e0827979f..b171d5b86f 100644 --- a/src/constructs/iam/policies/ssm.ts +++ b/src/constructs/iam/policies/ssm.ts @@ -1,10 +1,12 @@ import type { GuStack } from "../../core"; -import type { GuNoStatementsPolicyProps } from "./base-policy"; import { GuAllowPolicy } from "./base-policy"; export class GuSSMRunCommandPolicy extends GuAllowPolicy { - constructor(scope: GuStack, id: string = "SSMRunCommandPolicy", props?: GuNoStatementsPolicyProps) { - super(scope, id, { + private static instance: GuSSMRunCommandPolicy | undefined; + + // eslint-disable-next-line custom-rules/valid-constructors -- WIP + private constructor(scope: GuStack) { + super(scope, "SSMRunCommandPolicy", { policyName: "ssm-run-command-policy", resources: ["*"], actions: [ @@ -23,7 +25,18 @@ export class GuSSMRunCommandPolicy extends GuAllowPolicy { "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel", ], - ...props, }); } + + public static getInstance(stack: GuStack): GuSSMRunCommandPolicy { + // Resources can only live in the same App so return a new `GuSSMRunCommandPolicy` where necessary. + // See https://github.com/aws/aws-cdk/blob/0ea4b19afd639541e5f1d7c1783032ee480c307e/packages/%40aws-cdk/core/lib/private/refs.ts#L47-L50 + const isSameStack = this.instance?.node.root === stack.node.root; + + if (!this.instance || !isSameStack) { + this.instance = new GuSSMRunCommandPolicy(stack); + } + + return this.instance; + } } diff --git a/src/constructs/iam/roles/__snapshots__/instance-role.test.ts.snap b/src/constructs/iam/roles/__snapshots__/instance-role.test.ts.snap index 34d8f115e5..1eda9d6a7d 100644 --- a/src/constructs/iam/roles/__snapshots__/instance-role.test.ts.snap +++ b/src/constructs/iam/roles/__snapshots__/instance-role.test.ts.snap @@ -230,6 +230,395 @@ Object { } `; +exports[`The GuInstanceRole construct should be possible to create multiple instance roles in a single stack 1`] = ` +Object { + "Parameters": Object { + "DistributionBucketName": Object { + "Default": "/account/services/artifact.bucket", + "Description": "SSM parameter containing the S3 bucket name holding distribution artifacts", + "Type": "AWS::SSM::Parameter::Value", + }, + "LoggingStreamName": Object { + "Default": "/account/services/logging.stream.name", + "Description": "SSM parameter containing the Name (not ARN) on the kinesis stream", + "Type": "AWS::SSM::Parameter::Value", + }, + "Stage": Object { + "AllowedValues": Array [ + "CODE", + "PROD", + ], + "Default": "CODE", + "Description": "Stage name", + "Type": "String", + }, + }, + "Resources": Object { + "DescribeEC2PolicyFF5F9295": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "autoscaling:DescribeAutoScalingInstances", + "autoscaling:DescribeAutoScalingGroups", + "ec2:DescribeTags", + "ec2:DescribeInstances", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "describe-ec2-policy", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-first-app", + }, + Object { + "Ref": "InstanceRoleMy-second-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "GetDistributablePolicyMyfirstapp9CD90B92": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:s3:::", + Object { + "Ref": "DistributionBucketName", + }, + "/test-stack/", + Object { + "Ref": "Stage", + }, + "/my-first-app/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "GetDistributablePolicyMyfirstapp9CD90B92", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-first-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "GetDistributablePolicyMysecondappA8D9FE69": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:s3:::", + Object { + "Ref": "DistributionBucketName", + }, + "/test-stack/", + Object { + "Ref": "Stage", + }, + "/my-second-app/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "GetDistributablePolicyMysecondappA8D9FE69", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-second-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "GuLogShippingPolicy981BFE5A": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "kinesis:Describe*", + "kinesis:Put*", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:kinesis:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":stream/", + Object { + "Ref": "LoggingStreamName", + }, + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "GuLogShippingPolicy981BFE5A", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-first-app", + }, + Object { + "Ref": "InstanceRoleMy-second-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "InstanceRoleMy-first-app": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": Object { + "Fn::Join": Array [ + "", + Array [ + "ec2.", + Object { + "Ref": "AWS::URLSuffix", + }, + ], + ], + }, + }, + }, + ], + "Version": "2012-10-17", + }, + "Path": "/", + "Tags": Array [ + Object { + "Key": "App", + "Value": "my-first-app", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": Object { + "Ref": "Stage", + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "InstanceRoleMy-second-app": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": Object { + "Fn::Join": Array [ + "", + Array [ + "ec2.", + Object { + "Ref": "AWS::URLSuffix", + }, + ], + ], + }, + }, + }, + ], + "Version": "2012-10-17", + }, + "Path": "/", + "Tags": Array [ + Object { + "Key": "App", + "Value": "my-second-app", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": Object { + "Ref": "Stage", + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "ParameterStoreReadMyfirstappBCF3BB3A": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/", + Object { + "Ref": "Stage", + }, + "/test-stack/my-first-app", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "parameter-store-read-policy", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-first-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "ParameterStoreReadMysecondapp7B80ABE2": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/", + Object { + "Ref": "Stage", + }, + "/test-stack/my-second-app", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "parameter-store-read-policy", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-second-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "SSMRunCommandPolicy244E1613": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "ec2messages:AcknowledgeMessage", + "ec2messages:DeleteMessage", + "ec2messages:FailMessage", + "ec2messages:GetEndpoint", + "ec2messages:GetMessages", + "ec2messages:SendReply", + "ssm:UpdateInstanceInformation", + "ssm:ListInstanceAssociations", + "ssm:DescribeInstanceProperties", + "ssm:DescribeDocumentParameters", + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ssm-run-command-policy", + "Roles": Array [ + Object { + "Ref": "InstanceRoleMy-first-app", + }, + Object { + "Ref": "InstanceRoleMy-second-app", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + }, +} +`; + exports[`The GuInstanceRole construct should create an additional logging policy if logging stream is specified 1`] = ` Object { "Parameters": Object { diff --git a/src/constructs/iam/roles/instance-role.test.ts b/src/constructs/iam/roles/instance-role.test.ts index a1f9a66723..db84da1bf0 100644 --- a/src/constructs/iam/roles/instance-role.test.ts +++ b/src/constructs/iam/roles/instance-role.test.ts @@ -36,4 +36,20 @@ describe("The GuInstanceRole construct", () => { expect(stack).toCountResources("AWS::IAM::Role", 1); expect(stack).toCountResources("AWS::IAM::Policy", 5); }); + + it("should be possible to create multiple instance roles in a single stack", () => { + const stack = simpleGuStackForTesting(); + + new GuInstanceRole(stack, { + app: "my-first-app", + }); + + new GuInstanceRole(stack, { + app: "my-second-app", + }); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(stack).toCountResources("AWS::IAM::Role", 2); + expect(stack).toCountResources("AWS::IAM::Policy", 7); // 3 shared policies + 2 policies per role (3 + (2*2)) + }); }); diff --git a/src/constructs/iam/roles/instance-role.ts b/src/constructs/iam/roles/instance-role.ts index 1eed3c8539..f0a5cc5e73 100644 --- a/src/constructs/iam/roles/instance-role.ts +++ b/src/constructs/iam/roles/instance-role.ts @@ -17,8 +17,6 @@ interface GuInstanceRoleProps extends AppIdentity { } export class GuInstanceRole extends GuRole { - private policies: GuPolicy[]; - // eslint-disable-next-line custom-rules/valid-constructors -- TODO be better constructor(scope: GuStack, props: GuInstanceRoleProps) { super(scope, AppIdentity.suffixText(props, "InstanceRole"), { @@ -27,16 +25,20 @@ export class GuInstanceRole extends GuRole { assumedBy: new ServicePrincipal("ec2.amazonaws.com"), }); - this.policies = [ - new GuSSMRunCommandPolicy(scope), + const sharedPolicies = [ + GuSSMRunCommandPolicy.getInstance(scope), + GuDescribeEC2Policy.getInstance(scope), + ...(props.withoutLogShipping ? [] : [GuLogShippingPolicy.getInstance(scope)]), + ]; + + const policies = [ + ...sharedPolicies, new GuGetDistributablePolicy(scope, props), - new GuDescribeEC2Policy(scope), new GuParameterStoreReadPolicy(scope, props), - ...(props.withoutLogShipping ? [] : [GuLogShippingPolicy.getInstance(scope)]), ...(props.additionalPolicies ? props.additionalPolicies : []), ]; - this.policies.forEach((p) => p.attachToRole(this)); + policies.forEach((p) => p.attachToRole(this)); AppIdentity.taggedConstruct(props, this); }