Multi-Account Terraform

Simfra supports multiple AWS accounts on a single instance. Each account has its own credentials, resources, and IAM policies. This lets you test cross-account patterns like shared S3 buckets, cross-account role assumption, and resource policies.

Prerequisites

  • Simfra running on localhost:4599
  • Environment variables set for the default account (see Provider Configuration)

Creating Accounts

Use the Simfra admin API to create accounts:

# Create account 111111111111
curl -s -X POST http://localhost:4599/_simfra/accounts \
  -d '{"accountId":"111111111111"}' | jq .

Response:

{
  "accountId": "111111111111",
  "rootAccessKeyId": "AKIA111111111EXAMPLE",
  "rootSecretAccessKey": "wJalr111111111SECRET111111111KEY",
  "createdAt": "2026-04-22T12:00:00Z"
}

The response contains the root credentials for the new account. Save these for use in Terraform provider blocks.

Retrieving Credentials Later

curl -s http://localhost:4599/_simfra/accounts/111111111111 | jq .

Creating Accounts with Bootstrap

You can bootstrap a new account at creation time:

curl -s -X POST http://localhost:4599/_simfra/accounts \
  -d '{"accountId":"111111111111","bootstrap":"standard","region":"us-east-1"}' | jq .

This creates the account and runs standard bootstrap (default VPC, KMS keys) in one call.

Listing and Deleting Accounts

# List all accounts
curl -s http://localhost:4599/_simfra/accounts | jq .

# Delete an account and all its resources
curl -s -X DELETE http://localhost:4599/_simfra/accounts/111111111111

Terraform Provider Aliases

Use provider aliases with different credentials to operate on separate accounts:

# Default account (000000000000)
provider "aws" {
  region = "us-east-1"
}

# Second account (111111111111)
provider "aws" {
  alias  = "account_b"
  region = "us-east-1"

  access_key = "AKIA111111111EXAMPLE"
  secret_key = "wJalr111111111SECRET111111111KEY"
}

AWS_ENDPOINT_URL applies to all provider instances, so both aliases route to the same Simfra instance. The SigV4 signature determines which account each request targets.

Example: Cross-Account S3 Bucket Policy

Account A creates a bucket and grants read access to Account B:

# Account A: create bucket with cross-account policy
resource "aws_s3_bucket" "shared" {
  bucket = "shared-data-bucket"
}

resource "aws_s3_bucket_policy" "cross_account" {
  bucket = aws_s3_bucket.shared.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "AllowAccountBRead"
      Effect    = "Allow"
      Principal = { AWS = "arn:aws:iam::111111111111:root" }
      Action    = ["s3:GetObject", "s3:ListBucket"]
      Resource  = [
        aws_s3_bucket.shared.arn,
        "${aws_s3_bucket.shared.arn}/*"
      ]
    }]
  })
}

# Account A: upload a test object
resource "aws_s3_object" "test" {
  bucket  = aws_s3_bucket.shared.id
  key     = "data/test.txt"
  content = "cross-account data"
}

# Account B: read the object (uses the bucket policy for authorization)
data "aws_s3_object" "read_cross_account" {
  provider = aws.account_b
  bucket   = aws_s3_bucket.shared.id
  key      = "data/test.txt"

  depends_on = [
    aws_s3_bucket_policy.cross_account,
    aws_s3_object.test,
  ]
}

output "cross_account_content" {
  value = data.aws_s3_object.read_cross_account.body
}

Simfra evaluates the bucket policy on Account B's request, just like AWS does.

Example: Cross-Account Role Assumption

Account A creates a role that Account B can assume:

# Account A: create a role with a trust policy for Account B
resource "aws_iam_role" "cross_account" {
  name = "cross-account-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = "arn:aws:iam::111111111111:root" }
      Action    = "sts:AssumeRole"
    }]
  })
}

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

output "cross_account_role_arn" {
  value = aws_iam_role.cross_account.arn
}

Account B assumes the role using the AWS CLI or SDK:

# Using Account B's credentials, assume the role in Account A
AWS_ACCESS_KEY_ID=AKIA111111111EXAMPLE \
AWS_SECRET_ACCESS_KEY=wJalr111111111SECRET111111111KEY \
  aws sts assume-role \
    --role-arn arn:aws:iam::000000000000:role/cross-account-role \
    --role-session-name test-session

The response includes temporary credentials scoped to Account A with the role's permissions.

Example: Cross-Account KMS Key Access

Account A creates a KMS key with a key policy that grants Account B usage:

resource "aws_kms_key" "shared" {
  description = "Shared encryption key"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowAccountAAdmin"
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::000000000000:root" }
        Action    = "kms:*"
        Resource  = "*"
      },
      {
        Sid       = "AllowAccountBEncrypt"
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::111111111111:root" }
        Action    = ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey"]
        Resource  = "*"
      }
    ]
  })
}

Setup Script

A complete script that creates two accounts and sets up Terraform:

#!/bin/bash
set -euo pipefail

SIMFRA=http://localhost:4599

# Create Account B
ACCOUNT_B=$(curl -s -X POST "$SIMFRA/_simfra/accounts" \
  -d '{"accountId":"111111111111","bootstrap":"standard"}')

ACCOUNT_B_KEY=$(echo "$ACCOUNT_B" | jq -r .rootAccessKeyId)
ACCOUNT_B_SECRET=$(echo "$ACCOUNT_B" | jq -r .rootSecretAccessKey)

# Write a Terraform vars file with Account B credentials
cat > account_b.auto.tfvars <<EOF
account_b_access_key = "$ACCOUNT_B_KEY"
account_b_secret_key = "$ACCOUNT_B_SECRET"
EOF

# Set default account credentials
export AWS_ENDPOINT_URL=$SIMFRA
export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
export AWS_DEFAULT_REGION=us-east-1

terraform init
terraform apply

With the corresponding Terraform variables:

variable "account_b_access_key" {
  type      = string
  sensitive = true
}

variable "account_b_secret_key" {
  type      = string
  sensitive = true
}

provider "aws" {
  alias  = "account_b"
  region = "us-east-1"

  access_key = var.account_b_access_key
  secret_key = var.account_b_secret_key
}

Next Steps