Skip to content
GitHub

Service Control Policies

Service Control Policies (SCPs) define the maximum permissions available to identities in member accounts. The Data Landing Zone composes SCPs from three additive tiers and ships a library of vetted preset statements you can drop in.

Composition model

SCPs in DLZ are composed from three tiers. All tiers are additive only — a tier cannot weaken or remove statements from a tier above it.

TierApplies toConfigured via
Org baselineEvery workload accountscpBaselineStatements
Per-account-typeAll accounts of one typescpStatementsByAccountType.{development | production}
Per-accountOne specific accountDLzAccount.scpStatements

The mandatory-tags SCP is always appended after the org baseline and cannot be opted out of.

SCP preset library

DLZ ships a library of preset statements covering common deny patterns. Each preset returns an iam.PolicyStatement you can compose into any tier. Presets exempt the Control Tower execution role automatically where required.

PresetPurpose
ScpDenyActionsOutsideRegionsDeny regional API calls outside an allow-list (global services exempt).
ScpDenyServiceActionsDeny a caller-supplied list of service:action strings.
ScpDenyDisablingSecurityServicesBlock disabling GuardDuty, Macie, Security Hub, Config, CloudTrail, etc.
ScpDenyLeavingOrganizationBlock organizations:LeaveOrganization.
ScpDenyRootUserActionsBlock actions performed by the account root user.
ScpDenyRootCredentialsManagementInMemberAccountsBlock management of root credentials in member accounts.
ScpDenyIamWithoutPermissionsBoundaryRequire a permissions boundary on all IAM principals.
ScpDenyS3PublicAccessBypassBlock bypassing S3 Block Public Access settings.
ScpDenyS3ObjectLockAndRetentionBlock tampering with S3 Object Lock and retention.
ScpDenyBackupVaultLockBlock tampering with AWS Backup Vault Lock.
ScpDenyGlacierVaultLockBlock tampering with S3 Glacier Vault Lock.
ScpDenyMarketplaceSubscriptionsBlock AWS Marketplace subscription changes.
ScpDenyDomainRegistrationsBlock Route53 domain registrations and transfers.
ScpDenyDedicatedInfraAndSubscriptionsBlock dedicated hosts, capacity reservations, and similar commitments.
ScpDenyReservedCapacityPurchasesBlock purchasing reserved capacity (EC2 RIs, etc.).
ScpDenySavingsPlanPurchasesBlock purchasing Savings Plans.
ScpDenyBedrockProvisionedThroughputBlock creating Bedrock Provisioned Throughput.
ScpDenyCfnStacksWithoutStandardTagsRequire mandatory tags on CloudFormation stacks (always appended).
ScpDenyResourceCreationWithoutStandardTagsRequire mandatory tags on direct resource creation (opt-in; see below).

Defining a baseline

Use scpBaselineStatements for full control over the org baseline. Compose it from presets and your own statements.

import { App } from 'aws-cdk-lib';
import {
DataLandingZone,
ScpDenyActionsOutsideRegions,
ScpDenyDisablingSecurityServices,
ScpDenyLeavingOrganization,
ScpDenyRootUserActions,
ScpDenyServiceActions,
} from 'aws-data-landing-zone';
const app = new App();
const dlz = new DataLandingZone(app, {
scpBaselineStatements: [
ScpDenyActionsOutsideRegions.statement(['eu-west-1', 'eu-central-1']),
ScpDenyDisablingSecurityServices.statement(),
ScpDenyLeavingOrganization.statement(),
ScpDenyRootUserActions.statement(),
ScpDenyServiceActions.statement(['ecs:*', 'workspaces:*']),
],
...
});

Layering by account type

Apply additional restrictions to all accounts of one type via scpStatementsByAccountType. For example, prevent production accounts from purchasing long-term commitments while leaving development accounts unrestricted:

import {
DataLandingZone,
ScpDenyReservedCapacityPurchases,
ScpDenySavingsPlanPurchases,
} from 'aws-data-landing-zone';
const dlz = new DataLandingZone(app, {
scpStatementsByAccountType: {
production: [
ScpDenyReservedCapacityPurchases.statement(),
ScpDenySavingsPlanPurchases.statement(),
],
},
...
});

Per-account extras

Use scpStatements on a DLzAccount for one-off restrictions that only apply to a single account.

