Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 062 / 100

Service control policies with Terraform, explained simply

Guardrails for every account.

Expona Admin6 min read

Service control policies with Terraform, explained simply

Your AWS organization has dozens of accounts, dozens of teams, and no easy way to enforce a company-wide security baseline. SCPs won't solve everything, but they're one of the most effective tools for stopping mistakes before they happen. Let's walk through how to implement service control policies with Terraform, explained simply, so guardrails exist in every account without micromanaging each team.

What service control policies actually do

A service control policy is an access control list that sits above IAM. It defines the maximum permissions available in an AWS account—not the minimum. Think of it as a ceiling: no matter what IAM role you attach to a user, if the SCP denies an action, it stays denied. SCPs apply at the organizational unit (OU) level, so you can enforce different rules for different teams.

The key insight is that SCPs don't grant permissions. They only restrict them. You still need IAM policies to actually allow actions. This two-layer system prevents accidental sprawl and makes security enforcement scalable.

Deny leaving the organization

The most critical guardrail is preventing anyone from removing an account from your organization. A disgruntled developer, a compromised credential, or a misconfigured automation script could all lead to account loss if this isn't locked down.

Here's how we deny the organizations:LeaveOrganization action across every account:

hcl
resource "aws_organizations_policy" "deny_leave_org" {
  name        = "DenyLeaveOrganization"
  description = "Prevents accounts from leaving the organization"
  type        = "SERVICE_CONTROL_POLICY"
  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyLeaveOrganization"
        Effect = "Deny"
        Action = "organizations:LeaveOrganization"
        Resource = "*"
      }
    ]
  })
}

resource "aws_organizations_policy_attachment" "deny_leave_root" {
  policy_id = aws_organizations_policy.deny_leave_org.id
  target_id = aws_organizations_organization.main.roots[0].id
}

Attaching this to the root OU means it applies everywhere. No account can leave, even if an engineer has full admin access. We've seen teams skip this step, then watch a departing contractor remove the staging environment from the organization mid-project. Don't be that team.

Restrict regions

Your company operates in EU and US. Why would anyone spin up infrastructure in ap-southeast-1? Restricting regions prevents cost surprises, compliance headaches, and resource sprawl in places your team doesn't monitor.

This policy denies all API calls to regions outside your approved list:

hcl
resource "aws_organizations_policy" "restrict_regions" {
  name        = "RestrictRegions"
  description = "Limit AWS API calls to approved regions"
  type        = "SERVICE_CONTROL_POLICY"
  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "DenyUnapprovedRegions"
        Effect    = "Deny"
        NotAction = [
          "a4b:*",
          "acm:*",
          "aws-portal:*",
          "budgets:*",
          "ce:*",
          "chime:*",
          "cur:*",
          "directconnect:*",
          "fms:*",
          "globalaccelerator:*",
          "iam:*",
          "importexport:*",
          "opsworks-cm:*",
          "organizations:*",
          "route53:*",
          "route53domains:*",
          "s3:*",
          "sts:*",
          "support:*",
          "tax:*",
          "trustedadvisor:*",
          "waf:*",
          "wafv2:*",
          "wellarchitected:*"
        ]
        Resource = "*"
        Condition = {
          StringNotEquals = {
            "aws:RequestedRegion" = [
              "us-east-1",
              "us-west-2",
              "eu-west-1"
            ]
          }
        }
      }
    ]
  })
}

resource "aws_organizations_policy_attachment" "restrict_regions_production" {
  policy_id = aws_organizations_policy.restrict_regions.id
  target_id = aws_organizations_ou.production.id
}

The NotAction list includes global services that don't support region restrictions. These services are allowed everywhere. All others require the request to target an approved region or it gets denied. Attach this to your production OU first, then roll it to development if it doesn't break anything.

Protect logging resources

CloudTrail, VPC Flow Logs, Config—your logging infrastructure is the source of truth for audits and incident response. If a developer can delete the central logging bucket, your compliance posture collapses instantly. We protect these resources by denying modifications to anything tagged Environment: Logging:

