Last reviewed: May 2026
Build the AWS services on the SAP-C02 exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have provisioned, with plain Terraform, the enterprise scaffolding every SAP-C02 multi-account question assumes — an AWS Organizations OU structure, a Service Control Policy that enforces an allowed-regions guardrail, a cross-account IAM role that shared-services workloads can assume, and a centralized CloudTrail organization trail writing into an immutable S3 bucket. This is the multi-account governed-at-scale architecture the exam tests 30%+ of the time.
Every resource is plain Terraform. Drop the snippets into a single main.tf, run terraform init, then terraform apply step-by-step.
>= 1.5 or OpenTofu >= 1.6.us-east-1 against the management account.All Organizations and IAM resources are always free. The two paid items:
Whole stack idles at $0. Destroy when done to keep the audit-log bucket from accumulating compliance evidence you may not want to retain forever.
Standard opener. Organizations is a global service, but its API endpoints are in us-east-1. Always run organization-level Terraform from us-east-1 — the SAP-C02 exam tests this exact detail under Network Connectivity and Content Delivery when scoring multi-region architectures.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = "certlabpro-sap-c02"
ManagedBy = "terraform"
}
}
}
data "aws_caller_identity" "current" {}
# Replace with the 12-digit account ID of the *shared-services* account
# we are wiring cross-account trust to in Step 4.
locals {
shared_services_account_id = "111122223333"
}If the management account isn't already an Org master, this resource creates the Organization. The feature_set = "ALL" setting enables SCPs (the consolidated billing only alternative omits SCPs entirely — SAP-C02 tests both modes). The enabled_policy_types list opts into the three policy types this lab uses; you'd add BACKUP_POLICY and AISERVICES_OPT_OUT_POLICY for those domains.
The two OUs we create — Production and NonProduction — are the SAP-C02 reference shape for guardrail scoping. SCPs attach to OUs; OUs hold accounts. Account-to-OU placement is the single biggest lever a multi-account architect has, because every SCP cascades from the OU down to every account beneath it.
resource "aws_organizations_organization" "main" {
feature_set = "ALL"
enabled_policy_types = [
"SERVICE_CONTROL_POLICY",
"TAG_POLICY",
]
aws_service_access_principals = [
"cloudtrail.amazonaws.com",
]
}
resource "aws_organizations_organizational_unit" "production" {
name = "Production"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "non_production" {
name = "NonProduction"
parent_id = aws_organizations_organization.main.roots[0].id
}SCPs are the SAP-C02 governance primitive — they're the only mechanism that can prevent a member account's administrators from doing something, full stop. The most-tested SCP pattern by far is allowed-regions restriction: even if a member account's IAM administrator grants *:* to everyone, the SCP-imposed region guardrail still blocks operations outside the allowed set.
The policy below denies every ec2:*, rds:*, and lambda:* action whose aws:RequestedRegion isn't one of us-east-1 or us-west-2. Global services (IAM, Organizations, CloudFront) are unaffected because they don't honor the RequestedRegion condition. SAP-C02 tests this exact carveout — students who deny *:* outside allowed regions accidentally break IAM and lock themselves out.
The policy attaches to both OUs from Step 2. Accounts inside those OUs are governed; the management account is unaffected (a recurring SAP-C02 trap question: "why does the SCP not block the org master?" — because by design it can't).
resource "aws_organizations_policy" "allowed_regions" {
name = "certlabpro-sap-c02-allowed-regions"
description = "Deny ec2/rds/lambda outside the allowed regions."
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyNonAllowedRegions"
Effect = "Deny"
NotAction = [
"iam:*",
"organizations:*",
"cloudfront:*",
"route53:*",
"support:*",
"sts:*",
"waf:*",
]
Resource = "*"
Condition = {
StringNotEquals = {
"aws:RequestedRegion" = ["us-east-1", "us-west-2"]
}
}
}]
})
}
resource "aws_organizations_policy_attachment" "production" {
policy_id = aws_organizations_policy.allowed_regions.id
target_id = aws_organizations_organizational_unit.production.id
}
resource "aws_organizations_policy_attachment" "non_production" {
policy_id = aws_organizations_policy.allowed_regions.id
target_id = aws_organizations_organizational_unit.non_production.id
}Cross-account IAM is the SAP-C02 shared-services primitive. The pattern: a workload account creates a role with a trust policy naming the shared-services account as the trusted principal; people or services in the shared-services account sts:AssumeRole into the workload account to do their job. This is how every SAP-C02 reference architecture handles centralized observability, audit, deployment, and data-platform-as-a-service.
We include an MFA condition (aws:MultiFactorAuthPresent = true) — the exam tests this as the difference between a trust policy that's secure and one that's just functional. The ExternalId condition is the second leg of the deputy problem defense — recommended for third-party access, but commonly seen on internal cross-account too.
The inline permission policy gives the assumed role read access to whatever the workload needs to do; for the lab we grant read-only access to the audit-log bucket we'll create in Step 5. In production you'd narrow this further.
resource "aws_iam_role" "shared_services_reader" {
name = "certlabpro-sap-c02-shared-services-reader"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${local.shared_services_account_id}:root"
}
Action = "sts:AssumeRole"
Condition = {
Bool = {
"aws:MultiFactorAuthPresent" = "true"
}
StringEquals = {
"sts:ExternalId" = "certlabpro-sap-c02-external-id"
}
}
}]
})
max_session_duration = 3600
}
# The reader-policy is wired in Step 5 once the audit bucket exists.The capstone for any SAP-C02 multi-account governance story is the organization trail — one CloudTrail trail, configured in the management account, that captures management events across every member account in the org. SAP-C02 tests this against the one trail per account anti-pattern (operationally noisy, audit-incomplete, costs more).
The S3 bucket needs a specific bucket policy that grants CloudTrail the s3:PutObject permission with the right path conventions — cloudtrail.amazonaws.com as the service principal, the aws:SourceArn matching the trail. SAP-C02 questions about why CloudTrail can't write to a centralized bucket almost always point at this policy.
With the trail in place, every API call across every account in the org lands in this bucket. The IAM role from Step 4 grants the shared-services account read access to the bucket — meaning a centralized audit team can review activity across the entire org without ever needing access to individual member accounts. That's the SAP-C02 enterprise architecture in five blocks.
resource "aws_s3_bucket" "audit_logs" {
bucket_prefix = "certlabpro-sap-c02-audit-"
}
resource "aws_s3_bucket_public_access_block" "audit_logs" {
bucket = aws_s3_bucket.audit_logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "audit_logs" {
bucket = aws_s3_bucket.audit_logs.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_policy" "audit_logs_cloudtrail" {
bucket = aws_s3_bucket.audit_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = { Service = "cloudtrail.amazonaws.com" }
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.audit_logs.arn
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = { Service = "cloudtrail.amazonaws.com" }
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.audit_logs.arn}/AWSLogs/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
}
}
},
]
})
}
resource "aws_cloudtrail" "org_trail" {
name = "certlabpro-sap-c02-org-trail"
s3_bucket_name = aws_s3_bucket.audit_logs.bucket
is_multi_region_trail = true
is_organization_trail = true
include_global_service_events = true
enable_log_file_validation = true
depends_on = [
aws_s3_bucket_policy.audit_logs_cloudtrail,
aws_organizations_organization.main,
]
}
# Cross-account IAM permissions (continued from Step 4): the role we
# created earlier now gets read access to the audit bucket so a
# shared-services audit team can query CloudTrail across the org.
resource "aws_iam_role_policy" "shared_services_audit_read" {
name = "read-audit-logs"
role = aws_iam_role.shared_services_reader.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [aws_s3_bucket.audit_logs.arn, "${aws_s3_bucket.audit_logs.arn}/*"]
}]
})
}terraform destroy tears down everything in this lab, with caveats:
terraform destroy if member accounts exist. If you joined accounts to the org during the lab, you must remove them via the AWS Organizations console first (or via the API). A management account with no member accounts can leave its own organization, but Terraform's destroy will fail if the org has children. For a clean lab, only run this against a fresh account with no member accounts joined.force_destroy = false. CloudTrail will have written at least some events into it by the time you destroy. Empty via aws s3 rm s3://<bucket> --recursive first.SAP-C02 covers an enormous architectural surface that this lab can't fit — Control Tower (no Terraform-clean way to provision; AWS API requires console interaction for landing-zone setup), Service Catalog (multi-account products), AWS Config aggregator for org-wide compliance, AWS Backup vault policies across accounts, AWS RAM (Resource Access Manager), CloudFormation StackSets for multi-account IaC, Direct Connect Gateway, Aurora Global Database, AWS Migration Hub, AWS Application Migration Service (AWS MGN), the Storage Gateway family, and the various hybrid-AWS services (Outposts, Local Zones, Wavelength).
We stick to the four core enterprise primitives because they're the foundation every other SAP-C02 pattern attaches to. RAM shares resources between accounts in the Organization from Step 2. Config aggregator pulls compliance findings into the management account that owns the trail from Step 5. StackSets roll out IaC into the OU structure from Step 2 using the cross-account role pattern from Step 4. Build the foundation first.
For the surfaces not provisioned, the Browse, Playbook, and Editorial sections of this cert page have conceptual coverage. The SAP-C02 exam tests recognition of these patterns far more than hands-on provisioning of each — the lab is about getting the four foundational shapes into muscle memory.