As a solutions architect, I was tasked with building an AWS Organizations hierarchy for a Canadian startup that needed to comply with local laws and enable multi-site configurations for networking.
To get started, I built an AWS Organizations hierarchy using Terraform. I chose Terraform because it allows me to use the same workflow for building organizations across multiple clouds. This post will focus on building an Organizational Unit (OU) tree for regions and localities.
To create OUs, I have a “basic” Terraform module that is a wrapper on the aws_organizations_organizational_unit
resource. To make it reusable, I expose the name and parent. I then specialize the “basic” Terraform module into ones more specific to each organization by injecting tags and appending a postfix to the name of the OU, such as the region or locality.
For compliance, I restrict at the OU-level which zones can be used by the AWS account and any IAM users assuming the role of this AWS account. I use a Service Control Policy (SCP) to deny access to all regions except for those specified in the local.regions
value. Because a lot of core infrastructure for AWS is located within us-east-1
and us-east-2
, such as Billing, I need to always include it in the local.regions
value.
Since I need to cater for both compliance and multi-site, I used my modules to build the OUs in the following hierarchy:
- Root Organization
- Region OU (e.g: North America)
- Country OU (e.g: Canada)
- Locality OU (e.g: Vancouver)
- Country OU (e.g: Canada)
- Region OU (e.g: North America)
And with Terraform modules structured in the following way:
- Root Organization
- Client Module
- Client Root Organizational Unit
- Region / Country / Locality Module
- Base OU Module
- Region / Country / Locality Organizational Unit
- Region Policies
- SCP Policy
- Base OU Module
- Client Module
In the case of Vancouver, while the Seattle local zone or us-west-2 region is closer, it’s not located within Canada which may be a problem when looking at local labor laws and compliance, so Calgary (ca-west-1) is the next best thing. I’m waiting for the Vancouver local zone to become publicly available so that I can use that, but it will fall under Calgary anyway.
This means that my SCPs restrict the organizational units to the following regions:
- North American OU
- us-west-1, us-west-2, us-east-1, us-east-2, ca-central-1, ca-west-1
- Canadian OU
- us-east-1, us-east-2, ca-central-1, ca-west-1
- Vancouver OU
- us-east-1, us-east-2, ca-west-1
Because of the hierarchy approach, I can have AWS accounts in parents with shared resources such as VPCs, Databases, S3 and EFS shares. This will be hugely beneficial when working multiple sites.
My re-usable modules follow the following structure:
Core Organizational Unit
This holds the default values for all OUs within the organization, where tags for example would be shared.
resource "aws_organizations_organizational_unit" "root" {
name = var.name
parent_id = var.parent
tags = var.tags
}
Inheriting the Basic OU into Locality, Country & Region OUs
I re-use the basic module to make it follow a strict naming and tag convention based on the context (e.g: locality, country and region). This module is for the context and not specifically the region in question. The region in question will then re-use this module.
This makes sure that the NA Region and European Region have the same fundamentals between them.
module "basic" {
source = "../basic"
parent = var.parent
name = "${var.name} - ${var.locality}"
tags = local.tags
}
module "policies" {
source = "../../../regions/policies"
policy_name = local.policy_name
target_id = module.basic.id
regions = var.regions
}
Inheriting the Context OU Module into literal regions
Here, I take the region context module and adapt it specific to North America. The same logic applies to country and locality. This simply enforces that the tags and name of the OU contain the region and that the SCPs generated block all regions except the regions provided
module "region" {
source = "../../templates/organization/region"
region = "North America"
parent = var.parent
name = var.name
tags = local.tags
regions = [
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"ca-west-1"
]
}
Generating the SCPs from Terraform
policy_name
here is the same as the name of an OU with spaces removed. Since SCPs require Deny rules, using the StringNotEquals
test is needed.
data "aws_iam_policy_document" "region_restriction" {
statement {
sid = "RestrictRegionFor${var.policy_name}"
effect = "Deny"
actions = ["*"]
resources = ["*"]
condition {
test = "StringNotEquals"
variable = "aws:RequestedRegion"
values = local.regions
}
}
}
resource "aws_organizations_policy" "region_restriction" {
name = "RestrictRegionFor${var.policy_name}"
content = data.aws_iam_policy_document.region_restriction.json
type = "SERVICE_CONTROL_POLICY"
}
Declaring a Regional OU for an Organization
Finally, I can use the North American
OU to declare an OU that restricts any AWS Accounts inside to only create resources within North America.
module "region-na" {
source = "../regions/north-america"
parent = aws_organizations_organizational_unit.root.id
name = var.name
tags = local
}
I can do the same with Canada, and Vancouver.
module "region-ca" {
source = "../regions/north-america/canada"
parent = module.region-na.id
name = var.name
tags = module.region-na.tags
}
module "region-yvr" {
source = "../regions/north-america/canada/vancouver"
parent = module.region-ca.id
name = var.name
tags = module.region-ca.tags
}
By the end of the deployment, my hierarchy looks like the following:
And the attached SCP policies look like the following, where the SCP that is the direct parent of an AWS Account takes the most precedence:
ZAI – North America
{ "Version": "2012-10-17", "Statement": [ { "Sid": "RestrictRegionForZAINorthAmerica", "Effect": "Deny", "Action": "*", "Resource": "*", "Condition": { "StringNotEquals": { "aws:RequestedRegion": [ "us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1", "ca-west-1", "us-east-1", "us-east-2" ] } } } ] }
ZAI – Canada
{ "Version": "2012-10-17", "Statement": [ { "Sid": "RestrictRegionForZAICanada", "Effect": "Deny", "Action": "*", "Resource": "*", "Condition": { "StringNotEquals": { "aws:RequestedRegion": [ "ca-central-1", "ca-west-1", "us-east-1", "us-east-2" ] } } } ] }
ZAI – Vancouver
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RestrictRegionForZAIVancouver",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"ca-west-1",
"us-east-1",
"us-east-2"
]
}
}
}
]
}
Here it is in action, when exploring a region that is blocked by the SCP Policy: