AWS Account Factory with the CDK
The first step to building anything on AWS is to have an AWS Account. Whilst you can get a long way with a single account, sometimes you need more than one account to cleaning split project or environment. In this blog post I’ll show one of the many ways you can go about utilizing the AWS CDK to create accounts on demand with a baseline set of resources that gives you an out the box GitHub Actions CI pipeline and an account bootstrapped with the CDK 😃.
Before we get started I should mention that the AWS opintionated way of doing this is with the Control Tower Account Factory for Terraform. This is a great tool, but for some of my own projects there were a few reasons that I chose not to use it. The primary one being that I wanted everything in Cloudformation. Cloudformation is a fully managed and hosted Infrastructure as Code tool from AWS. You don’t need your own resources to manage the Terraform State. AWS manage all of this for you. The second one being that Control Tower does a lot. It can also spin up some resources that have a baseline cost just for existing. For these reasons, and others, I will look at how you can do a very similar thing with just the CDK and Cloudformation.
First we’ll need to create a new Organisation. I’ll call it ‘Infinidash’. My Infinidash project will have an account for Staging and Production.
import * as organizations from 'aws-cdk-lib/aws-organizations';
const orgUnit = new organizations.CfnOrganizationalUnit(this, 'InfinidashOu', {
name: 'infinidash',
parentId: 'r-root',
});
return orgUnit;This will create an OrganizationalUnit in my Cloudformation Stack. It needs a name, and a parent ID. In this case, I’m putting it at the root of my Org. You can alternatively place it under and existing OU too.
import * as organizations from 'aws-cdk-lib/aws-organizations';
const stagingAccount = new organizations.CfnAccount(this, 'InfinidashStaging', {
email: 'youremail+infinidash-staging@gmail.com',
accountName: 'infinidash-staging',
parentIds: orgUnit.attrId,
});
const productionAccount = new organizations.CfnAccount(this, 'InfinidashProduction', {
email: 'youremail+infinidash-production@gmail.com',
accountName: 'infinidash-production',
parentIds: orgUnit.attrId,
});Next we need to create the AWS Account itself. Again, we’re using the L1 Construct here. Naming things is hard (rumoured to be amongst the 2 hardest things in computer science, alongside cache invalidation and off by one errors). I’d strongly recomment a consistent naming convention for your AWS Account and email contact details. I’ve chosen to name my accounts ‘what-it-is’-’environment’. This has worked well for me across hundreds of accounts. I keep that account name as part of the email address too. Finally, we need to add the parent IDs. This is the Org Unit that account will be placed in. This could either be the root OU, or in this example, I’ve used the Org Unit ID from the Org Unit I created above.
If we were to deploy this now, we’d have 2 AWS Accounts created inside a new Org Unit. However, there are often extra steps we’d need to take before an account is ‘ready’.
I use AWS SSO (or AWS Identity Centre Successor to SSO if we’re using the correct name) to log into all my accounts. This means I don’t need an admin or root user and makes things like obtaining temporary credentials really easy. I’d like to ensure SSO is set up with my existing user for my new accounts, so I can actually log in and see things.
import * as sso from 'aws-cdk-lib/aws-sso';
const stagingReadonlyAssignment = new sso.CfnAssignment(this, 'InfinidashReadonlySso', {
targetId: stagingAccount.attrAccountId,
instanceArn: 'arn:aws:sso:::instance/ssoins-your-arn-here',
permissionSetArn: 'arn:aws:sso:::permissionSet/ssoins-your-sso-instance/ps-some-id',
principalType: 'USER',
principalId: 'your-sso-id-guid',
targetType: 'AWS_ACCOUNT',
});I’m creating the SSO Assignment using the L1 CDK Construct here. I already have my SSO instance set up in my Administrator Account, and can pass in the instance and permission set ARN, as well as the SSO ID of my personal SSO user. You need to do this for each AWS Account you make. The CDK makes it really easy to abstract this out as a method you can re-use.
Finally I’d like to add some baseline resources to the newly created accounts. As I’m a fan of the AWS CDK I’d like to ensure both accounts are set up with the CDK already bootstrapped and ready to use, as well as ensuring OIDC for GitHub Actions is ready to use, so my pipelines will work in these accounts. To do this I’ll use AWS Cloudformation StackSets. Support for StackSets in the CDK isn’t the greatest, and you unfortunately can’t easily create the Cloudformation needed for the StackSet using CDK code — you have to write the Cloudformation. I recently showed how you can use the CDK to create the OIDC IAM Provider for GitHub Actions here. This time I’ll be writing similar code but using Cloudformation.
// github-oidc-role.template.yml
Resources:
GitHubOIDCIamProvider:
Type: AWS::IAM::OIDCProvider
Properties:
ClientIdList: ['sts.amazonaws.com']
ThumbprintList: ['6938fd4d98bab03faadb97b34396831e3780aea1']
Url: 'https://token.actions.githubusercontent.com'
GitHubOIDCRole:
Type: AWS::IAM::Role
Properties:
RoleName: 'github-actions-deployment-role'
AssumeRolePolicyDocument:
Statement:
- Action: ['sts:AssumeRoleWithWebIdentity']
Effect: Allow
Principal:
Federated: !GetAtt GitHubOIDCIamProvider.Arn
Condition:
StringLike:
token.actions.githubusercontent.com:sub: 'repo:ryancormack/*'
token.actions.githubusercontent.com:aud: 'sts.amazonaws.com'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/DeploymentUserThis template is native Cloudformation. Back in our CDK Stack we need to read this file and deploy it as a StackSet.
import { CfnStackSet } from 'aws-cdk-lib/aws-cloudformation';
import { readFileSync } from 'fs';
const githubOidcStackSet = new CfnStackSet(this, 'GithubOidcStackSet', {
stackSetName: 'GithubOidcStackSet',
templateBody: readFileSync('./lib/StackSets/github-oidc-role.template.yml', 'utf8'),
permissionModel: 'SERVICE_MANAGED',
capabilities: [cdk.CfnCapabilities.NAMED_IAM],
autoDeployment: {
enabled: true,
retainStacksOnAccountRemoval: false,
},
stackInstancesGroup: [{
deploymentTargets: {
organizationalUnitIds: [orgUnit.attrId],
},
regions: ['eu-west-1'],
}],
});There’s a bit more going on here than the other Constructs we’ve create and there are quite a few more properties available on this L1 Construct. The full docs are here. The interesting bits in this context are that I’m reading the yaml file from disk with readFileSync and for the Stack Instance Group I’m passing in the Org Unit ID from the Org Unit we created at the start. This will mean that this specific stackset will be deployed to all Accounts inside that OU, in this case the staging and production accounts. Because our StackSet template is creating a named IAM Role we need the Cloudformation Capability property.
It’s exactly the same process for creating the CDK Bootstrapping StackSet. But because the Cloudformation Stack for the CDK is 600+ lines long, the CDK has tooling that makes it easy to auto generate that, rather than writing out all the lines by hand. Those docs are here. Using the CDK to Bootstrap the CDK 👏.
You can add other StackSets to your account like this if you wish. Adding things like limited scoped IAM Roles might be something extra here, or maybe a baseline topic for alerts. Because it’s just Cloudformation there’s a massive amount of possibilities.
In this post we’ve looked at how you can use the CDK to create new AWS Org Units, Accounts and baseline them with default resources on creation. This CDK Stack can now be deployed to our Admin Account (or any delegate of that) and will go off and create the OU, Accounts and baseline Cloudformation Stacks in those accounts, allowing you to quickly get started making a new Infinidash feature in our accounts with GitHub Actions automatically set up to start deploying. Now go build! 🎉