hcl
resource "aws_organizations_policy" "protect_logging" {
  name        = "ProtectLoggingResources"
  description = "Prevent modification of resources tagged for logging"
  type        = "SERVICE_CONTROL_POLICY"
  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyLoggingResourceModification"
        Effect = "Deny"
        Action = [
          "s3:DeleteBucket",
          "s3:DeleteBucketPolicy",
          "s3:PutBucketPolicy",
          "s3:PutLifecycleConfiguration",
          "logs:DeleteLogGroup",
          "logs:DeleteLogStream",
          "cloudtrail:DeleteTrail",
          "cloudtrail:StopLogging"
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:ResourceTag/Environment" = "Logging"
          }
        }
      }
    ]
  })
}

resource "aws_organizations_policy_attachment" "protect_logging_all" {
  policy_id = aws_organizations_policy.protect_logging.id
  target_id = aws_organizations_organization.main.roots[0].id
}

Apply this to the root OU. The condition restricts the denial to only resources with the matching tag, so developers can still delete their own buckets or logs. Only protected logging resources are off-limits. Make sure your central logging account's resources are tagged appropriately.

Test in non-production OUs first

The worst time to discover an SCP blocks critical infrastructure is production Friday at 6 PM. Always test policies in a staging or sandbox OU before rolling to production accounts.

Create a separate organizational unit for testing:

hcl
resource "aws_organizations_organizational_unit" "sandbox" {
  name      = "Sandbox"
  parent_id = aws_organizations_organization.main.roots[0].id
}

resource "aws_organizations_policy_attachment" "test_new_policy" {
  policy_id = aws_organizations_policy.restrict_regions.id
  target_id = aws_organizations_ou.sandbox.id
}

Attach your new policy to the sandbox OU. Give one engineer an account there. Have them run their normal workflow: deploy an application, test cross-region failover, spin up temporary infrastructure. If the policy breaks anything, you discover it in minutes with minimal blast radius. Once you're confident, attach to production.

We recommend maintaining a checklist: test in sandbox, document expected behavior, get team lead sign-off, then attach to production on a Tuesday or Wednesday morning when support is staffed.

Common mistakes

Forgetting that SCPs don't grant permissions. An SCP that allows an action is useless if IAM doesn't also allow it. New teams sometimes write overly permissive SCPs thinking they solve authorization. They don't. SCPs are for saying no; IAM says yes.

Making SCPs too broad. A policy that denies all EC2 actions stops legitimate deployments as fast as malicious ones. Be specific. Deny ec2:TerminateInstances for production instances, not all EC2 actions.

Attaching to root immediately. Test first. We've seen single-region restrictions accidentally block multi-region failover automation because someone didn't account for CloudFormation's cross-region stack sets. Sandbox catches this.

Ignoring exceptions. Some service accounts genuinely need cross-region access or higher privileges. Instead of disabling the SCP, use the Principal element to exempt trusted roles:

hcl
{
  Sid       = "AllowServiceRoleAnyRegion"
  Effect    = "Allow"
  Action    = "*"
  Resource  = "*"
  Principal = {
    AWS = "arn:aws:iam::123456789012:role/TrustedServiceRole"
  }
}

This approach is cleaner than removing the policy from the OU entirely.

Key takeaways

  • SCPs define the maximum permissions in an account. They don't grant; they restrict. Always combine with IAM policies.
  • Deny organization leave at the root OU to prevent account removal. This is non-negotiable for most organizations.
  • Restrict regions to your operational areas. This prevents cost surprises and keeps your team focused where it matters.
  • Protect logging resources with tag-based conditions. Audit trails are your safety net; guard them fiercely.
  • Test new policies in a sandbox OU before attaching to production. A Friday breakage is expensive; a Tuesday sandbox test is cheap insurance.
  • Be specific in denials. Broad policies catch problems but also catch legitimate work.
  • Document exceptions. If a role genuinely needs cross-region access, state it in code with a comment explaining why.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles