AWS Security Blog

How to implement the principle of least privilege with CloudFormation StackSets

March 24, 2021: We’ve corrected errors in the policy statements in steps 2 and 3 of the section “To create the IAM policy document.”


AWS CloudFormation is a service that lets you create a collection of related Amazon Web Services and third-party resources and provision them in an orderly and predictable fashion. A typical access control pattern is to delegate permissions for users to interact with CloudFormation and remove or limit their permissions to provision resources directly. You can grant the AWS CloudFormation service permission to create resources by creating a role that the user passes to CloudFormation when a stack or stack set is created. This can be used to ensure that only pre-authorized services and resources are provisioned in your AWS account. In this post, I show you how to conform to the principle of least privilege while still allowing users to use CloudFormation to create the resources they need.

Permission boundaries

If a user has permission to create a stack or stack set using a template of their creation, what stops them from using CloudFormation to create an AWS Identity and Access Management (IAM) identity that has elevated permissions that they can then use? Or, what stops the user from creating resources of a certain size or within AWS services that they’re not otherwise permitted to use?

A basic approach would be to deny the user the ability to create IAM identities, but there are many legitimate reasons why a user would need to create IAM identities. For example, creating an AWS Lambda function requires the function to have an IAM role to run under. It’s therefore common to create a role at the same time you create a function.

Instead of denying these actions, let’s look at how you can limit the scope of what can be created via CloudFormation by enforcing the application of permissions boundaries. Permissions boundaries are an IAM feature that set the maximum permissions that an identity-based policy can grant to an IAM identity. For example, consider this managed policy—called MyLambdaBoundaryPolicy—which allows the s3:GetObject action on any resource:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "*"
        }
    ]
}

And consider this policy—called AdminPolicy—which allows all actions on all resources:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "*",
            "Resource": "*"
        }
    ]
}

If you apply AdminPolicy as an identity-based policy on an IAM role and the MyLambdaBoundaryPolicy policy as the permissions boundary policy for the role, the effective permissions that the role has are the intersection of the two policies. In this example, the permission is limited to s3:GetObject.

Figure 1: The intersection of identity and permissions boundary policies

Figure 1: The intersection of identity and permissions boundary policies

As illustrated in Figure 1, a permissions boundary policy will scope the effective permissions for the role to something less than what the identity policy allows. This is the mechanism used in this post to limit the effective permissions of IAM identities created by CloudFormation on behalf of a user.

Roles and policies used by CloudFormation

CloudFormation can initiate stack and stack set deployments by assuming an IAM role that the user passes to the service. You must ensure that this role has the necessary permissions to create, update, and delete the resources that are part of the stack or stack set. The role should also adhere to the principal of least privilege by not having more permissions than are needed to deploy the stack or stack set.

This post focuses on the stack set use case as it is slightly more involved due to its use of two roles compared to the use of a single role for a stack. The two roles a stack set uses are:

  • Administration role – This role is passed directly to CloudFormation by the user when creating the stack set. CloudFormation uses this role to assume the execution role within the AWS accounts that are in-scope of the stack set.
  • Execution role – This is a role within each of the AWS accounts that are in scope of the stack set. CloudFormation will assume this role in each account and use it to provision resources. There must be a trust policy on this role that allows the administration role from the AWS account containing the stack set to assume it.

Figure 2 shows the relationship between these roles.

Figure 2: Relationship between CloudFormation StackSet roles

Figure 2: Relationship between CloudFormation StackSet roles

The permissions policy on the administration role must allow the role to assume the execution role in any AWS accounts that are in scope for the stack set:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "sts:AssumeRole"
            ],
            "Resource": [
                "arn:aws:iam::*:role/<CLOUDFORMATION-EXECUTION-ROLE-NAME>"
            ],
            "Effect": "Allow"
        }
    ]
}

Note: As described in the CloudFormation documentation, the administration role permissions policy can limit which AWS accounts CloudFormation can operate in by specifying the account ID as part of the Amazon Resource Name (ARN) of the role and listing each role individually. This example uses a wildcard account ID (*) to allow CloudFormation to assume the execution role in any account where the execution role trusts the administration account.

The permissions policy for the execution role is more complex as it must account for every action that CloudFormation must call as it creates, updates, and deletes resources. The next section covers how to build that policy.

Define the least privilege permissions for CloudFormation

In keeping with the practice of granting least privilege, the permissions policy for the CloudFormation execution role should be as detailed as possible while avoiding overly generous permissions. For example, instead of allowing lambda:*—which allows all AWS Lambda actions—use specific actions such as lambda:CreateFunction and lambda:DeleteFunction.

The question now becomes, what are the specific actions that CloudFormation needs permissions for?

This is best gleaned by exploring the actions that CloudFormation calls when launching, updating, and deleting the stack in a non-production or development environment. In a non-production setting, the user can launch their template as a CloudFormation stack using their own, privileged permissions without affecting the production environment. A list of the resulting actions taken can be generated by examining the event history captured by AWS CloudTrail. The user can use Amazon Athena to query CloudTrail logs and build a concise list of actions.

Note: Be sure to query the CloudTrail logs only after launching and deleting the stack in order to capture actions from both operations. Refer to the AWS CloudTrail documentation for information about how to enable and use CloudTrail.

To define the required permissions

As the user who is launching a template in a non-production setting, follow these steps:

  1. Follow the instructions for querying CloudTrail logs using Athena to create the required data catalog table and partitions.
  2. Follow the instructions for creating a stack to launch your CloudFormation template. Wait for it to reach the CREATE_COMPLETE state before continuing.
  3. Follow the instructions for deleting a stack to delete the CloudFormation stack you just launched. Wait for it to reach the DELETE_COMPLETE state before continuing.
  4. CloudTrail delivers log files to its target Amazon Simple Storage Service (Amazon S3) bucket approximately every 5 minutes. As a result, the query in Athena doesn’t show results in real time. Wait a few minutes before going to step 5.
  5. Run the following Athena query to yield a list of actions and the associated service principals:
    SELECT DISTINCT eventsource, eventname
    FROM cloudtrail_logs
    WHERE 
        userIdentity.arn LIKE '%/<USER-IDENTIFIER>' AND
        sourceIPAddress = 'cloudformation.amazonaws.com' AND
        errorCode is NULL
    ORDER BY eventsource, eventname;
    

Note: If you are following these steps in a Region other than us-east-1, be aware that some services, such as IAM, create CloudTrail logs in the us-east-1 Region regardless of which Region the event actually occurred in. Ensure that you create a partition in your data catalog for us-east-1 and that if you’re querying by Region, you include us-east-1 in the query.

The format of the userIdentity.arn value will depend on how you’ve authenticated to AWS. If you logged in as an IAM user, <USER-IDENTIFIER> is your IAM user name. If you’ve logged in via federation, it will be your federated ID such as you@example.net. You can explore the fields and possible values found in the userIdentity element in the CloudTrail documentation. This query can also be further refined to include only results from certain AWS Regions or within a specific time window. Refer to this section of the CloudTrail documentation for information on the fields contained in log records.

Assemble the permissions policy actions for CloudFormation

The results of the Athena query will resemble those shown in Figure 3.

Figure 3: Example Athena query results

Figure 3: Example Athena query results

The eventsource column shows the AWS service principal that is reporting that an event occurred and the eventname column shows the name of the event. You can use these two pieces of data to form an action that can be used in an IAM policy.

To assemble the permissions policy actions

  1. Shorten the values in the eventsource column to just the service name. For example, lambda.amazonaws.com becomes lambda.
  2. Use the values in the eventname column as-is in most cases—for example, with CreateRole. In some cases, you must modify the name to match a valid IAM action. For example, CreateFunction20150331 should be trimmed to just CreateFunction. Based on the preceding query results, the actions become iam:CreateRole, lambda:CreateFunction, and so on.
  3. Review the resulting actions using the IAM visual policy editor in the AWS Management Console by looking for the transformed eventname in the visual editor and selecting the name as shown in Figure 4. If you cannot find the transformed name in the list, proceed to Step 5.

    Figure 4: Finding an eventname in the IAM visual policy editor

    Figure 4: Finding an eventname in the IAM visual policy editor

  4. Select the JSON tab and validate that the Action matches the action that you assembled in Step 1. An example for the lambda:CreationFunction action is shown in Figure 5.

    Figure 5: Viewing the action from the JSON tab

    Figure 5: Viewing the action from the JSON tab

  5. Use the AWS documentation of the services you will be using to validate the proper action for a given eventname. For example, the Amazon S3 API documentation says that the GetBucketEncryption action—which correlates to the eventname in the Athena query results—requires the s3:GetEncryptionConfiguration IAM action. This can be further verified by going back to the IAM visual policy editor and confirming there is no GetBucketEncryption action but there is a GetEncryptionConfiguration action.

Create the IAM policy document for the CloudFormation execution role

Now that the actions are assembled, you can combine them with your knowledge of the resources that your CloudFormation template describes to build an IAM policy document.

To create the IAM policy document

  1. Build an IAM policy document using the assembled permissions policy actions along with your knowledge of the resources in your CloudFormation template. For example, the following policy is for a CloudFormation template that describes a Lambda function named MyLambdaFunction and an IAM role called MyLambdaRole. The policy is built from the preceding Athena query results—Assemble the permissions policy actions for CloudFormation.
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": [
                    "iam:AttachRolePolicy",
                    "iam:CreateRole",
                    "iam:DeleteRole",
                    "iam:DetachRolePolicy",
                    "iam:GetRole"
                ],
                "Resource": [
                    "arn:aws:iam::<AWS-ACCOUNT-ID>:role/MyLambdaRole"
                ],
                "Effect": "Allow"
            },
            {
                "Action": [
                    "lambda:CreateFunction",
                    "lambda:GetFunction"
                    "lambda:DeleteFunction"
                ],
                "Resource": [
                    "arn:aws:lambda:*:<AWS-ACCOUNT-ID>:function:MyLambdaFunction"            
    			],
                "Effect": "Allow"
            }
    [...]
    
  2. Inspect the CloudFormation template for any resources that take an IAM role as a property. For example, a Lambda function takes an execution role as one of its properties. If the template has such a property, then the CloudFormation execution role needs permission to iam:PassRole on the role that’s being given as a property in the template. PassRole isn’t an API call, it’s a permission; it’s important to be aware when this is required because it won’t be included in the Athena query results.
  3. Enforce the assignment of a permissions boundary policy whenever CloudFormation creates an IAM identity. For example, break out the iam:CreateRole action into its own statement and then add an IAM condition to that statement:
    [...]
           {
                "Action": [
                    "iam:CreateRole"
                ],
                "Resource": [
                    "arn:aws:iam::<AWS-ACCOUNT-ID>:role/MyLambdaRole"
                ],
                "Effect": "Allow",
                 "Condition": {            
                    "StringEquals": {
                        "iam:PermissionsBoundary": "arn:aws:iam::<AWS-ACCOUNT-ID>:policy/MyLambdaBoundaryPolicy"
                    }
                }
           },
    [...]
    

This statement says that in order to create the role named MyLambdaRole, the call to iam:CreateRole must include a permissions boundary policy that has an ARN of arn:aws:iam::<AWS-ACCOUNT-ID>:policy/MyLambdaBoundaryPolicy. Attempts to create the role with a different or null permissions boundary policy will be denied.

Least privilege permissions for the CloudFormation user

As the cloud administrator, you need to define the permissions for the CloudFormation user. Since all the initial work of creating resources is being done under the CloudFormation execution role, the user needs few permissions in order to launch a stack set. For example, the permissions policy that you create for them could be as simple as:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:*"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:HeadBucket",
                "s3:CreateBucket"
            ],
            "Resource": "arn:aws:s3:::cf-templates-*"
        },
        {
            "Effect": "Allow",
            "Action": "iam:PassRole",
            "Resource": "arn:aws:iam::<AWS-ACCOUNT-ID>:role/<CLOUDFORMATION-ADMIN-ROLE-NAME>"
        },
        {
            "Effect": "Allow",
            "Action": "sns:ListTopics",
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": "iam:ListRoles",
            "Resource": "*"
        }
    ]
}

This policy gives the user just enough permissions to:

  • Launch a CloudFormation stack or stack set.
  • PUT and GET objects to and from the S3 bucket used to store CloudFormation templates that are uploaded via the CloudFormation console. The policy also allows creation of the S3 bucket if it doesn’t exist.
  • Pass the CloudFormation administration role to CloudFormation.
  • List Amazon Simple Notification Service (Amazon SNS) topics and IAM roles in order to populate those respective fields in the CloudFormation console.

Run the workflow

When you put the CloudFormation templates, IAM roles, permissions boundary policy, and least privilege policies together in the right sequence, here’s what it looks like.

    1. The CloudFormation user creates two CloudFormation templates for the workload:
      1. A permissions template that contains the CloudFormation administration role, execution role, permissions policies, and the permissions boundary policies for any IAM identities that the second template defines. The user gives this template to you, the cloud administrator, to launch.
      2. A resources template that contains all of the resources related to the workload. This template must apply the boundary policies specified in the first template to any IAM identities that it creates. The user launches this template themselves.
    2. As the cloud administrator, you review the policies in the permissions template to ensure compliance with the principle of least privilege and with the policies of your organization. You must ensure that there is a condition key in the CloudFormation execution role policy that requires the presence of the permissions boundary policy when creating IAM identities.The following is an example of a permissions template that the user could create for a Lambda-based workload:
      AWSTemplateFormatVersion: "2010-09-09"
      
      Parameters:
      
        CfnAdminAccountId:
          Type: String
          Description: >
            The 12-digit AWS account ID where the user will deploy
            the resources template.
          AllowedPattern: ^[0-9]{12}$
          ConstraintDescription: must be a 12-digit number
      
        CfnAdminRoleName:
          Type: String
          Description: >
            The name of the IAM Role that will be created for CloudFormation to use as
            the StackSets administration role in the "CfnAdminAccountId" account.
          Default: CloudFormationAdminRole
      
        CfnExecRoleName:
          Type: String
          Description: >
            The name of the IAM Role that will be created for CloudFormation to use
            as the StackSets execution role.
          Default: CloudFormationExecRole
      
      
      Conditions:
      
        IsAdminAccount: !Equals [!Ref CfnAdminAccountId, !Ref 'AWS::AccountId']
      
      
      Resources:
      
        # This policy will be used as the boundary policy for the Lambda execution role
        # that the "resources" template creates. It scopes permissions down to just
        # relevant CloudWatch Logs actions and PUTs/GETs of objects in a specific
        # Amazon S3 bucket. The cloud administrator must audit this policy carefully.
        LambdaBoundaryPolicy:
          Type: AWS::IAM::ManagedPolicy
          Properties:
            ManagedPolicyName: MyLambdaBoundaryPolicy
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                -
                  Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                  Resource: '*'
                -
                  Effect: Allow
                  Action:
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                  Resource: !Sub "arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:/aws/lambda/MyLambdaFunction"
                -
                  Effect: Allow
                  Action:
                    - s3:GetObject
                    - s3:PutObject
                  Resource: !Sub "arn:${AWS::Partition}:s3:::MY-BUCKET-NAME/*"
      
        # The initial role that is passed to CloudFormation when creating the StackSet.
        # Only gets created in the AWS account where the resources template will be deployed.
        CfnAdminRole:
          Type: AWS::IAM::Role
          Condition: IsAdminAccount
          Properties:
            AssumeRolePolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Principal:
                    Service:
                      - cloudformation.amazonaws.com
                  Action:
                    - sts:AssumeRole
            Path: /
            RoleName: !Ref CfnAdminRoleName
      
        # Managed policy that is attached to the CloudFormation admin role. Allows
        # CloudFormation to assume the execution role across accounts.
        # Only gets created in the AWS account where StackSets are being managed.
        CfnAdminPolicy:
          Type: AWS::IAM::ManagedPolicy
          Condition: IsAdminAccount
          Properties:
            ManagedPolicyName: CloudFormationAdminPolicy
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action: sts:AssumeRole
                  # List individual accounts instead of "*" to limit scope of where
                  # this role can deploy StackSets.
                  Resource: !Sub "arn:${AWS::Partition}:iam::*:role/${CfnExecRole}"
            Roles:
              - !Ref CfnAdminRole
      
        # The role that CloudFormation assumes in the member accounts in order to
        # create, update, and delete resources.
        CfnExecRole:
          Type: AWS::IAM::Role
          Properties:
            AssumeRolePolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Principal:
                    AWS:
                    - !Sub "arn:${AWS::Partition}:iam::${CfnAdminAccountId}:root"
                  Action:
                    - sts:AssumeRole
            Path: /
            ManagedPolicyArns:
              - !Ref CfnExecPolicy
            RoleName: !Ref CfnExecRoleName
      
        # The least-privilege policy that was built by examining the Athena query
        # results. Critically, the policy allows "iam:CreateRole" as long as 1/ the
        # name of the role is "MyLambdaRole" and 2/ a permissions boundary policy is
        # specified and the ARN of that policy is the ARN of "LambdaBoundaryPolicy"
        # from earlier in this template. The policy also enforces the name of the
        # StackSet to match the pattern "MyLambda-*". The cloud administrator must
        # audit this policy to ensure it has a condition for the permissions
        # boundary policy.
        CfnExecPolicy:
          Type: AWS::IAM::ManagedPolicy
          Properties:
            ManagedPolicyName: CloudFormationExecPolicy
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                -
                  Effect: Allow
                  Action:
                    - cloudformation:*
                  Resource:
                    - !Sub "arn:${AWS::Partition}:cloudformation:*:${AWS::AccountId}:stack/StackSet-MyLambda-*"
                -
                  Effect: Allow
                  Action:
                    - iam:CreateRole
                  Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/MyLambdaRole"
                  Condition:
                    StringEquals:
                      # Permissions boundary condition is present. Good!
                      iam:PermissionsBoundary:
                        - !Ref LambdaBoundaryPolicy
                -
                  Effect: Allow
                  Action:
                    - iam:AttachRolePolicy
                    - iam:DeleteRole
                    - iam:DetachRolePolicy
                    - iam:GetRole
                    - iam:PassRole
                    - iam:TagRole
                    - iam:UpdateRole
                    - iam:UnTagRole
                  Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/MyLambdaRole"
                -
                  Effect: Allow
                  Action:
                    - lambda:CreateFunction
                    - lambda:GetFunction
                    - lambda:DeleteFunction
                  Resource: !Sub "arn:${AWS::Partition}:lambda:*:${AWS::AccountId}:function:MyLambdaFunction"
                -
                  # Needed by CloudFormation StackSets.
                  Effect: Allow
                  Action: sns:Publish
                  Resource: '*'
      
    3. As the cloud administrator, launch the permissions template as a CloudFormation stack in a single AWS account or across multiple accounts using CloudFormation StackSets to support also deploying the resources template across multiple accounts. When launching as a stack set, the template should be launched in the AWS account that manages your AWS Organizations service. The AWS account ID specified in the CfnAdminAccountId parameter must be included in the stack set scope.The CfnAdminAccountId parameter must specify the AWS account ID where the user will launch the resources template. According to best practice when working with AWS Organizations, this AWS account should be a member account and not the organization management account.
    4. As the cloud administrator, apply a permissions policy to the user’s IAM identity in the CfnAdminAccountId account similar to the preceding example—Least privilege permissions for the CloudFormation user.
    5. After you launch the permissions template, the user can launch the resources template. They must do this in the same AWS account as was specified in the CfnAdminAccountId parameter of the permissions template because that’s where the CloudFormation administration role has been created.