import * as iam from 'aws-cdk-lib/aws-iam';
const dlz = new DataLandingZone(app, {
organization: {
ous: {
workloads: {
accounts: [
{
name: 'data-prod',
type: DlzAccountType.PRODUCTION,
scpStatements: [
new iam.PolicyStatement({
sid: 'DenyDynamoDBDelete',
effect: iam.Effect.DENY,
actions: ['dynamodb:DeleteTable'],
resources: ['*'],
}),
],
...
},
],
},
...
},
...
},
...
});

Enforcing mandatory tags on resource creation

mandatoryTags has three enforcement layers, and none is a blanket “every resource must be tagged” gate:

  • Tag policy. Enforces allowed values on tagging operations for supported services. It cannot require a tag to be present at creation, so an untagged resource is still allowed.
  • ScpDenyCfnStacksWithoutStandardTags. Hard-denies, but only cloudformation:CreateStack. Resources created directly via console, CLI, or SDK never hit CreateStack, so they bypass it.
  • AWS Config REQUIRED_TAGS rule. Detective only. It flags non-compliant resources after the fact.

To make tag presence a hard stop on direct resource creation, add the opt-in ScpDenyResourceCreationWithoutStandardTags preset. It emits one Deny per tag key (so a single missing tag blocks the request) over a list of create actions you supply, and it exempts the Control Tower execution role.

It is not part of the baseline. You add it deliberately, because it takes three conscious choices:

  • Which actions to gate. Only include create actions that support aws:RequestTag at creation. An action that cannot carry request tags would have the tag permanently absent and be denied outright. iam:CreateGroup is one such case (its reference row lists no tag condition keys), which is why it is excluded from the IAM set below. The preset ships four ready-made action sets (in the table below), each verified against the AWS Service Authorization Reference. Verify anything you add the same way.
  • Body size. The rendered policy grows with actions × tagKeys. DLZ packs the entire baseline + account-type + per-account tier into a single SCP per account and validates that one body against the 5120-byte limit at synth (see Validation); it does not auto-split. Each action set fits its own SCP, but two large sets in one account will overflow, so plan to pick one or split across account tiers.
  • Value enforcement stays with the tag policy. This preset checks presence only.

The preset exposes four verified action sets (each a public static readonly string[]). Spread the ones you want into statements():

Action setCoversApprox. SCP body (×5 tag keys)
CORE_TAG_ON_CREATE_ACTIONSCore compute/data (EC2, RDS, DynamoDB, Lambda, EKS/ECS, SQS/SNS, KMS, Secrets Manager, ELB, Redshift)~2.8 KB
DATA_PLATFORM_TAG_ON_CREATE_ACTIONSAnalytics/ML (Glue, Athena, EMR, Firehose, MSK, OpenSearch es:CreateDomain, Redshift Serverless, Step Functions, SageMaker, Bedrock)~4.1 KB
INFRA_TAG_ON_CREATE_ACTIONSNetworking/storage/compute (CloudWatch Logs, ECR, EFS, ElastiCache, Auto Scaling, extra EC2, EKS node groups, RDS sub-resources)~3.8 KB
IAM_TAG_ON_CREATE_ACTIONSiam:CreateUser, iam:CreateRole, iam:CreatePolicy (excludes iam:CreateGroup, which has no tag support)~1.4 KB
import {
DataLandingZone,
ScpDenyResourceCreationWithoutStandardTags,
} from 'aws-data-landing-zone';
const Preset = ScpDenyResourceCreationWithoutStandardTags;
const dlz = new DataLandingZone(app, {
scpBaselineStatements: [
...Preset.statements([
...Preset.CORE_TAG_ON_CREATE_ACTIONS,
...Preset.IAM_TAG_ON_CREATE_ACTIONS,
]),
],
...
});

By default it requires the five canonical mandatory tag keys (Owner, Project, Environment, CostCenter, Domain). Pass a second argument to require a different set, e.g. when you also gate additionalMandatoryTags.

Validation

DLZ validates each account’s resolved SCP at synthesis time and fails with a clear error if:

  • the resolved policy is empty (AWS rejects empty policies)
  • more than 5 SCPs would be attached to the account
  • the policy body exceeds 5120 bytes

This catches misconfiguration before any CloudFormation deployment runs.

API References