It’s pre:Invent 2022, the time of year AWS releases a bunch of new products and features that aren’t big enough to make it on the keynote state of re:Invent. One of my long-awaited features was released last night: CloudFormation support for AWS Organizations!
Before this release, the management of Service Control Policies, Organizational Units, and AWS Accounts was either artisanal or via third-party tools like org-formation. I can finally manage my AWS Organization using the same IaC as I manage the accounts in that organization.
About this time last year, my team at the previous employer adopted org-formation. It worked reasonably well. I had a single YAML template to maintain my OUs, SCPs, and AWS Accounts. I built processes around org-formation so that we could generate a change-set, review it, and apply the change set, similar to terraform plan and terraform apply. It worked reasonably well, but it had some hiccups when processing it at the scale of 200+ AWS accounts.
For this post, I will document my (attempted) migration from org-formation to CloudFormation in my Fooli Media AWS organization.
Spoiler Alert: I ran into issues and do not recommend using CloudFormation at this time.
About CloudFormation for AWS Organizations
CloudFormation introduced three new resource types:
Unlike org-formation, the AWS::Organizations::Policy
supports all the organizations’ policy types: Service Control, Backup, Tagging, and AI Services Opt-Out policies.
As would be expected for an AWS Account, there are several documented limitations on the Account resource type. You can’t change attributes of the account via CloudFormation that normally require a root login, specifically the Account Name and Email address. Account creation isn’t as deterministic as creating a resource, so the new account may not be 100% provisioned when the CloudFormation services says Create Complete
. Creation of Multiple AWS accounts simultaneously will fail, as there is a strict limit on the number of action CreateAccount API calls that can be made at once.
In org-formation, the OU is the resource that defines which accounts are members of the OU and which SCPs apply to the OU.
WorkloadsOU:
Type: OC::ORG::OrganizationalUnit
Properties:
OrganizationalUnitName: Workloads
ServiceControlPolicies:
- !Ref SecurityControlsSCP
- !Ref DisableRegionsPolicySCP
- !Ref DenyUnapprovedServicesSCP
Accounts:
- !Ref FooliProdAccount
- !Ref FooliDevAccount
- !Ref FooliMemeFactoryAccount
With CloudFormation, the AWS::Organizations::Account
resource defines OU membership with the ParentIds
attribute. Similarly, the AWS::Organizations::Policy
resource defines which OUs the policy applies to with the TargetIds
attribute.
Working with these new Resource Types
AWS::Organizations::Policy
The first disappointing feature is that the Policy definition is:
Type: AWS::Organizations::Policy
Properties:
Content: String
Description: String <-- BOO
Name: String
Tags:
- Tag
TargetIds:
- String
Type: String
While the IAM Policy definition is:
Type: AWS::IAM::Policy
Properties:
Groups:
- String
PolicyDocument: Json <-- YAY
PolicyName: String
Roles:
- String
Users:
- String
The difference here of Content: String
vs PolicyDocument: Json
means that the Organizations policies can’t be defined as YAML in the template. They must be the formatted JSON Strings. I couldn’t simply copy the policies from my org-formation document into my CFT, rather I had to extract the org-formation rendered JSON via the AWS API and then insert it into my template.
This is the AWS CLI Command to do that. You’ll need to supply the correct policy-id for each existing SCP.
aws organizations describe-policy --policy-id p-REDACTED --query Policy.Content --output text
Once I did that, creating and managing SCPs worked:
SecurityControlsSCP:
Type: AWS::Organizations::Policy
Properties:
Name: SecurityControlsSCP-2
Type: SERVICE_CONTROL_POLICY
Description: Global Security Controls
TargetIds:
- "r-mlz5"
Content: |
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PreventCloudTrailModification",
"Effect": "Deny",
"Action": [
"cloudtrail:DeleteTrail",
"cloudtrail:PutEventSelectors",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
"cloudtrail:CreateTrail"
],
"Resource": ["*"]
}
...
}
AWS::Organizations::OrganizationalUnit
OUs are pretty straightforward. I define the name and the parent ID like so:
SandboxOU:
Type: AWS::Organizations::OrganizationalUnit
Properties:
Name: Sandbox-2
ParentId: !Ref pRootOUId
With OUs, here is a Major Red Flag
To update the ParentId parameter value, you must first remove all accounts attached to the organizational unit (OU). OUs can’t be moved within the organization with accounts still attached. (link)
This is a major limitation and one I recommend you architect your governance structure around.
AWS::Organizations::Account
Until now, I’ve been creating new Organizations resources in my payer account. I’m obviously not going to create all new accounts, so now I’m going to experiment with CloudFormation imports to see how viable this whole thing is.
First, I’m going to define the account in the CFT:
SandboxAccount:
Type: AWS::Organizations::Account
DeletionPolicy: Retain
Properties:
AccountName: fooli-sandbox
Email: <redacted>
ParentIds:
- !Ref SandboxOU
Tags:
- Key: ExecutiveOwner
Value: Erlich Bachman
- Key: TechnicalContact
Value: Dinesh Chugtai
- Key: DataClassification
Value: None
Even though the CloudFormation documentation indicates that there is an implied DeletionPolicy: Retain
for the Account resource, the CloudFormation import process requires it to be present in the template.
I decided to do this via the AWS Console. I first uploaded the new template with the Sandbox account definition in it. I then had to provide the AWS account ID for the Sandbox account.
It then generated an import change set, and I had to confirm
Once imported, the AWS Account was not moved into the newly created OU specified in the ParentIds
. It remained in my original Sandbox OU.
Attempting to Comment out the ParentIds and update the stack generated this error:
Resource handler returned message: "You specified an account that doesn't exist. (Service: Organizations, Status Code: 400, Request ID: 89c02268-5885-41b0-a8a2-32139e06fb9e)" (RequestToken: 337966af-89ef-7d7d-bc35-a4480137a302, HandlerErrorCode: NotFound)
And left my stack in the dreaded UPDATE_ROLLBACK_FAILED
, which prevents me from doing further updates.
I then deleted the entire stack and attempted to import all the resources at once. That included my SecurityControlsSCP, four existing OUs, and the Fooli Sandbox AWS Account. That worked, and once imported, I was able to change the OU the Fooli Sandbox was a member of. However, when I imported another Fooli account, the same “You specified an account that doesn’t exist.” error occurred when trying to modify that AWS Account.
Real-World Use
The inability to import an account into an existing stack is a show stopper for me, and I don’t recommend enterprise use of CloudFormation to manage AWS Organizations. In my experience as an enterprise cloud admin, I regularly had new accounts get added to my organization via M&A activity or because we found someone engaging in shadow IT, and we needed to bring them into the fold.
I’ll update this post if and when the service’s behavior changes, but for now, I’m not migrating any more of my AWS Organizations away from org-formation.