Architect an AWS Landing Zone from Scratch

Build a multi-account AWS Organization with SCPs, StackSets, and IAM Identity Center.

Introduction

30 Second Summary

When a company grows fast through acquisitions, each new team brings its own cloud accounts with zero oversight. One day the auditors show up and ask who has access to what, and nobody can answer.

In this project, you will architect and deploy a complete AWS governance foundation that locks down a multi-account environment before SOC 2 auditors arrive. You will use Terraform and Cursor IDE to build the Organization structure, preventive controls, and centralized access management.

What You'll Build

A fully functional AWS Organization with 4 OUs, 2 member accounts, 6 preventive Service Control Policies, a StackSets security baseline, and IAM Identity Center permission sets, all deployed via Terraform and validated against SOC 2 audit requirements.

By the end of this project, you'll have:

  • A live AWS Organization with 4 OUs and 2 member accounts governed by 6 preventive SCPs that address every auditor finding.
  • A StackSets security baseline that auto-deploys CloudTrail, Config, and GuardDuty to every current and future member account.
  • Centralized identity governance with permission sets and a cross-account audit role proving least-privilege access to the compliance team.
  • A polished HTML engagement report with architecture diagrams, KPI metrics, and a risk register for the board and auditors.
  • Secret Mission: Scale governance for the next acquisition with tag policies, cross-OU VPC sharing, and an Account Vending Machine.

Are there any prerequisites?

You need an AWS account that can serve as the Organization management account. No existing Organization is required.

You should be familiar with basic IAM concepts (users, roles, policies) and understand JSON policy syntax.

Verify Your Environment with Cursor Agent

Before you architect a governance foundation that protects every account in NovaBurst's Organization, you need a verified environment. A single version mismatch or misconfigured credential will surface as a cryptic Terraform error 20 minutes into the build.

In this step, you will use Cursor's agent to verify your toolchain, confirm your AWS credentials can manage Organizations, and scaffold the project directory that will hold your entire landing zone.

In this step, get ready to:
  • Verify Terraform and AWS CLI are installed at the correct versions.
  • Confirm your AWS credentials have OrganizationsFullAccess, IAMFullAccess, and AWSCloudFormationFullAccess.
  • Create the project directory structure and initialize Terraform with the AWS provider.
Launch Cursor and verify your tools with the agent

Cursor's agent can check your system for required tools and report back with version numbers. This saves you from manually hunting through terminal output.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open Cursor's agent by pressing Cmd+I (macOS) or Ctrl+I (Windows) to open Composer.
  • Switch to Agent mode using the dropdown at the top of the Composer panel.
  • Paste the following prompt into the agent:
Check my system for the following tools and report back with their version numbers:
1. Terraform (I need version 1.15.x or higher)
2. AWS CLI (I need version 2.x)

Run the version check commands in the terminal and tell me if anything is missing or outdated.

What does this prompt do?

The agent opens an integrated terminal, runs terraform --version and aws --version, then reports the results.

If Terraform is missing or below 1.15.0, the agent will offer to install or upgrade it.

The agent should report back with output similar to:

  • Terraform version 1.15.2 (or higher within 1.15.x).
  • AWS CLI version 2.x.

✔️ Both tools are installed at the correct versions

Your toolchain is ready. Move on to verifying your AWS credentials below.

ⓧ Terraform is missing or outdated

Ask the Cursor agent to install Terraform 1.15.2 for you.

  • Paste this follow-up prompt into the agent:
Install Terraform 1.15.2 on my system. Use the official HashiCorp installation method for my operating system.

After installation completes, ask the agent to re-run the version check to confirm.

ⓧ AWS CLI is missing

Ask the Cursor agent to install AWS CLI v2 for you.

  • Paste this follow-up prompt into the agent:
Install AWS CLI version 2 on my system using the official AWS installation method for my operating system.

After installation, re-run the version check to confirm both tools are present.

Confirm your AWS credentials have the required permissions

Your AWS credentials need three specific managed policies to build the landing zone: OrganizationsFullAccess (to create the Org and OUs), IAMFullAccess (to create audit roles and permission sets), and AWSCloudFormationFullAccess (to deploy StackSets).

  • Verify your identity by running this command in Cursor's integrated terminal:
aws sts get-caller-identity

You should see a JSON response with your Account, UserId, and Arn fields.

  • Verify Organizations access by running this command:
aws organizations describe-organization 2>&1 || echo "No Organization exists yet - that's expected. Permission check passed if you don't see AccessDenied."

What should I see?

If no Organization exists yet, you will see an AWSOrganizationsNotInUseException error. That is expected and correct.

The important thing is that you do NOT see AccessDeniedException. An access denied error means your credentials lack the required Organizations permissions.

🙋‍♀️ Seeing AccessDeniedException?

Your IAM user or role is missing the OrganizationsFullAccess managed policy. Attach it in the IAM console under your user's permissions tab, then re-run the command.

If you are using temporary credentials (SSO or assumed role), make sure your session has not expired.

Help me troubleshoot my AWS credentials for Organizations access

Create the project directory and initialize Terraform

The landing zone project needs a clear directory structure so Terraform configs, CloudFormation templates, operational scripts, and documentation each have a home.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project directory structure and move into it by running these commands:
mkdir -p novaburst-landing-zone/{terraform,docs,scripts,cloudformation}
cd novaburst-landing-zone
  • Confirm the directories exist by running:
ls

You should see cloudformation, docs, scripts, and terraform listed in the output.

Why this directory structure?

The terraform/ directory holds the Org module (OUs, SCPs, accounts). cloudformation/ holds StackSets templates that deploy security baselines. scripts/ stores operational runbooks and teardown automation. docs/ stores your architecture diagrams and the final engagement report.

Now you need to initialize the Terraform workspace with the AWS provider pinned to a stable version.

  • Open the novaburst-landing-zone folder in Cursor by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the novaburst-landing-zone folder on your Desktop, then click Open.
  • Create terraform/main.tf by right-clicking the terraform folder in Cursor's Explorer sidebar and selecting New File.
  • Name the file main.tf.
  • Paste the following code into terraform/main.tf:
terraform {
  required_providers {
    # Pin the AWS provider to the 6.44.x minor release line
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.44"
    }
  }
}

# Default provider configuration for the management account
provider "aws" {
  region = "us-east-1"
}

What does this code do?

The required_providers block tells Terraform to download the AWS provider from the HashiCorp registry at version ~> 6.44. The ~> operator means "any patch version within 6.44" (e.g., 6.44.0, 6.44.1) but never 6.45 or higher.

The provider block sets us-east-1 as the default region. AWS Organizations is a global service, but Terraform still requires a region for API calls.

  • Create another file in the terraform folder named versions.tf.
  • Paste the following code into terraform/versions.tf:
terraform {
  # Ensure anyone running this code has Terraform 1.15.0 or higher
  required_version = ">= 1.15.0"
}

What does this code do?

The required_version constraint prevents anyone on the team from accidentally running this code with an older Terraform version that might not support the resources you will create.

  • Save both files.
  • Initialize the Terraform workspace by running this command in Cursor's terminal (make sure you are inside the terraform/ directory):
cd terraform
terraform init

You should see output ending with Terraform has been successfully initialized! and a message that the AWS provider was installed.

Seeing a provider installation error?

Make sure you are running terraform init from inside the terraform/ directory, not the project root. The main.tf file must be in your current directory.

If you see a network timeout, check your internet connection. Terraform downloads the provider binary from registry.terraform.io.

Help me fix my terraform init error

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete terraform/main.tf:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.44"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

And here is the complete terraform/versions.tf:

terraform {
  required_version = ">= 1.15.0"
}

Your environment is locked in and your Terraform workspace is ready to accept infrastructure definitions. Next up, you will design the OU topology that determines how every SCP, StackSet, and permission boundary flows through NovaBurst's Organization.

Design the NovaBurst Org Topology

Your environment is ready. Now comes the most important part of any governance project: the design decisions that determine the shape of everything downstream.

Before writing a single line of Terraform, you need to decide how NovaBurst's accounts will be organized. A bad OU structure means SCPs either block too much (breaking legitimate workflows) or too little (leaving audit gaps). You will use Cursor to reason through the trade-offs, then capture your decisions in two artifacts an auditor would expect to see.

In this step, get ready to:
  • Reason through the 4-OU strategy using Cursor's inline chat.
  • Generate a Mermaid org topology diagram showing Root, OUs, accounts, and SCP attachment points.
  • Write an Architecture Decision Record documenting the rationale and trade-offs.
Reason through the OU strategy with Cursor

An Organizational Unit (OU) is a container inside AWS Organizations that groups accounts together. Every Service Control Policy (SCP) you attach to an OU applies to every account inside it.

This means the OU structure IS the governance boundary. Accounts in the same OU share the same guardrails. Accounts in different OUs can have different guardrails.

  • Open the Cursor inline chat by pressing Cmd+K (macOS) or Ctrl+K (Windows).
  • Paste the following prompt to reason through the OU design:
I am designing an OU hierarchy for a gaming company with multiple acquired studios.
Explain the purpose of Security, Sandbox, Workloads, and Suspended OUs and why
this structure ensures that a compromised studio account cannot affect other studios.
Also explain why 4 OUs is the right number - not 3 and not 6.

Why does this prompt matter?

You are thinking like a Principal Architect here, not a Terraform operator. The question is not "how do I create an OU" but "why this OU and not another one." Cursor's response helps you internalize the reasoning before you commit it to an ADR.

The answer lies in SCP inheritance boundaries. Each OU gets its own SCP set. A compromised Sandbox account cannot escape into Workloads because the two OUs have different SCP chains.

Read Cursor's response carefully. You should see an explanation covering these four OUs:

  • Security OU: holds centralized logging and audit accounts. Tightly restricted. Only the security team has access.
  • Sandbox OU: holds acquired studio sandbox environments. Permissive enough for experimentation but monitored.
  • Workloads OU: holds production and staging environments. Strictly governed with tighter SCPs than Sandbox.
  • Suspended OU: quarantine zone for compromised or decommissioned accounts during incident response.

What is the Suspended OU for?

During an incident, you move a compromised account into the Suspended OU. That OU has a Deny-All SCP attached. The account instantly loses all permissions without you needing to find and revoke individual IAM policies. It is your emergency kill switch.

Generate the Mermaid org topology diagram

A Mermaid diagram gives you a visual representation of the org structure that renders directly in Markdown files on GitHub.

  • Open the Cursor Composer by pressing Cmd+Shift+I (macOS) or Ctrl+Shift+I (Windows).
  • Paste this prompt to have Cursor generate the topology diagram:
Create a file at docs/org-topology.md with a Mermaid diagram showing the NovaBurst
Org topology. The diagram should show:
- Root at the top
- 4 OUs: Security, Sandbox, Workloads, Suspended
- Two studio accounts (Studio A Sandbox Account, Studio B Sandbox Account) under Sandbox OU
- A note below the diagram explaining SCP attachment points: Root for global SCPs,
  each OU for OU-specific SCPs.

Use a top-down graph (graph TD). Keep it simple and readable.
  • Review the generated file. Verify Cursor created it at docs/org-topology.md.
  • Confirm the diagram shows Root connected to all 4 OUs, and both studio accounts nested under the Sandbox OU.

The file should look like this:

# NovaBurst Org Topology

