Zero-Key AWS Security Architecture
Deploy a production-grade identity, crypto, and secrets system on AWS.
Introduction
30 Second Summary
Every major cloud breach in the last five years started the same way. Someone left a long-lived access key in a repository, a config file, or a CI/CD pipeline.
In this project, you will deploy a production-grade AWS security architecture that eliminates long-lived credentials across identity federation, cryptographic key management, secrets rotation, ransomware resistance, and remote workforce access. You'll validate every control end-to-end and present the finished system to stakeholders in a pitch deck with architecture diagrams and compliance mapping.
What You'll Build
A zero-long-lived-key security backbone spanning two AWS accounts, with federated identity from on-prem Active Directory, OIDC-based CI/CD, multi-region encryption, automatic secrets rotation, and failover-ready remote desktops.
By the end of this project, you'll have:
- IAM Identity Center federation chain linking on-prem Samba AD through AD Connector to cross-account Permission Sets, with GitHub OIDC as a parallel zero-key deployment path.
- Multi-region KMS keys with envelope encryption, automatic Secrets Manager rotation for RDS credentials, and S3 Object Lock ransomware resistance validated by a live simulation.
- Stakeholder-ready pitch deck with Mermaid architecture diagrams, a SOC-2 compliance mapping, threat model, and 90-day roadmap that communicates the build to engineering, security, and executive leadership.
- Secret Mission: Red-team your own architecture and prove four privilege escalation attacks are blocked.
Are there any prerequisites?
This project assumes you have an AWS account (single account is fine, you'll create an Organization during the project). You should be familiar with infrastructure-as-code concepts and have a basic understanding of Active Directory.
You'll need OpenTofu, AWS CLI v2, Cursor IDE, Git, and a GitHub account. Step 1 verifies each tool and provides install commands for anything missing.
Set Up Your Environment and Project Scaffold
Before you can deploy a production-grade security architecture, you need a verified toolchain and a structured project scaffold.
In this step, you will confirm that every tool is at the correct version, initialize your OpenTofu project with multi-region AWS provider configuration, and connect your management account.
In this step, get ready to:
- Verify OpenTofu, AWS CLI, Git, and GitHub CLI are installed at the correct versions.
- Initialize the OpenTofu project scaffold with multi-region provider aliases and module directories.
- Configure and verify AWS CLI access to your management account.
Verify your tools are installed at correct versions
This project depends on specific tool versions to ensure consistent behavior across environments. A version mismatch can cause silent failures later.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your OpenTofu version by running this command:
tofu -version
✔️ I see version 1.11.6
OpenTofu is ready. Move on to the AWS CLI check below.
ⓧ I see an older version
You need to upgrade to v1.11.6. Run the appropriate upgrade command for your platform:
macOS
- Upgrade OpenTofu via Homebrew by running:
brew update
brew upgrade opentofu
- Verify the upgrade by running tofu -version again. You should now see v1.11.6.
Windows
- Upgrade OpenTofu via winget by running:
winget install --exact --id=OpenTofu.Tofu
- Verify the upgrade by running tofu -version again. You should now see v1.11.6.
ⓧ Command not found
OpenTofu is not installed yet. Install it for your platform:
macOS
- Install OpenTofu via Homebrew by running:
brew update
brew install opentofu
- Verify the installation by running tofu -version. You should see v1.11.6.
Windows
- Install OpenTofu via winget by running:
winget install --exact --id=OpenTofu.Tofu
- Verify the installation by running tofu -version. You should see v1.11.6.
- Check your AWS CLI version by running this command:
aws --version
✔️ I see aws-cli/2.x.x
AWS CLI v2 is ready. Move on to the Git check below.
ⓧ I see aws-cli/1.x.x
You have AWS CLI version 1 installed. This project requires version 2. Install it for your platform:
macOS
- Download and install the AWS CLI v2 package by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- Verify by running aws --version again. You should see aws-cli/2.x.x.
Windows
- Download and install the AWS CLI v2 MSI by running:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
- Close and reopen your terminal, then verify by running aws --version.
ⓧ Command not found
AWS CLI is not installed. Install version 2 for your platform:
macOS
- Download and install the AWS CLI v2 package by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- Verify by running aws --version. You should see aws-cli/2.x.x.
Windows
- Download and install the AWS CLI v2 MSI by running:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
- Close and reopen your terminal, then verify by running aws --version.
- Check your Git version by running this command:
git --version
✔️ I see a version number
Git is ready. Move on to the GitHub CLI check below.
ⓧ Command not found
Install Git from the official site at https://git-scm.com/downloads. After installation, close and reopen your terminal, then run git --version to confirm.
- Verify your GitHub CLI authentication by running this command:
gh auth status
✔️ I see Logged in to github.com
Your GitHub CLI is authenticated. You are ready to proceed.
ⓧ I see not logged in or command not found
If the command is not found, install the GitHub CLI from https://cli.github.com.
- Authenticate by running:
gh auth login
- Follow the prompts to authenticate with your GitHub account via the browser.
- After login completes, run gh auth status again to confirm you see Logged in to github.com.
Seeing permission or network errors?
Make sure you have a stable internet connection and your firewall is not blocking GitHub.
If winget is not recognized on Windows, make sure you are running Windows 10 1709 or later and have the App Installer package from the Microsoft Store.
Help me troubleshoot my tool installation errors.
Initialize the OpenTofu project scaffold
Your infrastructure code needs a structured layout to keep identity, cryptography, end-user computing, and validation modules separate. This mirrors the parallel agent strategy you will use in Cursor.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir aws-security-architecture
cd aws-security-architecture
- Create the module directory layout by running these commands:
mkdir -p modules/identity modules/crypto modules/euc modules/validation
touch modules/identity/.gitkeep modules/crypto/.gitkeep modules/euc/.gitkeep modules/validation/.gitkeep
- Confirm the directories were created by running:
ls modules/
You should see crypto, euc, identity, and validation listed.
Why this directory layout?
Each module directory maps to an architecture pillar. In later steps, you will assign each one to a separate Cursor AI agent so they can work in parallel without merge conflicts.
The .gitkeep files are empty placeholders. Git does not track empty directories, so these files ensure the structure is preserved when you commit.
- Create the main OpenTofu configuration file by running:
touch main.tf
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open Cursor IDE.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Select the aws-security-architecture folder on your Desktop, then click Open.
- Open main.tf from the Explorer sidebar and paste the following configuration:
terraform {
required_providers {
aws = {
source = "registry.opentofu.org/hashicorp/aws"
version = "~> 5.0"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
provider "aws" {
alias = "us_west_2"
region = "us-west-2"
}
What does this configuration do?
The required_providers block pins the AWS provider from the OpenTofu registry at version 5.x. The backend "local" stores state on your machine for this lab environment.
Two provider aliases (us_east_1 and us_west_2) configure multi-region deployment. You will reference these aliases when creating resources like KMS multi-region keys and Secrets Manager replicas.
- Save main.tf.
- Initialize the OpenTofu project by running this command in the terminal:
tofu init
You should see a message that says the backend and provider plugins have been successfully initialized.
Seeing a provider download error?
Make sure you have a stable internet connection. OpenTofu downloads the AWS provider plugin from the registry during initialization.
If you see a registry error, confirm the source in your main.tf matches registry.opentofu.org/hashicorp/aws exactly.
Help me fix my tofu init error.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf.
ⓧ I'd like to double check the full code
terraform {
required_providers {
aws = {
source = "registry.opentofu.org/hashicorp/aws"
version = "~> 5.0"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
provider "aws" {
alias = "us_west_2"
region = "us-west-2"
}
Configure AWS CLI credentials and set up Cursor agent contexts
Your AWS CLI needs credentials for the management account. This is the account that will become the root of your AWS Organizations hierarchy.
- Configure your AWS CLI credentials by running:
aws configure
What does aws configure ask for?
You will be prompted for four values: your AWS Access Key ID, Secret Access Key, default region (enter us-east-1), and default output format (enter json).
Use the access key for an IAM user or root credentials with Administrator access. You will replace these long-lived keys with federated access later in the project.
- Enter your AWS Access Key ID when prompted.
- Enter your AWS Secret Access Key when prompted.
- Enter us-east-1 for the default region.
- Enter json for the default output format.
- Verify your credentials work by running:
aws sts get-caller-identity
You should see a JSON response containing your Account number, UserId, and Arn. This confirms your management account access.
Seeing InvalidClientTokenId or SignatureDoesNotMatch?
Double check that you copied the Access Key ID and Secret Access Key correctly. Extra spaces or missing characters are the most common cause.
If you see an expired token error, your credentials may have been rotated. Generate new access keys from the IAM console.
Help me fix my AWS CLI authentication error.
Now set up your Cursor IDE workspace with the 4 agent contexts that map to your architecture pillars.
- In Cursor, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the command palette.
- Create 4 Composer windows by opening Composer from the command palette four times.
- Name each Composer context by its architecture pillar: identity, crypto, euc, and validation.
Why 4 parallel agents?
Each agent handles one architecture pillar: identity federation, cryptographic key management, end-user computing, and validation testing. This lets you instruct multiple agents simultaneously without context pollution between unrelated concerns.
- In the identity Composer, add the modules/identity directory as its context.
- In the crypto Composer, add the modules/crypto directory as its context.
- In the euc Composer, add the modules/euc directory as its context.
- In the validation Composer, add the modules/validation directory as its context.
Your environment is locked in. Next up, you will create the AWS Organization and deploy the multi-account networking that all your security controls depend on.
Create AWS Organizations and Multi-Account Networking
Your tools are ready and your project scaffold is in place. Now you need the foundational multi-account structure that every enterprise AWS deployment starts with.
A single AWS account is a security liability. One compromised credential can access everything. AWS Organizations solves this by splitting workloads across accounts while keeping centralized governance. In this step, you will create an Organization, enable IAM Identity Center, provision a spoke account, and build the network fabric that connects them.
In this step, get ready to:
- Create an AWS Organization and enable IAM Identity Center.
- Create VPCs with private and public subnets across both accounts.
- Verify cross-account role assumption using temporary credentials.
Create the Organization and SecuritySpoke account
Your existing AWS account becomes the management account the moment you create an Organization. This management account controls billing, governance policies, and member account creation.
- Create a new AWS Organization by running this command:
aws organizations create-organization
What does this command do?
This creates an Organization with all features enabled (the default). Your current account automatically becomes the management account. Service control policies (SCPs) are enabled on the root.
You should see a JSON response containing your Organization ID (starts with o-) and FeatureSet: ALL.
- Now enable IAM Identity Center (Organization instance) through the AWS Console.
- Open the IAM Identity Center console in us-east-1.
- Under Enable IAM Identity Center, choose Enable.
- On the Enable IAM Identity Center with AWS Organizations page, select Enable to complete the process.
Why enable via the console?
An Organization instance of IAM Identity Center must be created from the management account. The aws sso-admin create-instance CLI command only works for standalone or member accounts. The console is the supported path for Organization instances.
- Create the SecuritySpoke member account by running:
aws organizations create-account \
--email [[SPOKE_EMAIL="unique-email-for-spoke@yourdomain.com"]] \
--account-name "SecuritySpoke"
What does this command do?
This creates a new member account named SecuritySpoke. The operation is asynchronous. AWS also pre-creates a role called OrganizationAccountAccessRole in the new account that trusts your management account.
The email address must be unique across all AWS accounts. You cannot reuse an email that is already associated with another account.
- Note the Id value from the CreateAccountStatus response. Check that the account creation has completed by running:
aws organizations describe-create-account-status \
--create-account-request-id [[CAR_ID="your-car-id-from-the-previous-response"]]
- Wait until the State field shows SUCCEEDED. Record the AccountId value. This is your spoke account ID.
Account creation stuck in IN_PROGRESS?
Account creation can take a few minutes. Re-run the describe-create-account-status command after 60 seconds. If it remains in progress for more than 5 minutes, check your email for an AWS verification request.
help me troubleshoot why my AWS Organizations create-account request is stuck
Create VPCs in both accounts
Both accounts need VPCs. The management account VPC will host the Samba AD Domain Controller (public subnet) and the AD Connector (requires 2 private subnets in different AZs). The spoke account VPC holds resources that will be governed by ABAC policies.
- Create the management account VPC in us-east-1 by running:
# Create management VPC
aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--region us-east-1 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=MgmtVPC}]' \
--query Vpc.VpcId --output text
- Record the VPC ID from the output. Create the two private subnets in separate AZs (required by AD Connector) and one public subnet by running:
# Private subnet AZ1
aws ec2 create-subnet \
--vpc-id [[MGMT_VPC_ID="your-management-vpc-id"]] \
--cidr-block 10.0.1.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=MgmtPrivate-AZ1}]'
# Private subnet AZ2
aws ec2 create-subnet \
--vpc-id [[MGMT_VPC_ID="your-management-vpc-id"]] \
--cidr-block 10.0.2.0/24 \
--availability-zone us-east-1b \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=MgmtPrivate-AZ2}]'
# Public subnet for Samba EC2
aws ec2 create-subnet \
--vpc-id [[MGMT_VPC_ID="your-management-vpc-id"]] \
--cidr-block 10.0.0.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=MgmtPublic-AZ1}]'
Why two private subnets in different AZs?
AD Connector requires subnets in at least two different Availability Zones. This is a hard requirement from the AWS Directory Service. You will use these subnets when creating the AD Connector in a later step.
- Now create the spoke account VPC. First, assume the OrganizationAccountAccessRole in the spoke account to run commands there:
aws sts assume-role \
--role-arn arn:aws:iam::[[SPOKE_ACCOUNT_ID="your-spoke-account-id"]]:role/OrganizationAccountAccessRole \
--role-session-name spoke-vpc-setup
- Export the temporary credentials returned in the response (AccessKeyId, SecretAccessKey, SessionToken) as environment variables:
export AWS_ACCESS_KEY_ID=your-access-key-id-here
export AWS_SECRET_ACCESS_KEY=your-secret-access-key-here
export AWS_SESSION_TOKEN=your-session-token-here
- Create the spoke VPC with 2 private subnets by running:
# Create spoke VPC
aws ec2 create-vpc \
--cidr-block 10.1.0.0/16 \
--region us-east-1 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=SpokeVPC}]' \
--query Vpc.VpcId --output text
# Private subnet AZ1
aws ec2 create-subnet \
--vpc-id [[SPOKE_VPC_ID="your-spoke-vpc-id"]] \
--cidr-block 10.1.1.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=SpokePrivate-AZ1}]'
# Private subnet AZ2
aws ec2 create-subnet \
--vpc-id [[SPOKE_VPC_ID="your-spoke-vpc-id"]] \
--cidr-block 10.1.2.0/24 \
--availability-zone us-east-1b \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=SpokePrivate-AZ2}]'
- Record the spoke VPC ID and subnet IDs.
- Unset the temporary credentials to return to your management account context:
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
Seeing AccessDenied when creating spoke resources?
Verify that you exported all three credential environment variables correctly. The SessionToken is often missed. Also confirm that the account creation completed successfully before trying to assume the role.
help me debug AccessDenied errors when assuming OrganizationAccountAccessRole
Verify cross-account role assumption
The OrganizationAccountAccessRole is automatically created in every member account by AWS Organizations. It grants administrator access from the management account. This is the trust policy pattern the SAP-C02 exam tests: the trust policy lives in the resource account (the spoke), not the calling account.
- Test cross-account access by assuming the role from the management account:
aws sts assume-role --role-arn arn:aws:iam::[[SPOKE_ACCOUNT_ID="your-spoke-account-id"]]:role/OrganizationAccountAccessRole --role-session-name test
- You should see a JSON response containing Credentials with AccessKeyId, SecretAccessKey, and SessionToken. These are temporary credentials valid for 1 hour.
What is the trust policy placement pattern?
The trust policy lives in the RESOURCE account (the spoke, where the role is). The management account needs sts:AssumeRole permission to call the role ARN. Exam candidates frequently confuse which account holds which policy. Remember: the trust policy says WHO can assume. The permissions policy says WHAT they can do.
Your multi-account foundation is set. Next up, you will deploy a Samba 4 Active Directory Domain Controller with zero inbound ports using Session Manager.
Deploy Samba 4 AD Domain Controller via Session Manager
Your AWS Organization and networking are in place. Now you need an identity source for the 3,000 healthcare staff.
In this step, you will deploy a Samba 4 Active Directory Domain Controller on an EC2 instance. You will access it exclusively through Session Manager with zero inbound ports. This eliminates SSH keys, bastion hosts, and another class of long-lived credentials.
In this step, get ready to:
- Deploy an EC2 instance with an IAM instance profile for Session Manager access.
- Install and configure Samba 4 as an AD Domain Controller.
- Create test users and groups representing the healthcare scenario.
Deploy the EC2 instance with Session Manager access
Session Manager provides shell access through AWS Systems Manager without opening inbound ports or managing SSH keys. All sessions are logged to CloudTrail automatically.
You need three IAM resources first: a role, a policy attachment, and an instance profile.
- Create a trust policy file for the EC2 role by running this command:
cat > ec2-trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
EOF
- Create the IAM role and attach the Session Manager policy by running these commands:
# Create the IAM role for the Samba DC instance
aws iam create-role \
--role-name SambaDC-SSM-Role \
--assume-role-policy-document file://ec2-trust-policy.json
# Attach the Session Manager core policy
aws iam attach-role-policy \
--role-name SambaDC-SSM-Role \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
# Create an instance profile and add the role to it
aws iam create-instance-profile \
--instance-profile-name SambaDC-SSM-Profile
aws iam add-role-to-instance-profile \
--instance-profile-name SambaDC-SSM-Profile \
--role-name SambaDC-SSM-Role
What does this code do?
The trust policy allows the EC2 service to assume this role. The AmazonSSMManagedInstanceCore managed policy grants the minimum permissions for Session Manager to communicate with the instance.
The instance profile is the container that lets you attach the IAM role to an EC2 instance at launch time.
- Wait about 10 seconds for IAM to propagate, then create a security group with zero inbound rules by running:
# Create a security group with NO inbound rules
# Session Manager uses outbound HTTPS only
aws ec2 create-security-group \
--group-name SambaDC-SG \
--description "Samba DC - zero inbound, SSM only" \
--vpc-id [[VPC_ID="your management account VPC ID"]]
Why zero inbound rules?
Session Manager connects via outbound HTTPS to the Systems Manager endpoint. No inbound ports are needed for shell access. This is the SAP-C02 recommended pattern for secure admin access.
- Record the security group ID from the output (it starts with sg-).
- Launch the EC2 instance by running this command:
# Launch Amazon Linux 2023 t3.micro with Session Manager profile
aws ec2 run-instances \
--image-id resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--instance-type t3.micro \
--subnet-id [[PUBLIC_SUBNET_ID="your management account public subnet ID"]] \
--security-group-ids [[SG_ID="sg-your-security-group-id"]] \
--iam-instance-profile Name=SambaDC-SSM-Profile \
--associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=SambaDC}]' \
--count 1
What does this command do?
The resolve:ssm: prefix automatically resolves to the latest Amazon Linux 2023 AMI ID in your region. The instance gets a public IP for outbound internet access (needed for the SSM agent to reach the Systems Manager endpoint).
The --iam-instance-profile attaches the Session Manager role you just created.
- Record the Instance ID from the output (it starts with i-).
- Wait 1-2 minutes for the instance to reach the running state and for the SSM agent to register. Check status by running:
aws ec2 describe-instances \
--instance-ids [[INSTANCE_ID="i-your-instance-id"]] \
--query 'Reservations[0].Instances[0].State.Name' \
--output text
- You should see running in the output.
Instance not showing as running?
If you see pending, wait another 30 seconds and retry the command. If you see terminated, check that your subnet ID and security group ID are from the same VPC.
Help me debug my EC2 instance
Connect via Session Manager and install Samba 4
Now you will connect to your instance without SSH keys, without a bastion host, and without any open ports.
- Start a Session Manager session by running:
aws ssm start-session --target [[INSTANCE_ID="i-your-instance-id"]]
What is Session Manager?
Session Manager provides browser-based and CLI-based shell access to EC2 instances through AWS Systems Manager. It requires the Session Manager plugin installed on your local machine and the SSM agent running on the instance (pre-installed on Amazon Linux 2023).
- You should see a shell prompt. You are now connected to your EC2 instance.
- Switch to root and set the hostname by running:
# Switch to root for system-level configuration
sudo -i
# Set hostname for the domain controller
hostnamectl set-hostname dc1
- Install Samba and its dependencies by running:
# Update packages and install Samba with AD DC support
dnf update -y
dnf install -y samba samba-dc-libs samba-winbind \
python3-samba-dc python3-samba krb5-workstation \
samba-common-tools bind-utils
What are these packages?
- samba provides the core Samba services.
- samba-dc-libs and python3-samba-dc provide the AD Domain Controller libraries and samba-tool CLI.
- krb5-workstation provides Kerberos client tools for testing authentication.
- bind-utils provides dig and nslookup for DNS verification.
- Stop and disable the default Samba services (the AD DC will manage its own) by running:
# Stop default services before provisioning
systemctl stop smb nmb winbind 2>/dev/null
systemctl disable smb nmb winbind 2>/dev/null
- Remove the default Samba configuration file so provisioning can create a fresh one:
# Remove default config - provisioning creates its own
mv /etc/samba/smb.conf /etc/samba/smb.conf.bak 2>/dev/null
- Provision the Samba AD Domain Controller by running:
# Provision Samba as an AD Domain Controller
samba-tool domain provision \
--realm=CORP.HEALTHCARE.LOCAL \
--domain=CORP \
--server-role=dc \
--use-rfc2307 \
--dns-backend=SAMBA_INTERNAL \
--adminpass='H3althC@r3Adm1n!'
What does this command do?
- --realm=CORP.HEALTHCARE.LOCAL sets the Kerberos realm and DNS domain for the AD forest.
- --domain=CORP sets the NetBIOS domain name (the short name users see at login).
- --server-role=dc makes this server the domain controller.
- --use-rfc2307 enables NIS extensions for storing Unix attributes (UIDs/GIDs) in AD. There are no disadvantages to enabling this.
- --dns-backend=SAMBA_INTERNAL uses Samba's built-in DNS server instead of BIND.
- You should see output confirming the server role as active directory domain controller with the domain SID.
- Configure the DNS resolver to point to itself and start the Samba AD DC service:
# Point DNS at the local Samba DNS server
cat > /etc/resolv.conf << 'EOF'
nameserver 127.0.0.1
search corp.healthcare.local
EOF
# Copy the Kerberos config generated by provisioning
cp /var/lib/samba/private/krb5.conf /etc/krb5.conf
# Start the Samba AD DC service
samba
# Verify the service is running
ps aux | grep samba
- You should see multiple samba processes in the output. This confirms the domain controller is running.
Samba not starting?
If the samba command fails, check that you removed the old smb.conf before provisioning. Run cat /etc/samba/smb.conf and verify it contains a [global] section with server role = active directory domain controller.
Help me troubleshoot Samba startup
Create test users and groups, then verify the domain
The healthcare scenario requires three groups: clinicians, billing, and contractors. Each group represents a different access level that you will map to Permission Sets in the next step.
- Create the three groups by running:
# Create groups representing the healthcare organization
samba-tool group add clinicians
samba-tool group add billing
samba-tool group add contractors
- You should see Added group for each command.
- Create test users for each group by running:
# Create test users with passwords
samba-tool user create dr.smith 'Cl1n1c@lAccess!' --given-name=Sarah --surname=Smith
samba-tool user create j.martinez 'B1ll1ng@ccess!' --given-name=Jose --surname=Martinez
samba-tool user create c.wong 'C0ntr@ct0r!' --given-name=Chris --surname=Wong
- Add each user to their respective group by running:
# Assign users to their groups
samba-tool group addmembers clinicians dr.smith
samba-tool group addmembers billing j.martinez
samba-tool group addmembers contractors c.wong
What does this create?
Each user is now a member of a group that maps to a real organizational role. In Step 4, you will connect this AD to IAM Identity Center and map these groups to AWS Permission Sets. Clinicians get ReadOnlyAccess, billing gets PowerUserAccess, and contractors get a custom restricted policy.
- Verify the users exist by running:
# List all AD users
samba-tool user list
- You should see dr.smith, j.martinez, c.wong, and the built-in Administrator account in the output.
- Test DNS resolution for the domain by running:
# Test DNS - should resolve the domain controller
host -t A corp.healthcare.local 127.0.0.1
# Test LDAP SRV record - proves AD services are discoverable
host -t SRV _ldap._tcp.corp.healthcare.local 127.0.0.1
- The first command should return the EC2 instance's private IP. The second should return an SRV record pointing to your DC hostname.
- Test LDAP bind to confirm the directory is queryable:
# Verify LDAP bind works with the admin account
samba-tool domain info 127.0.0.1
- You should see output listing the domain name, DC name, and forest information.
- Record the EC2 instance's private IP address. You will need this as the DNS IP for AD Connector in the next step. Run:
# Get the private IP - record this for AD Connector setup
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 60")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/local-ipv4
echo
- Record this IP address. This is your Samba DC DNS IP that AD Connector will use.
Why does AD Connector need this IP?
AD Connector resolves the domain by querying DNS at the domain controller IP. In Step 4, you will provide this IP as one of the DNS server addresses when creating the AD Connector.
🙋♀️ DNS queries returning errors?
Make sure /etc/resolv.conf points to 127.0.0.1 and the Samba process is running. Run ps aux | grep samba to confirm. If the process died, check /var/log/samba/ for error logs.
Help me diagnose Samba DNS issues
Your Samba 4 AD Domain Controller is running with zero inbound ports and three groups ready for federation. Next up, you will connect this AD to IAM Identity Center through an AD Connector and map groups to Permission Sets.
Connect AD Connector and Configure Identity Center Federation
Your Samba 4 domain controller is running and reachable via Session Manager. The next challenge is bridging that self-managed Active Directory into AWS so that your 3,000 healthcare staff can authenticate through a single portal.
In this step, you will create an AD Connector that links your Samba DC to AWS Directory Service, wire it into IAM Identity Center as the identity source, and assign Permission Sets so synced AD groups can assume roles in the spoke account.
In this step, get ready to:
- Create a Small AD Connector pointed at the Samba DC.
- Connect the AD Connector to IAM Identity Center and sync users and groups.
- Create Permission Sets and test federation into the spoke account.
Create the AD Connector in AWS Directory Service
AD Connector is a directory gateway that forwards authentication requests to your self-managed Active Directory without caching any data in the cloud. It requires two subnets in different Availability Zones and the DNS IP of your domain controller.
- Create the Small AD Connector by running this command (replace the placeholder values with your actual VPC ID, subnet IDs, and EC2 private IP):
aws ds connect-directory \
--name corp.healthcare.local \
--short-name CORP \
--password [[SAMBA_SVC_PASSWORD="password for your Samba service account"]] \
--size Small \
--connect-settings VpcId=[[MGMT_VPC_ID="your management VPC ID"]],SubnetIds=[[PRIVATE_SUBNET_1="first private subnet ID"]],[[PRIVATE_SUBNET_2="second private subnet ID"]],CustomerDnsIps=[[SAMBA_DC_IP="Samba EC2 private IP"]],CustomerUserName=Administrator
What does this command do?
The aws ds connect-directory command creates an AD Connector in AWS Directory Service.
- --name is the fully qualified domain name of your self-managed directory (corp.healthcare.local).
- --short-name is the NetBIOS name (CORP).
- --size Small provisions a small connector designed for lower operation counts.
- --connect-settings specifies the VPC, two private subnets in different AZs, the DNS IP pointing at the Samba DC, and the service account username.
The command returns a DirectoryId (format d-XXXXXXXXXX). Record this value.
- The AD Connector takes several minutes to provision. Check its status by running:
aws ds describe-directories \
--directory-ids [[AD_CONNECTOR_ID="your AD Connector directory ID"]] \
--query 'DirectoryDescriptions[0].Stage'
- Re-run this command every 60 seconds until the output shows Active.
What does Active mean?
The Active status confirms that the AD Connector successfully connected to your Samba DC over ports 53 (DNS), 88 (Kerberos), and 389 (LDAP). If it stays in Creating for more than 20 minutes, check your security group and subnet routing.
The AD Connector must reside in the management account for IAM Identity Center integration. It must also be in the same AWS Region as IAM Identity Center.
🙋♀️ Seeing a Connectivity issues detected error?
This usually means the AD Connector cannot reach the Samba DC on the required ports. Verify that your Samba EC2 instance is in the same VPC, that route tables allow traffic between the private subnets and the public subnet where the EC2 lives, and that DNS resolution works.
Also check that the password meets AWS complexity requirements (uppercase, lowercase, numeric characters).
help me troubleshoot my AD Connector connectivity issue
Connect AD Connector to IAM Identity Center as the identity source
Now that the AD Connector is active, you need to tell IAM Identity Center to use it as the identity source instead of the default built-in directory. This enables pass-through authentication to your Samba AD.
- Open the IAM Identity Center console.
- Click Settings in the left navigation.
- Select the Identity source tab.
- Click Actions, then Change identity source.
- Under Choose identity source, select Active Directory and click Next.
- Select your AD Connector from the dropdown list and click Next.
- Read the disclaimer. Type ACCEPT in the confirmation field.
- Click Change identity source.
Why does this change my AWS access portal URL?
Switching from the default Identity Center directory to Active Directory generates a new Identity Store ID. This changes your AWS access portal URL. Record the new portal URL from the Settings page after the change completes.
Any previously created users and groups in the Identity Center directory are removed. Since we have not created any assignments yet, this has no impact on your project.
After the identity source change completes, configure the sync scope to pull your test users and groups from the Samba domain.
- On the Settings page, click Start guided setup in the notification banner.
- In Step 1 (Attribute mappings), accept the defaults and click Next.
- In Step 2 (Sync scope), switch to the Groups tab.
- Type the exact name of your clinicians group and click Add.
- Repeat for the billing group and the contractors group.
- Click Next.
- In Step 3 (Review and save), confirm your selections and click Save configuration.
- Navigate to Users in the IAM Identity Center left navigation. Refresh the page until your synced AD users appear.
Why add groups to the sync scope?
IAM Identity Center does not sync all AD objects automatically. You must explicitly add users or groups to the sync scope. By adding groups, all members of those groups are synced. This keeps your Identity Center clean and scoped to only the principals you need.
🙋♀️ Users not appearing after sync?
Group names in the sync scope must match exactly (case-sensitive) what exists in the Samba AD. Re-check the group names you created in the previous step by running samba-tool group list via Session Manager.
help me troubleshoot IAM Identity Center AD sync issues
Create Permission Sets and test federation
Permission Sets define the IAM policies that synced users receive when they federate into an AWS account. You will create three sets matching the healthcare scenario: read-only for clinicians, power-user for billing, and a restricted custom set for contractors.
- In the IAM Identity Center console, click Multi-account permissions in the left navigation.
- Click Permission sets.
- Click Create permission set.
- Select Predefined permission set and choose ReadOnlyAccess from the policy list. Click Next.
- Accept the defaults for session duration and click Create.
- Repeat the process to create a second predefined permission set using PowerUserAccess.
- Create a third permission set. This time select Custom permission set.
- Name it ContractorLimited.
- On the inline policy page, paste a JSON policy that grants read access to S3 and DynamoDB but denies IAM actions. Click Next, then Create.
What is a Permission Set?
A Permission Set is a template stored in IAM Identity Center. When you assign it to an account, Identity Center creates a corresponding IAM role in that account with the policies you specified. Users who federate in assume that role automatically.
This means you manage access centrally in Identity Center rather than creating individual IAM roles in every account.
Now assign the groups to the SecuritySpoke account with their respective permission sets.
- In the IAM Identity Center console, click AWS accounts under Multi-account permissions.
- Select the SecuritySpoke account checkbox.
- Click Assign users or groups.
- On the Groups tab, select the clinicians group and click Next.
- Select the ReadOnlyAccess permission set and click Submit.
- Repeat: assign the billing group with PowerUserAccess.
- Repeat: assign the contractors group with ContractorLimited.
Test the federation end to end.
- Copy the AWS access portal URL from the Settings page in IAM Identity Center.
- Open the URL in an incognito browser window.
- Log in with a test AD user from the clinicians group (use their Samba username and password).
- You should see the SecuritySpoke account listed with the ReadOnlyAccess role available.
- Click the account and select Management console to federate in.
- Confirm you are logged into the spoke account by checking the account ID in the top-right corner.
What just happened?
The user authenticated against your Samba AD through the AD Connector. IAM Identity Center verified the group membership, matched it to the ReadOnlyAccess permission set assigned to the SecuritySpoke account, and issued temporary STS credentials. The user assumed the corresponding IAM role without any long-lived keys stored anywhere.
This is the federation chain: Samba AD > AD Connector > IAM Identity Center > Permission Set > STS role in spoke account.
🙋♀️ Cannot log in to the access portal?
Verify the user was synced by checking the Users list in IAM Identity Center. If the user is missing, re-check your sync scope group names.
Also ensure you are using the NEW access portal URL (it changed when you switched identity sources). The old URL will not work.
help me debug why my AD user cannot log into the AWS access portal
Your federation chain is live. On-prem AD users can now assume roles in your spoke account through IAM Identity Center. Next, you will wire up GitHub OIDC so your CI/CD pipeline deploys with zero stored secrets.
Set Up GitHub OIDC Provider and Permission Boundary
Your identity federation is working. AD users can assume roles in the spoke account through IAM Identity Center. But there is still a class of credentials you need to eliminate: the long-lived access keys that CI/CD pipelines typically store as secrets.
In this step, you will configure GitHub Actions to deploy infrastructure using OIDC federation. GitHub proves its identity to AWS using a short-lived token. AWS trusts the token and grants temporary credentials. Zero secrets are stored anywhere.
In this step, get ready to:
- Create a GitHub OIDC identity provider in IAM.
- Build a deployment role with trust policy and Permission Boundary.
- Push a GitHub Actions workflow that deploys with zero stored secrets.
Create the GitHub OIDC identity provider in IAM
An OIDC identity provider tells IAM to trust tokens issued by a specific external service. Once you register GitHub as a trusted provider, any GitHub Actions workflow can exchange its short-lived token for temporary AWS credentials.
- Create the GitHub OIDC identity provider in your management account by running this command:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
What does this command do?
This registers GitHub's token service as a trusted identity provider in your AWS account.
- --url points to GitHub's OIDC token endpoint. Every GitHub Actions workflow mints tokens from this URL.
- --client-id-list specifies the audience value (sts.amazonaws.com). This tells IAM to only accept tokens intended for AWS STS.
- Record the OpenIDConnectProviderArn from the output. You will need it in the next substep.
- Verify the provider was created by running:
aws iam list-open-id-connect-providers
You should see an entry with an ARN containing token.actions.githubusercontent.com in the output.
Seeing an error about thumbprints?
AWS no longer requires thumbprints for GitHub's OIDC provider. If your CLI version is older, try adding --thumbprint-list "" as an empty value. Updating your AWS CLI to the latest v2 resolves this.
help me troubleshoot creating the GitHub OIDC provider in IAM
💡 Why OIDC instead of stored access keys?
Long-lived access keys stored in CI/CD secrets are the number one credential theft vector for cloud breaches. If the secret leaks (through logs, a compromised runner, or a misconfigured repo), the attacker has persistent access.
OIDC federation eliminates this entire class of risk. Credentials last only minutes, are scoped to a single workflow run, and are never stored anywhere.
Create the deployment role with a trust policy and Permission Boundary
The OIDC provider exists, but nothing can assume a role yet. You need a role whose trust policy accepts tokens from your specific GitHub repository only. You also need a Permission Boundary that caps what this role can ever do, regardless of the permissions policy attached.
- Create the trust policy file by running this command:
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID="your management account ID"]]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:[[GITHUB_ORG_REPO="YOUR-ORG/YOUR-REPO"]]:*"
}
}
}
]
}
EOF
What does this trust policy do?
- The Principal field points to your GitHub OIDC provider ARN. Only tokens from that provider are accepted.
- The token.actions.githubusercontent.com:aud condition ensures the token was minted for AWS STS specifically.
- The token.actions.githubusercontent.com:sub condition pins access to your specific repository. Without this condition, IAM rejects the policy with a MalformedPolicyDocument error.
- Create the Permission Boundary policy file by running this command:
cat > permission-boundary.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
},
{
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"cloudtrail:StopLogging"
],
"Resource": "*"
}
]
}
EOF
What does the Permission Boundary do?
A Permission Boundary sets the maximum permissions a role can ever have. Even if you attach AdministratorAccess to this role, the boundary still blocks the denied actions.
The explicit Deny on iam:CreateUser prevents the role from creating persistent credentials (privilege escalation). The Deny on cloudtrail:StopLogging ensures audit trails can never be disabled.
- Create the Permission Boundary policy in IAM by running:
aws iam create-policy \
--policy-name DeployPermissionBoundary \
--policy-document file://permission-boundary.json
- Record the policy ARN from the output. It follows the format arn:aws:iam::ACCOUNT_ID:policy/DeployPermissionBoundary.
- Create the deployment role with the trust policy and Permission Boundary attached by running:
aws iam create-role \
--role-name GitHubOIDCDeployRole \
--assume-role-policy-document file://trust-policy.json \
--permissions-boundary arn:aws:iam::[[AWS_ACCOUNT_ID="your management account ID"]]:policy/DeployPermissionBoundary
What's happening here?
- --assume-role-policy-document attaches the trust policy (who can assume this role).
- --permissions-boundary sets the ceiling on what the role can do. This caps the role at creation time.
- Create a deployment permissions policy and attach it to the role by running these commands:
cat > deploy-permissions.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*",
"ec2:*",
"kms:*",
"secretsmanager:*",
"iam:GetRole",
"iam:PassRole",
"iam:ListRoles",
"sts:AssumeRole"
],
"Resource": "*"
}
]
}
EOF
aws iam create-policy \
--policy-name GitHubDeployPolicy \
--policy-document file://deploy-permissions.json
aws iam attach-role-policy \
--role-name GitHubOIDCDeployRole \
--policy-arn arn:aws:iam::[[AWS_ACCOUNT_ID="your management account ID"]]:policy/GitHubDeployPolicy
How do the permissions policy and boundary interact?
The permissions policy grants broad deployment capabilities. The Permission Boundary then caps those permissions. The effective permissions are the intersection of both policies.
Even though the permissions policy allows iam:* is not in this policy, the boundary blocks iam:CreateUser regardless. This prevents privilege escalation.
- Verify the role exists and has the Permission Boundary by running:
aws iam get-role --role-name GitHubOIDCDeployRole
You should see PermissionsBoundary in the output referencing DeployPermissionBoundary.
Getting a MalformedPolicyDocument error?
IAM mandates the token.actions.githubusercontent.com:sub condition key in any trust policy that uses GitHub's OIDC provider. If you omitted it, AWS rejects the policy. Check your trust-policy.json includes both condition keys.
Also verify your account ID is correct in the Federated principal ARN. Run aws sts get-caller-identity to confirm.
help me fix the MalformedPolicyDocument error when creating my GitHub OIDC deployment role
Create and push the GitHub Actions workflow
The OIDC provider and deployment role are ready. Now you need a GitHub Actions workflow that requests an OIDC token, exchanges it for temporary credentials, and runs tofu plan against your infrastructure.
- Create the workflow directory in your project by running:
mkdir -p .github/workflows
- Create the workflow file at .github/workflows/deploy.yml and paste the following content:
name: Deploy
on: [push]
permissions:
id-token: write
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Tofu Plan
run: |
cd infrastructure
tofu init
tofu plan
What does this workflow do?
- permissions: id-token: write grants the workflow permission to request an OIDC token from GitHub.
- aws-actions/configure-aws-credentials@v4 exchanges the OIDC token for temporary AWS credentials by calling sts:AssumeRoleWithWebIdentity.
- role-to-assume references the deployment role ARN stored as a GitHub repository secret. The ARN itself is not sensitive (it is public metadata), but storing it as a secret keeps the workflow portable.
- The final step runs tofu init and tofu plan inside the infrastructure directory using those temporary credentials.
- Add the deployment role ARN as a GitHub repository secret by running:
gh secret set DEPLOY_ROLE_ARN --body arn:aws:iam::[[AWS_ACCOUNT_ID="your management account ID"]]:role/GitHubOIDCDeployRole
Why store the role ARN as a secret?
The role ARN is not a credential. It is public metadata. However, storing it as a repository secret keeps the workflow portable across environments without hardcoding account IDs in YAML.
- Commit and push the workflow to trigger the first run:
git add .github/workflows/deploy.yml
git commit -m "Add OIDC deploy workflow"
git push
- Open your GitHub repository in a browser.
- Click the **Actions** tab.
- You should see the **Deploy** workflow running. Wait for it to complete with a green checkmark.
Workflow failing with AccessDenied?
- Check that the token.actions.githubusercontent.com:sub condition in your trust policy matches your repository path. The format is repo:OWNER/REPO:ref:refs/heads/BRANCH. If you used a wildcard (:*), make sure the org and repo name match exactly.
- Verify the OIDC provider ARN in the trust policy matches what aws iam list-open-id-connect-providers returns.
- Confirm the DEPLOY_ROLE_ARN secret value matches the role ARN in your account.
help me debug why my GitHub Actions OIDC workflow is getting AccessDenied from AWS
- Verify the OIDC federation in CloudTrail by running:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
--max-results 5
You should see an event with AssumeRoleWithWebIdentity and the source token.actions.githubusercontent.com in the output. This proves your deployment ran with zero stored secrets.
✔️ Awesome, I've got everything!
Great. Double check you have saved your .github/workflows/deploy.yml file and that it has been pushed to your repository.
ⓧ I'd like to double check the full code
Here is the complete workflow file for comparison:
name: Deploy
on: [push]
permissions:
id-token: write
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Tofu Plan
run: |
cd infrastructure
tofu init
tofu plan
Your CI/CD pipeline now deploys infrastructure without a single stored secret. Next up, you will enforce attribute-based access control across accounts using session tags and cross-account role chains.
Implement ABAC Session Tags and Cross-Account Role Chain
Your GitHub OIDC federation is live and your deployment role has a permission boundary. The next challenge is isolating project teams within the same spoke account without writing separate policies for every project.
In this step, you will create two project-scoped roles in the SecuritySpoke account and enforce isolation using session tags. Instead of maintaining one policy per project, you will use ABAC (Attribute-Based Access Control) to dynamically match a session's project tag against resource tags. This is how teams at scale avoid policy explosions.
In this step, get ready to:
- Create two IAM roles (ProjectAlpha, ProjectBeta) in the SecuritySpoke account with trust policies requiring session tags.
- Create an S3 bucket policy that uses aws:PrincipalTag/project for ABAC.
- Test cross-account ABAC isolation by proving one project cannot access another project's objects.
Create project-scoped roles with session tag trust policies
Traditional access control requires a separate IAM policy for each project. When you add a new project, you write new policies, attach them, and audit them. With ABAC, you write one policy that references aws:PrincipalTag/project and it works for every project automatically.
You will create two roles in the spoke account. The trust policy for each role requires that the caller passes a specific session tag during role assumption.
- First, assume the OrganizationAccountAccessRole to operate in the spoke account by running this command:
aws sts assume-role \
--role-arn arn:aws:iam::[[SPOKE_ACCOUNT_ID="your SecuritySpoke account ID"]]:role/OrganizationAccountAccessRole \
--role-session-name abac-setup
- Export the temporary credentials returned by that command:
export AWS_ACCESS_KEY_ID=<AccessKeyId from output>
export AWS_SECRET_ACCESS_KEY=<SecretAccessKey from output>
export AWS_SESSION_TOKEN=<SessionToken from output>
- Verify you are now operating in the spoke account by running:
aws sts get-caller-identity
The output should show the SecuritySpoke account ID and OrganizationAccountAccessRole as the assumed role.
- Create the trust policy file for the ProjectAlpha role. Save this as alpha-trust-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAssumeFromManagement",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Principal": {
"AWS": "arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:root"
},
"Condition": {
"StringEquals": {
"aws:RequestTag/project": "alpha"
}
}
},
{
"Sid": "AllowTagSession",
"Effect": "Allow",
"Action": "sts:TagSession",
"Principal": {
"AWS": "arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:root"
},
"Condition": {
"StringEquals": {
"aws:RequestTag/project": "alpha"
}
}
}
]
}
What does this trust policy do?
The trust policy contains two statements because session tags require both permissions.
- The first statement allows sts:AssumeRole from the management account, but ONLY when the request includes the session tag project=alpha.
- The second statement allows sts:TagSession, which is required for the session tag to actually be applied. Without it, the AssumeRole call fails.
- The aws:RequestTag/project condition checks the tag value at request time, ensuring only the correct tag can be passed.
- Create the ProjectAlpha role by running:
aws iam create-role \
--role-name ProjectAlpha \
--assume-role-policy-document file://alpha-trust-policy.json
- Attach a permissions policy to ProjectAlpha that allows S3 and KMS access (you will add ABAC conditions at the resource level):
aws iam attach-role-policy \
--role-name ProjectAlpha \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
- Now create the trust policy for the ProjectBeta role. Save this as beta-trust-policy.json. The only difference is the tag value changes to beta:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAssumeFromManagement",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Principal": {
"AWS": "arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:root"
},
"Condition": {
"StringEquals": {
"aws:RequestTag/project": "beta"
}
}
},
{
"Sid": "AllowTagSession",
"Effect": "Allow",
"Action": "sts:TagSession",
"Principal": {
"AWS": "arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:root"
},
"Condition": {
"StringEquals": {
"aws:RequestTag/project": "beta"
}
}
}
]
}
- Create the ProjectBeta role by running:
aws iam create-role \
--role-name ProjectBeta \
--assume-role-policy-document file://beta-trust-policy.json
- Attach the same permissions policy to ProjectBeta:
aws iam attach-role-policy \
--role-name ProjectBeta \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
Why does the trust policy live in the spoke account?
The trust policy always lives in the account where the role is (the resource account). The management account only needs sts:AssumeRole permission to call the role's ARN. This trust-policy-placement distinction is a common SAP-C02 exam trap.
Verify both roles exist by running:
aws iam get-role --role-name ProjectAlpha --query 'Role.Arn'
aws iam get-role --role-name ProjectBeta --query 'Role.Arn'
You should see both role ARNs printed with the SecuritySpoke account ID.
Seeing MalformedPolicyDocument?
Check that your JSON file has no trailing commas and that the Principal ARN matches your management account ID exactly. Also confirm both sts:AssumeRole and sts:TagSession are present as separate statements.
help me debug a MalformedPolicyDocument error when creating IAM roles with session tag conditions
Create an S3 bucket with ABAC conditions
Now you need resources that enforce ABAC. You will create an S3 bucket with a bucket policy that uses aws:PrincipalTag/project as a condition. When a session carries project=alpha, it can only access objects tagged project=alpha.
- Create an S3 bucket in the spoke account for ABAC testing by running:
aws s3api create-bucket \
--bucket [[ABAC_BUCKET="your-unique-abac-bucket-name"]] \
--region us-east-1
- Upload a test object tagged as project alpha:
echo "Alpha project data" > alpha-data.txt
aws s3api put-object \
--bucket [[ABAC_BUCKET="your-unique-abac-bucket-name"]] \
--key alpha-data.txt \
--body alpha-data.txt \
--tagging "project=alpha"
- Upload a second test object tagged as project beta:
echo "Beta project data" > beta-data.txt
aws s3api put-object \
--bucket [[ABAC_BUCKET="your-unique-abac-bucket-name"]] \
--key beta-data.txt \
--body beta-data.txt \
--tagging "project=beta"
- Now apply a bucket policy that restricts GetObject based on the caller's session tag matching the object's tag. Save the following as abac-bucket-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ABACGetObject",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::[[SPOKE_ACCOUNT_ID="your SecuritySpoke account ID"]]:role/ProjectAlpha",
"arn:aws:iam::[[SPOKE_ACCOUNT_ID="your SecuritySpoke account ID"]]:role/ProjectBeta"
]
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::[[ABAC_BUCKET="your-unique-abac-bucket-name"]]/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/project": "${aws:PrincipalTag/project}"
}
}
}
]
}
What does this bucket policy do?
The s3:ExistingObjectTag/project condition key checks the tag on the S3 object being accessed. The ${aws:PrincipalTag/project} variable resolves to whatever session tag the caller passed when they assumed the role.
If the session tag is project=alpha, the condition evaluates to s3:ExistingObjectTag/project = alpha. Only objects tagged project=alpha pass this check. No policy rewrites needed when you add new projects. Just tag the resources.
- Apply the bucket policy by running:
aws s3api put-bucket-policy \
--bucket [[ABAC_BUCKET="your-unique-abac-bucket-name"]] \
--policy file://abac-bucket-policy.json
The command completes silently on success.
Seeing an invalid principal error?
If the bucket policy fails with an invalid principal, confirm the role ARNs match exactly. The roles must already exist before you can reference them in a bucket policy. Run aws iam get-role --role-name ProjectAlpha to confirm the role exists and copy the ARN exactly.
help me fix an invalid principal error when applying an S3 bucket policy with cross-account role ARNs
Test the ABAC chain from the management account
The roles and bucket policy are in place. Now you will switch back to the management account, assume the ProjectAlpha role with a session tag, and prove that alpha can access alpha-tagged objects but NOT beta-tagged objects.
- First, unset the spoke account credentials to return to your management account context:
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
- Confirm you are back in the management account:
aws sts get-caller-identity
- Assume the ProjectAlpha role with the required session tag by running:
aws sts assume-role \
--role-arn arn:aws:iam::[[SPOKE_ACCOUNT_ID="your SecuritySpoke account ID"]]:role/ProjectAlpha \
--role-session-name alpha-test \
--tags Key=project,Value=alpha
What does --tags do here?
The --tags Key=project,Value=alpha flag passes a session tag along with the AssumeRole request. The trust policy's condition verifies this tag matches alpha, and the sts:TagSession permission allows the tag to be applied to the resulting session. Once applied, the tag becomes available as aws:PrincipalTag/project in any downstream policy evaluation.
- Export the temporary credentials from the output:
export AWS_ACCESS_KEY_ID=<AccessKeyId from output>
export AWS_SECRET_ACCESS_KEY=<SecretAccessKey from output>
export AWS_SESSION_TOKEN=<SessionToken from output>
- Attempt to download the alpha-tagged object (this should succeed):
aws s3api get-object \
--bucket [[ABAC_BUCKET="your-unique-abac-bucket-name"]] \
--key alpha-data.txt \
alpha-download.txt
You should see a successful response with metadata about the object. The file downloads to alpha-download.txt.
- Now attempt to download the beta-tagged object (this should be denied):
aws s3api get-object \
--bucket [[ABAC_BUCKET="your-unique-abac-bucket-name"]] \
--key beta-data.txt \
beta-download.txt
You should see an AccessDenied error. The ProjectAlpha session carries project=alpha but the object is tagged project=beta. The condition fails and S3 denies the request.
Why this matters at scale
You just proved that project isolation works without writing separate policies for each project. To onboard ProjectCharlie tomorrow, you would create one new role with aws:RequestTag/project = charlie in its trust policy, tag the resources project=charlie, and the existing bucket policy handles access control automatically.
- Unset the session credentials to clean up:
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
Alpha access also denied?
If the alpha object access also returns AccessDenied, check two things. First, verify the object was uploaded with the correct tag by running aws s3api get-object-tagging from the spoke account. Second, confirm the bucket policy principal ARN matches the role ARN exactly (account ID, role name, and case must match).
help me debug why my ABAC session tag is not granting access to S3 objects even though the tags should match
ABAC isolation is proven. Your two project roles operate in the same account, use the same permissions policy, and the same bucket policy, yet cannot touch each other's data. Next up, you will create multi-region KMS keys with envelope encryption and grants to protect data at rest across regions.
Create KMS Multi-Region Keys, Grants, and Envelope Encryption
Your ABAC isolation is proven. Sessions tagged project=alpha can only touch alpha-tagged resources, and the same holds for beta. Now you need a cryptographic foundation that respects those same tag boundaries.
In this step, you will create a KMS multi-region key that automatically replicates key material to a second region for disaster recovery. You will demonstrate envelope encryption (the same pattern every AWS service uses to encrypt data at rest) and use KMS Grants to provide temporary, scoped access to the key without modifying the key policy.
In this step, get ready to:
- Create a multi-region KMS key and replicate it across regions.
- Encrypt and decrypt a sample file using envelope encryption with openssl.
- Create and retire a KMS Grant for temporary Lambda access.
Create and replicate a multi-region KMS key
A multi-region key shares the same key material across multiple AWS regions. If us-east-1 goes down, workloads in us-west-2 can still decrypt data without cross-region API calls.
The multi-region property is immutable. You set it at key creation time and it cannot be changed afterwards.
- Create the multi-region primary key in us-east-1 by running this command:
# Create a multi-region primary KMS key in us-east-1
aws kms create-key \
--multi-region \
--region us-east-1 \
--query 'KeyMetadata.{KeyId:KeyId,Arn:Arn,MultiRegion:MultiRegion}' \
--output table
What does this command do?
The --multi-region flag creates a key whose material can be replicated to other regions. The key ID will start with mrk- to distinguish it from single-region keys.
The --query flag filters the output so you can verify MultiRegion is True and record the key ARN for subsequent commands.
- Verify your key ID starts with mrk- in the output. Copy the full key ARN for the next commands.
- Replicate the key to us-west-2 by running this command:
# Replicate the multi-region key to us-west-2
aws kms replicate-key \
--key-id [[KMS_KEY_ARN="your mrk- key ARN from the previous output"]] \
--replica-region us-west-2
Why replicate-key instead of create-key?
You cannot use create-key to create a replica of a multi-region key. Replicas share the same cryptographic material as the primary. The replicate-key API is the only way to establish that shared-material relationship.
Key policies, grants, and aliases are independent per region. Key material and key spec are automatically synchronized.
- Confirm the replica exists by running:
# Verify the replica key in us-west-2 shares the same mrk- ID
aws kms describe-key \
--key-id [[KMS_KEY_ARN="your mrk- key ARN"]] \
--region us-west-2 \
--query 'KeyMetadata.{KeyId:KeyId,MultiRegion:MultiRegion,MultiRegionKeyType:MultiRegionConfiguration.MultiRegionKeyType}'
You should see MultiRegionKeyType displayed as REPLICA and the same mrk- key ID as your primary.
- Now add a key policy that restricts usage to roles carrying the project=alpha session tag. This ties your KMS key into the ABAC system you built in the previous step. Run this command:
# Put a key policy restricting usage to roles with project=alpha tag
aws kms put-key-policy \
--key-id [[KMS_KEY_ARN="your mrk- key ARN"]] \
--policy-name default \
--policy '{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnableRootAccount",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowAlphaProjectOnly",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:CreateGrant"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/project": "alpha"
}
}
}
]
}'
What does this key policy do?
The first statement gives the root account full management access so you can still administer the key. The second statement allows cryptographic operations only when the calling principal's session carries aws:PrincipalTag/project equal to alpha.
This means your ProjectBeta role cannot use this key at all. The ABAC boundary you set up in the previous step now extends to your cryptographic layer.
🙋♀️ Getting an AccessDenied on put-key-policy?
Make sure you are running the command from the management account (the account that owns the key). Only the key owner or a principal allowed by the current key policy can update the policy.
If you accidentally lock yourself out, you can still access the key as the root account within 5 minutes of creation.
help me fix an AccessDenied error on kms put-key-policy
Demonstrate envelope encryption with GenerateDataKey
Envelope encryption is how every AWS service encrypts data at rest. Instead of sending your data to KMS for encryption (which has a 4 KB limit), KMS generates a data key that you use locally.
You store only the encrypted copy of the data key alongside your ciphertext. To decrypt, you call KMS to unwrap the data key first.
- Create a sample PHI file to encrypt by running:
# Create a sample PHI file representing healthcare data
echo "Patient: Jane Doe, DOB: 1985-03-15, MRN: 12345, Diagnosis: Type-2 Diabetes" > phi_sample.txt
- Generate a data key from your multi-region KMS key by running:
# Generate a 256-bit data key encrypted under your multi-region KMS key
aws kms generate-data-key \
--key-id [[KMS_KEY_ARN="your mrk- key ARN"]] \
--key-spec AES_256 \
--output json > data_key.json
What does GenerateDataKey return?
The response contains two fields (both base64-encoded):
- Plaintext: the usable data key for immediate local encryption.
- CiphertextBlob: the same data key encrypted under your KMS key. This is what you store alongside the encrypted data.
KMS never stores the plaintext data key. Once you use it, you must discard it from memory.
- Extract the plaintext data key, decode it from base64, and save it to a temporary file by running:
# Extract and decode the plaintext data key for local encryption
cat data_key.json | python3 -c "import sys,json; print(json.load(sys.stdin)['Plaintext'])" | base64 --decode > plaintext_datakey.bin
# Extract the encrypted data key (this is what you keep)
cat data_key.json | python3 -c "import sys,json; print(json.load(sys.stdin)['CiphertextBlob'])" | base64 --decode > encrypted_datakey.bin
What do these two files represent?
The plaintext_datakey.bin file is the raw 32-byte encryption key you will use with openssl. You will delete this immediately after encrypting.
The encrypted_datakey.bin file is the same key wrapped by KMS. You store this alongside your encrypted data. To decrypt later, you send this blob back to KMS to recover the plaintext key.
- Encrypt the PHI file using the plaintext data key with openssl by running:
# Encrypt the PHI file locally using AES-256 and the plaintext data key
openssl enc -e -aes-256-cbc \
-kfile plaintext_datakey.bin \
-in phi_sample.txt \
-out phi_sample.enc \
-pbkdf2
- Immediately discard the plaintext data key by running:
# Delete the plaintext data key immediately after use
rm plaintext_datakey.bin
Why delete the plaintext key immediately?
The plaintext data key is the only thing that can decrypt your file. By deleting it, you ensure that decryption is only possible by first calling KMS to decrypt the encrypted data key (encrypted_datakey.bin).
This forces all decryption through KMS access controls and creates an auditable CloudTrail event every time someone decrypts data.
- Now verify decryption works. Recover the plaintext data key from KMS by decrypting the encrypted copy:
# Decrypt the encrypted data key using KMS
aws kms decrypt \
--ciphertext-blob fileb://encrypted_datakey.bin \
--query Plaintext \
--output text | base64 --decode > plaintext_datakey.bin
- Decrypt the PHI file using the recovered data key by running:
# Decrypt the PHI file locally
openssl enc -d -aes-256-cbc \
-kfile plaintext_datakey.bin \
-in phi_sample.enc \
-out phi_decrypted.txt \
-pbkdf2
# Verify the content matches the original
cat phi_decrypted.txt
You should see the original patient record printed to your terminal. This proves the full envelope encryption cycle works.
- Clean up the plaintext key again by running:
# Clean up the plaintext key after verification
rm plaintext_datakey.bin
Seeing a decryption error from openssl?
Make sure you passed -pbkdf2 to both the encrypt and decrypt commands. If you used it during encryption but omit it during decryption, openssl will fail to derive the same internal key from the file.
Also check that plaintext_datakey.bin is a 32-byte binary file, not a base64-encoded string. If the file is 44 bytes (base64 with padding), the base64 --decode step did not run correctly.
help me debug an openssl decryption failure with my KMS data key
Create and retire a KMS Grant for temporary Lambda access
In the next step, a Lambda function will rotate database credentials. That function needs temporary access to your KMS key for Decrypt and GenerateDataKey operations.
Instead of editing the key policy every time a new function needs access, you create a Grant. Grants provide temporary, scoped permissions that can be retired when no longer needed.
- Create a KMS Grant for the Lambda rotation function (using a placeholder role ARN for now) by running:
# Create a grant allowing a Lambda rotation function to use the key
aws kms create-grant \
--key-id [[KMS_KEY_ARN="your mrk- key ARN"]] \
--grantee-principal arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:role/SecretsManagerRotationRole \
--operations Decrypt GenerateDataKey \
--retiring-principal arn:aws:iam::[[MGMT_ACCOUNT_ID="your management account ID"]]:root
What does this grant do?
The --operations Decrypt GenerateDataKey flag specifies the only two actions this grant allows. The grantee (the rotation Lambda role) cannot do anything else with the key.
The --retiring-principal designates who can retire (delete) the grant when it is no longer needed. Grants are the right tool when access is temporary or programmatic.
- The command returns a GrantId and GrantToken. Copy the GrantId for the next command.
- Retire the grant to demonstrate the full lifecycle by running:
# Retire the grant after the rotation is complete
aws kms retire-grant \
--grant-id [[GRANT_ID="the GrantId from the create-grant output"]] \
--key-id [[KMS_KEY_ARN="your mrk- key ARN"]]
Why retire instead of revoke?
Retiring a grant is the standard cleanup path when granted permissions are no longer needed. Revoking is a more forceful action typically used when you suspect the grant was created improperly or compromised. Both remove all permissions the grant provided.
- Confirm the grant is gone by listing grants on the key:
# Verify no grants remain on the key
aws kms list-grants \
--key-id [[KMS_KEY_ARN="your mrk- key ARN"]]
You should see an empty Grants array in the output.
- Verify the grant lifecycle events in CloudTrail by running:
# Check CloudTrail for the CreateGrant event
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=CreateGrant \
--max-results 1 \
--query 'Events[0].{EventName:EventName,EventTime:EventTime}'
- Run the same command with AttributeValue=RetireGrant to confirm the retirement event:
# Check CloudTrail for the RetireGrant event
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=RetireGrant \
--max-results 1 \
--query 'Events[0].{EventName:EventName,EventTime:EventTime}'
CloudTrail not showing the events yet?
CloudTrail events can take up to 15 minutes to appear. If you do not see CreateGrant or RetireGrant events immediately, wait a few minutes and re-run the lookup command.
help me find KMS grant events in CloudTrail
Your cryptographic backbone is in place. You have a multi-region key with ABAC-restricted access, a proven envelope encryption flow, and a demonstrated grant lifecycle. Next up, you will use Secrets Manager to automatically rotate database credentials using this key.
Configure Secrets Manager Rotation and Expose the Parameter Store Trap
Your cryptographic backbone is in place. Now you need to answer a critical question for the SOC-2 audit: how do database credentials get rotated without human intervention and without application downtime?
Many AWS candidates reach for Parameter Store because it offers encrypted storage via SecureString. In this step, you will deliberately fall into that trap, observe its limitations firsthand, then deploy the correct solution: Secrets Manager managed rotation through RDS.
In this step, get ready to:
- Expose the Parameter Store rotation trap by creating a SecureString and confirming no native rotation API exists.
- Deploy an RDS MySQL instance with managed rotation that automatically creates and rotates secrets.
- Replicate the secret cross-region and force a manual rotation to prove zero-downtime credential cycling.
Create a Parameter Store SecureString and observe the rotation gap
On the SAP-C02 exam, a question about encrypted credential storage with automatic rotation has exactly one correct answer. You are about to see why.
- Create a SecureString parameter simulating a database credential by running this command:
aws ssm put-parameter \
--name "/healthcare/db/master-password" \
--value "SimulatedDbPassword123" \
--type "SecureString"
What does this command do?
This creates a KMS-encrypted parameter in Systems Manager Parameter Store. The value is encrypted at rest using the default aws/ssm managed key. You can retrieve it with --with-decryption and it looks like a perfectly good place to store secrets.
The problem is what comes next: there is no aws ssm rotate-parameter command, no built-in rotation schedule, and no rotation API. To rotate this value, you would need to write a custom Lambda function, wire it to an EventBridge schedule, manually update the parameter version, and handle rollback yourself.
- Verify the parameter was created by running:
aws ssm get-parameter \
--name "/healthcare/db/master-password" \
--with-decryption
You should see the decrypted value returned with a version number of 1.
- Now search the SSM CLI for any rotation-related subcommand:
aws ssm help | grep -i rotate
No results. Parameter Store has no native rotation capability.
Why this matters for the exam
Parameter Store provides SecureString (KMS-encrypted values) but has no native automatic rotation capability. Candidates who see "encrypted credential storage" and pick Parameter Store fail the rotation requirement.
Secrets Manager costs more ($0.40/secret/month vs free for Parameter Store) but rotation is the differentiator. When the question says "automatic rotation," the answer is always Secrets Manager.
🙋♀️ Parameter already exists?
If you see a ParameterAlreadyExists error, add --overwrite to the put-parameter command. This replaces the existing value.
help me troubleshoot Parameter Store errors
Deploy RDS with managed master password rotation
The correct solution for database credentials that must rotate automatically is managed rotation. RDS can create the secret in Secrets Manager and handle the entire rotation lifecycle without any Lambda function.
- Create an RDS MySQL instance with managed password rotation by running this command:
aws rds create-db-instance \
--db-instance-identifier mydb \
--db-instance-class db.t3.micro \
--engine mysql \
--master-username admin \
--manage-master-user-password \
--allocated-storage 20
What does this command do?
The --manage-master-user-password flag tells RDS to generate the master user password and store it in Secrets Manager. RDS configures managed rotation automatically with a default rotation period of every 7 days.
No Lambda function is needed. No rotation configuration is needed. RDS handles the create_secret, set_secret, test_secret, and finish_secret steps internally.
📣 This will take several minutes
RDS instance creation typically takes 5-10 minutes. Wait for the status to become "available" before proceeding.
- Wait for the instance to become available by running:
aws rds wait db-instance-available \
--db-instance-identifier mydb
- Verify the secret was created in Secrets Manager by running:
aws secretsmanager list-secrets \
--filters Key=name,Values=rds
You should see a secret with a name containing rds and mydb in the output. Note the secret ARN for the next substep.
- Confirm that rotation is enabled on the secret by running:
aws secretsmanager describe-secret \
--secret-id rds!db-mydb-master-user
Look for RotationEnabled: true in the output. The rotation schedule defaults to every 7 days.
Why managed rotation instead of Lambda rotation?
For RDS master credentials, AWS offers managed rotation where RDS handles the rotation lifecycle without a customer-managed Lambda function. This avoids the Lambda networking and permissions complexity entirely.
The Lambda-based approach is still needed for application-level credentials or non-database secrets where Secrets Manager does not have native integration.
🙋♀️ Instance creation failing?
Ensure your VPC has at least 2 subnets in different Availability Zones. RDS requires a DB subnet group. If you see a subnet group error, create one first using your management account VPC private subnets.
help me fix my RDS instance creation
Replicate the secret cross-region and force manual rotation
For disaster recovery, the secret must exist in your secondary region. After replication, you will force a rotation and confirm the application reconnects without downtime using the AWSCURRENT staging label.
- Replicate the RDS secret to us-west-2 by running this command (replace the secret ID with your actual secret ARN from the previous output):
aws secretsmanager replicate-secret-to-regions \
--secret-id [[SECRET_ARN="your RDS secret ARN from describe-secret output"]] \
--add-replica-regions Region=us-west-2
What does replication do?
Replication creates a read-only copy of the secret in us-west-2. When rotation happens on the primary in us-east-1, the new secret value automatically propagates to all replicas. This means your DR region always has current credentials.
- Verify replication status by running:
aws secretsmanager describe-secret \
--secret-id [[SECRET_ARN="your RDS secret ARN"]]
Look for ReplicationStatus showing InSync for us-west-2.
- Force a manual rotation of the RDS master password by running:
aws rds modify-db-instance \
--db-instance-identifier mydb \
--rotate-master-user-password \
--apply-immediately
What happens during rotation?
RDS creates a new secret version with a new password, updates the database master password, labels the new version AWSCURRENT, and moves the old version to AWSPREVIOUS. Applications using the Secrets Manager SDK automatically pick up AWSCURRENT on their next call.
This is zero-downtime rotation. No connection string changes. No application redeployment.
- Verify the new secret version exists by listing secret versions:
aws secretsmanager list-secret-version-ids \
--secret-id [[SECRET_ARN="your RDS secret ARN"]]
You should see at least two versions. One labeled AWSCURRENT (the new password) and one labeled AWSPREVIOUS (the old password).
- Test that you can retrieve the current credentials by running:
aws secretsmanager get-secret-value \
--secret-id [[SECRET_ARN="your RDS secret ARN"]] \
--version-stage AWSCURRENT
The returned JSON contains the new username and password. Any application using the Secrets Manager SDK to fetch credentials with the AWSCURRENT staging label would seamlessly reconnect with zero downtime.
Rotation taking too long?
Managed rotation can take a few minutes to complete. Run aws secretsmanager describe-secret and check the LastRotatedDate field. If it has not updated after 5 minutes, check the RDS instance status to ensure it is in the "available" state.
help me debug Secrets Manager rotation
Your secrets infrastructure is now production-grade. Credentials rotate on schedule, replicate cross-region for DR, and applications reconnect without downtime. Next up, you will build ransomware resistance with S3 Object Lock and prove immutability under attack.
Enable S3 Object Lock and Run the Ransomware Simulation
Your crypto and rotation stack is proven. Secrets rotate without downtime, and KMS multi-region keys replicate across regions.
Now you need to answer a different question: what happens when an attacker gets credentials and tries to destroy your backups? Object Lock makes objects immutable for a defined retention period. In this step, you will create a locked bucket, upload encrypted backups, and prove that even privileged roles cannot overwrite or delete them.
In this step, get ready to:
- Create a versioned S3 bucket with Object Lock (Compliance mode) and S3 Bucket Keys enabled.
- Simulate a ransomware attack and prove both PutObject and DeleteObject are blocked.
- Enable MFA Delete and document how it stacks with Object Lock for defense in depth.
Create the Object Lock bucket with S3 Bucket Keys
Object Lock must be enabled at bucket creation time. You cannot add it to an existing bucket. Enabling it automatically enables versioning as well.
- Create a new S3 bucket with Object Lock enabled by running this command:
aws s3api create-bucket \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--region us-east-1 \
--object-lock-enabled-for-bucket
Why does Object Lock require creation-time enablement?
Once Object Lock is enabled on a bucket, you cannot disable it or suspend versioning. This permanence is intentional. A compliance auditor needs to trust that the WORM (write-once-read-many) guarantee was never interrupted.
- Set the default retention to Compliance mode with a 30-day period by running:
aws s3api put-object-lock-configuration \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--object-lock-configuration '{"ObjectLockEnabled": "Enabled", "Rule": {"DefaultRetention": {"Mode": "COMPLIANCE", "Days": 30}}}'
What does this configuration do?
Every new object uploaded to this bucket inherits a 30-day Compliance retention. During that window, no principal (including root) can delete or overwrite the object. This is the SAP-C02 exam's correct answer for ransomware-proof backup storage.
- Enable S3 Bucket Keys with SSE-KMS encryption using your multi-region key by running:
aws s3api put-bucket-encryption \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--server-side-encryption-configuration '{"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "[[KMS_MRK_ARN="your mrk- KMS key ARN from us-east-1"]]"}, "BucketKeyEnabled": true}]}'
What are S3 Bucket Keys?
When you enable Bucket Keys, S3 creates a bucket-level key derived from your KMS CMK. S3 uses that derived key for object-level encryption instead of calling KMS for every single object operation.
At scale (millions of objects), this reduces KMS API calls dramatically and saves significant cost on request charges.
- Upload 10 sample backup files encrypted with your multi-region KMS key by running this loop:
# Upload 10 sample backup files encrypted with SSE-KMS
for i in $(seq 1 10); do
echo "PHI backup data sample $i - $(date)" > /tmp/backup-$i.txt
aws s3api put-object \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--key backups/backup-$i.txt \
--body /tmp/backup-$i.txt \
--server-side-encryption aws:kms \
--ssekms-key-id [[KMS_MRK_ARN="your mrk- KMS key ARN from us-east-1"]]
done
- Verify that S3 Bucket Keys are active by checking the response header on one of the uploaded objects:
aws s3api head-object \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--key backups/backup-1.txt
- In the output, confirm that BucketKeyEnabled is true. This corresponds to the x-amz-server-side-encryption-bucket-key-enabled response header.
BucketKeyEnabled shows false?
If the field shows false, your put-bucket-encryption command may not have applied correctly. Re-run the encryption configuration command and re-upload the file.
S3 does not validate the KMS key ID you provide at encryption-config time. Double-check your key ARN is correct and starts with mrk-.
help me verify my S3 Bucket Key configuration is working
Simulate a ransomware attack on the locked objects
A real ransomware attack encrypts or deletes your backups so you cannot recover without paying the ransom. You will now simulate both attack vectors and prove Object Lock blocks them.
- Attempt to overwrite a locked object by running a PutObject with the same key:
# Ransomware simulation: attempt to overwrite a locked object
echo "RANSOMWARE ENCRYPTED PAYLOAD" > /tmp/ransomware.txt
aws s3api put-object \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--key backups/backup-1.txt \
--body /tmp/ransomware.txt
What should I see?
In a versioned bucket with Object Lock in Compliance mode, a PutObject creates a new version. The original version remains protected by its retention period and cannot be deleted or altered.
If your restore process targets the locked version ID, ransomware cannot destroy your recovery point.
- Get the version IDs of the original object to find the locked version:
# List versions to find the original locked version
aws s3api list-object-versions \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--prefix backups/backup-1.txt
- Copy the VersionId of the original (first) version, then attempt to permanently delete it:
# Attempt to permanently delete the locked version
aws s3api delete-object \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--key backups/backup-1.txt \
--version-id [[VERSION_ID="the VersionId of the original upload"]]
What should I see?
You should see an AccessDenied error. Compliance mode prevents any principal, including the account root user, from deleting a version during the retention period. This is the proof that your backups survive a full credential compromise.
💡 Governance mode vs Compliance mode
Governance mode allows principals with the s3:BypassGovernanceRetention permission to override the lock. This is useful for test environments where you need the ability to clean up.
Compliance mode has no override for any principal, including root. This is the correct choice for regulatory backup protection (SOC-2, HIPAA, SEC Rule 17a-4). The SAP-C02 exam tests this distinction directly.
Enable MFA Delete and document the threat coverage
MFA Delete and Object Lock solve different problems. Object Lock prevents overwrites and deletions during a retention window. MFA Delete requires a second authentication factor to permanently remove a version from version history.
- Enable MFA Delete on the bucket using root account credentials. This cannot be done with IAM user credentials or from the console. Run:
aws s3api put-bucket-versioning \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--versioning-configuration Status=Enabled,MFADelete=Enabled \
--mfa "[[MFA_SERIAL="arn:aws:iam::ACCOUNT-ID:mfa/root-account-mfa-device CODE"]]"
What goes in the --mfa parameter?
The value is a concatenation of your MFA device serial number, a space, and the current 6-digit code displayed on your authenticator. For virtual MFA devices, the serial number is the device ARN.
Only the bucket owner (root account) can enable MFA Delete. You must be signed in with root credentials, not an IAM role or federated identity.
- Test MFA Delete by attempting a version delete without providing the MFA token:
# Attempt version delete WITHOUT MFA token (should be denied)
aws s3api delete-object \
--bucket [[OBJECTLOCK_BUCKET="your-objectlock-bucket-name"]] \
--key backups/backup-2.txt \
--version-id [[VERSION_ID_2="the VersionId of backup-2.txt"]]
What should I see?
You should see an AccessDenied error because MFA Delete requires the x-amz-mfa header with a valid code for any version deletion. Without it, the request is rejected regardless of your IAM permissions.
💡 Object Lock vs MFA Delete: different threats, stacked defenses
- Object Lock prevents object overwrites and deletions during the retention period. It blocks ransomware that attempts to encrypt-in-place or purge backups.
- MFA Delete prevents permanent version deletion from version history. It blocks an attacker who has credentials but not physical access to your MFA device.
- Together: even if credentials are compromised AND retention expires, the attacker still cannot purge version history without the MFA token. The SAP-C02 exam asks about both in the same question because they solve orthogonal threats.
🙋♀️ Getting an error enabling MFA Delete?
MFA Delete requires root account credentials. If you are using an IAM user, federated role, or assumed role, you will get an AccessDenied error. Sign in as the account root user and configure AWS CLI credentials for root before running the command.
help me enable MFA Delete on my S3 bucket
Your Object Lock bucket is now ransomware-proof with defense in depth. Next up, you will deploy WorkSpaces with connection alias failover architecture for the remote workforce.
Deploy WorkSpaces with Connection Alias Failover
Your security backbone now has federation, cryptography, secrets rotation, and ransomware resistance. The final pillar is End User Computing (EUC). Your 3,000 healthcare staff need persistent virtual desktops that survive a regional outage without changing their login workflow.
In this step, you will deploy an Amazon WorkSpaces desktop backed by the AD Connector you built earlier, then configure a connection alias that enables transparent failover across AWS Regions.
In this step, get ready to:
- Register the AD Connector directory with WorkSpaces and launch a Standard AutoStop Ubuntu desktop.
- Create a WorkSpaces Connection Alias with an FQDN and associate it with the directory.
- Document the failover architecture with a Mermaid diagram showing DNS TXT record resolution.
Register the WorkSpaces directory and launch a desktop
WorkSpaces needs a registered directory before it can provision desktops. You will register the AD Connector from Step 4, then launch an Ubuntu WorkSpace for one of your test AD users.
- Identify the Ubuntu Standard bundle available in your region by running:
aws workspaces describe-workspace-bundles \
--owner AMAZON \
--query "Bundles[?starts_with(Name, 'Standard with Ubuntu')].[Name, BundleId]" \
--output table
What does this command do?
This queries the AWS-provided bundle catalog and filters for the Ubuntu Standard bundle. The output shows you the bundle name and its ID (a wsb- prefixed string) that you will use in the next command.
- Record the BundleId from the output. You will use it below.
- Register the AD Connector directory with WorkSpaces by running:
# Register the AD Connector directory for WorkSpaces
aws workspaces register-workspace-directory \
--directory-id [[AD_CONNECTOR_ID="your AD Connector directory ID (d-xxxxxxxxxx)"]] \
--subnet-ids [[SUBNET_1="first private subnet ID"]] [[SUBNET_2="second private subnet ID"]] \
--tenancy SHARED
What does this command do?
The register-workspace-directory command registers your AD Connector with WorkSpaces. The two subnet IDs must be in different Availability Zones within the same VPC where the AD Connector lives.
The --tenancy SHARED flag places WorkSpaces on shared hardware, which is required for free-tier eligibility. The operation is asynchronous. The directory transitions from REGISTERING to REGISTERED within a few minutes.
- Verify the registration status by running:
aws workspaces describe-workspace-directories \
--directory-ids [[AD_CONNECTOR_ID="your AD Connector directory ID (d-xxxxxxxxxx)"]]
- Wait until the State field shows REGISTERED. This typically takes 1-2 minutes.
- Launch a Standard AutoStop Ubuntu WorkSpace for a test AD user by running:
# Launch an AutoStop Ubuntu WorkSpace for a test clinician user
aws workspaces create-workspaces \
--workspaces DirectoryId=[[AD_CONNECTOR_ID="your AD Connector directory ID"]],UserName=[[AD_USERNAME="test AD username e.g. clinician01"]],BundleId=[[UBUNTU_BUNDLE_ID="your Ubuntu Standard bundle ID (wsb-xxxxxxxxx)"]],WorkspaceProperties={RunningMode=AUTO_STOP}
What does this command do?
The RunningMode=AUTO_STOP setting makes this a Standard AutoStop WorkSpace. It automatically stops after a period of inactivity, which qualifies for the free tier (2 Standard AutoStop WorkSpaces, 40 hours combined monthly, for 3 months).
The UserName must match a user that exists in your Samba AD domain. The command is asynchronous. The WorkSpace state starts as PENDING and transitions to AVAILABLE once provisioning completes (typically 10-20 minutes).
- Check WorkSpace provisioning status by running:
aws workspaces describe-workspaces \
--directory-id [[AD_CONNECTOR_ID="your AD Connector directory ID"]]
- Wait until the State field shows AVAILABLE. This can take 10-20 minutes while AWS provisions the Ubuntu desktop.
- Download the WorkSpaces client from the AWS WorkSpaces downloads page.
- Log in using the registration code shown in the describe-workspaces output and the AD user credentials (domain: CORP, username and password from your Samba AD setup).
WorkSpaces free tier limits
The free tier includes 2 Standard AutoStop WorkSpaces with 40 hours of combined monthly usage for 3 months. AutoStop WorkSpaces automatically stop after inactivity, so you will not burn through hours while testing.
🙋♀️ WorkSpace stuck in PENDING?
WorkSpaces must be in the same VPC as the AD Connector. Verify your subnet IDs belong to the management account VPC where the AD Connector resides. Also confirm the AD Connector status is still Active in Directory Service.
help me debug my WorkSpace that is stuck in PENDING state
Create a connection alias and associate it with the directory
In production, users connect to WorkSpaces using a region-specific registration code. If that region fails, users must manually switch to a different code. A connection alias replaces the registration code with an FQDN that can be redirected via DNS, enabling transparent failover.
- Create the connection alias with your simulated FQDN by running:
# Create a connection alias for cross-region redirection
aws workspaces create-connection-alias \
--connection-string workspaces.corp-healthcare.internal
What does this command do?
The --connection-string must be a fully qualified domain name (FQDN). This FQDN replaces the region-specific registration code that users normally type into the WorkSpaces client.
Once created, this connection string is permanently tied to your AWS account. You cannot recreate the same string under a different account, even if you delete the alias. The command returns an AliasId prefixed with wsca- that you will use next.
- Record the AliasId from the output.
- Verify the connection alias is in the CREATED state by running:
aws workspaces describe-connection-aliases
- Associate the connection alias with your registered WorkSpaces directory by running:
# Associate the alias with the WorkSpaces directory
aws workspaces associate-connection-alias \
--alias-id [[ALIAS_ID="your connection alias ID (wsca-xxxxxxxxxx)"]] \
--resource-id [[AD_CONNECTOR_ID="your AD Connector directory ID (d-xxxxxxxxxx)"]]
What is the ConnectionIdentifier?
The associate-connection-alias command returns a ConnectionIdentifier. This is a short alphanumeric string (1-20 characters) that you place in a DNS TXT record. When a WorkSpaces client resolves the FQDN, it reads this TXT record to determine which regional directory to connect to.
Record this ConnectionIdentifier. In production, you would create a Route 53 TXT record pointing workspaces.corp-healthcare.internal to this value.
- Record the ConnectionIdentifier value from the output. This is the TXT record value that Route 53 would serve.
Connection alias association failed?
The connection alias must be in the CREATED state before you can associate it. Run describe-connection-aliases to verify the state. Also confirm your directory is registered (State: REGISTERED) in WorkSpaces.
help me fix my connection alias association error
Document the failover architecture
With the connection alias in place, you now have the building block for cross-region failover. In production, a second WorkSpaces deployment in us-west-2 would use the same connection alias. Route 53 failover routing with health checks would automatically redirect users to the healthy region.
- Create a file called workspaces-failover.md in your project's docs directory.
- Add the following Mermaid diagram showing the DNS TXT record resolution flow:
# WorkSpaces Cross-Region Failover Architecture
## DNS TXT Record Resolution Flow
```mermaid
flowchart TD
User["WorkSpaces Client"] -->|"1. Resolve FQDN"| DNS["Route 53\nworkspaces.corp-healthcare.internal"]
DNS -->|"2. TXT record lookup"| TXT["TXT Record\nConnectionIdentifier"]
TXT -->|"3. Failover routing policy"| HC["Health Check\n(CloudWatch Alarm)"]
HC -->|"Healthy"| Primary["us-east-1\nWorkSpaces Directory\n(AD Connector)"]
HC -->|"Unhealthy"| Secondary["us-west-2\nWorkSpaces Directory\n(AD Connector Replica)"]
Primary -->|"4. Connect"| WS1["Ubuntu WorkSpace\n(Primary)"]
Secondary -->|"4. Connect"| WS2["Ubuntu WorkSpace\n(Standby)"]
```
## Architecture Components
| Component | Primary (us-east-1) | Secondary (us-west-2) |
|-----------|--------------------|-----------------------|
| Connection Alias | workspaces.corp-healthcare.internal | Same FQDN |
| AD Connector | Connected to Samba DC | Connected to replicated DC |
| WorkSpaces | Standard AutoStop Ubuntu | Standard AutoStop Ubuntu |
| Route 53 | Failover primary record | Failover secondary record |
| Health Check | CloudWatch alarm on directory status | N/A (passive) |
## Failover Sequence
1. CloudWatch alarm detects us-east-1 directory unhealthy
2. Route 53 health check fails for primary record
3. Route 53 returns secondary TXT record (us-west-2 ConnectionIdentifier)
4. WorkSpaces client connects to us-west-2 directory
5. User sees their standby WorkSpace with no registration code change
Why connection aliases instead of registration codes?
Without connection aliases, each region has its own registration code (like WSpdx+ABC12D). If us-east-1 fails, someone must notify all 3,000 users to switch codes. With a connection alias, the FQDN stays the same. DNS handles the redirect transparently.
WorkSpaces Multi-Region Resilience builds on this pattern using Route 53 health checks that monitor CloudWatch alarms on directory health.
- Save workspaces-failover.md.
- Verify the Mermaid diagram renders correctly by previewing it in Cursor IDE (or paste it into any Mermaid live editor).
What would the production setup require?
In production you would create a second AD Connector in us-west-2 pointing to a replicated Samba DC (or AWS Managed Microsoft AD with multi-region replication). You would register that directory with WorkSpaces, associate the same connection alias in us-west-2, and create a Route 53 failover routing policy with health checks monitoring a CloudWatch alarm on directory status.
Users would register with the FQDN once. If the primary region fails, Route 53 returns the secondary region's ConnectionIdentifier, and the client connects to us-west-2 without any user intervention.
Your EUC pillar is in place. You have a working WorkSpace backed by AD credentials, and the connection alias architecture is ready for multi-region failover. Next up, you will run a full validation suite proving every control in the system works end-to-end.
Build the Stakeholder Pitch Deck
Your entire system is validated. Federation, crypto, rotation, ransomware resistance, and remote workforce access all passed end-to-end testing.
Now you need to communicate what you built to people who do not speak CloudTrail. In this step, you will create a stakeholder-ready pitch deck as a Markdown file with Mermaid diagrams that renders in any modern tool (GitHub, VS Code, Obsidian, or a slide export pipeline).
In this step, get ready to:
- Build the executive summary slide with SOC-2 control mapping.
- Build the architecture topology diagram showing every component and its SAP-C02 domain reference.
- Build the 90-day roadmap slide covering three phases of maturity.
Build the executive summary slide
The executive summary is your opening slide. It answers three questions in plain language: what did you build, what threats does it eliminate, and which compliance controls does it satisfy.
What is SOC-2 control mapping?
SOC-2 is an audit framework that checks whether your systems meet specific Trust Services Criteria. Each criterion has a code like CC6.1 (logical access security), CC6.6 (encryption), CC6.7 (transmission security), or CC7.2 (monitoring anomalous activity).
Mapping your architecture to these codes tells auditors exactly which control requirement each component satisfies.
- Create the pitch deck file by running this command from your project root:
touch pitch-deck.md
- Open pitch-deck.md in Cursor and add the following content:
# Healthcare Security Architecture Pitch Deck
## Executive Summary
<!-- SOC-2 mapping: CC6.1, CC6.6, CC6.7, CC7.2 -->
What does this content do?
- The top-level heading names the deck for the audience (healthcare leadership).
- The HTML comment stores the SOC-2 control codes inline so auditors can trace each architectural decision to its compliance requirement.
- CC6.1 maps to IAM Identity Center and ABAC (logical access). CC6.6 maps to KMS encryption. CC6.7 maps to TLS and envelope encryption in transit. CC7.2 maps to CloudTrail and rotation monitoring.
- Save pitch-deck.md.
- Confirm the file exists by running:
ls pitch-deck.md
You should see pitch-deck.md listed in the output.
File not found?
Make sure you ran the touch command from the same directory where you ran ls. Run pwd to check your current location.
Help me find my pitch-deck.md file
Build the architecture topology diagram
The topology diagram is the core visual of the deck. It shows how every component connects, from the on-prem AD simulation through federation to the spoke account, with the OIDC deployment path running in parallel.
Why Mermaid for architecture diagrams?
Mermaid renders diagrams from plain text inside Markdown code fences. The syntax graph TD declares a top-down flowchart where A --> B draws an arrow from node A to node B.
Because Mermaid diagrams are text, they version-control cleanly in Git and render natively on GitHub, GitLab, Obsidian, and VS Code with the Markdown Preview extension.
- In pitch-deck.md, add the architecture topology section below the executive summary:
## Architecture Topology
```mermaid
graph TD
SambaAD --> ADConnector
ADConnector --> IdentityCenter
IdentityCenter --> PermissionSets
PermissionSets --> SecuritySpoke
GitHub --> OIDCProvider
OIDCProvider --> DeployRole
DeployRole --> SecuritySpoke
KMSPrimary --> KMSReplica
KMSPrimary --> EnvelopeEncryption
SecretsManager --> RDSRotation
ObjectLock --> RansomwareResistance
WorkSpaces --> ConnectionAlias
ConnectionAlias --> Failover
```
What does this diagram show?
- The federation chain flows top-down: Samba AD to AD Connector to Identity Center to Permission Sets to the SecuritySpoke account (SAP-C02 Domain 1).
- The GitHub OIDC path runs in parallel: GitHub issues a token to the OIDC Provider, which lets the DeployRole assume into the spoke with zero stored secrets (SAP-C02 Domain 2).
- The KMS branch shows multi-region replication and envelope encryption (SAP-C02 Domain 3).
- SecretsManager connects to RDS rotation. Object Lock connects to ransomware resistance. WorkSpaces connects through the connection alias to failover.
- Save pitch-deck.md.
- To verify the diagram renders, open the Markdown preview in Cursor (Cmd+Shift+V on macOS or Ctrl+Shift+V on Windows).
- You should see a top-down flowchart with nodes connected by arrows matching the component names above.
Diagram not rendering in preview?
Make sure the opening line of the code fence is exactly three backticks followed by mermaid with no extra spaces. Also confirm the closing line is exactly three backticks with nothing after them.
If Cursor's built-in preview does not support Mermaid, paste the diagram block into the Mermaid Live Editor at mermaid.live to verify it renders.
Help me fix my Mermaid diagram rendering
Build the 90-day roadmap slide
The roadmap shows leadership that this build is Phase 1 of a larger security program. It sets expectations for what comes next without requiring immediate budget approval.
- In pitch-deck.md, add the 90-day roadmap section below the architecture topology:
## 90-Day Roadmap
### Phase 1 (Complete)
### Phase 2 (Weeks 2-4)
### Phase 3 (Weeks 5-12)
What does each phase contain?
- Phase 1 is everything you just built and validated: Identity Center federation, OIDC, KMS multi-region, Secrets Manager rotation, Object Lock, and WorkSpaces failover.
- Phase 2 (weeks 2-4) replaces Samba with AWS Managed Microsoft AD for production-grade two-way trust. It adds IAM Roles Anywhere for on-prem servers and Verified Access for zero-trust application access.
- Phase 3 (weeks 5-12) adds CloudHSM for FIPS 140-2 Level 3 compliance, AWS Backup Vault Lock for backup immutability, and multi-account guardrails via SCPs.
- Save pitch-deck.md.
- Open the Markdown preview again. You should see three headings under the 90-Day Roadmap section, each labeled with its timeline.
Flesh out the phases for your audience
Under each phase heading, add bullet points describing the specific deliverables. Write at a 7th-grade reading level. Define every technical term in one sentence before using it. Your audience includes security, compliance, and executive leadership.
✔️ Awesome, I've got everything!
Great. Double check you have saved pitch-deck.md with all three sections: Executive Summary, Architecture Topology, and 90-Day Roadmap.
ⓧ I'd like to double check the full code
Here is the complete pitch-deck.md file:
# Healthcare Security Architecture Pitch Deck
## Executive Summary
<!-- SOC-2 mapping: CC6.1, CC6.6, CC6.7, CC7.2 -->
## Architecture Topology
```mermaid
graph TD
SambaAD --> ADConnector
ADConnector --> IdentityCenter
IdentityCenter --> PermissionSets
PermissionSets --> SecuritySpoke
GitHub --> OIDCProvider
OIDCProvider --> DeployRole
DeployRole --> SecuritySpoke
KMSPrimary --> KMSReplica
KMSPrimary --> EnvelopeEncryption
SecretsManager --> RDSRotation
ObjectLock --> RansomwareResistance
WorkSpaces --> ConnectionAlias
ConnectionAlias --> Failover
```
## 90-Day Roadmap
### Phase 1 (Complete)
### Phase 2 (Weeks 2-4)
### Phase 3 (Weeks 5-12)
Your pitch deck is ready to present. Next up, you will red-team your own architecture in the Secret Mission and prove that four attack vectors are blocked.
Secret mission
Red-Team Privilege Escalation Chain
You built the fortress. Now try to break in. Attempt four privilege escalation attacks against your own architecture and prove every one is blocked. Your results become the Security Validation appendix of your stakeholder pitch deck.
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. Several of these resources are free-tier eligible, but KMS keys charge $1/month each regardless of usage.
Cost warning
If you leave all resources running, KMS keys alone cost $2/month (two keys at $1 each, prorated). The AD Connector charges $0.05/hour if no WorkSpaces user is active. EC2 and RDS instances consume free-tier hours that could be used for other projects. If you are done with this project, choose Pause or Delete to avoid unnecessary charges.
Resources you used:
- EC2 instance (Samba DC, t3.micro).
- RDS MySQL instance (db.t3.micro).
- AD Connector (Small) in Directory Service.
- WorkSpaces (1 Standard AutoStop).
- KMS keys (2 multi-region keys).
- S3 bucket with Object Lock.
- Secrets Manager secret (RDS managed password).
- AWS Organization member account (SecuritySpoke).
- GitHub OIDC identity provider in IAM.
- VPCs, subnets, and VPC peering connections.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue within the next few days.
- Monitor costs via AWS Cost Explorer to confirm free-tier coverage.
- KMS keys will charge $1/month each, prorated to the hour.
- WorkSpaces AutoStop will not charge while idle (within the 40-hour monthly free-tier limit).
- The OpenTofu state file remains on your local machine.
Pause - I'll come back to this later
Shut down running instances to reduce costs while keeping all resources in place for later use.
- Stop the EC2 Samba DC instance (no compute charges while stopped, but EBS storage remains).
- Stop the RDS MySQL instance (no compute charges while stopped for up to 7 days before auto-restart).
- WorkSpaces in AutoStop mode already shut down when idle. No additional action needed.
- KMS keys cannot be paused. They continue to charge $1/month each.
- AD Connector charges $0.05/hour unless a WorkSpaces user is active. Consider deleting it if pausing for more than a few days.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps in order to avoid dependency errors.
- Terminate the WorkSpace for the test user. In the WorkSpaces console, select the WorkSpace, choose **Actions**, then **Terminate WorkSpaces**.
- Deregister the WorkSpaces directory by running:
aws workspaces deregister-workspace-directory \
--directory-id YOUR_DIRECTORY_ID
- Change IAM Identity Center identity source back to the default (Identity Center directory) before deleting the AD Connector. Navigate to IAM Identity Center, select **Settings**, then **Identity source**, then **Change identity source**.
- Delete the AD Connector directory by running:
aws ds delete-directory --directory-id YOUR_DIRECTORY_ID
- Run OpenTofu destroy from your project directory to remove infrastructure managed by your configuration:
tofu destroy
- Schedule deletion of both KMS keys (7-day minimum waiting period) by running:
aws kms schedule-key-deletion \
--key-id YOUR_PRIMARY_KEY_ARN \
--pending-window-in-days 7
aws kms schedule-key-deletion \
--key-id YOUR_REPLICA_KEY_ARN \
--pending-window-in-days 7
- Close the SecuritySpoke member account. This has a 90-day waiting period before permanent closure:
aws organizations close-account \
--account-id YOUR_SPOKE_ACCOUNT_ID
- Delete the GitHub OIDC identity provider from IAM. Navigate to IAM, select **Identity providers**, choose the GitHub provider, then click **Delete**.
- Delete the S3 bucket. Empty all objects first (including versioned objects), then delete the bucket from the S3 console.
- Delete the VPCs and VPC peering connections from the VPC console in both accounts.
- Delete the Secrets Manager secret (the managed RDS password) by running:
aws secretsmanager delete-secret \
--secret-id YOUR_SECRET_ARN \
--force-delete-without-recovery
- Delete local project files by running:
rm -rf ~/Desktop/YOUR_PROJECT_FOLDER
Why the specific order?
WorkSpaces must be terminated before you can deregister the directory. The directory must be deregistered from WorkSpaces before you can delete it from Directory Service. IAM Identity Center must be disconnected from the AD Connector before deletion. KMS key deletion has a mandatory 7-30 day waiting period that cannot be bypassed.
That's a Wrap!
That's a Wrap!
You just built and validated a production-grade AWS security architecture that eliminates long-lived credentials across federation, cryptography, secrets rotation, ransomware resistance, and remote workforce access. Then you presented it all to leadership with compliance mapping and a roadmap.
You've learned how to:
- Built a zero-long-lived-key identity architecture spanning AWS Organizations, IAM Identity Center federation through AD Connector, GitHub OIDC deployments, and ABAC session-tag isolation across accounts.
- Configured automated secrets rotation with Secrets Manager managed rotation, multi-region KMS keys with envelope encryption, and ransomware-proof backups using S3 Object Lock (Compliance mode) stacked with MFA Delete.
- Presented the complete architecture to stakeholders with Mermaid diagrams, a SOC-2 control mapping, threat model, and 90-day production roadmap covering WorkSpaces failover, Session Manager zero-port access, and the Parameter Store vs Secrets Manager rotation trap.
- Secret Mission: Red-teamed your own architecture and proved four privilege escalation attacks (ABAC boundary crossing, permission boundary escape, Object Lock override, and KMS grant abuse) are all blocked.
Ready to quiz yourself?