How to Automate Restricting Access to a VPC by Using AWS IAM and AWS CloudFormation

Back in September, I wrote about How to Help Lock Down a User’s Amazon EC2 Capabilities to a Single VPC. In that blog post, I highlighted what I have found to be an effective approach to the virtual private cloud (VPC) lockdown scenario. Since that time, I have worked on making the related information easier to implement in your environment. As a result, I have developed an AWS CloudFormation template that automates the creation of the resources necessary to lock down AWS Identity and Access Management (IAM) entities (users, groups, and roles) to a VPC. In this blog post, I explain this CloudFormation template in detail and describe its individual sections in order to help you better understand what happens when you create a CloudFormation stack from the template.

This CloudFormation template creates a stack (related resources managed as a single unit). This stack generates an IAM role and instance profile in your account. Use the instance profile—a container for the IAM role that you use to pass role information—when launching your instances. The template also creates a managed policy with the role name, account ID, and region populated for you within the policy document, and attaches the policy to the IAM users, groups, or roles that you specify. Because you can establish a VPC in a single region only and the managed policy that is created is specific to the region and VPC, you must create a CloudFormation stack for each region to which you want to allow access.

Explaining the CloudFormation template

Parameters section

The first section of the template (see the following code block) includes required parameters that are used to define the user input for the CloudFormation template. In this template, you must define the VPC to which your users will have access as well as the IAM roles, users, or groups to which you want to attach the managed policy. The IAMUsers, IAMRoles, and IAMGroups parameters must use the CommaDelimitedList type, which allows you to pass a single value or list of values in a manner that the template can refer to later. For VPCId, use the AWS-specific parameter, AWS::EC2::VPC::Id. This parameter allows you to input a VPCId during the creation of the CloudFormation stack and outputs the VPCId as a string that you can refer to in this template.

"Parameters":{
            "VPCId":{
                  "Description" : "Select VPC to which to grant access",
                  "Type" : "AWS::EC2::VPC::Id"
                 
            },
            "IAMUsers":{
                  "Description" : "List the IAM users to which you want to apply the VPCLockDown policy, separated by a comma",
                  "Default": "",
                  "Type" : "CommaDelimitedList"
            },
            "IAMRoles":{
                  "Description" : "List the IAM roles to which you want to apply the VPCLockDown policy, separated by a comma",
                  "Default": "",
                  "Type" : "CommaDelimitedList"
            },
            "IAMGroups":{
                  "Description" : "List the IAM groups to which you want to apply the VPCLockDown policy, separated by a comma",
                  "Default": "",
                  "Type" : "CommaDelimitedList"
            }
      }

Conditions section

The next section of the CloudFormation template is the Conditions section (see the following code block), which includes statements that define when a resource is created or property is defined. This section is needed so that the template doesnot fail if one of the parameters from the previous code block—IAMUsers, IAMGroups, or IAMRoles—is left blank. This allows you to specify the user, group, or role to which you want to attach the policy, and still allow for the field to be left blank. Each condition's logic is the same, based on the value that is being read from the parameter that you are specifying when running the template.

The condition checks the value of the referenced parameter to see if the first comma-delimited field has a value. If it does, the condition passes the full value to the rest of the template. If the condition finds nothing in the first value of the comma-delimited output, it evaluates to False and allow the template to continue with the lack of value for the parameter. These parameters and conditions are used to determine to whom the managed policy is applied. Because these values are not required to create a managed policy, use the condition to enable support for an empty or False value.  

"Conditions" : {
            "IAMUserNames" : {"Fn::Not": [{"Fn::Equals" : [{"Fn::Select": [0, {"Ref" : "IAMUsers" }]}, ""]}]},
            "IAMRoleNames" : {"Fn::Not": [{"Fn::Equals" : [{"Fn::Select": [0, {"Ref" : "IAMRoles" }]}, ""]}]},
            "IAMGroupNames": {"Fn::Not": [{"Fn::Equals" : [{"Fn::Select": [0, {"Ref" : "IAMGroups"}]}, ""]}]}
    }

Resources section

The next section of the CloudFormation template is the Resources section (see the following code block), which defines all of the resources that are created. First, you must define the IAM role and instance profile that you are using with these instances. This is the same set of resources that is created when you create an EC2 service role in the console. Because you are creating the IAM role through CloudFormation, though, the IAM instance profile is not automatically generated for you. Later in this post I will show you how to create this instance profile and link it to your role so that the role will be usable by the EC2 instances that you launch.