```mermaid
graph TD
  Root[Root] --> Security[Security OU]
  Root --> Sandbox[Sandbox OU]
  Root --> Workloads[Workloads OU]
  Root --> Suspended[Suspended OU]
  Sandbox --> StudioA[Studio A Sandbox Account]
  Sandbox --> StudioB[Studio B Sandbox Account]
```

SCP attachment points: Root (global SCPs), each OU (OU-specific SCPs).

What does this diagram show?

The topology makes the SCP inheritance chain visible. Any SCP attached at Root flows down to every OU and every account. An SCP attached to the Sandbox OU only affects accounts inside it.

This is the key insight for the SAP-C02 exam: SCPs are evaluated as an intersection (logical AND) with IAM policies. An explicit Deny at any level in the hierarchy overrides any Allow, even if the account has AdministratorAccess.

  • Save docs/org-topology.md.
  • Confirm the file exists by running:
ls docs/

You should see org-topology.md listed in the output.

Don't see the file?

Make sure you are running ls docs/ from the novaburst-landing-zone project root. If Cursor saved the file to a different path, move it into the docs/ directory.

If the file content doesn't match the expected output, you can copy the code block above directly into the file. Help me find my org-topology.md file

Write the Architecture Decision Record

An Architecture Decision Record (ADR) captures the reasoning behind a design choice so future engineers (and auditors) understand why you made it. This is the artifact you point to when someone asks "why not 6 OUs?" three months from now.

  • In Cursor Composer, paste this prompt to generate the ADR:
Create a file at docs/adr-001-ou-strategy.md with the following Architecture Decision
Record for NovaBurst Interactive's OU strategy:

- Title: ADR-001: OU Strategy for NovaBurst Interactive
- Decision: Use 4 OUs: Security, Sandbox, Workloads, Suspended
- Rationale section explaining each OU's purpose:
  - Security OU: centralized logging and audit accounts, tightly restricted
  - Sandbox OU: acquired studio sandbox environments, permissive but monitored
  - Workloads OU: production and staging workloads, strictly governed
  - Suspended OU: quarantine zone for compromised or decommissioned accounts
- SCP Inheritance section explaining: SCPs attach at OU level to govern all accounts
  within. Root-level SCPs apply universally. Leaf accounts inherit all ancestor SCPs.
- Trade-offs section: fewer OUs reduce management overhead but reduce SCP granularity.
  4 OUs balance governance precision with operational simplicity.
  • Review the generated file at docs/adr-001-ou-strategy.md.

The file should look like this:

# ADR-001: OU Strategy for NovaBurst Interactive

## Decision
Use 4 OUs: Security, Sandbox, Workloads, Suspended.

## Rationale
- Security OU: centralized logging and audit accounts, tightly restricted.
- Sandbox OU: acquired studio sandbox environments, permissive but monitored.
- Workloads OU: production and staging workloads, strictly governed.
- Suspended OU: quarantine zone for compromised or decommissioned accounts.

## SCP Inheritance
SCPs attach at OU level to govern all accounts within. Root-level SCPs apply universally. Leaf accounts inherit all ancestor SCPs.

## Trade-offs
Fewer OUs reduce management overhead but reduce SCP granularity. 4 OUs balance governance precision with operational simplicity.

Why attach SCPs at the OU level instead of the account level?

Attaching SCPs to individual accounts does not scale. When NovaBurst acquires another studio with 5 accounts, you would need to attach 6 SCPs to each of the 5 accounts individually. That is 30 policy attachments to manage.

With OU-level attachment, you drop all 5 accounts into the Sandbox OU and they inherit every SCP automatically. Zero additional policy attachments needed.

  • Save docs/adr-001-ou-strategy.md.
  • Confirm both docs exist by running:
ls docs/

You should see both adr-001-ou-strategy.md and org-topology.md listed.

Cursor generated something different?

Cursor may add extra sections or different formatting. That is fine as long as the four required sections are present: Decision, Rationale, SCP Inheritance, and Trade-offs. If any section is missing, ask Cursor to add it or copy the code block above directly into the file.

Help me fix my ADR file

💡 What is the SCP inheritance chain?

When an IAM principal in a member account makes an API call, AWS evaluates the SCP chain from Root down to the account's OU. The effective permission is the intersection of ALL SCPs in the chain plus the account's IAM policies.

SCPs never grant access. They only restrict the maximum possible permissions. You still need IAM policies to actually allow actions within that ceiling. This is the most-tested concept on the SAP-C02 exam.

✔️ Awesome, I've got both files!

Great. Make sure both files are saved. You will reference the topology diagram and ADR in later steps when attaching SCPs to specific OUs.

ⓧ I'd like to double check the full code

Here is the exact content for both files.

# NovaBurst Org Topology

```mermaid
graph TD
  Root[Root] --> Security[Security OU]
  Root --> Sandbox[Sandbox OU]
  Root --> Workloads[Workloads OU]
  Root --> Suspended[Suspended OU]
  Sandbox --> StudioA[Studio A Sandbox Account]
  Sandbox --> StudioB[Studio B Sandbox Account]
```

SCP attachment points: Root (global SCPs), each OU (OU-specific SCPs).
# ADR-001: OU Strategy for NovaBurst Interactive

## Decision
Use 4 OUs: Security, Sandbox, Workloads, Suspended.

## Rationale
- Security OU: centralized logging and audit accounts, tightly restricted.
- Sandbox OU: acquired studio sandbox environments, permissive but monitored.
- Workloads OU: production and staging workloads, strictly governed.
- Suspended OU: quarantine zone for compromised or decommissioned accounts.

## SCP Inheritance
SCPs attach at OU level to govern all accounts within. Root-level SCPs apply universally. Leaf accounts inherit all ancestor SCPs.

## Trade-offs
Fewer OUs reduce management overhead but reduce SCP granularity. 4 OUs balance governance precision with operational simplicity.

Your governance blueprint is locked in. You have a visual topology, a written rationale, and a clear understanding of why each OU exists. Next up, you will turn this design into a live AWS Organization with Terraform.

Bootstrap the Organization with Terraform

Your OU topology exists as a Mermaid diagram and an ADR. Now it is time to make it real. In this step, you will turn that architectural design into a live AWS Organization with actual OUs and member accounts that inherit governance from the moment they are created.

You will prompt Cursor Composer to scaffold the entire Org module in a single multi-file prompt. Then you will review the generated code against your ADR to confirm every architectural decision translates correctly into Terraform resources.

In this step, get ready to:
  • Prompt Cursor Composer to scaffold the Terraform Org module with OUs and member accounts.
  • Review the generated code against your ADR to verify correctness.
  • Apply with Terraform and validate the live Organization in the AWS Console.
Scaffold the Org module with Cursor Composer

A Principal Architect does not hand-type HCL line by line. You make the structural decisions, then direct Cursor to implement them in a single coherent prompt. One multi-file prompt ensures all resource references resolve correctly across the module.

  • Open Cursor Composer by pressing Cmd+I (macOS) or Ctrl+I (Windows).
  • Paste the following prompt into Composer:
Create a new file terraform/org.tf that defines the NovaBurst AWS Organization.

Requirements:
1. Create an aws_organizations_organization resource named "novaburst" with feature_set = "ALL" and enabled_policy_types including both SERVICE_CONTROL_POLICY and TAG_POLICY.
2. Create 4 OUs (Security, Sandbox, Workloads, Suspended), each as an aws_organizations_organizational_unit resource. Each OU's parent_id should reference the organization's root ID via aws_organizations_organization.novaburst.roots[0].id.
3. Create 2 member accounts (StudioA-Sandbox and StudioB-Sandbox) as aws_organizations_account resources. Place both in the Sandbox OU using parent_id = aws_organizations_organizational_unit.sandbox.id. Use email addresses studio-a-sandbox@novaburst.example.com and studio-b-sandbox@novaburst.example.com.

Do not modify existing files. Only create terraform/org.tf.
  • Press Enter to submit the prompt.
  • Wait for Cursor to generate the file. Click Accept All when the generated code appears.

What did Cursor just create?

Cursor generated a single file called org.tf inside your terraform/ directory. This file contains three resource types: the Organization itself, 4 Organizational Units, and 2 member accounts.

All resources live in one file because they form a single dependency chain. The OUs depend on the Organization root ID, and the accounts depend on the Sandbox OU ID. Terraform resolves these references automatically.

🙋‍♀️ Cursor generated multiple files or modified main.tf?

If Cursor split the code across multiple files or modified existing files, click Reject All and re-submit the prompt. Make sure the last line of your prompt says "Do not modify existing files. Only create terraform/org.tf."

Cursor not generating code?

Review the generated code against your ADR

An architect always reviews generated code before applying. Your ADR documented specific design decisions. Now you verify that Cursor translated every one of them correctly into Terraform resources.

  • Open terraform/org.tf in the editor.
  • Verify the following against your ADR:
  • The Organization has feature_set = "ALL" (required for SCPs to work).
  • The enabled_policy_types list includes SERVICE_CONTROL_POLICY.
  • All 4 OUs (Security, Sandbox, Workloads, Suspended) reference the same parent: aws_organizations_organization.novaburst.roots[0].id.
  • Both member accounts use parent_id = aws_organizations_organizational_unit.sandbox.id to land in the Sandbox OU.
  • Each account has a unique email address.

Your org.tf should look like this:

resource "aws_organizations_organization" "novaburst" {
  feature_set = "ALL"
  enabled_policy_types = ["SERVICE_CONTROL_POLICY", "TAG_POLICY"]
}

resource "aws_organizations_organizational_unit" "security" {
  name      = "Security"
  parent_id = aws_organizations_organization.novaburst.roots[0].id
}

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

resource "aws_organizations_organizational_unit" "workloads" {
  name      = "Workloads"
  parent_id = aws_organizations_organization.novaburst.roots[0].id
}

resource "aws_organizations_organizational_unit" "suspended" {
  name      = "Suspended"
  parent_id = aws_organizations_organization.novaburst.roots[0].id
}

resource "aws_organizations_account" "studio_a_sandbox" {
  name      = "StudioA-Sandbox"
  email     = "studio-a-sandbox@novaburst.example.com"
  parent_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_account" "studio_b_sandbox" {
  name      = "StudioB-Sandbox"
  email     = "studio-b-sandbox@novaburst.example.com"
  parent_id = aws_organizations_organizational_unit.sandbox.id
}

Why does each OU reference roots[0].id?

Every AWS Organization has exactly one Root. Terraform exposes it as a list, so roots[0].id grabs the single Root ID. All top-level OUs must be children of this Root. This matches your ADR: SCPs attached at Root apply universally, then each OU adds its own guardrails.

📣 Replace the email addresses before applying

AWS requires globally unique, real email addresses for each member account. Replace the example.com emails with addresses you control. A common pattern is to use Gmail plus-addressing: yourname+studio-a@gmail.com and yourname+studio-b@gmail.com.

Each email can only be used for one AWS account ever. Do not reuse emails from previous accounts.

💡 What is the FullAWSAccess SCP?

When you enable SCPs, AWS automatically attaches a policy called FullAWSAccess to every OU and account. This is an Allow * policy that permits everything. Your deny SCPs (coming in the next step) will layer on top of it. An explicit Deny always overrides an Allow.

This is the deny-list strategy: allow everything by default, then explicitly block what you do not want. It is the pattern AWS recommends for enterprise Organizations.

  • If anything differs from the expected code or your ADR, edit org.tf manually or re-prompt Cursor with corrections.
  • Save org.tf once the code matches.
Apply Terraform and validate in the AWS Console

