·5 min read·#aws#organizations#security#terraform

Organizational Guardrails with Service Control Policies

Organizational Guardrails with Service Control Policies architecture diagram
Click to expand
708 × 433px

Service Control Policies (SCPs) are organizational guardrails. They set the maximum permissions for accounts in your organization - even if an IAM policy or permission set would grant access, an SCP can deny it.

Here are the baseline SCPs we deploy, common patterns worth adopting, and the gotchas that catch people out.

How SCPs Work

SCPs don't grant permissions. They set boundaries on what permissions can be granted:

aws-account-structure-part-9-scps/scp-evaluation diagram

Think of it as a filter (see the SCP evaluation logic for the full picture):

  • IAM policy says "allow S3 full access"
  • SCP says "deny S3 in eu-west-3"
  • Result: S3 access everywhere except eu-west-3

The effective permission is the intersection of all policies.

SCP Inheritance

SCPs follow the organizational hierarchy:

Root (FullAWSAccess + RestrictRegions + DenyRoot + ProtectCloudTrail)
├── Production OU (+ RequireEncryption)
│   └── website-prod account (inherits all five)
└── Infrastructure OU (no additional SCPs)
    └── dns account (inherits root's four)

An account's effective permissions are limited by:

  1. SCPs attached to the root
  2. SCPs attached to parent OUs
  3. SCPs attached to the account directly

More restrictive policies at higher levels affect all descendants.

The FullAWSAccess Policy

Every organization starts with FullAWSAccess attached to the root:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "*",
      "Resource": "*"
    }
  ]
}

This allows everything by default. You add restrictive SCPs to narrow permissions.

Never remove FullAWSAccess without a replacement Allow policy. If you do, all actions in all accounts will be denied.

Common SCP Patterns

Restrict Regions

Limit resources to approved regions:

hcl
resource "aws_organizations_policy" "restrict_regions" {
  name        = "RestrictRegions"
  description = "Restrict operations to approved regions"
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyUnapprovedRegions"
        Effect = "Deny"
        Action = "*"
        Resource = "*"
        Condition = {
          StringNotEquals = {
            "aws:RequestedRegion" = [
              "eu-west-2",
              "eu-west-1",
              "us-east-1"  # Required for global services
            ]
          }
          # Exclude global services that don't support regions
          "ForAllValues:StringNotEquals" = {
            "aws:PrincipalServiceName" = [
              "cloudfront.amazonaws.com",
              "iam.amazonaws.com",
              "route53.amazonaws.com",
              "organizations.amazonaws.com",
              "support.amazonaws.com"
            ]
          }
        }
      }
    ]
  })
}

This prevents creating resources in unapproved regions - useful for data residency compliance.

In real deployments, region policies usually need a few explicit carve-outs. Ours did too:

Exception typeWhy it was needed
us-east-1 control plane callsSome global service workflows still rely on it
Global service principalsServices like IAM/CloudFront/Route 53 are not regional in the same way as EC2

Expect one or two rollout iterations before the allowlist settles.

Prevent Root User Access

Deny all actions by root users (except in management account):

hcl
resource "aws_organizations_policy" "deny_root" {
  name        = "DenyRootUserAccess"
  description = "Prevent root user access in member accounts"
  type        = "SERVICE_CONTROL_POLICY"

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

Note: This doesn't apply to the management account - SCPs never affect the management account.

Protect CloudTrail

Prevent disabling or modifying audit logging:

hcl
resource "aws_organizations_policy" "protect_cloudtrail" {
  name        = "ProtectCloudTrail"
  description = "Prevent modification of CloudTrail logging"
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ProtectCloudTrail"
        Effect = "Deny"
        Action = [
          "cloudtrail:DeleteTrail",
          "cloudtrail:StopLogging",
          "cloudtrail:UpdateTrail",
          "cloudtrail:PutEventSelectors"
        ]
        Resource = "*"
        Condition = {
          StringNotLike = {
            "aws:PrincipalArn" = [
              "arn:aws:iam::*:role/OrganizationAccountAccessRole",
              "arn:aws:iam::*:role/IdentityManagementDeploymentRole"
            ]
          }
        }
      }
    ]
  })
}

The condition allows specific deployment roles to manage CloudTrail while denying everyone else.

Require Encryption

Deny creation of unencrypted resources:

hcl
resource "aws_organizations_policy" "require_encryption" {
  name        = "RequireEncryption"
  description = "Require encryption on storage resources"
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyUnencryptedS3"
        Effect = "Deny"
        Action = "s3:PutObject"
        Resource = "*"
        Condition = {
          Null = {
            "s3:x-amz-server-side-encryption" = "true"
          }
        }
      },
      {
        Sid    = "DenyUnencryptedEBS"
        Effect = "Deny"
        Action = "ec2:CreateVolume"
        Resource = "*"
        Condition = {
          Bool = {
            "ec2:Encrypted" = "false"
          }
        }
      }
    ]
  })
}

Attaching SCPs