Next, you create the IAM managed policy that references the IAM role and instance profile, and attaches to your IAM user, groups, or roles that you defined in the user input section when creating the CloudFormation stack. CloudFormation works to resolve any dependencies first. Because the managed policy that is created depends on the IAM role and instance profile to be created first, it creates the managed policy last.

Looking at each resource more in depth, you define the IAM role first, and define the trust policy that allows a unique IAM principal to assume the role from within the account. For this template, you will use the ec2.amazonaws.com service principal as the role that is applied to EC2 resources when they are created. Each supported CloudFormation resource has its own Type whose properties you need to define as well. Some properties are required, but others are optional. For example, the AssumeRolePolicyDocument property is required to create the IAM role. IAM roles do not require an attached user policy to function, and because this role is only being attached to this instance as a placeholder, I will not be attaching a user policy to it.

If you find that the instances that you launch in this manner require IAM permissions, you can simply add a policy to the role with which you launched the instances. Keep in mind that if you do this, it will give the same level of permissions to all of the instances that were launched with this role. For more information about properties for the AWS::IAM::Role resource type, see AWS::IAM::Role.

"VPCLockDownRole": {
            "Type": "AWS::IAM::Role",
            "Properties": {
                "AssumeRolePolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Effect": "Allow",
                            "Principal": {
                                "Service": [
                                    "ec2.amazonaws.com"
                                ]
                            },
                            "Action": [
                                "sts:AssumeRole"
                            ]
                        }
                    ]
                }
            }
       }

Next, you must create the IAM instance profile as shown in the following code block. Use a reference to the output of the VPCLockDownRole to properly map the instance profile to the role. As with the IAM role, some properties are required and some are optional. The required property for AWS::IAM::InstanceProfile is the Roles property, for which I do a straight Ref to the output.

If you were not using the Ref option, you would place the role’s resource name as a string in the value of Roles. For more information about the properties of the AWS::IAM::InstanceProfile resource, see AWS::IAM::InstanceProfile.

"VpcLockDownInstanceProfile":{
                  "Type": "AWS::IAM::InstanceProfile",
                        "Properties": {
                              "Path": "/",
                              "Roles": [{ "Ref" : "VPCLockDownRole" }]
                        }
                  }

The Ref in the preceding code block looks at the AWS::IAM::Role resource’s output, and places it as a string to define the role to which AWS:IAM:InstanceProfile is attached. Because the AWS::IAM::Role resource does not have a property for RoleName, CloudFormation automatically assigns this name during creation. This Ref allows the instance profile to be dynamically created, depending on the output of the role creation earlier in the template.

Last, you define the VPCLockDownPolicy resource, as shown in the following code block. I go into detail in my previous post about what exactly this policy does, so in today’s post, I will just highlight how this is used in the template. Create an AWS::IAM::ManagedPolicy resource, which allows you to define the policy as well as the IAM users, roles, or groups to which you want to attach this policy. These variables were defined in the Parameters section of the template and passed into the Conditions section for evaluation. The Fn::If condition evaluates whether there is data with which to populate the Users, Roles, or Groups fields. If there is a value for the condition, the template references the parameter that was defined. If the value of the condition is returned as False, it passes the standard parameter of AWS::NoValue for the given field.

In this resource, you also leverage the ability of CloudFormation to join strings together and to reference AWS-defined parameters, such as AWS::Region and AWS::AccountId, as well as user-defined parameters, such as VPCId. This means that if you run this template in the us-east-1 Region, that region is placed in the ARN string automatically. This is why you must create a stack in each region that you want to control in this manner. Also, when you run the template in a region, you will be prompted for VPCs only in that region.

"VPCLockDownPolicy" :{
                  "Type" : "AWS::IAM::ManagedPolicy",
                  "Properties" : {
                        "Description" : "Policy for locking down to a VPC",
                        "Users" : {
                              "Fn::If" : [
                                    "IAMUserNames",
                                    { "Ref" : "IAMUsers" },
                                    { "Ref" : "AWS::NoValue" }
                                    ]},
                        "Roles" : {
                              "Fn::If" : [
                                    "IAMRoleNames",
                                    { "Ref" : "IAMRoles" },
                                    { "Ref" : "AWS::NoValue" }
                                    ]},
                        "Groups" : {
                              "Fn::If" : [
                                    "IAMGroupNames",
                                    { "Ref" : "IAMGroups" },
                                    { "Ref" : "AWS::NoValue" }
                                    ]},
                        "PolicyDocument" : {
                            "Version": "2012-10-17",
                            "Statement": [
                                {
                                    "Sid": "NonResourceBasedReadOnlyPermissions",
                                    "Action": [
                                        "ec2:Describe*",
                                        "ec2:CreateKeyPair",
                                        "ec2:CreateSecurityGroup",
                                        "iam:GetInstanceProfiles",
                                        "iam:ListInstanceProfiles"
                                    ],
                                    "Effect": "Allow",
                                    "Resource": "*"
                                },
                                {
                                    "Sid": "IAMPassroleToInstance",
                                    "Action": [
                                        "iam:PassRole"
                                    ],
                                    "Effect": "Allow",
                                    "Resource": {"Fn::Join":["", [ "arn:aws:iam::",{ "Ref" : "AWS::AccountId" },":role/", { "Ref" : "VPCLockDownRole" }]]}
                                },
                                {
                                    "Sid": "AllowInstanceActions",
                                    "Effect": "Allow",
                                    "Action": [
                                        "ec2:RebootInstances",
                                        "ec2:StopInstances",
                                        "ec2:TerminateInstances",
                                        "ec2:StartInstances",
                                        "ec2:AttachVolume",
                                        "ec2:DetachVolume"
                                    ],
                                    "Resource": {"Fn::Join":["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":instance/*"]]},
                                    "Condition": {
                                        "StringEquals": {
                                            "ec2:InstanceProfile": {"Fn::Join" : ["",[ "arn:aws:iam::",{ "Ref" : "AWS::AccountId" },":instance-profile/", { "Ref" : "VpcLockDownInstanceProfile" }]]}
                                        }
                                    }
                                },
                                {
                                    "Sid": "EC2RunInstances",
                                    "Effect": "Allow",
                                    "Action": "ec2:RunInstances",
                                    "Resource": {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":instance/*"]]},
                                    "Condition": {
                                        "StringEquals": {
                                            "ec2:InstanceProfile": {"Fn::Join" : ["",[ "arn:aws:iam::",{ "Ref" : "AWS::AccountId" },":instance-profile/", { "Ref" : "VpcLockDownInstanceProfile" }]]}
                                        }
                                    }
                                },
                                {
                                    "Sid": "EC2RunInstancesSubnet",
                                    "Effect": "Allow",
                                    "Action": "ec2:RunInstances",
                                    "Resource": {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":subnet/*"]]},
                                    "Condition": {
                                        "StringEquals": {
                                            "ec2:vpc": {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },"vpc/",{ "Ref" : "VPCId" },"" ]]}
                                        }
                                    }
                                },
                                {
                                    "Sid": "RemainingRunInstancePermissions",
                                    "Effect": "Allow",
                                    "Action": "ec2:RunInstances",
                                    "Resource": [
                                        {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":volume/*"]]},
                                        {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },"::image/*"]]},
                                        {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },"::snapshot/*"]]},
                                        {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":network-interface/*"]]},
                                        {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":key-pair/*"]]},
                                        {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":security-group/*"]]}
                                    ]
                                },
                                {
                                    "Sid": "EC2VpcNonresourceSpecificActions",
                                    "Effect": "Allow",
                                    "Action": [
                                        "ec2:DeleteNetworkAcl",
                                        "ec2:DeleteNetworkAclEntry",
                                        "ec2:DeleteRoute",
                                        "ec2:DeleteRouteTable",
                                        "ec2:AuthorizeSecurityGroupEgress",
                                        "ec2:AuthorizeSecurityGroupIngress",
                                        "ec2:RevokeSecurityGroupEgress",
                                        "ec2:RevokeSecurityGroupIngress",
                                        "ec2:DeleteSecurityGroup"
                                    ],
                                    "Resource": "*",
                                    "Condition": {
                                        "StringEquals": {
                                            "ec2:vpc": {"Fn::Join" : ["",[ "arn:aws:ec2:",{ "Ref" : "AWS::Region" },":",{ "Ref" : "AWS::AccountId" },":vpc/", { "Ref" : "VPCId" },""]]}
                                        }
                                    }
                                }
                            ]
                                    }
                  }
            }

After you have created a CloudFormation stack from this template in the desired region, an IAM policy is created and applied to the IAM entities that you specified. This policy requires users to launch instances in the VPC that you specified and requires that they also create the EC2 instance with the IAM instance profile that you created with this template. This approach means you don’t have to know where to apply the policy, and as a result, this approach should streamline deployment within your account.

If you have comments about this post, add them to the “Comments” section below. If you have questions about or issues implementing this solution, please open a new thread on the IAM forum.

- Chris

Comments