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
- You run AWS Organizations with multiple OUs (e.g.
Workloads/Prod,Workloads/NonProd,Sandbox,Security) and want consistent preventive guardrails per OU. - You need to pin allowed regions (deny everything outside
eu-west-1/us-east-1) for data-residency or cost reasons. - You want to protect security controls — deny
cloudtrail:StopLogging,guardduty:DeleteDetector,config:DeleteConfigurationRecorder, or deletion of a specific IAM break-glass role. - You are building a landing zone and want SCPs reviewed in PRs, version-pinned, and rolled out OU-by-OU with a
dry_run(attach-or-not) switch.
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 config — live/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 config — live/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
- Start in dry-run, then attach narrowly. Apply with
dry_run = true(or attach to one throwaway account) and read the renderedpolicy_contentbefore binding an SCP to a production OU — a badDenywithAction: "*"can lock everyone out instantly, and SCPs take effect immediately. - Always carve out global services in region-locks. A naive
aws:RequestedRegiondeny will break IAM, STS, CloudFront, Route 53, and Organizations calls. Scope the deny withNotActionfor global services or condition onaws:PrincipalArnfor break-glass roles. - Never attach SCPs to the management account. SCPs do not restrict the management account; relying on them there gives a false sense of safety. Keep workloads out of the management account entirely.
- Watch the 5,120-character compiled limit. The module fails the plan if you exceed it. Split large guardrails into several small, single-purpose SCPs (each OU can have up to five attached) rather than one monolith.
- Name guardrails by intent, not by service.
deny-leave-org,region-lock-eu,protect-trailread clearly in the console and in PRs;scp-1does not. Tag every policy with an owning team andManaged = terraform. - Pair
Denywith detective controls. SCPs prevent the action but produce no findings; back them with AWS Config rules or GuardDuty so attempts to hit a guardrail are still recorded and alerted on.