Least deployment privilege with CDK Bootstrap
Tags: aws, cdk
Published: 2022-09-26 13:00:34

Security is not convenient. That’s probably why the CDK, by default, uses `AdministratorAccess` Policy to deploy resources. But we can easily change it and increase the security of our AWS account, following the least privilege principle with a minimal additional burden.

## Dangers of default CDK Bootstrap

To start using the CDK, we must bootstrap our AWS account. Bootstrapping creates the resources required by the CDK on the account.

If we follow [the official docs](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) for getting started with CDK, the process is as simple as it can be:

```bash
$ npm install -g aws-cdk

$ cdk bootstrap aws://372507991746/eu-west-1 \

 ⏳  Bootstrapping environment aws://372507991746/eu-west-1...
Trusted accounts for deployment: (none)
Trusted accounts for lookup: (none)
Using default execution policy of 'arn:aws:iam::aws:policy/AdministratorAccess'.
Pass '--cloudformation-execution-policies' to customize.
CDKToolkit: creating CloudFormation changeset...
 ✅  Environment aws://372507991746/eu-west-1 bootstrapped.
```

The `cdk bootstrap` command creates a CloudFormation Stack named `CDKToolkit`.

This stack contains 5 IAM Roles:

- `CloudFormationExecutionRole`
- `DeploymentActionRole`
- `LookupRole`
- `FilePublishingRole`
- `ImagePublishingRole`

What are they used for?  
**Why leaving them as they are is against the least privilege principle?**  
And how can we fix this?

Read on.

## IAM Roles created by CDK Bootstrap

### CloudFormationExecutionRole

This is the Role that CloudFormation will assume to deploy our Stacks. CloudFormation will use this Role both when we deploy from our local machine with cdk deploy command and through [CDK Pipelines](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.pipelines-readme.html) for CI/CD.

The `CloudFormationExecutionRole` must have permissions to list, create, modify and delete all the resources we use in our Stacks. For example, if our Stack contains a Lambda function, CloudFormation must have permission to create it.

To allow creating any kind of resources with CDK, this Role has `arn:aws:iam::aws:policy/AdministratorAccess` Policy assigned by default. That’s right – **it gives full access to our account, allowing to do anything**. That’s very much against the least privilege principle.

#### Dangers of the `AdministratorAccess` Policy

Why is it bad? Don’t we want the CDK to be able to create any resources we need in our Stacks?

**We want the CDK to be able to deploy only the resources we use.** So, for example, if we build an app utilizing just a few serverless services, like Lambda, API Gateway, and DynamoDB, we don’t want the CDK to be able to spin up EC2 machines.

Suppose our computer or the code repository with automatic deployment through the CI pipeline gets compromised. In that case, the attacker can use the CDK to deploy a CloudFormation stack with a bunch of EC2 machines mining bitcoins.

Security, like onions and ogres, has layers. **Each layer should prevent the attacker from achieving their goal.** The fact we have a password on our computer and the code repository is private doesn’t justify leaving the next doors behind them wide open.

Thankfully, we can improve it. Looking again at the output of the cdk bootstrap command, we can notice this message:

```bash
Using default execution policy of 'arn:aws:iam::aws:policy/AdministratorAccess'.
Pass '--cloudformation-execution-policies' to customize.
```

Stay tuned; we will fix it in a moment. But, first, let’s make sure we know what other IAM Roles created by the CDK do.

### DeploymentActionRole

The CDK CLI and CDK Pipelines assume this Role to create and manage CloudFormation Stacks and the files in a CDK assets S3 Bucket.

It also allows passing the `CloudFormationExecutionRole` to CloudFormation. Then CloudFormation can use it to create, update and delete resources.

Moreover, the `DeploymentActionRole` allows accessing and managing objects in S3 Buckets on other accounts, which is needed for cross-account deployments.

### LookupRole

CDK CLI uses the `LookupRole` when it needs to get information about the already existing resources that we want to use in our CDK app. Those resources include Route53 Hosted Zones, VPCs, SSM Parameters, and a few others.

The bad part is that the `LookupRole` uses a `ReadOnlyAccess` IAM Policy, which gives it access to read everything, not only the resources the CDK can do a lookup for.

On the bright side, it’s just read-only access, and `kms:Decrypt` is explicitly excluded from it through the second Policy attached to the `LookupRole`, so it can’t be used to read encrypted data and secrets.

### FilePublishingRole and ImagePublishingRole

Those two Roles allow CDK uploading and managing:

- assets (like Lambda function sources) in the CDK assets bucket,
- container images in the CDK ECR repository.

Those assets and images are built from our application code, uploaded by the CDK, and then referenced in the CloudFormation Stacks.

## Limiting the CDK Execution Policy access

After reviewing the IAM Roles created by the CDK bootstrap process, we can see the most problematic is the `CloudFormationExecutionRole`. It gives CDK full access to our AWS account, while it should only allow deploying and managing the types of resources we use in our app.

Let’s fix this.

### Creating own CloudFormation Execution Policy

We start with creating our own IAM Policy. It should allow accessing only the AWS services that we use in our CDK application. But, on the other hand, it needs broad access to those selected services. So we will just give full access to them with an asterisk wildcard (`*`).

