LAB34_AWS_Cross-Account S3 Replication with KMS Encryption

Cross-Account S3 Replication with KMS Encryption

☁ AWS 🔥 Advanced S3 Replication Cross-Account KMS Assume Role Provider Aliases

Scenario

Your compliance team requires that all objects uploaded to the production S3 bucket in Account A are automatically replicated to a disaster recovery vault bucket in Account B. Both buckets must use KMS encryption — with different CMKs, one per account. The replication must survive even if Account A is compromised.

Your Objectives
  • Use two Terraform provider aliases — one for each AWS account — to manage both accounts from a single configuration.
  • In Account A: create a source S3 bucket (versioning enabled, SSE-KMS), a replication IAM role, and a replication configuration targeting the Account B bucket.
  • In Account B: create a destination bucket (versioning enabled, SSE-KMS with its own CMK) and a bucket policy granting Account A's replication role s3:ReplicateObject, s3:ReplicateTags, and s3:ReplicateDelete.
  • Grant Account A's replication role kms:GenerateDataKey on the destination KMS key so it can re-encrypt objects during replication.
  • Test replication by uploading an object and verifying it appears in the destination. Output both bucket ARNs and the replication role ARN.

Additional Context

The replication role in Account A needs permissions in two different accounts: s3:GetObject from the source bucket (Account A) and s3:ReplicateObject to the destination bucket (Account B). The second permission is granted via a resource-based policy on the Account B bucket, not via an IAM policy in Account A. Cross-account access always requires both sides: an IAM permission in the originating account AND a resource policy in the target account.

S3 versioning is a hard prerequisite for replication — both source and destination buckets must have versioning enabled. Replication does not copy existing objects — it only replicates objects uploaded after the replication rule is created.

variables.tf

variable "aws_region"         { type = string; default = "us-east-1" }
variable "source_account_id"  { type = string; description = "Account A — where objects are uploaded." }
variable "dest_account_id"    { type = string; description = "Account B — disaster-recovery vault." }
variable "source_profile"     { type = string; description = "AWS CLI profile for Account A." }
variable "dest_profile"       { type = string; description = "AWS CLI profile for Account B." }
variable "environment"        { type = string; default = "prod" }

providers.tf

# Account A — source (production)
provider "aws" {
  alias   = "account_a"
  region  = var.aws_region
  profile = var.source_profile
}

# Account B — destination (DR vault)
provider "aws" {
  alias   = "account_b"
  region  = var.aws_region
  profile = var.dest_profile
}

main.tf (Account B — destination, must exist before Account A references it)

### ACCOUNT B RESOURCES ###

# KMS key for the DR vault bucket.
resource "aws_kms_key" "dest" {
  provider            = aws.account_b
  description         = "CMK for S3 DR vault — ${var.environment}"
  enable_key_rotation = true
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Sid = "AccountBRoot"; Effect = "Allow"
        Principal = { AWS = "arn:aws:iam::${var.dest_account_id}:root" }
        Action = "kms:*"; Resource = "*" },
      # Allow Account A's replication role to use this key for re-encryption.
      { Sid = "AllowReplicationRole"; Effect = "Allow"
        Principal = { AWS = aws_iam_role.replication.arn }
        Action    = ["kms:GenerateDataKey", "kms:Decrypt"]; Resource = "*" }
    ]
  })
}

resource "aws_kms_alias" "dest" {
  provider      = aws.account_b
  name          = "alias/s3-dr-${var.environment}"
  target_key_id = aws_kms_key.dest.key_id
}

# Destination (DR vault) bucket in Account B.
resource "aws_s3_bucket" "dest" {
  provider = aws.account_b
  bucket   = "dr-vault-${var.environment}-${var.dest_account_id}"
  tags     = { Environment = var.environment, Purpose = "dr-replication" }
}

resource "aws_s3_bucket_versioning" "dest" {
  provider = aws.account_b
  bucket   = aws_s3_bucket.dest.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "dest" {
  provider = aws.account_b
  bucket   = aws_s3_bucket.dest.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms"; kms_master_key_id = aws_kms_key.dest.arn } }
}

# Destination bucket policy — grants Account A's replication role cross-account write access.
resource "aws_s3_bucket_policy" "dest" {
  provider = aws.account_b
  bucket   = aws_s3_bucket.dest.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "AllowReplication"; Effect = "Allow"
      Principal = { AWS = aws_iam_role.replication.arn }
      Action = ["s3:ReplicateObject","s3:ReplicateTags","s3:ReplicateDelete","s3:GetBucketVersioning","s3:PutBucketVersioning"]
      Resource = ["${aws_s3_bucket.dest.arn}","${aws_s3_bucket.dest.arn}/*"]
    }]
  })
}