SCPs can attach to:

  • The organization root (affects all accounts except management)
  • Organizational units (affects accounts in that OU and child OUs)
  • Individual accounts
hcl
resource "aws_organizations_policy_attachment" "restrict_regions_production" {
  policy_id = aws_organizations_policy.restrict_regions.id
  target_id = aws_organizations_organizational_unit.production.id
}

The Baseline Set

For this setup, we deploy four SCPs as a baseline and attach them to the appropriate OUs:

hcl
# Region restriction on all member accounts
resource "aws_organizations_policy_attachment" "restrict_regions_root" {
  policy_id = aws_organizations_policy.restrict_regions.id
  target_id = data.aws_organizations_organization.org.roots[0].id
}

# Root user denial on all member accounts
resource "aws_organizations_policy_attachment" "deny_root_root" {
  policy_id = aws_organizations_policy.deny_root.id
  target_id = data.aws_organizations_organization.org.roots[0].id
}

# Protect audit logging on all member accounts
resource "aws_organizations_policy_attachment" "protect_cloudtrail_root" {
  policy_id = aws_organizations_policy.protect_cloudtrail.id
  target_id = data.aws_organizations_organization.org.roots[0].id
}

# Require encryption on production accounts only
resource "aws_organizations_policy_attachment" "require_encryption_production" {
  policy_id = aws_organizations_policy.require_encryption.id
  target_id = aws_organizations_organizational_unit.production.id
}

The first three attach to the organization root, so every member account inherits them. The encryption requirement applies only to the Production OU - infrastructure accounts may need more flexibility during bootstrap.

Rollout Strategy

SCPs can lock you out of accounts if misconfigured. We follow a staged rollout:

  1. Define the SCP but don't attach it
  2. Create a test OU with a non-critical account
  3. Attach and verify workloads still function
  4. Expand to production OUs once validated
  5. Monitor CloudTrail for AccessDenied events that indicate the SCP is too restrictive

For this setup, the region restriction and root user denial are safe to attach immediately - they're well-understood patterns. The CloudTrail protection and encryption requirements need more care, particularly around the deployment role exceptions.

SCP Gotchas

Management Account Immunity

SCPs never affect the management account. This is by AWS design - you can't lock yourself out of organizational management.

This also means: don't run workloads in the management account, because SCPs won't protect it.

Deny-Only Can Surprise You

A common mistake:

json
{
  "Effect": "Deny",
  "Action": "ec2:*",
  "Resource": "*",
  "Condition": {
    "StringNotEquals": {
      "ec2:InstanceType": ["t3.micro", "t3.small"]
    }
  }
}

Intention: Only allow t3.micro and t3.small instances.

Reality: Denies all EC2 actions that don't involve instance types (like describing VPCs), because ec2:InstanceType is null for those actions.

Solution: Scope the action to ec2:RunInstances specifically.

SCPs vs IAM Policies

AspectSCPIAM Policy
ScopeOrganization/OU/AccountPrincipal
EffectLimits maximum permissionsGrants permissions
Management accountNever appliesApplies normally
Use caseGuardrails, complianceDay-to-day access

Use SCPs for organization-wide rules. Use IAM policies for specific access grants.

Beyond the Baseline

Once the four core SCPs are in place, common next steps include:

RequireIMDSv2: Enforce Instance Metadata Service v2 for EC2 instances, blocking the insecure v1 endpoint:

hcl
resource "aws_organizations_policy" "require_imdsv2" {
  name        = "RequireIMDSv2"
  description = "Require EC2 instances to use IMDSv2"
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "RequireIMDSv2"
        Effect = "Deny"
        Action = "ec2:RunInstances"
        Resource = "arn:aws:ec2:*:*:instance/*"
        Condition = {
          StringNotEquals = {
            "ec2:MetadataHttpTokens" = "required"
          }
        }
      }
    ]
  })
}

DenyLeavingOrganization: Prevent member accounts from removing themselves:

hcl
{
  "Effect": "Deny",
  "Action": "organizations:LeaveOrganization",
  "Resource": "*"
}

ProtectConfig: Similar to CloudTrail protection, prevent disabling AWS Config.

The pattern is always the same: define a clear boundary, test in isolation, then attach to the appropriate level in the hierarchy. The SCP examples library has many more patterns to draw from.

Summary

SCPs provide the guardrails that make a multi-account setup trustworthy:

  • They set permission ceilings, not grants
  • Start with the four-SCP baseline: region restriction, root user denial, audit log protection, and encryption requirements
  • Test thoroughly before attaching to production OUs
  • Expand coverage as compliance requirements evolve

The investment is small - a handful of policies - but the protection is real. A misconfigured IAM policy in a member account can't disable CloudTrail, can't create resources in unapproved regions, and can't use the root user.

What's Next

With organisational boundaries enforced, the next piece is DNS. Part 10 covers Route 53 zone management, email configuration patterns, cross-account DNS access for certificate validation, and the CI/CD pipeline for DNS changes.


← Back to all posts