Additionally, we will limit access to only the region where we operate. In this example, it will be `eu-west-1`. Some services, like CloudFront, are global, so we list them separately with no region restriction.

And finally, permissions to the IAM actions. As a service managing access to other AWS components, IAM is critical to security. At the same time, it has over 200 actions. So we select only the ones required for our Stacks to work. We also exclude access to the Roles generated by the CDK and the Policy itself for additional protection.

`cdkCFExecutionPolicy.json`:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "apigateway:*",
        "cloudwatch:*",
        "lambda:*",
        "logs:*",
        "s3:*",
        "ssm:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "eu-west-1"
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudfront:*"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:*Role*",
        "iam:GetPolicy",
        "iam:CreatePolicy",
        "iam:DeletePolicy",
        "iam:*PolicyVersion*"
      ],
      "NotResource": [
        "arn:aws:iam::*:role/cdk-*",
        "arn:aws:iam::*:policy/cdkCFExecutionPolicy"
      ]
    }
  ]
}
```

This policy document should be committed to the project repository, as it will evolve with time.

Having the JSON file, we need to create the IAM Policy:

```bash
aws iam create-policy \
  --policy-name cdkCFExecutionPolicy \
  --policy-document file://cdkCFExecutionPolicy.json
```

### Bootstrapping CDK with custom Execution Policy

Now we need to bootstrap the CDK, providing the created IAM Policy to be used instead of the default `AdministratorAccess` one:

```bash
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
cdk bootstrap aws://$ACCOUNT_ID/eu-west-1 \
  --cloudformation-execution-policies "arn:aws:iam::$ACCOUNT_ID:policy/cdkCFExecutionPolicy"
```

And… that’s it. **That’s how easy it is to apply the least privilege principle to CDK deployments.**

If we have the CDK already bootstrapped on our account, simply rerunning `cdk bootstrap`, this time with our custom execution policy, will update it.

### Updating the Policy

With time, we add more services to our application. This requires us to extend the `cdkCFExecutionPolicy` with access to additional services.

To do this, firstly, we modify the definition in the `cdkCFExecutionPolicy.json`. Then we create a new Policy version and set it as a default one:

```bash
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
aws iam create-policy-version \
  --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/cdkCFExecutionPolicy \
  --policy-document file://cdkCFExecutionPolicy.json \
  --set-as-default
```

From now on, the CDK will be using an updated Policy.

There is a limit to 5 Policy versions, so we need to delete old versions to make updates. But it’s not difficult. We simply list existing versions:

```bash
aws iam list-policy-versions \
  --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/cdkCFExecutionPolicy
```

And then delete the selected old version:

```bash
aws iam delete-policy-version \
  --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/cdkCFExecutionPolicy \
  --version-id <VERSION>
```

## Multiple projects

Best practices recommend having only a single project per AWS account. But if we really need to deploy a second CDK project to the same account, here is how to bootstrap it with its own execution policy.

The first step is to create and deploy the new IAM Policy for the second project:

```bash
aws iam create-policy \
  --policy-name cdkCFExecutionPolicy2ndProject \
  --policy-document file://cdkCFExecutionPolicy.json
```

Then we bootstrap the CDK on the same account, creating a separate set of IAM Roles by adding two flags to the `cdk bootstrap` command:

```bash
cdk bootstrap aws://$ACCOUNT_ID/eu-west-1 \
  --cloudformation-execution-policies "arn:aws:iam::$ACCOUNT_ID:policy/cdkCFExecutionPolicy2ndProject" \
  --toolkit-stack-name CDKToolkitMySecondProject \
  --qualifier 2ndProject
```

The first one, `--toolkit-stack-name`, assures that a separate CDK stack with its own resources will be created. The default Stack name is `CDKToolkit`, so we provide a distinct one.

The second parameter, `--qualifier`, is a short string added to many resource names created by the CDK to avoid name collisions. It must be unique for every project.

And lastly, for the second project to actually use these newly bootstrapped CDK Roles, we need to add the same qualifier to the project’s `cdk.json` configuration:

```json
{
  "app": "...",
  "context": {
    "@aws-cdk/core:bootstrapQualifier": "2ndProject"
  }
}
```

## Summary

By default, CDK uses the `AdministratorAccess` IAM Policy to deploy CloudFormation Stacks. That’s far from the “least privilege” principle.

Thankfully, we can quickly improve it for better security. First, we create a custom IAM Policy with access to only the services we use in our application. Then we (re)bootstrap the CDK, providing our Policy ARN as an `--cloudformation-execution-policies` argument.

Over time, if we need to grant the CDK access to more services, we just update the IAM Policy.

### Afterthoughts on the convenience

They say the security is not convenient. In fact, I believe this is why the CDK uses `AdministratorAccess` Policy by default – this allows using CDK right away, with just one simple `cdk bootstrap` command.

It’s good the `cdk bootstrap` output warns about using the `AdministratorAccess`, but sadly, I suspect it’s ignored in most cases.

Luckily, creating a custom Policy and maintaining it is straightforward, so the problem can be fixed quickly.