Your code is reviewed and saved. Now Terraform will create the live Organization, all 4 OUs, and both member accounts in a single apply.

  • Open the integrated terminal in Cursor by pressing Ctrl+` (both macOS and Windows).
  • Initialize and apply Terraform by running these commands:
cd terraform
terraform init
terraform apply
  • Terraform will display a plan showing 7 resources to create (1 Organization, 4 OUs, 2 accounts).
  • Type yes when prompted to confirm the apply.

What does terraform apply do here?

Terraform calls the AWS Organizations API to create each resource in dependency order. First the Organization, then the 4 OUs (which depend on the Root ID), then the 2 accounts (which depend on the Sandbox OU ID). Each new member account gets an auto-created OrganizationAccountAccessRole IAM role that trusts the management account.

The apply takes 1-2 minutes. Account creation is the slowest part because AWS provisions a fresh account behind the scenes.

Seeing an AlreadyInOrganization error?

This means your management account already belongs to an existing Organization. You must either use a different AWS account or delete the existing Organization first. Deleting an Organization requires removing all member accounts and disabling all policy types.

Help me resolve my Organizations error.

🙋‍♀️ Email address already in use?

AWS requires globally unique emails per account. If you see a ConstraintViolationException about the email, choose different email addresses. Try Gmail plus-addressing with a different suffix.

Once Terraform shows "Apply complete! Resources: 7 added", validate the Organization in the AWS Console.

  • Open the AWS Management Console in your browser.
  • Type Organizations in the search bar at the top and select AWS Organizations.
  • Click AWS accounts in the left sidebar.
  • Expand the Root node. You should see all 4 OUs: Security, Sandbox, Workloads, and Suspended.
  • Expand the Sandbox OU. You should see StudioA-Sandbox and StudioB-Sandbox listed as member accounts.

The console tree should match the Mermaid diagram you created in Step 2. The design is now live infrastructure.

  • Record the following IDs from the console (click each OU to find its ID). You will need them in later steps:
  • Org ID (starts with o-).
  • Root ID (starts with r-).
  • All 4 OU IDs (each starts with ou-).

Use Terraform output instead

You can also retrieve these IDs by running terraform show in your terminal. The state file stores every ID Terraform created. This is faster than clicking through the console for each OU.

💡 Why does the management account stay outside the OUs?

The management account sits at the Root level. SCPs do not restrict the management account. They only restrict member accounts. This is a critical SAP-C02 concept and a common exam trap.

The StackSets baseline you deploy in Step 5 also cannot target the management account. You must secure it separately with IAM policies and CloudTrail configured directly.

✔️ Awesome, I've got everything!

Great. Make sure you have saved org.tf and have your Org ID, Root ID, and OU IDs noted down.

ⓧ I'd like to double check the full code

Here is the complete org.tf file:

resource "aws_organizations_organization" "novaburst" {
  feature_set = "ALL"
  enabled_policy_types = ["SERVICE_CONTROL_POLICY", "TAG_POLICY"]
}

resource "aws_organizations_organizational_unit" "security" {
  name      = "Security"
  parent_id = aws_organizations_organization.novaburst.roots[0].id
}

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

resource "aws_organizations_organizational_unit" "workloads" {
  name      = "Workloads"
  parent_id = aws_organizations_organization.novaburst.roots[0].id
}

resource "aws_organizations_organizational_unit" "suspended" {
  name      = "Suspended"
  parent_id = aws_organizations_organization.novaburst.roots[0].id
}

resource "aws_organizations_account" "studio_a_sandbox" {
  name      = "StudioA-Sandbox"
  email     = "studio-a-sandbox@novaburst.example.com"
  parent_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_account" "studio_b_sandbox" {
  name      = "StudioB-Sandbox"
  email     = "studio-b-sandbox@novaburst.example.com"
  parent_id = aws_organizations_organizational_unit.sandbox.id
}

And the unchanged main.tf:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.44"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

And the unchanged versions.tf:

terraform {
  required_version = ">= 1.15.0"
}

Your Organization is live. Four OUs and two member accounts exist exactly as your ADR designed them. Next up, you will build the SCP library that locks down every auditor finding. These deny policies attach at the OU level, and the moment they are applied, every account in that OU inherits them instantly.

Build the SCP Library

Your Organization structure is live, but right now it has no guardrails. Any IAM principal in a member account can do anything AWS allows.

Service Control Policies fix this by defining the maximum permissions ceiling for every account in an OU. An SCP never grants access. It only restricts what IAM policies are allowed to do.

In this step, you will prompt Cursor to generate 6 SCPs that each close one of the SOC 2 findings flagged by the auditors. Then you will red-team your own policies and deploy them to the Organization.

In this step, get ready to:
  • Generate 6 SCP JSON policy files addressing each SOC 2 auditor finding.
  • Red-team the SCP library with a Cursor agent acting as the SOC 2 auditor.
  • Deploy all SCPs to the Organization and attach them to the correct OUs.
Prompt Cursor to generate the SCP library

Each SCP maps directly to one auditor finding. Your job as the architect is to understand what each SCP prevents and what traps exist in its design.

  • Open Cursor Composer by pressing Cmd+I (macOS) or Ctrl+I (Windows).
  • Paste the following prompt to generate all 6 SCP policy files at once:
Create a directory `terraform/policies/` and generate 6 SCP JSON policy files inside it. Each policy addresses a specific SOC 2 finding for a gaming company (NovaBurst Interactive) that acquired multiple studios.

File 1: `deny-root-actions.json`
- Deny ALL actions when the principal ARN matches the root user pattern `arn:aws:iam::*:root`
- Auditor finding: "No control preventing root access in member accounts."

File 2: `deny-non-approved-regions.json`
- Deny all actions EXCEPT global services (iam, sts, cloudfront, route53, support) when the requested region is NOT us-east-1 or eu-west-1
- Use NotAction to exempt global services
- Auditor finding: "No guardrail preventing GDPR data from landing in non-EU regions."

File 3: `deny-public-s3-bucket-policies.json`
- Deny s3:PutBucketPolicy when the ACL condition equals public-read
- Auditor finding: "Player telemetry data exposed via public buckets."

File 4: `deny-oversized-instances.json`
- Deny ec2:RunInstances on instance resources when the instance type does NOT match sizes up to and including 4xlarge (nano, micro, small, medium, large, xlarge, 2xlarge, 4xlarge) plus r5.4xlarge specifically
- Use StringNotLike with wildcard patterns like "*.nano", "*.micro" etc.
- Business need: Studios spinning up GPU monsters without approval.

File 5: `deny-leave-organization.json`
- Deny organizations:LeaveOrganization
- Auditor finding: "No control preventing accounts from leaving governance."

File 6: `deny-disable-security-services.json`
- Three statement blocks: one denying GuardDuty disable actions (DeleteDetector, DisassociateFromMasterAccount, StopMonitoringMembers), one denying Config disable actions (DeleteConfigurationRecorder, StopConfigurationRecorder, DeleteDeliveryChannel), one denying CloudTrail disable actions (DeleteTrail, StopLogging, UpdateTrail)
- Auditor finding: "Detection tools can be disabled by account admins."

Use descriptive Sid fields in each statement. All policies use "Version": "2012-10-17".

Why 6 separate files instead of one big policy?

Each SCP has a maximum size limit, and every character counts. Keeping policies separate means you can attach them independently to different OUs.

For example, the oversized-instance restriction might only apply to Sandbox while the deny-leave-organization policy applies at Root to every account.

Cursor generates all 6 files in your terraform/policies/ directory. Review the output against these two critical policies to make sure Cursor got the architecture right.

The region restriction SCP is the most architecturally complex. Verify your deny-non-approved-regions.json matches this structure:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyNonApprovedRegions",
      "Effect": "Deny",
      "NotAction": [
        "iam:*",
        "sts:*",
        "cloudfront:*",
        "route53:*",
        "support:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotIn": {
          "aws:RequestedRegion": ["us-east-1", "eu-west-1"]
        }
      }
    }
  ]
}

What is NotAction and why does it matter here?

NotAction means "deny everything EXCEPT these services." Without it, you would block IAM, STS, CloudFront, Route 53, and Support, which are global services that do not operate in a specific region.

This is a common SAP-C02 exam trap. If you use a plain Action deny with region conditions, global services break and users cannot even authenticate.

Next, verify your deny-root-actions.json matches this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRootActions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:PrincipalArn": "arn:aws:iam::*:root"
        }
      }
    }
  ]
}

Why does this SCP NOT protect the management account?

SCPs only restrict member accounts. The management account is always exempt from SCP enforcement. This means your Organization's management account root user can still do anything.

This is the single most tested SCP concept on the SAP-C02 exam. You must secure the management account through other means (MFA, IAM policies, minimal usage).

💡 What about the other 4 SCPs?

  • deny-public-s3-bucket-policies.json blocks s3:PutBucketPolicy when the ACL is public-read. Note: this is preventive. It does not retroactively fix existing public buckets.
  • deny-oversized-instances.json blocks ec2:RunInstances for anything larger than 4xlarge. Studios cannot spin up GPU instances without removing this SCP.
  • deny-leave-organization.json blocks organizations:LeaveOrganization. This is preventive. A detective control (CloudWatch Event) would fire AFTER the account already left.
  • deny-disable-security-services.json protects GuardDuty, Config, and CloudTrail from being disabled by account admins. This is layered defense: preventive controls protecting detective controls.
  • Confirm all 6 files exist in terraform/policies/ by running:
ls terraform/policies/

You should see all 6 JSON files listed: deny-root-actions.json, deny-non-approved-regions.json, deny-public-s3-bucket-policies.json, deny-oversized-instances.json, deny-leave-organization.json, and deny-disable-security-services.json.

Missing files or wrong directory?

Make sure the policies/ directory is inside terraform/ (not at the project root). If Cursor placed the files elsewhere, move them so the path is terraform/policies/.

Cursor didn't generate the SCP policies I need

Red-team your SCPs with a Cursor agent

Before deploying, you want to find gaps the auditor would flag. You can use Cursor as a red-team agent that reviews your policies from the auditor's perspective.

  • Open a new Cursor chat by pressing Cmd+L (macOS) or Ctrl+L (Windows).
  • Paste the following red-team prompt:
You are the SOC 2 auditor reviewing NovaBurst's SCP library in terraform/policies/. Review all 6 JSON policy files.

For each policy, answer:
1. What specific attack or misconfiguration does this SCP prevent?
2. What does this Organization STILL allow that I would flag as a critical finding?
3. Are there any bypasses or edge cases that weaken this control?

Be adversarial. Think like an attacker, not a defender. What gaps remain in this SCP library?

What will the red-team agent typically flag?

Common findings include: the management account is unprotected by SCPs (true by design), the public S3 policy only blocks one specific ACL pattern (not all public access configurations), and the oversized instance SCP does not cover Spot or Reserved Instance launches.

These are acceptable trade-offs for a Phase 1 deployment. Document the gaps for the risk register rather than trying to solve everything now.

  • Read through the agent's findings.
  • If the agent suggests any changes to the 6 policy files that align with the target architecture, accept those edits.
  • For findings that represent future work (management account protection, broader S3 public access blocks), note them mentally. You will document these in the risk register in Step 8.

Deny-list strategy depends on FullAWSAccess

Your Organization uses the deny-list strategy, which means the default FullAWSAccess SCP remains attached at Root. This SCP allows all actions. Your custom Deny SCPs then carve out specific restrictions.

If you accidentally remove FullAWSAccess without adding an equivalent Allow SCP, all actions become implicitly denied and member accounts are locked out completely.

Deploy SCPs to your Organization with Terraform

The JSON policies define what to deny. Now you need Terraform resources to create the SCPs in AWS and attach them to the correct OUs.

  • Open Cursor Composer again with Cmd+I (macOS) or Ctrl+I (Windows).
  • Prompt Cursor to generate a new file terraform/scps.tf with the following requirements:
Create `terraform/scps.tf` with:

1. Six `aws_organizations_policy` resources (type SERVICE_CONTROL_POLICY) that load their content from the JSON files in `policies/` using `file("${path.module}/policies/<filename>.json")`:
   - deny_root_actions (name: "DenyRootActions")
   - deny_non_approved_regions (name: "DenyNonApprovedRegions")
   - deny_public_s3 (name: "DenyPublicS3BucketPolicies")
   - deny_oversized_instances (name: "DenyOversizedInstances")
   - deny_leave_org (name: "DenyLeaveOrganization")
   - deny_disable_security (name: "DenyDisableSecurityServices")

2. Policy attachments:
   - Attach deny_root_actions, deny_non_approved_regions, deny_public_s3, and deny_oversized_instances to the SANDBOX OU (aws_organizations_organizational_unit.sandbox.id)
   - Attach deny_leave_org and deny_disable_security to the ORG ROOT (aws_organizations_organization.novaburst.roots[0].id)

Reference the existing resources from org.tf for OU IDs and Root ID.

Why do some SCPs attach to Root and others to Sandbox?

The deny-leave-organization and deny-disable-security SCPs apply universally. No account should ever leave governance or disable security tooling. Attaching at Root means every current and future OU inherits them.

The other 4 SCPs target the Sandbox OU specifically because NovaBurst's acquired studio accounts live there. Production workloads in the Workloads OU might need different size limits or region access.

Verify the generated scps.tf matches this structure:

resource "aws_organizations_policy" "deny_root_actions" {
  name    = "DenyRootActions"
  content = file("${path.module}/policies/deny-root-actions.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_non_approved_regions" {
  name    = "DenyNonApprovedRegions"
  content = file("${path.module}/policies/deny-non-approved-regions.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_public_s3" {
  name    = "DenyPublicS3BucketPolicies"
  content = file("${path.module}/policies/deny-public-s3-bucket-policies.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_oversized_instances" {
  name    = "DenyOversizedInstances"
  content = file("${path.module}/policies/deny-oversized-instances.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_leave_org" {
  name    = "DenyLeaveOrganization"
  content = file("${path.module}/policies/deny-leave-organization.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_disable_security" {
  name    = "DenyDisableSecurityServices"
  content = file("${path.module}/policies/deny-disable-security-services.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy_attachment" "deny_root_sandbox" {
  policy_id = aws_organizations_policy.deny_root_actions.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_policy_attachment" "deny_regions_sandbox" {
  policy_id = aws_organizations_policy.deny_non_approved_regions.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_policy_attachment" "deny_public_s3_sandbox" {
  policy_id = aws_organizations_policy.deny_public_s3.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_policy_attachment" "deny_oversized_sandbox" {
  policy_id = aws_organizations_policy.deny_oversized_instances.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

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

resource "aws_organizations_policy_attachment" "deny_disable_security_root" {
  policy_id = aws_organizations_policy.deny_disable_security.id
  target_id = aws_organizations_organization.novaburst.roots[0].id
}

What does this Terraform code do?

  • The 6 aws_organizations_policy resources create the SCPs in AWS Organizations. Each loads its JSON content from the local file using file().
  • The aws_organizations_policy_attachment resources bind each SCP to a target (either an OU ID or the Root ID). This is what makes the SCP actually enforce its deny rules.
  • Notice that FullAWSAccess is NOT referenced here. AWS automatically attaches it to Root when you enable SCPs. You are layering deny policies on top of it.
  • Save terraform/scps.tf.
  • Deploy the SCPs by running these commands from your terraform/ directory:
terraform init
terraform apply
  • Terraform will show a plan with 12 new resources (6 policies + 6 attachments).
  • Type yes when prompted to confirm the apply.

After the apply completes, you should see Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

  • Verify in the AWS Console by navigating to AWS Organizations.
  • Click Policies in the left sidebar.
  • Select Service control policies.

You should see all 6 custom SCPs listed alongside the default FullAWSAccess policy.

Seeing an error about policy content or syntax?

The most common issue is invalid JSON in the policy files. Check for trailing commas after the last item in arrays, missing closing braces, or mismatched quotes.

If Terraform reports "MalformedPolicyDocument", the JSON structure is valid but the policy content violates AWS rules. Verify that each Version field is exactly 2012-10-17 and that Action/NotAction values are valid AWS action strings.

My terraform apply is failing with SCP errors

✔️ Awesome, I've got everything!

Make sure you have saved all files and confirmed the 6 SCPs are visible in the AWS Console.

ⓧ I'd like to double check the full code

Here is the complete terraform/scps.tf file:

resource "aws_organizations_policy" "deny_root_actions" {
  name    = "DenyRootActions"
  content = file("${path.module}/policies/deny-root-actions.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_non_approved_regions" {
  name    = "DenyNonApprovedRegions"
  content = file("${path.module}/policies/deny-non-approved-regions.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_public_s3" {
  name    = "DenyPublicS3BucketPolicies"
  content = file("${path.module}/policies/deny-public-s3-bucket-policies.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_oversized_instances" {
  name    = "DenyOversizedInstances"
  content = file("${path.module}/policies/deny-oversized-instances.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_leave_org" {
  name    = "DenyLeaveOrganization"
  content = file("${path.module}/policies/deny-leave-organization.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy" "deny_disable_security" {
  name    = "DenyDisableSecurityServices"
  content = file("${path.module}/policies/deny-disable-security-services.json")
  type    = "SERVICE_CONTROL_POLICY"
}

resource "aws_organizations_policy_attachment" "deny_root_sandbox" {
  policy_id = aws_organizations_policy.deny_root_actions.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_policy_attachment" "deny_regions_sandbox" {
  policy_id = aws_organizations_policy.deny_non_approved_regions.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_policy_attachment" "deny_public_s3_sandbox" {
  policy_id = aws_organizations_policy.deny_public_s3.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

resource "aws_organizations_policy_attachment" "deny_oversized_sandbox" {
  policy_id = aws_organizations_policy.deny_oversized_instances.id
  target_id = aws_organizations_organizational_unit.sandbox.id
}

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

resource "aws_organizations_policy_attachment" "deny_disable_security_root" {
  policy_id = aws_organizations_policy.deny_disable_security.id
  target_id = aws_organizations_organization.novaburst.roots[0].id
}

And here are all 6 policy JSON files:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRootActions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:PrincipalArn": "arn:aws:iam::*:root"
        }
      }
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyNonApprovedRegions",
      "Effect": "Deny",
      "NotAction": [
        "iam:*",
        "sts:*",
        "cloudfront:*",
        "route53:*",
        "support:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotIn": {
          "aws:RequestedRegion": ["us-east-1", "eu-west-1"]
        }
      }
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyPublicS3BucketPolicies",
      "Effect": "Deny",
      "Action": "s3:PutBucketPolicy",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-acl": "public-read"
        }
      }
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOversizedInstances",
      "Effect": "Deny",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:*:*:instance/*",
      "Condition": {
        "StringNotLike": {
          "ec2:InstanceType": [
            "*.nano", "*.micro", "*.small", "*.medium", "*.large",
            "*.xlarge", "*.2xlarge", "*.4xlarge",
            "r5.4xlarge"
          ]
        }
      }
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyLeaveOrganization",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    }
  ]
}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDisableGuardDuty",
      "Effect": "Deny",
      "Action": [
        "guardduty:DeleteDetector",
        "guardduty:DisassociateFromMasterAccount",
        "guardduty:StopMonitoringMembers"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyDisableConfig",
      "Effect": "Deny",
      "Action": [
        "config:DeleteConfigurationRecorder",
        "config:StopConfigurationRecorder",
        "config:DeleteDeliveryChannel"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyDisableCloudTrail",
      "Effect": "Deny",
      "Action": [
        "cloudtrail:DeleteTrail",
        "cloudtrail:StopLogging",
        "cloudtrail:UpdateTrail"
      ],
      "Resource": "*"
    }
  ]
}

Your Organization now has teeth. Every account in the Sandbox OU is governed by 6 preventive guardrails that address each auditor finding. Next, you will deploy the detective controls that make sure every account is actively monitored, because preventive controls alone are only half the equation.

Deploy the StackSets Security Baseline

Your SCPs now prevent bad actions from happening. But an auditor asks a different question: "How do you prove that every account is being monitored?" Preventive controls block threats. Detective controls catch the threats that slip through or evolve after deployment.

In this step, you will deploy CloudFormation StackSets with service-managed permissions. StackSets auto-deploy CloudTrail, Config, and GuardDuty to every current and future member account. When NovaBurst acquires MoonRift Studios next quarter, their accounts inherit monitoring on day one. No human has to remember.

In this step, get ready to:
  • Activate trusted access for CloudFormation StackSets on the management account.
  • Generate CloudFormation templates for CloudTrail, Config, and GuardDuty baselines.
  • Deploy StackSets with auto-deployment so no account goes unmonitored.
Understand the StackSets architecture and activate trusted access

Before deploying anything, internalize why StackSets is the right mechanism here and what traps to watch for.

Why StackSets instead of deploying per-account?

StackSets with service-managed permissions use AWS Organizations trusted access to automatically deploy CloudFormation stacks to every account in a target OU or the entire Org. When a new account joins, StackSets detects it and deploys the baseline without manual intervention.

The alternative is manually deploying templates in each account. That works for 2 accounts but becomes an audit liability at 20. StackSets gives you provable, automated coverage.

❌ Architect traps to internalize

  • StackSets do NOT deploy to the management account. Even if the management account is in a targeted OU, CloudFormation skips it. You must secure the management account separately.
  • Delegated administrators have full org-wide deployment permissions. They cannot be scoped to specific OUs. Choose your delegated admin carefully.
  • Auto-deployment is configured at the StackSet level, not per-OU or per-account. It triggers when accounts are added, removed, or moved between OUs.

Trusted access creates service-linked roles in your management account and member accounts. These roles allow CloudFormation to deploy stacks across the Organization without you manually creating cross-account IAM roles.

  • Activate trusted access for CloudFormation StackSets by running this command in your terminal:
aws organizations enable-aws-service-access --service-principal member.org.stacksets.cloudformation.amazonaws.com

What does this command do?

This grants CloudFormation StackSets permission to operate across your Organization. It creates two service-linked roles: AWSServiceRoleForCloudFormationStackSetsOrgAdmin in the management account and AWSServiceRoleForCloudFormationStackSetsOrgMember in each member account.

  • Verify trusted access is active by running:
aws organizations list-aws-service-access-for-organization --query "EnabledServicePrincipals[?ServicePrincipal=='member.org.stacksets.cloudformation.amazonaws.com']"

You should see a result with the service principal and the date it was enabled. If the result is an empty array, run the enable command again.

Getting an access denied error?

This command must be run from the management account with OrganizationsFullAccess permissions. Verify your AWS CLI is configured with the correct credentials by running aws sts get-caller-identity and confirming it shows your management account ID.

Help me troubleshoot the trusted access activation for StackSets

Generate CloudFormation templates for the security baseline

Each StackSet needs a CloudFormation template that defines what to deploy. You need three templates: one for CloudTrail (audit logging), one for AWS Config (resource state tracking), and one for GuardDuty (threat detection).

  • Create the CloudFormation directory by running:
mkdir cloudformation
  • Open Cursor Composer and prompt it to generate all three CloudFormation templates with this prompt:
Generate three CloudFormation templates in the cloudformation/ directory for our NovaBurst security baseline StackSets:

1. cloudtrail-baseline.yaml: Create an org-wide CloudTrail trail named NovaBurstOrgTrail. Include an S3 bucket named novaburst-cloudtrail-${AWS::AccountId} for log storage. Enable multi-region trail and global service events.

2. config-baseline.yaml: Create an AWS Config configuration recorder named NovaBurstConfigRecorder. Record all supported resource types including global resources.

3. guardduty-baseline.yaml: Create a GuardDuty detector with Enable set to true.

Keep each template minimal and focused on the single service. Use AWSTemplateFormatVersion 2010-09-09 with a Description field.

Why three separate templates instead of one?

Each template becomes its own StackSet. If GuardDuty fails to deploy in one account, it does not block CloudTrail or Config from deploying. Isolation means partial failures do not compromise the entire baseline.

  • Verify Cursor generated the three files. Open each one and compare against the expected templates below.

Your cloudtrail-baseline.yaml should look like this:

AWSTemplateFormatVersion: '2010-09-09'
Description: Org-wide CloudTrail baseline
Resources:
  CloudTrailBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub 'novaburst-cloudtrail-${AWS::AccountId}'
  OrgTrail:
    Type: AWS::CloudTrail::Trail
    Properties:
      TrailName: NovaBurstOrgTrail
      S3BucketName: !Ref CloudTrailBucket
      IsLogging: true
      IsMultiRegionTrail: true
      IncludeGlobalServiceEvents: true

What does this template do?

  • Creates an S3 bucket per account using the account ID in the name to ensure uniqueness.
  • Creates a multi-region CloudTrail trail that logs API calls from all regions and global services (IAM, STS).
  • The auditor can now point at this and say: every API call in every account is recorded.

Your config-baseline.yaml should look like this:

AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config recorder baseline
Resources:
  ConfigRecorder:
    Type: AWS::Config::ConfigurationRecorder
    Properties:
      Name: NovaBurstConfigRecorder
      RecordingGroup:
        AllSupported: true
        IncludeGlobalResourceTypes: true

What does this template do?

Config records the configuration state of every supported AWS resource in the account. When something changes (a security group rule opens, a bucket policy updates), Config captures the before-and-after. This is how the auditor proves "we know every change that happened."

Your guardduty-baseline.yaml should look like this:

AWSTemplateFormatVersion: '2010-09-09'
Description: GuardDuty enablement baseline
Resources:
  GuardDutyDetector:
    Type: AWS::GuardDuty::Detector
    Properties:
      Enable: true

What does this template do?

GuardDuty is a threat detection service that continuously monitors for malicious activity. Enabling the detector is all it takes. GuardDuty analyzes VPC Flow Logs, DNS logs, and CloudTrail events to surface threats like credential compromise or crypto-mining.

  • Save all three files in your cloudformation/ directory.
  • Confirm the files exist by running:
ls cloudformation/

You should see cloudtrail-baseline.yaml, config-baseline.yaml, and guardduty-baseline.yaml listed.

Cursor generated different template content?

Cursor may add extra resources like S3 bucket policies or delivery channels. The templates above are the minimal baseline. If Cursor added extras, you can keep them or trim to match the versions above. The critical fields are the resource types and their core properties.

Help me review what Cursor generated for my StackSets templates

Deploy StackSets and enable auto-deployment

Now you will create three StackSets targeting the Organization Root. Every member account under the Root receives a stack instance. Auto-deployment ensures future accounts get the baseline the moment they join.

  • Deploy the CloudTrail StackSet by running this command:
aws cloudformation create-stack-set \
  --stack-set-name NovaBurst-CloudTrail-Baseline \
  --template-body file://cloudformation/cloudtrail-baseline.yaml \
  --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false

What does this command do?

  • --permission-model SERVICE_MANAGED uses the trusted access you just activated. No manual IAM roles needed.
  • --auto-deployment Enabled=true means any account added to the Org in the future automatically gets this stack.
  • RetainStacksOnAccountRemoval=false cleans up resources if an account leaves the Org.
  • Deploy the Config StackSet by running:
aws cloudformation create-stack-set \
  --stack-set-name NovaBurst-Config-Baseline \
  --template-body file://cloudformation/config-baseline.yaml \
  --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false
  • Deploy the GuardDuty StackSet by running:
aws cloudformation create-stack-set \
  --stack-set-name NovaBurst-GuardDuty-Baseline \
  --template-body file://cloudformation/guardduty-baseline.yaml \
  --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false

The StackSets are created but have no stack instances yet. You need to tell them which OU or Root to target.

  • Get your Organization Root ID by running:
aws organizations list-roots --query "Roots[0].Id" --output text

You should see a value like r-xxxx. Copy this value for the next commands.

  • Create stack instances for all three StackSets targeting the Org Root. Run each command, replacing the Root ID:
aws cloudformation create-stack-instances \
  --stack-set-name NovaBurst-CloudTrail-Baseline \
  --deployment-targets OrganizationalUnitIds=[[ORG_ROOT_ID="your Organization Root ID (e.g. r-ab12)"]] \
  --regions us-east-1
aws cloudformation create-stack-instances \
  --stack-set-name NovaBurst-Config-Baseline \
  --deployment-targets OrganizationalUnitIds=[[ORG_ROOT_ID="your Organization Root ID (e.g. r-ab12)"]] \
  --regions us-east-1
aws cloudformation create-stack-instances \
  --stack-set-name NovaBurst-GuardDuty-Baseline \
  --deployment-targets OrganizationalUnitIds=[[ORG_ROOT_ID="your Organization Root ID (e.g. r-ab12)"]] \
  --regions us-east-1

Why target the Root instead of individual OUs?

Targeting the Organization Root means every OU and every account underneath it receives the stack. You could target individual OUs for more granular control, but the auditor wants universal coverage. Root gives you that in one command.

  • Wait 1-2 minutes for the stack instances to deploy, then verify by running:
aws cloudformation list-stack-instances --stack-set-name NovaBurst-CloudTrail-Baseline --query "Summaries[].{Account:Account,Status:Status}" --output table

You should see your two sandbox accounts (StudioA-Sandbox and StudioB-Sandbox) listed with a status of CURRENT. This confirms CloudTrail is now active in both member accounts.

Seeing OUTDATED or INOPERABLE status?

If the status shows OUTDATED, the stack instance is still deploying. Wait another minute and check again. If it shows INOPERABLE, check the detailed status reason for permission errors.

Help me debug my StackSets deployment status

  • Repeat the verification for the other two StackSets:
aws cloudformation list-stack-instances --stack-set-name NovaBurst-Config-Baseline --query "Summaries[].{Account:Account,Status:Status}" --output table

aws cloudformation list-stack-instances --stack-set-name NovaBurst-GuardDuty-Baseline --query "Summaries[].{Account:Account,Status:Status}" --output table

All six stack instances (two accounts across three StackSets) should show CURRENT status.

What about the management account?

Notice that the management account does NOT appear in the stack instances list. This is the architect trap: StackSets skip the management account even when targeting the Root. You must enable CloudTrail, Config, and GuardDuty in the management account separately.

✔️ Awesome, I've got everything!

Great. Double check that all three YAML files are saved in your cloudformation/ directory and that your StackSets show auto-deployment enabled.

ⓧ I'd like to double check the full code

Here are the three CloudFormation templates in their final form:

AWSTemplateFormatVersion: '2010-09-09'
Description: Org-wide CloudTrail baseline
Resources:
  CloudTrailBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub 'novaburst-cloudtrail-${AWS::AccountId}'
  OrgTrail:
    Type: AWS::CloudTrail::Trail
    Properties:
      TrailName: NovaBurstOrgTrail
      S3BucketName: !Ref CloudTrailBucket
      IsLogging: true
      IsMultiRegionTrail: true
      IncludeGlobalServiceEvents: true
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config recorder baseline
Resources:
  ConfigRecorder:
    Type: AWS::Config::ConfigurationRecorder
    Properties:
      Name: NovaBurstConfigRecorder
      RecordingGroup:
        AllSupported: true
        IncludeGlobalResourceTypes: true
AWSTemplateFormatVersion: '2010-09-09'
Description: GuardDuty enablement baseline
Resources:
  GuardDutyDetector:
    Type: AWS::GuardDuty::Detector
    Properties:
      Enable: true

Your detective controls are now deployed across the Organization with auto-deployment active. When the auditor asks "how do you ensure every account has logging?" you show them the StackSets auto-deployment configuration. No human intervention required. Next up, you will configure centralized identity management so no one in NovaBurst has standing admin access.

Configure Identity Center and Cross-Account Audit Role

Your Organization now has preventive guardrails (SCPs) and detective tooling (StackSets baseline) deployed across every account.

The next auditor question is: "How do humans access these accounts?" If the answer is "shared IAM credentials with no expiry," that is a critical finding. IAM Identity Center solves this by providing session-based access that expires automatically and logs every assumption to CloudTrail.

In this step, get ready to:
  • Enable IAM Identity Center and create 3 permission sets representing NovaBurst's access tiers.
  • Deploy a cross-account audit role that the Security team can assume during incident response.
  • Understand how permission sets become IAM roles with session expiry, proving least-privilege to the auditor.
Enable IAM Identity Center and generate permission sets

Permission sets are the Identity Center abstraction that maps a named access tier (like "PlatformAdmin") to a managed IAM policy (like AdministratorAccess). When you assign a permission set to a user for a specific account, Identity Center creates an IAM role in that account with a trust policy for SSO.

NovaBurst needs three tiers: PlatformAdmin for platform engineers, StudioReadOnly for studio developers who need visibility, and FinanceViewer for cost tracking.

  • In the AWS Management Console, navigate to IAM Identity Center.
  • If Identity Center is not yet enabled, click Enable.
  • Accept the default identity source (Identity Center directory) and click Confirm.

Why enable Identity Center manually first?

Terraform's data.aws_ssoadmin_instances data source reads the existing Identity Center instance ARN. If Identity Center is not enabled yet, the data source returns empty and Terraform fails.

Enabling it via the console first gives Terraform something to read from.

Now you will prompt Cursor to generate the Terraform code for all three permission sets.

  • Open Cursor Composer by pressing Cmd+Shift+I (macOS) or Ctrl+Shift+I (Windows).
  • Enter the following prompt to generate the Identity Center Terraform file:
Generate a Terraform file at terraform/identity-center.tf that creates 3 IAM Identity Center permission sets for NovaBurst Interactive.

Requirements:
- Use a data source `aws_ssoadmin_instances` named "main" to fetch the SSO instance ARN.
- Reference the instance ARN as: tolist(data.aws_ssoadmin_instances.main.arns)[0]
- Each permission set needs session_duration = "PT8H".

The 3 permission sets:
1. PlatformAdmin - attach AWS managed policy AdministratorAccess (arn:aws:iam::aws:policy/AdministratorAccess)
2. StudioReadOnly - attach AWS managed policy ViewOnlyAccess (arn:aws:iam::aws:policy/job-function/ViewOnlyAccess)
3. FinanceViewer - attach AWS managed policy Billing (arn:aws:iam::aws:policy/job-function/Billing)

Use aws_ssoadmin_permission_set for each set and aws_ssoadmin_managed_policy_attachment to attach the managed policy. Name the resources platform_admin, studio_readonly, and finance_viewer respectively.

What does this prompt produce?

Cursor generates terraform/identity-center.tf with a data source to look up the existing Identity Center instance, then three permission sets each paired with a managed policy attachment.

The 8-hour session duration means that even PlatformAdmin access expires after a workday. The auditor sees this and checks "session-based access" off their list.

  • Review the generated terraform/identity-center.tf file in Cursor.
  • Verify the file matches the following code exactly:
data "aws_ssoadmin_instances" "main" {}

resource "aws_ssoadmin_permission_set" "platform_admin" {
  name             = "PlatformAdmin"
  instance_arn     = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "platform_admin" {
  instance_arn       = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.platform_admin.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

resource "aws_ssoadmin_permission_set" "studio_readonly" {
  name             = "StudioReadOnly"
  instance_arn     = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "studio_readonly" {
  instance_arn       = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.studio_readonly.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}

resource "aws_ssoadmin_permission_set" "finance_viewer" {
  name             = "FinanceViewer"
  instance_arn     = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "finance_viewer" {
  instance_arn       = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.finance_viewer.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/job-function/Billing"
}

What does each permission set map to?

  • PlatformAdmin maps to AdministratorAccess. This is the platform engineering team who builds shared infrastructure.
  • StudioReadOnly maps to ViewOnlyAccess. Studio developers can observe resources but cannot modify anything.
  • FinanceViewer maps to Billing. Finance can see cost data for per-studio chargeback without seeing infrastructure details.
  • Save terraform/identity-center.tf.
  • If Cursor's output does not match, manually adjust it to match the code above.

Cursor generated different resource names or structure?

LLM output is non-deterministic. If Cursor used different resource names (like admin_set instead of platform_admin), replace the generated code with the exact code block above. The resource names must match for later steps to reference them correctly.

Help me fix my identity-center.tf file to match the expected structure.

Generate the cross-account audit role

The audit role is a "break-glass" mechanism. During an incident, the Security team needs to inspect a compromised studio account without having permanent standing access.

The role trusts only the Security OU account, meaning only principals in that account can assume it. Once assumed, the session has ReadOnlyAccess and SecurityAudit policies. It cannot modify anything.

  • In Cursor Composer, enter the following prompt to generate the audit role:
Generate a Terraform file at terraform/audit-role.tf that creates a cross-account audit IAM role named "NovaBurstAuditRole".

Requirements:
- Add a variable "security_account_id" (type string) with description "Account ID of the Security OU account that will assume the audit role".
- The role's trust policy allows sts:AssumeRole from the Security account root principal: arn:aws:iam::${var.security_account_id}:root
- Use jsonencode() for the assume_role_policy.
- Attach two AWS managed policies via aws_iam_role_policy_attachment:
  1. ReadOnlyAccess (arn:aws:iam::aws:policy/ReadOnlyAccess) - resource name: audit_readonly
  2. SecurityAudit (arn:aws:iam::aws:policy/SecurityAudit) - resource name: audit_security
- Reference the role by aws_iam_role.novaburst_audit_role.name in attachments.

Why trust only the Security account?

The trust policy uses the Security OU account's root ARN as the principal. Only IAM entities within that specific account can call sts:AssumeRole on this role.

This is the principle of least privilege applied to cross-account access. No other account in the Organization can assume this role, even if they have AdministratorAccess locally.

  • Review the generated terraform/audit-role.tf file in Cursor.
  • Verify the file matches the following code exactly:
variable "security_account_id" {
  description = "Account ID of the Security OU account that will assume the audit role"
  type        = string
}

resource "aws_iam_role" "novaburst_audit_role" {
  name = "NovaBurstAuditRole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::${var.security_account_id}:root" }
        Action    = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "audit_readonly" {
  role       = aws_iam_role.novaburst_audit_role.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

resource "aws_iam_role_policy_attachment" "audit_security" {
  role       = aws_iam_role.novaburst_audit_role.name
  policy_arn = "arn:aws:iam::aws:policy/SecurityAudit"
}

What does this code do?

  • The variable block takes the Security account's 12-digit ID as input. You will supply this when running terraform apply.
  • The assume_role_policy is the trust relationship. It says: "Only the Security account can wear this hat."
  • ReadOnlyAccess lets the audit role read all resources. SecurityAudit adds access to security-specific metadata like CloudTrail events and IAM credential reports.
  • Save terraform/audit-role.tf.
  • If Cursor's output does not match, replace it with the exact code above.

Now apply both new files together. Run the following in your terminal from the terraform/ directory:

terraform apply
  • Terraform will prompt you for the security_account_id variable. Enter the 12-digit account ID of your Security OU account.
  • Review the plan. You should see 7 new resources: 3 permission sets, 3 managed policy attachments, and 1 IAM role with 2 policy attachments.
  • Type yes and press Enter to apply.

Find your Security account ID

If you do not have a dedicated Security OU account yet, use your management account ID as a placeholder. In a production environment, you would create a dedicated audit account in the Security OU and use its ID here.

  • Confirm Terraform outputs Apply complete! with all resources created.
  • In the AWS Console, navigate to IAM Identity Center, then select Permission sets from the left sidebar.
  • You should see PlatformAdmin, StudioReadOnly, and FinanceViewer listed.

Terraform apply failed?

  • If you see an error about aws_ssoadmin_instances returning empty, Identity Center is not enabled. Go back to the console and enable it.
  • If you see a permissions error, verify your CLI credentials have IAMFullAccess and AWSSSOMasterAccountAdministrator policies attached.

Help me troubleshoot my terraform apply failure for Identity Center and audit role resources.

Understand the session-based access architecture

This is the architectural insight the SOC 2 auditor cares about most. The entire access model you just built eliminates standing credentials.

How Identity Center proves least-privilege to the auditor

When a user logs into Identity Center and selects a permission set for an account, here is what happens behind the scenes:

  • Identity Center creates a temporary IAM role in the target account with a trust policy for AWS SSO.
  • The user receives temporary credentials (access key, secret key, session token) that last for the session_duration you configured (8 hours).
  • When the session expires, access stops. No passwords to rotate, no keys to revoke.
  • Every role assumption is logged in CloudTrail with the user's identity, timestamp, and target account.

This is what "zero standing credentials" means in practice. Nobody has persistent access to any account. They request it, use it, and it vanishes.

💡 Architect trap: SCPs still apply to assumed roles

Even if a PlatformAdmin permission set grants AdministratorAccess, the SCPs attached to the account's OU still restrict what that role can do. A PlatformAdmin in the Sandbox OU cannot launch instances in eu-central-1 because the region-restriction SCP blocks it.

Effective permissions are always the intersection of SCP ceiling and IAM role permissions. SCPs win.

✔️ Awesome, I've got everything!

Great. Double check that both terraform/identity-center.tf and terraform/audit-role.tf are saved.

ⓧ I'd like to double check the full code

Here is the full terraform/identity-center.tf:

data "aws_ssoadmin_instances" "main" {}

resource "aws_ssoadmin_permission_set" "platform_admin" {
  name             = "PlatformAdmin"
  instance_arn     = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "platform_admin" {
  instance_arn       = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.platform_admin.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

resource "aws_ssoadmin_permission_set" "studio_readonly" {
  name             = "StudioReadOnly"
  instance_arn     = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "studio_readonly" {
  instance_arn       = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.studio_readonly.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}

resource "aws_ssoadmin_permission_set" "finance_viewer" {
  name             = "FinanceViewer"
  instance_arn     = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_managed_policy_attachment" "finance_viewer" {
  instance_arn       = tolist(data.aws_ssoadmin_instances.main.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.finance_viewer.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/job-function/Billing"
}

Here is the full terraform/audit-role.tf:

variable "security_account_id" {
  description = "Account ID of the Security OU account that will assume the audit role"
  type        = string
}

resource "aws_iam_role" "novaburst_audit_role" {
  name = "NovaBurstAuditRole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::${var.security_account_id}:root" }
        Action    = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "audit_readonly" {
  role       = aws_iam_role.novaburst_audit_role.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

resource "aws_iam_role_policy_attachment" "audit_security" {
  role       = aws_iam_role.novaburst_audit_role.name
  policy_arn = "arn:aws:iam::aws:policy/SecurityAudit"
}

Your Organization now has the complete access governance layer. Preventive controls (SCPs) set the ceiling. Detective controls (StackSets baseline) watch for anomalies. Identity controls (Permission Sets + audit role) ensure nobody has standing access. Next up, you will prove all of this actually works by running compliance smoke tests.

Prove Compliance and Generate Operational Artifacts

Your governance infrastructure is deployed. Permission sets govern identity access. The audit role enables cross-account incident response. But a SOC 2 auditor does not accept "we deployed it" as evidence. They need proof that controls work.

In this step, you will run smoke tests that prove your SCPs actively block unauthorized actions. You will verify the audit role works cross-account. Then you will generate the operational documentation that an auditor expects to see on day one: diagrams, runbooks, and monitoring scripts.

In this step, get ready to:
  • Run SCP smoke tests that prove GDPR region guardrails and governance-escape blocking work.
  • Verify cross-account audit role assumption from the Security account.
  • Generate an SCP precedence diagram, operational runbook, and StackSets drift check script.
Run SCP inheritance smoke tests

An auditor wants to see that your preventive controls actually prevent. The best evidence is a live test showing the SCP returning AccessDenied when someone attempts a restricted action from a governed account.

You need to run these commands as the StudioA-Sandbox member account. To do that, you will assume the OrganizationAccountAccessRole that AWS automatically created in the member account when it joined the Organization.

What is OrganizationAccountAccessRole?

When you create a member account via AWS Organizations, AWS auto-creates an IAM role called OrganizationAccountAccessRole in that account. It trusts the management account, giving you admin access to member accounts for testing and operations.

  • Assume the role into StudioA-Sandbox by running this command (replace the account ID with your StudioA-Sandbox account ID from the AWS Console):
aws sts assume-role --role-arn arn:aws:iam::[[STUDIO_A_ACCOUNT_ID="your StudioA-Sandbox 12-digit account ID"]]:role/OrganizationAccountAccessRole --role-session-name SCPTest
  • Copy the AccessKeyId, SecretAccessKey, and SessionToken from the output.
  • Export these credentials as environment variables by running:
export AWS_ACCESS_KEY_ID=your-access-key-here
export AWS_SECRET_ACCESS_KEY=your-secret-key-here
export AWS_SESSION_TOKEN=your-session-token-here

Why environment variables?

Exporting temporary credentials as environment variables lets you run subsequent CLI commands as the member account without modifying your AWS config. These credentials expire after one hour.

  • Confirm you are operating as StudioA-Sandbox by running:
aws sts get-caller-identity

You should see an ARN containing your StudioA-Sandbox account ID and the role name OrganizationAccountAccessRole.

  • Test the GDPR region guardrail by attempting to launch an EC2 instance in a non-approved region:
aws ec2 run-instances --region eu-central-1 --image-id ami-0123456789abcdef0 --instance-type t3.micro

You should see an AccessDenied error. This proves your DenyNonApprovedRegions SCP is blocking actions in eu-central-1 because only us-east-1 and eu-west-1 are approved.

Why does this prove the SCP works?

The OrganizationAccountAccessRole has full administrator access at the IAM level. Without the SCP, this call would succeed. The AccessDenied error proves the SCP ceiling is enforced above IAM permissions.

This is the key architectural concept: SCPs define the maximum permissions boundary. Even AdministratorAccess cannot bypass a Deny SCP.

  • Test the governance-escape guardrail by attempting to leave the Organization:
aws organizations leave-organization

You should see an AccessDenied error. This confirms that no member account can remove itself from governance, regardless of its IAM permissions.

  • Clear the temporary credentials so your terminal returns to using your management account:
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

Not seeing AccessDenied?

If the region test returns a different error (like InvalidAMIID), the SCP is still working. The AMI ID is intentionally fake. What matters is that you do NOT see a successful instance launch or a permissions-related success message.

If you see "You are not authorized" on the leave-organization call, that is the same as AccessDenied. The SCP is blocking the action.

If neither command returns any access error, verify your SCP attachments in the AWS Organizations console under the Sandbox OU.

Help me troubleshoot my SCP smoke test.

Test cross-account audit role assumption

The security team needs a way to investigate member accounts during incidents without having standing access. The NovaBurstAuditRole you created in Step 6 provides this. Now you need to prove it works by assuming it from the Security account.

  • Assume the NovaBurstAuditRole in StudioA-Sandbox from your management account by running:
aws sts assume-role --role-arn arn:aws:iam::[[STUDIO_A_ACCOUNT_ID="your StudioA-Sandbox 12-digit account ID"]]:role/NovaBurstAuditRole --role-session-name AuditTest
  • Export the returned credentials as environment variables (same process as the SCP smoke test above).
  • Confirm the audit role identity by running:
aws sts get-caller-identity

You should see an ARN containing your StudioA-Sandbox account ID and the role name NovaBurstAuditRole. This confirms the security team can investigate any member account on demand.

Why is this important for auditors?

This proves three things to the SOC 2 auditor. First, the security team has no standing credentials in member accounts. Second, all access is session-based and time-limited. Third, every role assumption is logged in CloudTrail, creating a complete audit trail of who accessed what and when.

  • Clear the temporary credentials after your test:
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

Getting AccessDenied on the audit role?

The audit role trusts your Security OU account, not the management account. If you are running this from the management account directly, verify that the security_account_id variable in terraform/audit-role.tf matches the account you are calling from. If you do not have a dedicated Security account yet, temporarily update the trust policy to include your management account ID.

Help me debug my audit role assumption.

Generate operational documentation with Cursor

Auditors expect three categories of documentation beyond the controls themselves: architecture diagrams that explain how the system works, runbooks that explain how to operate it, and monitoring scripts that detect drift.

You will prompt Cursor to generate all three in one pass. This is the kind of documentation that takes hours to write manually but minutes when Cursor has full context of your codebase.

  • Open Cursor's Composer by pressing Cmd+Shift+I (macOS) or Ctrl+Shift+I (Windows).
  • Paste the following prompt into Composer to generate all three operational artifacts:
Generate three operational documentation files for NovaBurst's AWS Organization governance:

1. `docs/scp-precedence.md` - A Mermaid diagram showing how an API request is evaluated through the SCP chain. Show: Request arrives -> Root SCPs evaluated -> OU SCPs evaluated -> Account SCPs evaluated -> IAM Policy evaluated -> Allow or Deny decision. Include a note that any Deny at ANY level results in denial regardless of other Allows.

2. `docs/runbook.md` - An operational runbook with three sections:
   - Bootstrap: steps to initialize the Organization from scratch
   - Teardown: steps to safely dismantle the Organization
   - SCP Rollback Drill: what to do if an SCP accidentally blocks legitimate traffic (identify via CloudTrail, detach, fix, re-attach)

3. `scripts/stacksets-drift-check.sh` - A bash script that runs drift detection on all three StackSets (NovaBurstCloudTrail, NovaBurstConfig, NovaBurstGuardDuty). Loop through each name and call aws cloudformation detect-stack-set-drift.

Use the existing terraform/ and docs/ files as context for accurate resource names.

Why generate these with Cursor?

Cursor has full context of your Terraform code, SCP policies, and StackSets configuration. It generates documentation that references your actual resource names. Manual documentation drifts from the code over time. Generated documentation can be regenerated whenever the infrastructure changes.

  • Press Enter to send the prompt.
  • Review Cursor's output. Accept the generated files.

Verify that three new files appeared in your project: docs/scp-precedence.md, docs/runbook.md, and scripts/stacksets-drift-check.sh.

  • Open docs/scp-precedence.md and verify it contains a Mermaid diagram showing the evaluation chain from Request through Root SCPs, OU SCPs, Account SCPs, and IAM Policy to a final Allow/Deny decision.

Your SCP precedence diagram should look similar to this:

# SCP Precedence and Evaluation Flow

```mermaid
graph TD
  Request[API Request] --> RootSCPs[Root SCPs evaluated]
  RootSCPs --> OUSCP[OU SCPs evaluated]
  OUSCP --> AccountSCP[Account SCPs evaluated]
  AccountSCP --> IAMPolicy[IAM Policy evaluated]
  IAMPolicy --> Decision{Allow or Deny?}
  Decision -->|Any Deny| Denied[Request DENIED]
  Decision -->|All Allow| Allowed[Request ALLOWED]
