Service Control Policies (Preventive Controls)
TSC mapping: CC5.2 (Control activities โ preventive), CC6.1, CC6.6, CC7.1
Every other SOC 2 guide focuses on detective controls โ Config rules detect a violation, GuardDuty fires an alert, your on-call investigates. That approach has a reaction window.
Service Control Policies eliminate the reaction window. An SCP is an IAM permission boundary applied at the AWS Organizations level. When an SCP denies an action, the API call fails โ it doesn't matter what the IAM policy says. The violation never happens, so there is no finding, no alert, and no remediation required.
Detective control: "We noticed someone disabled CloudTrail. We've re-enabled it and opened a ticket."
Preventive control: "CloudTrail cannot be disabled. The API call returns
AccessDenied."
Both approaches produce SOC 2 evidence. The preventive approach produces better evidence โ you can show the auditor that the control is structurally enforced, not dependent on a human responding to an alert within an SLA.
How SCPs Workโ
AWS Organization Root
โโโ Organizational Unit (OU): Production
โโโ Account: prod-main โ SCPs apply here
โโโ Account: prod-logs โ SCPs apply here
โโโ Account: prod-security โ SCPs apply here (with SCP exceptions as needed)
- SCPs are allowlists or denylists attached to OUs or individual accounts.
- The management (root) account is never restricted by SCPs โ keep it empty except for automation.
- SCPs interact with IAM policies as an AND: both must permit an action for it to succeed.
- SCPs do not grant permissions โ they only restrict what IAM policies can grant.
Attach an SCP:
# Get your OU ID
aws organizations list-organizational-units-for-parent \
--parent-id $(aws organizations list-roots --query 'Roots[0].Id' --output text) \
--query 'OrganizationalUnits[*].[Id,Name]' --output table
# Create the policy
aws organizations create-policy \
--name "DenyCloudTrailDisable" \
--type SERVICE_CONTROL_POLICY \
--description "Prevents CloudTrail from being stopped or deleted" \
--content file://scp-deny-cloudtrail-disable.json
# Attach to an OU
aws organizations attach-policy \
--policy-id p-xxxxxxxxxx \
--target-id ou-xxxx-xxxxxxxx
SOC 2 SCP Libraryโ
1. Deny Disabling CloudTrailโ
Without this, anyone with cloudtrail:StopLogging or cloudtrail:DeleteTrail can blind your audit trail. This is the single most critical SCP for SOC 2.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyCloudTrailDisable",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/BreakGlassRole",
"arn:aws:iam::*:role/AWSControlTowerExecution"
]
}
}
}
]
}
Always include a Condition exemption for your break-glass role and any automation roles that legitimately manage trails. Without it, you lock yourself out of updating your own trail configuration.
2. Deny Disabling GuardDuty and Security Hubโ
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDisableGuardDuty",
"Effect": "Deny",
"Action": [
"guardduty:DeleteDetector",
"guardduty:DisassociateFromMasterAccount",
"guardduty:StopMonitoringMembers",
"guardduty:UpdateDetector"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityAdminRole"
}
}
},
{
"Sid": "DenyDisableSecurityHub",
"Effect": "Deny",
"Action": [
"securityhub:DisableSecurityHub",
"securityhub:DeleteMembers",
"securityhub:DisassociateMembers"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityAdminRole"
}
}
}
]
}
3. Deny Public S3 Bucketsโ
Enforces at the API level โ blocks PutBucketAcl calls that would make a bucket public, regardless of who makes them.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyS3PublicAccess",
"Effect": "Deny",
"Action": [
"s3:PutBucketPublicAccessBlock",
"s3:PutAccountPublicAccessBlock"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"s3:PublicAccessBlockConfiguration/BlockPublicAcls": "false",
"s3:PublicAccessBlockConfiguration/IgnorePublicAcls": "false",
"s3:PublicAccessBlockConfiguration/BlockPublicPolicy": "false",
"s3:PublicAccessBlockConfiguration/RestrictPublicBuckets": "false"
}
}
},
{
"Sid": "DenyS3PublicACL",
"Effect": "Deny",
"Action": [
"s3:PutBucketAcl",
"s3:PutObjectAcl"
],
"Resource": "*",
"Condition": {
"StringEqualsIgnoreCase": {
"s3:x-amz-acl": [
"public-read",
"public-read-write",
"authenticated-read"
]
}
}
}
]
}
4. Require Encryption on S3 Uploadsโ
Denies PutObject calls that don't include server-side encryption headers. No unencrypted data can land in S3.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnencryptedS3Puts",
"Effect": "Deny",
"Action": "s3:PutObject",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": [
"aws:kms",
"AES256"
]
}
}
}
]
}
5. Restrict to Approved AWS Regionsโ
Prevents resources from being accidentally or maliciously created in unapproved regions โ a common compliance gap when engineers experiment.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyNonApprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*",
"organizations:*",
"route53:*",
"budgets:*",
"waf::*",
"cloudfront:*",
"sts:*",
"support:*",
"account:*",
"health:*",
"trustedadvisor:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"us-east-1",
"us-west-2",
"eu-west-1"
]
}
}
}
]
}
Global services (IAM, Route53, CloudFront, etc.) must be in NotAction โ they don't operate in a region and will fail if you include them in the region restriction.
6. Deny Root Account Usageโ
The root account appears in CloudTrail as userIdentity.type = Root. This SCP denies all root API activity except for the specific actions that can only be taken by root.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyRootUsage",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringLike": {
"aws:PrincipalArn": "arn:aws:iam::*:root"
}
}
}
]
}
Attach this only to member accounts, never to the management account or the root OU. Root-only actions (closing accounts, restoring S3 object ownership) must remain available in the management account for break-glass scenarios.
7. Deny IAM User Creation (Enforce Identity Center)โ
If your policy is "no long-lived IAM users โ use Identity Center," enforce it here.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyIAMUserCreation",
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:CreateAccessKey"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/TerraformRole",
"arn:aws:iam::*:role/BreakGlassRole"
]
}
}
}
]
}
8. Deny KMS Key Deletionโ
Accidental or malicious KMS key deletion is catastrophic โ it permanently destroys access to all data encrypted with that key.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyKMSKeyDeletion",
"Effect": "Deny",
"Action": [
"kms:ScheduleKeyDeletion",
"kms:DeleteImportedKeyMaterial"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/KeyAdminRole"
}
}
}
]
}
9. Deny Disabling AWS Configโ
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDisableConfig",
"Effect": "Deny",
"Action": [
"config:StopConfigurationRecorder",
"config:DeleteConfigurationRecorder",
"config:DeleteDeliveryChannel",
"config:DeleteRetentionConfiguration"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityAdminRole"
}
}
}
]
}
Testing SCPs Safelyโ
Before attaching to production OUs, test using IAM policy simulator or a sandbox account:
# Simulate whether a specific principal can perform an action
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::<account>:role/DeveloperRole \
--action-names cloudtrail:StopLogging \
--resource-arns "*"
# After attaching an SCP, verify it blocks the action from a non-exempt principal
aws sts assume-role \
--role-arn arn:aws:iam::<account>:role/DeveloperRole \
--role-session-name scp-test
# Then attempt the denied action with the assumed credentials
AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... \
aws cloudtrail stop-logging --name org-trail
# Expected: An error occurred (AccessDenied)
Detective vs. Preventive Controls โ Comparisonโ
| Scenario | Config Rule (detective) | SCP (preventive) |
|---|---|---|
| Engineer disables CloudTrail | Alert fires, ticket opened, re-enable within SLA | API call denied, nothing to remediate |
| Root account used in prod | CloudWatch alarm fires, investigation required | API call denied before it executes |
| Public S3 bucket created | s3-bucket-public-access-prohibited flags it | PutBucketAcl call denied |
| Unencrypted EBS volume created | encrypted-volumes rule flags it | Still detective โ pair with launch template enforcement |
| Resource created in unapproved region | No native Config rule covers this cleanly | SCP blocks the API call |
| IAM user created instead of SSO | iam-user-no-policies-check is indirect | iam:CreateUser denied outright |
Recommended approach: Implement both. SCPs prevent violations; Config rules catch anything SCPs can't cover and provide the compliance timeline evidence auditors want to see.
SOC 2 Evidence for SCPsโ
| Evidence item | How to collect |
|---|---|
| SCP list with content | aws organizations list-policies --filter SERVICE_CONTROL_POLICY |
| SCP attachment map | aws organizations list-targets-for-policy --policy-id <id> |
| Denied API call logs | CloudTrail โ filter errorCode = AccessDenied with userAgent showing SCP block |
| SCP test results | Screen recording or log output from sandbox account testing |
Reference: AWS Organizations SCPs โ ยท SCP examples โ