An automated solution for closing and suspending AWS accounts in AWS Control Tower environments using Terraform and Lambda functions. This project provides a secure, event-driven approach to account lifecycle management with proper governance controls.
- Overview
- Architecture
- Features
- Prerequisites
- Quick Start
- Configuration
- Deployment
- How It Works
- Security
- Monitoring
- Troubleshooting
- Best Practices
- Contributing
This solution automates the process of closing AWS accounts within an AWS Control Tower environment. When an account removal request is detected in the AFT (Account Factory for Terraform) audit trail, the system automatically:
- Terminates the Service Catalog provisioned product
- Moves the account to a SUSPENDED organizational unit
- Closes the AWS account permanently
The automation ensures proper governance, audit trails, and secure cross-account operations.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ AFT Account │ │ Control Tower │ │ Target OU │
│ │ │ Account │ │ (SUSPENDED) │
├─────────────────┤ ├──────────────────┤ ├─────────────────┤
│ DynamoDB Stream │───▶│ Lambda Function │───▶│ Closed Account │
│ (Audit Trail) │ │ (Account Closer) │ │ │
│ │ │ │ │ │
│ IAM Role │ │ Service Catalog │ │ │
│ (Lambda Exec) │ │ Organizations │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
- AFT Account: Contains the Lambda function and DynamoDB stream trigger
- Control Tower Account: Provides cross-account role for account operations
- Lambda Function: Processes account closure requests
- DynamoDB Stream: Triggers automation on audit trail changes
- IAM Roles: Secure cross-account access with least privilege
- Event-Driven: Automatically triggered by AFT audit trail changes
- Secure: Cross-account role assumption with minimal permissions
- Auditable: Comprehensive logging and CloudWatch integration
- Resilient: Error handling and retry mechanisms
- Configurable: Customizable organizational units and timeouts
- Code Signing: Lambda functions are signed for security
- Monitoring: CloudWatch logs with configurable retention
Before deploying this solution, ensure you have:
- AWS Control Tower deployed and configured
- Account Factory for Terraform (AFT) set up
- Appropriate AWS Organizations structure with SUSPENDED OU
- Cross-account trust relationships configured
- Administrative access to AFT management account
- Administrative access to Control Tower management account
- Permissions to create IAM roles and policies
- Permissions to deploy Lambda functions
- Terraform >= 1.0
- AWS CLI configured
- Python 3.11 (for Lambda runtime)
-
GitLab Runner Configuration:
# Ensure runner has required tags tags: ["test-runner"] # Install required tools on runner terraform --version # >= 0.15.0 aws --version # Latest AWS CLI
-
AWS Credentials:
# Configure runner with appropriate AWS credentials # Must have access to assume roles in both AFT and CT accounts export AWS_ACCESS_KEY_ID="your-access-key" export AWS_SECRET_ACCESS_KEY="your-secret-key" export AWS_DEFAULT_REGION="eu-west-1"
-
Clone and configure:
git clone <repository-url> cd aws-tf-close-account
-
Update configuration:
# Edit close-and-suspend/configuration/main.tf # Update account IDs, OUs, and other environment-specific values
-
Deploy via pipeline:
git add . git commit -m "Configure account closure automation" git push origin main
-
Monitor deployment:
- Navigate to GitLab → CI/CD → Pipelines
- Review terraform-plan-close-and-suspend job output
- Verify terraform-apply-close-and-suspend completes successfully
-
Verify deployment:
- Check AWS Lambda console for
aft-close-account-lambda
- Verify IAM roles in both AFT and CT accounts
- Review CloudWatch logs for any initialization issues
- Check AWS Lambda console for
If you prefer manual deployment or need to troubleshoot:
-
Local setup:
cd close-and-suspend/configuration terraform init
-
Plan and apply:
terraform plan -out=tfplan terraform apply tfplan
-
Cleanup (if needed):
terraform destroy
Edit close-and-suspend/configuration/main.tf
with your environment-specific values:
module "offboarding_lambda" {
source = "../module"
# CloudWatch Configuration
cloudwatch_log_group_retention = "90" # Days to retain logs
# AWS Configuration
region = "eu-west-1" # Primary region
aft_account_id = "123456789012" # AFT management account ID
ct_account_id = "210987654321" # Control Tower management account ID
# Organizational Units
ct_destination_ou = "ou-juup-d1e061ao" # SUSPENDED OU ID
ct_root_ou_id = "r-juup" # Root OU ID
# DynamoDB Configuration
aft-request-audit-table-encrption-key-id = "arn:aws:kms:eu-west-1:123456789012:key/..."
aft-request-audit-table-stream-arn = "arn:aws:dynamodb:eu-west-1:123456789012:table/aft-request-audit/stream/..."
# Tagging
default_tags = {
Environment = "AFT"
Project = "Offboarding Automation"
}
}
The Lambda function uses these environment variables:
REGION
: AWS region for operationsCT_ACCOUNT
: Control Tower management account IDDESTINATIONOU
: Target OU for suspended accountsROOTOU_ID
: Root organizational unit ID
The GitLab CI/CD pipeline provides automated, secure deployment of the account closure infrastructure with proper state management and cross-account role assumptions.
Purpose: Creates and validates Terraform execution plan
Triggers:
- Commits to
main
branch - Changes in
close-and-suspend/configuration/**/*
- Changes in
close-and-suspend/module/**/*
Process:
cd close-and-suspend/configuration
terraform init
terraform plan -out=tfplan
Artifacts:
tfplan
: Terraform execution planaft-close-account.zip
: Lambda deployment package- Expiration: 3 hours
Purpose: Applies the validated Terraform plan
Dependencies: Requires successful plan stage
Process:
cd close-and-suspend/configuration
terraform init
terraform apply -auto-approve tfplan
Safety Features:
- Uses pre-validated plan from artifacts
- No interactive approval required
- Automatic rollback on failure
Purpose: Removes all infrastructure (manual trigger only)
Trigger: Manual execution only
Process:
cd close-and-suspend/configuration
terraform init
terraform destroy -auto-approve
Safety: Manual trigger prevents accidental destruction
tags:
- test-runner
- Terraform >= 0.15.0
- AWS CLI (latest version)
- Git
- Bash/Shell access
The runner must have permissions to:
- Assume
AWSAFTExecution
role in AFT account - Assume
AWSAFTExecution
role in CT account - Access S3 backend bucket
- DynamoDB state locking
backend "s3" {
bucket = "aft-management-gitlab-runner-tfstate"
key = "offboarding-module.tfstate"
region = "eu-west-1"
use_lockfile = true # S3 native locking
encrypt = true # State encryption
workspace_key_prefix = "offboarding-module"
}
- Encryption: All state files encrypted at rest
- Locking: Prevents concurrent modifications
- Versioning: S3 versioning for state history
- Access Control: IAM-based access restrictions
Set these in GitLab → Settings → CI/CD → Variables:
# AWS Credentials (if not using IAM roles)
AWS_ACCESS_KEY_ID: "your-access-key"
AWS_SECRET_ACCESS_KEY: "your-secret-key"
AWS_DEFAULT_REGION: "eu-west-1"
# Terraform Variables (if overriding defaults)
TF_VAR_region: "eu-west-1"
TF_VAR_aft_account_id: "123456789012"
TF_VAR_ct_account_id: "210987654321"
- ✅ Plan stage completes without errors
- ✅ Apply stage creates all resources
- ✅ Lambda function is deployed and active
- ✅ IAM roles created in both accounts
- ❌ Terraform validation errors
- ❌ AWS permission issues
- ❌ Cross-account role assumption failures
- ❌ Resource creation conflicts
Plan Stage Failures:
# Check Terraform syntax
terraform validate
# Verify AWS credentials
aws sts get-caller-identity
# Test role assumptions
aws sts assume-role --role-arn arn:aws:iam::123456789012:role/AWSAFTExecution --role-session-name test
Apply Stage Failures:
# Check resource conflicts
terraform state list
# Verify permissions
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::123456789012:role/AWSAFTExecution --action-names lambda:CreateFunction
# Review CloudWatch logs
aws logs describe-log-groups --log-group-name-prefix /aws/lambda/aft-close-account
- Create feature branch for changes
- Test changes in development environment
- Create merge request to main
- Review pipeline output before merging
- Monitor production deployment
- Use IAM roles instead of access keys when possible
- Regularly rotate access credentials
- Monitor pipeline execution logs
- Implement proper approval workflows for sensitive changes
- Use branch protection rules
- Monitor pipeline execution times
- Set up notifications for pipeline failures
- Regularly review and update runner configurations
- Maintain backup of Terraform state
- Document any manual interventions
This project uses GitLab CI/CD for automated deployment with a three-stage pipeline:
stages:
- terraform-plan
- terraform-apply
- terraform-destroy
The pipeline is configured in .gitlab-ci.yml
with the following jobs:
Planning Stage:
terraform-plan-close-and-suspend
: Creates Terraform execution plan- Triggers on changes to
close-and-suspend/
directory - Stores plan artifacts for apply stage
Apply Stage:
terraform-apply-close-and-suspend
: Applies the Terraform plan- Requires successful planning stage
- Automatically applies on main branch
Destroy Stage:
terraform-destroy-close-and-suspend
: Destroys infrastructure- Manual trigger only for safety
-
Trigger Conditions:
rules: - if: $CI_COMMIT_BRANCH == "main" changes: - close-and-suspend/configuration/**/* - close-and-suspend/module/**/*
-
Artifact Management:
- Terraform plans stored as artifacts
- Lambda deployment packages included
- 3-hour expiration for security
-
Runner Requirements:
- Tagged with
test-runner
- Must have AWS credentials configured
- Terraform and AWS CLI installed
- Tagged with
The solution uses S3 backend for state management:
backend "s3" {
bucket = "aft-management-gitlab-runner-tfstate"
key = "offboarding-module.tfstate"
region = "eu-west-1"
use_lockfile = true
encrypt = true
workspace_key_prefix = "offboarding-module"
}
The configuration uses multiple AWS providers for cross-account deployment:
# Default provider
provider "aws" {
region = var.region
}
# AFT Management Account
provider "aws" {
alias = "aft"
region = var.region
assume_role {
role_arn = "arn:aws:iam::123456789012:role/AWSAFTExecution"
external_id = "ASSUME_ROLE_ON_TARGET_ACC"
}
}
# Control Tower Management Account
provider "aws" {
alias = "ct"
region = var.region
assume_role {
role_arn = "arn:aws:iam::210987654321:role/AWSAFTExecution"
external_id = "ASSUME_ROLE_ON_TARGET_ACC"
}
}
-
Push changes to repository:
git add . git commit -m "Update account closure configuration" git push origin main
-
Monitor pipeline:
- Navigate to GitLab CI/CD → Pipelines
- Review plan output in planning stage
- Verify successful apply stage
-
Manual destroy (if needed):
- Navigate to GitLab CI/CD → Pipelines
- Click "Run pipeline" → Select "terraform-destroy-close-and-suspend"
-
Configure AWS credentials:
# Set up profiles for both accounts aws configure --profile aft-account aws configure --profile ct-account
-
Initialize and deploy:
cd close-and-suspend/configuration terraform init terraform plan -out=tfplan terraform apply tfplan
-
Verify deployment:
# Check Lambda function aws lambda get-function --function-name aft-close-account-lambda --profile aft-account # Check CT role aws iam get-role --role-name aft-account-closure-role --profile ct-account
AFT Management Account (123456789012):
- Lambda function (
aft-close-account-lambda
) - Lambda execution role (
aft-close-account-lambda-role
) - CloudWatch log group (
/aws/lambda/aft-close-account-lambda
) - DynamoDB stream event source mapping
- Code signing configuration and profile
Control Tower Management Account (210987654321):
- Cross-account IAM role (
aft-account-closure-role
) - Service Catalog and Organizations permissions
- Account closure execution permissions
- State Encryption: Terraform state encrypted in S3
- State Locking: DynamoDB locking prevents concurrent runs
- Role Assumption: Cross-account access via IAM roles
- Artifact Security: Limited artifact expiration (3 hours)
- Manual Destroy: Destroy operations require manual approval
- Trigger: AFT audit trail DynamoDB stream detects account removal request
- Processing: Lambda function processes the stream event
- Validation: Verifies account details and permissions
- Service Catalog: Terminates the provisioned product
- Organizations: Moves account to SUSPENDED OU
- Closure: Initiates AWS account closure
- Logging: Records all operations in CloudWatch
The Lambda function processes DynamoDB stream events:
# Event structure
{
"Records": [
{
"eventName": "INSERT",
"dynamodb": {
"NewImage": {
"control_tower_parameters": {
"M": {
"AccountName": {"S": "test-account"},
"AccountEmail": {"S": "test@example.com"},
"ManagedOrganizationalUnit": {"S": "ou-source"}
}
},
"ddb_event_name": {"S": "REMOVE"}
}
}
}
]
}
- Account Lookup: Query AFT metadata table by email
- Role Assumption: Assume cross-account role in CT account
- Product Termination: Terminate Service Catalog product
- Account Movement: Move from current OU to SUSPENDED OU
- Account Closure: Call Organizations CloseAccount API
- Verification: Confirm successful closure
Lambda Execution Role (AFT Account):
- CloudWatch Logs access
- DynamoDB stream and table access
- SSM parameter access
- KMS decrypt permissions
- Cross-account role assumption
Account Closure Role (CT Account):
- Service Catalog terminate permissions
- Organizations account management
- Limited to specific operations only
- Code Signing: Lambda functions are signed for integrity
- Least Privilege: Minimal required permissions only
- Cross-Account: Secure role assumption between accounts
- Encryption: KMS encryption for DynamoDB streams
- Audit Trail: Comprehensive logging of all operations
- Lambda functions run in AWS managed VPC
- No internet access required for core functionality
- All AWS API calls use service endpoints
The Lambda function creates detailed logs:
/aws/lambda/aft-close-account-lambda
Log retention is configurable (default: 90 days).
- Lambda function invocations
- Lambda function errors
- Lambda function duration
- DynamoDB stream processing lag
- Account closure success/failure rates
Consider setting up CloudWatch alarms for:
# Lambda errors
aws cloudwatch put-metric-alarm \
--alarm-name "AFT-Account-Closure-Errors" \
--alarm-description "Lambda function errors" \
--metric-name Errors \
--namespace AWS/Lambda \
--statistic Sum \
--period 300 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold
Symptom: Function times out during execution Solution: Increase timeout value (current: 900 seconds)
Symptom: Cross-account role assumption fails Solution: Verify trust relationship and IAM policies
Symptom: Cannot find account in AFT metadata Solution: Verify account email and AFT table structure
Symptom: Cannot terminate provisioned product Solution: Verify product name matches account name exactly
-
Check pipeline logs:
# In GitLab UI: CI/CD → Pipelines → Select pipeline → View job logs # Look for specific error messages in plan/apply stages
-
Verify runner configuration:
# On GitLab runner gitlab-runner verify terraform --version aws --version aws sts get-caller-identity
-
Test Terraform locally:
cd close-and-suspend/configuration terraform init terraform validate terraform plan
-
Check Lambda logs:
aws logs filter-log-events \ --log-group-name /aws/lambda/aft-close-account-lambda \ --start-time $(date -d '1 hour ago' +%s)000 \ --profile aft-account
-
Verify DynamoDB stream:
aws dynamodb describe-table \ --table-name aft-request-audit \ --query 'Table.StreamSpecification' \ --profile aft-account
-
Test cross-account role assumption:
aws sts assume-role \ --role-arn arn:aws:iam::210987654321:role/aft-account-closure-role \ --role-session-name test-session \ --profile aft-account
- Check Terraform state:
# List state resources terraform state list # Check specific resource terraform state show aws_lambda_function.aft-close-account-lambda # Verify backend connectivity terraform init -backend-config="bucket=aft-management-gitlab-runner-tfstate"
terraform init failed
: Backend configuration or credentials issueterraform plan failed
: Configuration validation or permission errorsterraform apply failed
: Resource creation or dependency issuesJob failed: exit code 1
: General Terraform execution failureRunner system failure
: GitLab runner connectivity or resource issues
ResourceNotFoundException
: Account or Service Catalog product not foundAccessDeniedException
: Insufficient permissions for AWS operationsValidationException
: Invalid parameters passed to AWS APIsThrottlingException
: API rate limits exceededAssumeRoleFailure
: Cross-account role assumption failedLambdaTimeoutException
: Function execution exceeded 900 seconds
- Test in non-production environment first
- Use GitLab CI/CD for consistent deployments
- Monitor both pipeline and CloudWatch logs regularly
- Set up appropriate alerting for pipeline failures
- Document account closure procedures and pipeline workflows
- Maintain audit trails for all deployments
- Regularly review and update GitLab runner configurations
- Implement proper backup strategies for Terraform state
- Regularly review IAM permissions
- Use least privilege principles
- Enable CloudTrail logging
- Implement proper backup procedures
- Regular security assessments
- Use infrastructure as code
- Version control all configurations
- Implement proper testing
- Document all changes
- Follow AWS Well-Architected principles
- Maintain accurate OU structure
- Document account lifecycle processes
- Implement proper approval workflows
- Regular compliance reviews
- Backup critical account data before closure
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests if applicable
- Update documentation
- Submit a pull request
- Follow Terraform best practices
- Use consistent naming conventions
- Add appropriate comments
- Update README for any changes
- Test in development environment
This project is licensed under the MIT License - see the LICENSE file for details.
For questions or issues:
- Create an issue in this repository
- Review AWS Control Tower documentation
- Consult AWS Organizations documentation
- Check AWS Lambda best practices
- AWS Control Tower Documentation
- AWS Organizations Documentation
- Account Factory for Terraform
- AWS Lambda Documentation
- Terraform AWS Provider
- Initial release
- Basic account closure automation
- Cross-account role support
- CloudWatch logging integration
- Code signing implementation