```

What does this diagram communicate?

The diagram shows the critical architectural principle: SCPs are evaluated as a chain from Root to leaf. A Deny at any level in the hierarchy overrides any Allow, even AdministratorAccess at the IAM level. This is what makes SCPs a permissions ceiling, not a permissions floor.

  • Open docs/runbook.md and verify it contains Bootstrap, Teardown, and SCP Rollback Drill sections.

Your runbook should match this structure:

# NovaBurst Operational Runbook

## Bootstrap
1. Run `terraform init` in `terraform/`
2. Run `terraform apply`
3. Verify Org structure in AWS Console

## Teardown
1. Remove member accounts from Org
2. Delete SCPs
3. Disable StackSets
4. Delete Organization

## SCP Rollback Drill
1. Identify blocking SCP via CloudTrail AccessDenied event
2. Detach SCP from OU: `aws organizations detach-policy`
3. Verify legitimate traffic restored
4. Fix SCP policy document
5. Re-attach SCP

Why is the SCP Rollback Drill critical?

In production, an overly broad SCP can block legitimate API calls and cause an outage. The rollback drill documents how to quickly identify the blocking SCP (via CloudTrail AccessDenied events), detach it to restore service, fix the policy, and re-attach it. Auditors expect to see documented incident response procedures for every preventive control.

  • Open scripts/stacksets-drift-check.sh and verify it loops through all three StackSet names.

Your drift check script should look like this:

#!/bin/bash
# StackSets Drift Check Script
STACKSET_NAMES=("NovaBurstCloudTrail" "NovaBurstConfig" "NovaBurstGuardDuty")
for STACKSET in "${STACKSET_NAMES[@]}"; do
  echo "Checking drift for StackSet: $STACKSET"
  aws cloudformation detect-stack-set-drift --stack-set-name "$STACKSET"
done
echo "Drift detection initiated. Check AWS Console for results."

What does drift detection prove?

Drift detection checks whether anyone has manually modified the CloudTrail, Config, or GuardDuty resources that StackSets deployed. If an account admin disabled GuardDuty or changed a Config recorder setting, drift detection flags it. This is your detective control monitoring your other detective controls.

  • Make the drift check script executable by running:
chmod +x scripts/stacksets-drift-check.sh
  • Confirm your project now has all three new files by running:
ls docs/scp-precedence.md docs/runbook.md scripts/stacksets-drift-check.sh

You should see all three file paths listed in the output.

Cursor generated different content?

Cursor may produce slightly different Mermaid syntax or runbook formatting. The key requirements are: the SCP precedence diagram shows the evaluation chain ending in Allow/Deny, the runbook covers all three sections (Bootstrap, Teardown, Rollback Drill), and the drift script loops through all three StackSet names.

If any section is missing, prompt Cursor again with a targeted request like "Add an SCP Rollback Drill section to docs/runbook.md".

Help me fix my generated operational artifacts.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in your project.

ⓧ I'd like to double check the full code

Here are the three new files generated in this step:

# SCP Precedence and Evaluation Flow

```mermaid
graph TD
  Request[API Request] --> RootSCPs[Root SCPs evaluated]
  RootSCPs --> OUSCP[OU SCPs evaluated]
  OUSCP --> AccountSCP[Account SCPs evaluated]
  AccountSCP --> IAMPolicy[IAM Policy evaluated]
  IAMPolicy --> Decision{Allow or Deny?}
  Decision -->|Any Deny| Denied[Request DENIED]
  Decision -->|All Allow| Allowed[Request ALLOWED]
