IaC AWS

Terraform Module: AWS Service Control Policy (SCP) — guardrails as code across your AWS Organization

Quick take — Build a reusable Terraform module for AWS Service Control Policies using aws_organizations_policy: render JSON guardrails, attach to OUs/accounts, and gate enforcement with a dry-run flag. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "scp" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-scp?ref=v1.0.0"

  name       = "..."           # SCP name (1-128 chars, `[A-Za-z0-9_-]`).
  statements = ["...", "..."]  # List of SCP statements (`sid`, `effect`, `actions`, opt…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

A Service Control Policy (SCP) is an organization-level guardrail in AWS Organizations. Unlike an IAM policy, an SCP does not grant anything — it defines the maximum set of permissions that the identities in an account can ever use. When an SCP Deny matches, no IAM policy, role trust, or root user can override it. That property makes SCPs the primary control plane for preventive guardrails: blocking root usage, pinning regions, denying the disabling of CloudTrail/GuardDuty, or fencing off entire services for a sandbox OU.

In Terraform, an SCP is created with aws_organizations_policy (with type = "SERVICE_CONTROL_POLICY") and attached to a root, organizational unit, or account with aws_organizations_policy_attachment. Doing this by hand is error-prone: the policy content is a JSON document with a 5,120-character compiled limit, attachments must reference live OU/account IDs, and a single overly broad Deny can lock every workload out of a region. Wrapping all of that in a module gives you a versioned, reviewable, dry-run-capable way to ship guardrails: the policy JSON is rendered from variables, attachments are fan-out from a list of targets, and a single boolean lets you build-and-review a policy before you actually bind it to production OUs.

When to use it

Do not use SCPs for granting permissions, for the management account (SCPs do not restrict the management account), or as a substitute for IAM least-privilege — they are a ceiling, not a floor.

Module structure

terraform-module-aws-scp/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # aws_organizations_policy + attachments
├── variables.tf     # name, statements, targets, dry_run, tags
└── outputs.tf       # policy id/arn + rendered JSON + attachment ids

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Build one IAM-style statement per input rule. SCP statements carry no
  # Principal; they are evaluated against every principal in the target.
  statements = [
    for s in var.statements : merge(
      {
        Sid      = s.sid
        Effect   = s.effect
        Action   = s.actions
        Resource = s.resources
      },
      length(s.conditions) > 0 ? { Condition = s.conditions } : {}
    )
  ]

  policy_document = jsonencode({
    Version   = "2012-10-17"
    Statement = local.statements
  })

  # Targets are only wired to attachments when we are NOT in dry-run mode,
  # so you can plan/apply the policy itself and review it before binding.
  attachment_targets = var.dry_run ? [] : var.target_ids
}

resource "aws_organizations_policy" "this" {
  name        = var.name
  description = var.description
  type        = "SERVICE_CONTROL_POLICY"
  content     = local.policy_document

  # Surface oversized documents at plan time. The compiled SCP limit is
  # 5120 characters including whitespace AWS adds back in.
  lifecycle {
    precondition {
      condition     = length(local.policy_document) <= 5120
      error_message = "Compiled SCP document is ${length(local.policy_document)} chars; AWS limit is 5120. Split into multiple SCPs."
    }
  }

  tags = var.tags
}

resource "aws_organizations_policy_attachment" "this" {
  for_each = toset(local.attachment_targets)

  policy_id = aws_organizations_policy.this.id
  target_id = each.value
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Service Control Policy (shown in the AWS Organizations console)."

  validation {
    condition     = can(regex("^[a-zA-Z0-9_-]{1,128}$", var.name))
    error_message = "name must be 1-128 chars of letters, digits, hyphen or underscore."
  }
}

variable "description" {
  type        = string
  description = "Human-readable description of what this guardrail enforces."
  default     = "Managed by Terraform"
}

variable "statements" {
  description = "List of SCP statements. Each is rendered into the policy JSON. Use Effect=Deny for guardrails; conditions is an optional map."
  type = list(object({
    sid        = string
    effect     = string
    actions    = list(string)
    resources  = optional(list(string), ["*"])
    conditions = optional(any, {})
  }))

  validation {
    condition     = length(var.statements) > 0
    error_message = "At least one statement is required."
  }

  validation {
    condition     = alltrue([for s in var.statements : contains(["Allow", "Deny"], s.effect)])
    error_message = "Each statement.effect must be either 'Allow' or 'Deny'."
  }

  validation {
    condition     = length(distinct([for s in var.statements : s.sid])) == length(var.statements)
    error_message = "Each statement.sid must be unique within the policy."
  }
}