The following is an example resources template that matches the example permissions template shown in step 2:

AWSTemplateFormatVersion: 2010-09-09

Resources:

  # Execution role for my Lambda function. The name of the role
  # "MyLambdaRole" matches the name that the permissions template allows.
  # The permissions boundary property is also filled in with the ARN of the IAM
  # policy that the permissions template created.
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSLambdaExecute
      PermissionsBoundary: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/MyLambdaBoundaryPolicy"
      RoleName: MyLambdaRole
     
  # My actual Lambda function. The function name matches the name specified in the
  # permissions template.
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: MyLambdaFunction
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Runtime: python3.7
      Code:
        ZipFile: |
          def handler():
              print("Hello world!")

To deploy this template, the user will follow these steps:

  1. Navigate to CloudFormation in the console, expand the menu in the left-hand pane, and choose StackSets.
  2. On the StackSets page, select Create StackSet. Choose Upload a template file, choose the file for the resources template, and choose Next.
  3. Enter a StackSet name. Note that the name has relevance because of how the policy of the CloudFormation execution role has been written. The policy grants the role permissions to manage CloudFormation stacks with the name StackSet-MyLambda-*. This requires that you enter MyLambda as the name of the stack set. Choose Next.
  4. Use the IAM admin role ARN drop-down to choose CloudFormationAdminRole. In the IAM execution role name box, enter CloudFormationExecRole as shown in Figure 6.

    Figure 6: Specifying the CloudFormation StackSet roles

    Figure 6: Specifying the CloudFormation StackSet roles

  5. Choose Next.
  6. Select the AWS account numbers and Regions to deploy to and choose Next.
  7. Review the stack set configuration, select the check box to agree that CloudFormation will create IAM resources, and choose Submit.

The Lambda resources are created in the AWS accounts that are specified in the stack set. The role that the Lambda function runs under has enough permissions for the function to do its work, but no more. Even if the resources template assigns a more permissive permissions policy to the role, the permissions boundary policy ensures that the effective permissions are scoped to only what is required.

Summary

By applying guardrails to user permissions, you can empower users to build solutions while at the same time following the principle of least privilege and other organizational policies. As shown in this post, this is achieved through a combination of:

  • IAM permissions boundary policies.
  • A process for building detailed, least-privilege permissions policies.
  • A two-tier deployment system where a cloud administrator deploys the guardrails and a user deploys resources for a workload that aligns with those guardrails.

To learn more about the principle of least privilege in AWS, watch Separation of duties, least privilege, delegation, and CI/CD (SDD329), which was presented at AWS re:Inforce 2019.

If you have feedback about this post, submit comments in the Comments section below. If you have questions about this post, start a new thread on the AWS CloudFormation forum or contact AWS Support.

Want more AWS Security how-to content, news, and feature announcements? Follow us on Twitter.

Author

Joel Knight

Joel Knight is a Senior Consultant, Infrastructure Architecture, with AWS and is based in Calgary, Canada. When not wrangling infrastructure-as-code templates, Joel likes to spend time with his family and dabble in home automation.