LAB43_AWS_AWS Organizations: Service Control Policies as Account Guardrails

AWS Organizations: Service Control Policies as Account Guardrails

☁ AWS 🔥 Advanced AWS Organizations Service Control Policies OU Hierarchy Account Guardrails Deny by Default

Scenario

Your organisation uses AWS Organizations with three OUs: Production, Development, and Sandbox. Your security team needs preventative guardrails that no individual IAM policy can override: production accounts must never allow root user access, all accounts must stay in a single approved region list, sandbox accounts must be blocked from expensive services, and no account can disable CloudTrail.

Your Objectives
  • Create four SCPs: DenyRootAccess, AllowedRegions (us-east-1 and eu-west-1 only), DenyCloudTrailDisable, and SandboxDenyExpensiveServices (blocks EC2 instance types larger than t3.xlarge and RDS instance classes larger than db.t3.medium).
  • Look up the existing Organization root and OUs using data sources. Do not create the Organization or OUs — these already exist.
  • Attach DenyRootAccess, AllowedRegions, and DenyCloudTrailDisable to the Organization root so they apply to all accounts.
  • Attach SandboxDenyExpensiveServices only to the Sandbox OU.
  • Output all SCP IDs and the OU the sandbox SCP is attached to.

Additional Context

SCPs use an implicit deny all + explicit allow model. When an SCP is attached, only the actions explicitly Allowed in the SCP are permitted — even if an IAM policy grants broader access. However, SCPs do not apply to the management account of the organization — this is a common gotcha.

The AllowedRegions SCP must use a Deny statement with a StringNotEquals condition on aws:RequestedRegion and must include an exemption (using ArnNotLike on aws:PrincipalArn) for global/IAM actions that don't have a region (e.g., IAM, STS, CloudFront). Without this exemption, the SCP will block users from logging in.

variables.tf

variable "aws_region"         { type = string; default = "us-east-1" }
variable "sandbox_ou_id"      { type = string; description = "Organizational Unit ID for the Sandbox OU." }
variable "allowed_regions"    {
  type    = list(string)
  default = ["us-east-1", "eu-west-1"]
  description = "AWS regions where workloads are permitted."
}

main.tf

provider "aws" { region = var.aws_region }

# Look up the Organization root — SCPs at root level apply to ALL accounts.
data "aws_organizations_organization" "main" {}

# SCP 1: Deny root user access.
resource "aws_organizations_policy" "deny_root" {
  name        = "DenyRootAccess"
  description = "Prevent root user from performing any API actions."
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "DenyRootAccess"
      Effect    = "Deny"
      Action    = "*"
      Resource  = "*"
      Condition = { ArnEquals = { "aws:PrincipalArn" = ["arn:aws:iam::*:root"] } }
    }]
  })
}

# SCP 2: Restrict workloads to approved regions.
resource "aws_organizations_policy" "allowed_regions" {
  name        = "AllowedRegions"
  description = "Deny API calls to regions outside the approved list."
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "DenyOutsideApprovedRegions"
      Effect = "Deny"
      NotAction = [
        # These services are global and must be exempted from region restriction.
        "iam:*","sts:*","cloudfront:*","route53:*","support:*","budgets:*"
      ]
      Resource = "*"
      Condition = {
        StringNotEquals = { "aws:RequestedRegion" = var.allowed_regions }
      }
    }]
  })
}

# SCP 3: Prevent CloudTrail from being disabled.
resource "aws_organizations_policy" "deny_cloudtrail_disable" {
  name        = "DenyCloudTrailDisable"
  description = "Prevent anyone from stopping, deleting, or updating CloudTrail."
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "DenyCloudTrailModifications"
      Effect = "Deny"
      Action = [
        "cloudtrail:DeleteTrail","cloudtrail:StopLogging",
        "cloudtrail:UpdateTrail","cloudtrail:PutEventSelectors"
      ]
      Resource = "*"
    }]
  })
}

# SCP 4: Block expensive compute/database in Sandbox — restrict instance types.
resource "aws_organizations_policy" "sandbox_limits" {
  name        = "SandboxDenyExpensiveServices"
  description = "Block large EC2 and RDS instance types in Sandbox accounts."
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyLargeEC2"
        Effect = "Deny"
        Action = ["ec2:RunInstances"]
        Resource = "arn:aws:ec2:*:*:instance/*"
        Condition = {
          StringNotLike = {
            "ec2:InstanceType" = ["t2.*","t3.*","t3a.*","m5.large","m5.xlarge"]
          }
        }
      },
      {
        Sid    = "DenyLargeRDS"
        Effect = "Deny"
        Action = ["rds:CreateDBInstance","rds:ModifyDBInstance"]
        Resource = "*"
        Condition = {
          StringNotLike = { "rds:DatabaseClass" = ["db.t3.*","db.t4g.*"] }
        }
      }
    ]
  })
}

# Attach global SCPs to the Organization root.
resource "aws_organizations_policy_attachment" "root_deny_root" {
  policy_id = aws_organizations_policy.deny_root.id
  target_id = data.aws_organizations_organization.main.roots[0].id
}

resource "aws_organizations_policy_attachment" "root_allowed_regions" {
  policy_id = aws_organizations_policy.allowed_regions.id
  target_id = data.aws_organizations_organization.main.roots[0].id
}

resource "aws_organizations_policy_attachment" "root_deny_cloudtrail" {
  policy_id = aws_organizations_policy.deny_cloudtrail_disable.id
  target_id = data.aws_organizations_organization.main.roots[0].id
}

# Attach sandbox-only SCP to the Sandbox OU only.
resource "aws_organizations_policy_attachment" "sandbox_limits" {
  policy_id = aws_organizations_policy.sandbox_limits.id
  target_id = var.sandbox_ou_id
}

outputs.tf

output "deny_root_scp_id"         { value = aws_organizations_policy.deny_root.id }
output "allowed_regions_scp_id"   { value = aws_organizations_policy.allowed_regions.id }
output "deny_cloudtrail_scp_id"   { value = aws_organizations_policy.deny_cloudtrail_disable.id }
output "sandbox_limits_scp_id"    { value = aws_organizations_policy.sandbox_limits.id }
output "org_root_id"              { value = data.aws_organizations_organization.main.roots[0].id }

Workflow Commands

terraform init
terraform apply -var="sandbox_ou_id=ou-xxxx-yyyyyyy"

# List all SCPs attached to root
aws organizations list-policies-for-target \
  --target-id $(terraform output -raw org_root_id) \
  --filter SERVICE_CONTROL_POLICY \
  --query 'Policies[*].{Name:Name,Id:Id}'

# Verify sandbox OU has the extra SCP
aws organizations list-policies-for-target \
  --target-id ou-xxxx-yyyyyyy \
  --filter SERVICE_CONTROL_POLICY

# Test: attempt to run a large instance in a sandbox account (should be denied)
aws ec2 run-instances --image-id ami-xxxx --instance-type m5.4xlarge
# Expected: An error occurred (AccessDenied) ...

terraform destroy -var="sandbox_ou_id=ou-xxxx-yyyyyyy"
✓ Expected Output After Apply
Apply complete! Resources: 8 added, 0 changed, 0 destroyed.

Outputs:

allowed_regions_scp_id   = "p-aaaaa..."
deny_cloudtrail_scp_id   = "p-bbbbb..."
deny_root_scp_id         = "p-ccccc..."
org_root_id              = "r-xxxx"
sandbox_limits_scp_id    = "p-ddddd..."