### ACCOUNT A RESOURCES ###

# KMS key for the source production bucket.
resource "aws_kms_key" "source" {
  provider            = aws.account_a
  description         = "CMK for S3 source — ${var.environment}"
  enable_key_rotation = true
}
resource "aws_kms_alias" "source" {
  provider      = aws.account_a
  name          = "alias/s3-source-${var.environment}"
  target_key_id = aws_kms_key.source.key_id
}

# Source (production) bucket in Account A.
resource "aws_s3_bucket" "source" {
  provider = aws.account_a
  bucket   = "prod-primary-${var.environment}-${var.source_account_id}"
  tags     = { Environment = var.environment, Purpose = "source" }
}
resource "aws_s3_bucket_versioning" "source" {
  provider = aws.account_a
  bucket   = aws_s3_bucket.source.id
  versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "source" {
  provider = aws.account_a
  bucket   = aws_s3_bucket.source.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms"; kms_master_key_id = aws_kms_key.source.arn } }
}

# Replication IAM role in Account A.
resource "aws_iam_role" "replication" {
  provider = aws.account_a
  name     = "s3-replication-role-${var.environment}"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow"; Principal = { Service = "s3.amazonaws.com" }; Action = "sts:AssumeRole" }]
  })
}

resource "aws_iam_role_policy" "replication" {
  provider = aws.account_a
  name     = "replication-policy"
  role     = aws_iam_role.replication.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Effect = "Allow"
        Action = ["s3:GetReplicationConfiguration","s3:ListBucket"]
        Resource = aws_s3_bucket.source.arn },
      { Effect = "Allow"
        Action = ["s3:GetObjectVersionForReplication","s3:GetObjectVersionAcl","s3:GetObjectVersionTagging"]
        Resource = "${aws_s3_bucket.source.arn}/*" },
      { Effect = "Allow"
        Action = ["kms:Decrypt"]; Resource = aws_kms_key.source.arn },
      { Effect = "Allow"
        Action = ["kms:GenerateDataKey"]; Resource = aws_kms_key.dest.arn }
    ]
  })
}

# Replication configuration on the source bucket.
resource "aws_s3_bucket_replication_configuration" "replication" {
  provider = aws.account_a
  role     = aws_iam_role.replication.arn
  bucket   = aws_s3_bucket.source.bucket
  depends_on = [aws_s3_bucket_versioning.source]

  rule {
    id     = "replicate-all"
    status = "Enabled"

    destination {
      bucket        = aws_s3_bucket.dest.arn
      storage_class = "STANDARD_IA"

      # Re-encrypt with destination account's KMS key.
      encryption_configuration { replica_kms_key_id = aws_kms_key.dest.arn }

      # Preserve ACLs during replication.
      access_control_translation { owner = "Destination" }
      account = var.dest_account_id
    }

    source_selection_criteria {
      sse_kms_encrypted_objects { status = "Enabled" }
    }
  }
}

outputs.tf

output "source_bucket_arn"   { value = aws_s3_bucket.source.arn }
output "dest_bucket_arn"     { value = aws_s3_bucket.dest.arn }
output "replication_role_arn" { value = aws_iam_role.replication.arn }

Workflow Commands

terraform init
terraform apply \
  -var="source_account_id=111111111111" \
  -var="dest_account_id=222222222222" \
  -var="source_profile=prod-admin" \
  -var="dest_profile=dr-admin"

# Upload a test object to the source
aws s3 cp test.txt s3://prod-primary-prod-111111111111/ --profile prod-admin

# Verify replication (may take a few minutes)
aws s3 ls s3://dr-vault-prod-222222222222/ --profile dr-admin

# Check replication status of a specific object
aws s3api head-object --bucket prod-primary-prod-111111111111 --key test.txt \
  --profile prod-admin --query ReplicationStatus

terraform destroy -var="source_account_id=..." -var="dest_account_id=..." \
  -var="source_profile=..." -var="dest_profile=..."
✓ Expected Output After Apply
Apply complete! Resources: 14 added, 0 changed, 0 destroyed.

Outputs:

dest_bucket_arn      = "arn:aws:s3:::dr-vault-prod-222222222222"
replication_role_arn = "arn:aws:iam::111111111111:role/s3-replication-role-prod"
source_bucket_arn    = "arn:aws:s3:::prod-primary-prod-111111111111"