```
# NovaBurst Operational Runbook

## Bootstrap
1. Run `terraform init` in `terraform/`
2. Run `terraform apply`
3. Verify Org structure in AWS Console

## Teardown
1. Remove member accounts from Org
2. Delete SCPs
3. Disable StackSets
4. Delete Organization

## SCP Rollback Drill
1. Identify blocking SCP via CloudTrail AccessDenied event
2. Detach SCP from OU: `aws organizations detach-policy`
3. Verify legitimate traffic restored
4. Fix SCP policy document
5. Re-attach SCP
#!/bin/bash
# StackSets Drift Check Script
STACKSET_NAMES=("NovaBurstCloudTrail" "NovaBurstConfig" "NovaBurstGuardDuty")
for STACKSET in "${STACKSET_NAMES[@]}"; do
  echo "Checking drift for StackSet: $STACKSET"
  aws cloudformation detect-stack-set-drift --stack-set-name "$STACKSET"
done
echo "Drift detection initiated. Check AWS Console for results."

You now have a complete evidence package. Your SCPs are proven to block unauthorized actions. Your audit role enables session-based incident response. Your operational documentation gives the platform team everything they need for day-two operations. Next up, you will deliver the professional engagement report that ties everything together for the CTO and the board.

Deliver the Principal Architect's Engagement Report

Your compliance tests passed. SCPs blocked unauthorized regions, prevented governance escape, and the audit role proved cross-account access works for the security team. The technical evidence is solid.

A Principal Cloud Architect does not finish an engagement by pushing code. They deliver a report that the CTO presents to the board, the auditors reference during assessment, and the platform team uses as a living operational document. You will prompt Cursor to generate a polished, self-contained HTML report that ties together every decision, diagram, metric, and finding from this engagement.

In this step, get ready to:
  • Prompt Cursor Composer to generate a comprehensive HTML engagement report with architecture diagrams, KPI metrics, and compliance evidence.
  • Verify the report renders correctly in a browser with interactive Mermaid diagrams and professional styling.
  • Finalize the report as the deliverable you would hand to a CTO or audit committee.
Craft the Cursor Composer prompt for the engagement report

The engagement report is the most important non-code deliverable of the entire project. It consolidates everything you built into a single document that proves governance to auditors, communicates architecture to the board, and guides the platform team on day-two operations.

You will use a single, well-crafted prompt in Cursor Composer to generate the entire HTML report. This mirrors how a Principal Architect works in practice: you describe what you need at a high level and let the tool handle the implementation details.

  • Open Cursor Composer by pressing Cmd+I (macOS) or Ctrl+I (Windows).
  • Paste the following prompt into Composer to generate the full HTML engagement report:
Generate a comprehensive HTML engagement report saved as `docs/engagement-report.html` for NovaBurst Interactive's AWS governance engagement. This is the Principal Cloud Architect's close-out deliverable.

Requirements:

1. **Executive Summary**: One-paragraph description of the engagement, the business problem (6 critical SOC 2 findings across a multi-account AWS environment after multiple studio acquisitions), and the outcome (fully governed AWS Organization with preventive controls, automated security baselines, and centralized identity management). Written for a non-technical board audience.

2. **Architecture Diagrams** (rendered inline using Mermaid.js CDN - use <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>):
   - Org topology diagram: Root, 4 OUs (Security, Sandbox, Workloads, Suspended), 2 studio accounts in Sandbox OU, SCP attachment points
   - SCP precedence and evaluation flow: how a request travels from IAM policy through SCP chain to allow/deny
   - StackSets deployment model: management account, trusted access, auto-deployment to OUs
   - Identity Center access flow: user assumes permission set, becomes IAM role in target account, session expires

3. **Findings Remediation Matrix**: A table mapping each of the 6 original SOC 2 findings to the specific control implemented, the control type (preventive/detective), the AWS service used, and current status (implemented/verified/tested). The 6 findings are:
   - No control preventing root access in member accounts
   - No guardrail preventing data from landing in non-approved regions (GDPR)
   - Player telemetry data exposed via public S3 buckets
   - Oversized instances launched without approval
   - No control preventing accounts from leaving governance
   - Detection tools can be disabled by account admins

4. **KPI Metrics Dashboard** (styled as metric cards in a grid):
   - SCP coverage: 100% of OUs with preventive guardrails
   - StackSets drift: 0 accounts with baseline drift
   - Monitoring coverage: 100% of member accounts with CloudTrail + Config + GuardDuty
   - Standing admin credentials: 0 (all session-based via Identity Center)
   - Mean time to govern new account: < 10 minutes via auto-deployment
   - Region restriction compliance: 100% of accounts blocked from non-approved regions

5. **Implementation Timeline** (rendered as a horizontal timeline or Gantt-style visualization):
   - Phase 1: Org topology design and ADR
   - Phase 2: Organization bootstrap and OU creation
   - Phase 3: SCP library deployment and red-team review
   - Phase 4: StackSets security baseline rollout
   - Phase 5: Identity Center and audit role provisioning
   - Phase 6: Compliance validation and smoke testing
   - Phase 7: Report delivery and engagement close

6. **Risk Register**: Open risks and recommended next actions. Include:
   - Management account not protected by SCPs (recommend dedicated controls)
   - Tag policies not yet enforced (recommend Phase 2 engagement)
   - No account vending automation (recommend Service Catalog extension)
   - StackSets delegated admin has full org-wide permissions (recommend scoping review)

7. **Architectural Decision Log**: Summary of each ADR with rationale and trade-offs (4 OUs vs more/fewer, deny-list vs allow-list SCP strategy, StackSets vs Control Tower, service-managed vs self-managed StackSets permissions).

HTML Requirements:
- Self-contained single file (no external dependencies except Mermaid.js CDN)
- Clean, professional styling: dark header (#1a1a2e), card-based KPI section, responsive grid layout
- Mermaid diagrams render client-side from embedded <pre class="mermaid"> blocks
- Print-friendly (@media print styles)
- Header with: "NovaBurst Interactive", "Principal Architect Engagement Report", timestamp, and "Prepared by: Principal Cloud Architect" attribution
- Use a 3-column grid for KPI cards
- Tables use full-width with collapsed borders

Why a single prompt instead of iterating?

This prompt is deliberately comprehensive because an engagement report is a single cohesive document. Iterating section by section would produce inconsistent styling and tone. A well-structured single prompt gives the AI full context on how each section relates to the others.

This is also how a Principal Architect operates in practice. You delegate the implementation to your tools while you focus on reviewing the output for architectural accuracy.

  • Press Enter to submit the prompt. Cursor will generate the full HTML file.
  • When Cursor finishes, click Accept to save the generated file.

Cursor creates docs/engagement-report.html with all seven sections, embedded Mermaid diagrams, and professional styling.

What makes this report audit-ready?

The Findings Remediation Matrix directly maps each SOC 2 finding to a specific control with evidence of verification. Auditors need traceability from finding to fix. The KPI dashboard gives them quantitative proof. The Risk Register shows you are aware of remaining gaps, which is what auditors actually want to see.

🙋‍♀️ Cursor didn't generate all seven sections?

If Cursor truncated the output or missed sections, ask a follow-up in Composer: "The report is missing [section name]. Please add it to docs/engagement-report.html with the same styling as the existing sections." Composer retains context from the previous generation.

Help me get Cursor to generate the full engagement report without truncating sections.

Verify the report renders correctly in your browser

An engagement report that does not render properly is worse than no report at all. You need to verify that Mermaid diagrams initialize, KPI cards display in a grid, and the overall layout looks professional enough to present to a board.

  • Open docs/engagement-report.html in your browser by double-clicking the file in Finder (macOS) or File Explorer (Windows).
  • Verify the dark header shows "NovaBurst Interactive", "Principal Architect Engagement Report", and the "Prepared by: Principal Cloud Architect" attribution.
  • Scroll to the Architecture Diagrams section. Confirm that all 4 Mermaid diagrams render as visual flowcharts (not raw text).
  • Check the KPI Metrics Dashboard. You should see 6 metric cards in a grid layout showing values like "100%" and "0".
  • Scroll to the Findings Remediation Matrix. Confirm it is a table with 6 rows mapping each SOC 2 finding to a control and its status.
  • Check that the Implementation Timeline shows 7 phases in a visual format.

What if the Mermaid diagrams show as raw text?

Mermaid diagrams require the CDN script to load. If your browser blocks external scripts (due to CORS or content security), the diagrams appear as plain text. Make sure you are opening the file directly (not via a file:// restriction) and that your browser allows the cdn.jsdelivr.net script to execute.

  • If any section is missing or renders incorrectly, return to Cursor Composer and ask it to fix the specific issue.
  • Update any placeholder metrics to reflect your actual deployment values (number of OUs created, number of SCPs deployed, StackSets status).

Test print-friendliness

Press Cmd+P (macOS) or Ctrl+P (Windows) to open the print dialog. Use "Save as PDF" to confirm the report exports cleanly. This is how a CTO would save it for the board packet.

🙋‍♀️ KPI cards not appearing in a grid?

If the KPI cards stack vertically instead of showing in a 3-column grid, the CSS grid-template-columns property may be missing. Ask Cursor to fix the .kpi-grid class with display: grid; grid-template-columns: repeat(3, 1fr); styling.

Help me fix the engagement report layout issues.

Finalize the report as your engagement deliverable

This report lives alongside your Terraform code in the docs/ directory. Together, the code and the report form the complete engagement package. The code is the implementation. The report is the communication layer that makes the implementation legible to non-engineers.

  • Confirm docs/engagement-report.html is saved in your project directory by running:
ls docs/

You should see engagement-report.html alongside org-topology.md, scp-precedence.md, runbook.md, and adr-001-ou-strategy.md.

What would a real Principal Architect do next?

In a real engagement, you would schedule a 30-minute readout with the CTO and audit committee. You walk them through the report, answer questions on the Risk Register, and agree on Phase 2 priorities. The report is the starting point for that conversation, not the end of the engagement.

Your engagement is officially closed. You have a live governance foundation backed by code, proven by compliance tests, and documented in a professional report. The auditors have their evidence package. The board has clear metrics. The platform team has operational runbooks for day-two.

Secret mission

Scale NovaBurst's Governance for the Next Acquisition

The board just approved acquiring MoonRift Studios. Their accounts need to be onboarded into NovaBurst's governance within a week. Can you build the automation that makes onboarding a one-click operation while giving Finance per-studio cost visibility from day one?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Most resources in this project are free-tier eligible, but Config recorder and GuardDuty can incur small charges after their free allowances.

Cost warning

AWS Config recorder charges approximately $0.003 per configuration item recorded. GuardDuty is free for 30 days per account, then charges based on data volume. If you leave both running across 2 member accounts, expect a few dollars per month.

If you are not actively using this environment, choose Pause or Delete below to avoid unexpected charges.

Resources you used:

  • AWS Organization with 4 OUs (free).
  • 2 member accounts in the Sandbox OU (free unless resources are launched inside them).
  • 6 Service Control Policies attached to OUs (free).
  • CloudTrail trail with S3 bucket for logs (one trail per account is free-tier, minimal S3 storage cost).
  • AWS Config recorder in member accounts (~$0.003 per config item recorded).
  • GuardDuty enabled in member accounts (30-day free trial, then usage-based charges).
  • IAM Identity Center permission sets (free).
  • Cross-account audit IAM role in member accounts (free).
  • CloudFormation StackSets with service-managed permissions (free).
  • Local Terraform state files and project directory.

Keep everything running

No action needed. Choose this if you are still actively building or plan to use this Organization as a lab substrate for future governance projects.

  • Your cost is near-zero as long as no resources are launched inside the member accounts.
  • Config recorder and GuardDuty will continue running in member accounts. Monitor your billing dashboard monthly.
  • The Organization structure, SCPs, StackSets, and Identity Center configuration remain intact and ready for future experiments.

Pause - I'll come back to this later

Disable the cost-incurring services while keeping the Organization structure intact for reference.

  • Update the StackSets deployment to disable Config recorder and GuardDuty in member accounts. This stops the per-item and per-volume charges.
  • Delete the CloudTrail S3 bucket contents to stop storage charges. Keep the trail configuration so it can be re-enabled later.
  • Keep the Organization, OUs, SCPs, and Identity Center configuration in place. These are all free.
  • Keep your local Terraform state files and project directory. You will need them to resume.

Delete - I don't want to use this again

Remove all project resources and start fresh. Use the teardown script generated in Step 7 to automate most of this process.

  • Run the teardown script to remove StackSets stack instances, delete SCPs, and detach policies from OUs.
  • Remove member accounts from the Organization. They become standalone AWS accounts that you can close separately.
  • Delete the Organization from the management account.
  • Empty and delete the CloudTrail S3 bucket.
  • Delete the local project directory and Terraform state files.

Account closure is rate-limited

AWS limits account closures to a maximum of 10% of your accounts per month, or 10 accounts, whichever is greater. If you hit this limit, you will see a CLOSE_ACCOUNT_QUOTA_EXCEEDED error. Wait and try again next month.

That's a Wrap!

That's a Wrap!

Nice work, Principal Cloud Architect. You just designed and deployed a production-grade AWS governance foundation that would satisfy a SOC 2 auditor, scale to the next acquisition, and run without manual intervention.

NovaBurst's governance foundation is deployed, proven, and documented. The architecture scales without code changes because guardrails attach at the OU level. Adding a new account means dropping it into an OU, and it inherits every SCP and every StackSet automatically.

You've learned how to:

  • Build a live AWS Organization with 4 OUs and 2 member accounts governed by 6 preventive SCPs, each closing a specific SOC 2 finding and enforcing SCP precedence logic where deny overrides allow.
  • Deploy a StackSets security baseline with auto-deployment that provisions CloudTrail, Config, and GuardDuty to every current and future member account without human intervention.
  • Implement centralized access governance with IAM Identity Center permission sets and a cross-account audit role, then deliver a professional HTML engagement report with KPI metrics, architecture diagrams, and a risk register.
  • Secret Mission: Enforce tag policies for per-studio cost allocation, configure AWS RAM cross-OU VPC sharing, and build an Account Vending Machine so the next acquired studio auto-inherits governance from day one.

Ready to quiz yourself?