variable "target_ids" {
  type        = list(string)
  description = "Root, OU (ou-*) or account IDs to attach this SCP to. Ignored when dry_run = true."
  default     = []

  validation {
    condition = alltrue([
      for t in var.target_ids : can(regex("^(r-[0-9a-z]{4,32}|ou-[0-9a-z]{4,32}-[0-9a-z]{8,32}|[0-9]{12})$", t))
    ])
    error_message = "Each target_id must be a root (r-*), OU (ou-*) or 12-digit account ID."
  }
}

variable "dry_run" {
  type        = bool
  description = "When true, create/update the policy but do NOT attach it to any target. Lets you review a guardrail before enforcing it."
  default     = false
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to the policy resource."
  default     = {}
}

outputs.tf

output "policy_id" {
  description = "ID of the Service Control Policy."
  value       = aws_organizations_policy.this.id
}

output "policy_arn" {
  description = "ARN of the Service Control Policy."
  value       = aws_organizations_policy.this.arn
}

output "policy_name" {
  description = "Name of the Service Control Policy."
  value       = aws_organizations_policy.this.name
}

output "policy_content" {
  description = "Rendered SCP JSON document (useful for review/diff in CI)."
  value       = aws_organizations_policy.this.content
}

output "attachment_target_ids" {
  description = "Target IDs this SCP is actually attached to ([] when dry_run = true)."
  value       = [for a in aws_organizations_policy_attachment.this : a.target_id]
}

How to use it

# Look up the Production OU instead of hard-coding its id.
data "aws_organizations_organizational_units" "root" {
  parent_id = data.aws_organizations_organization.current.roots[0].id
}

data "aws_organizations_organization" "current" {}

locals {
  prod_ou_id = one([
    for ou in data.aws_organizations_organizational_units.root.children :
    ou.id if ou.name == "Workloads-Prod"
  ])
}

module "service_control_policy_scp_region_lock" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-scp?ref=v1.0.0"

  name        = "region-lock-and-protect-trail"
  description = "Pin workloads to eu-west-1 and prevent CloudTrail tampering."

  statements = [
    {
      sid       = "DenyOutsideAllowedRegions"
      effect    = "Deny"
      actions   = ["*"]
      resources = ["*"]
      conditions = {
        StringNotEquals = {
          "aws:RequestedRegion" = ["eu-west-1"]
        }
        # Let global/control-plane services through so IAM, STS, etc. still work.
        "ForAllValues:StringNotLike" = {
          "aws:CalledVia" = ["cloudformation.amazonaws.com"]
        }
      }
    },
    {
      sid     = "ProtectCloudTrail"
      effect  = "Deny"
      actions = ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail"]
      # resources defaults to ["*"]
    }
  ]

  target_ids = [local.prod_ou_id]
  dry_run    = false

  tags = {
    Team    = "platform-security"
    Managed = "terraform"
  }
}

# Downstream reference: surface the rendered JSON to a compliance bucket
# so auditors can diff guardrails over time.
resource "aws_s3_object" "scp_snapshot" {
  bucket  = "kloudvin-compliance-artifacts"
  key     = "scp/${module.service_control_policy_scp_region_lock.policy_name}.json"
  content = module.service_control_policy_scp_region_lock.policy_content
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/scp/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-scp?ref=v1.0.0"
}

inputs = {
  name = "..."
  statements = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/scp && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes SCP name (1-128 chars, [A-Za-z0-9_-]).
description string "Managed by Terraform" No Human-readable description of the guardrail.
statements list(object) Yes List of SCP statements (sid, effect, actions, optional resources, optional conditions). Validated for unique sids and Allow/Deny effects.
target_ids list(string) [] No Root (r-*), OU (ou-*) or 12-digit account IDs to attach to. Ignored when dry_run = true.
dry_run bool false No Create/update the policy but skip all attachments — review before enforcing.
tags map(string) {} No Tags applied to the policy.

Outputs

Name Description
policy_id ID of the Service Control Policy.
policy_arn ARN of the Service Control Policy.
policy_name Name of the Service Control Policy.
policy_content Rendered SCP JSON document, for review/diff in CI.
attachment_target_ids Target IDs the SCP is actually attached to ([] in dry-run).

Enterprise scenario

A fintech platform team runs a 60-account AWS Organization and must prove to auditors that no engineer — not even via the root user — can disable logging or operate outside the EU. They instantiate this module once per guardrail (region-lock, CloudTrail protection, deny-leave-organization) and attach each to the Workloads-Prod and Security OUs, while first rolling the region-lock SCP out with dry_run = true against a single test account. The policy_content output is pushed to a compliance S3 bucket on every apply, giving the audit team a versioned, diffable history of exactly which guardrails were in force on any date.

Best practices

TerraformAWSService Control Policy (SCP)ModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading