James Mugliston

James Mugliston

Testing Your Infrastructure with AWS CDK

If you're reading this, then I probably don't need to convince you that IaaC (Infrastructure as Code) is the best way to deploy reliable, repeatable, and auditable cloud-based infrastructure! By using a framework like AWS CDK (Cloud Development Kit), you can take the concepts of cloud-based infrastructure and combine them with all the benefits we get from regular application code such as version control, modular and reusable packages, as well as integration with deployment pipelines. Having all those advantages is great, but as a software developer, I also want to make sure my code is well-tested, and that I can maintain a tight feedback loop when adding new features to the code. This is why I wanted to focus this blog on something I feel often gets overlooked when deploying infrastructure - testing!

Because CDK can be written in several different programming languages, we can make use of its assertion module and popular testing frameworks to ensure that our IaaC is as testable as any other application code we might write. The testing docs state that CDK provides two types of tests:

  • Fine-grained assertions - Test specific aspects of synthesised constructs e.g. whether a resource has a specific property.

  • Snapshot tests - Compares synthesized CloudFormation templates against a stored baseline. These tests are particularly useful when refactoring code, to ensure the underlying CloudFormation template doesn't change.

In CDK these tests can be written in any supported language, but for this blog, I've written the examples in TypeScript.

Fine-grained assertions

Let's take a look at fine-grained assertions, and see how we can write some useful tests for our infrastructure!

Example 1 - Simple assertions

Below is a really simple example of how we can write an assertion test:

const app = new cdk.App()
const stack = new ExampleCdkTests.ExampleCdkTestsStack(app, 'ExampleStack')
const template = Template.fromStack(stack)

test("Should contain a bucket named 'example-bucket'", () => {
  template.findResources('AWS::S3::Bucket', {
    Name: 'example-bucket',
  })
})

This example shows how we might check that there is a bucket named "example-bucket" in the stack that we are synthesising.

That's all well and good, and we can continue to add more granular assertions like making sure the correct encryption attributes are set, etc. but what if we want to make our tests a bit smarter? For instance, what if we wanted to check for specific properties on all the S3 buckets in the stack? Well, yes, we can do that too!

Example 2 - Check all S3 buckets are private

Often I want to ensure that my infrastructure stacks don't allow public access to S3 buckets e.g. if it's an internal application.

We can enforce this using a test like the following:

const convertToPascalCase = (inputObject: Object) =>
  Object.entries(inputObject).reduce(
    (acc: { [key: string]: string }, [k, v]) => {
      acc[k[0].toUpperCase() + k.substring(1)] = v
      return acc
    },
    {},
  )

test('S3 buckets should always block public access', () => {
  const bucketConfigCapture = new Capture()

  // Find all S3 bucket resources
  template.findResources('AWS::S3::Bucket', {
    Properties: bucketConfigCapture,
  })

  if (bucketConfigCapture._captured.length === 0) {
    return
  }

  // Paginate through the bucket configs
  do {
    expect(bucketConfigCapture.asObject()).toHaveProperty(
      'PublicAccessBlockConfiguration',
      // Note: We have to convert properties to Pascal Case to match CloudFormation
      convertToPascalCase(s3.BlockPublicAccess.BLOCK_ALL),
    )
  } while (bucketConfigCapture.next())
})

Note that we're using Capture() from the matcher API to capture values from matching entries, which can then be paginated by using the next() API.

This test will fail if any S3 buckets in the stack don't have all the block public access settings enabled.

// This bucket will pass the test
new s3.Bucket(this, 'ExampleBucket', {
  bucketName: 'example-bucket',
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
})

// This bucket will fail the test because it has no configuration for blocking public access
new s3.Bucket(this, 'ExamplePublicBucket', {
  bucketName: 'example-public-bucket',
})

Example 3 - Check IAM role policies aren't too permissive

This test is a good example of checking that we are adhering to the principle of least privilege and making sure that our IAM role policies do not allow access to all resources using the wildcard syntax e.g. "*". We can enforce this rule for inline role policies with the following test:

test('IAM roles should not contain policies that allow actions on all resources', () => {
  const policyResourcesCapture = new Capture()

  template.hasResourceProperties(
    'AWS::IAM::Role',
    Match.objectLike({
      Policies: policyResourcesCapture,
    }),
  )

  if (policyResourcesCapture._captured.length === 0) {
    return
  }

  do {
    for (const { PolicyDocument } of policyResourcesCapture.asArray()) {
      for (const { Resource, Effect } of PolicyDocument.Statement) {
        if (Effect === 'Allow') {
          if (Array.isArray(Resource)) {
            expect(Resource).not.toContain('*')
          } else {
            expect(Resource).not.toEqual('*')
          }
        }
      }
    }
  } while (policyResourcesCapture.next())
})

We need to perform quite a lot of nested looping here to make sure we are checking all policy statements inside each policy document for each role!

// This role will pass the test because resources have been specified in the policy statement
new iam.Role(this, 'ExampleRole', {
  assumedBy: new iam.AnyPrincipal(),
  description: 'An example IAM role in AWS CDK',
  inlinePolicies: {
    example: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          actions: ['s3:GetObject'],
          resources: [
            'arn:aws:s3:::example-bucket',
            'arn:aws:s3:::example-bucket/*',
          ],
        }),
      ],
    }),
  },
})

// This role will fail the test because we have a wildcard in the policy statement resources
new iam.Role(this, 'ExampleRoleWildcardResources', {
  assumedBy: new iam.AnyPrincipal(),
  description: 'An example IAM role in AWS CDK',
  inlinePolicies: {
    example: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          actions: ['s3:GetObject'],
          resources: ['*'],
        }),
      ],
    }),
  },
})

Summary

In this blog, we've looked at how to unit test a CDK app, and specifically how we can enforce good patterns in the code by checking each of our resources for specific conditions.

I hope this has given you some ideas for how you might want to test your CDK apps!

I've added an example project to GitHub here so you can see the tests in the context of a full CDK project.