Artur Carter

0 %
Zak Abdel-Illah
Automation Enthusiast
  • 📍 Location
    🇬🇧 London
Certifications
  • AWS Solutions Architect - Associate
Languages
  • Python
  • Go
Technologies
  • Ansible
  • Linux
  • Terraform
  • Kubernetes

Deploying Region-locked AWS Organizations using Terraform

October 7, 2024

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)

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

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:

Posted in AWS, Cloud, Terraform
© 2020. All rights reserved
© 2024 All Rights Reserved. me@zai.dev