• Terraform: Cross Account S3 Bucket Access Control

    Whilst auditing a set of organizational AWS accounts, I wanted to consolidate operational S3 buckets into a single account and grant access as required. It might not be immediately obvious the first time you do this, so this post is a bit of a primer on cross-account S3 access control, and implementing such with Terraform.

    Connecting a remote IAM principle to an S3 bucket involves two distinct steps. First you create a trust relationship with the remote AWS account by specifying the account ID in the S3 bucket policy. Lastly, the remote AWS account may then delegate access to its IAM users (or roles) by specifying the bucket name in a policy. Because the S3 namespace is global, policies in the remote account can resolve the bucket by name.

    The S3 bucket policy might look something like this.

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "1",
                "Effect": "Allow",
                "Principal": {
                    "AWS": [
                        "arn:aws:iam::123456789012:root"
                    ]
                },
                "Action": [
                    "s3:ListBucket",
                    "s3:GetObjectVersion",
                    "s3:GetObject",
                    "s3:GetBucketVersioning",
                    "s3:GetBucketLocation"
                ],
                "Resource": [
                    "arn:aws:s3:::the-private-bucket/*",
                    "arn:aws:s3:::the-private-bucket"
                ]
            }
        ]
    }

    In this example, read-only access to the bucket the-private-bucket is delegated to the AWS account 123456789012. The specific principal referenced is the root user of that account, but this is effective for any IAM user/role on that account having access specifically granted via an IAM policy.

    Such an IAM policy might look something like this.

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "1",
                "Effect": "Allow",
                "Action": [
                    "s3:ListBucket",
                    "s3:GetObjectVersion",
                    "s3:GetObject",
                    "s3:GetBucketVersioning",
                    "s3:GetBucketLocation"
                ],
                "Resource": [
                    "arn:aws:s3:::the-private-bucket/*",
                    "arn:aws:s3:::the-private-bucket"
                ]
            }
        ]
    }

    Remember this policy should be defined on the remote (delegated) AWS account, and thus attached to any IAM principal in that account where access is being granted.

    After proving your setup by testing out your variation of the above policies, you can model this with Terraform. By defining both AWS accounts as Terraform providers, you can have Terraform manage this for you end-to-end. For completeness, my example below includes both AWS providers for the host and demo accounts, the creation of the S3 bucket, an IAM user and role, and definition & attachment of both policies.

    provider "aws" {
      region = "us-east-1"
    }
    
    provider "aws" {
      alias = "demo"
      region = "us-east-1"
      profile = "demo"
    }
    
    data "aws_caller_identity" "demo" {
      provider = "aws.demo"
    }
    
    data "aws_iam_policy_document" "s3_private_bucket" {
      statement {
        sid = "1"
    
        actions = [
          "s3:GetBucketLocation",
          "s3:GetBucketVersioning",
          "s3:GetObject",
          "s3:GetObjectVersion",
          "s3:ListBucket",
        ]
    
        effect = "Allow"
    
        resources = [
          "arn:aws:s3:::acme-private-bucket",
          "arn:aws:s3:::acme-private-bucket",
        ]
    
        principals {
          type = "AWS"
          identifiers = [
            "arn:aws:iam::${data.aws_caller_identity.demo.account_id}:root",
          ]
        }
      }
    }
    
    resource "aws_s3_bucket" "private_bucket" {
      bucket = "acme-private-bucket"
      acl = "private"
      policy = "${data.aws_iam_policy_document.s3_private_bucket.json}"
    
      tags {
        Name = "acme-private-bucket"
        terraform = "true"
      }
    }
    
    data "aws_iam_policy_document" "private_bucket" {
      statement {
        sid = "1"
        actions = [
          "s3:GetBucketLocation",
          "s3:GetBucketVersioning",
          "s3:GetObject",
          "s3:GetObjectVersion",
          "s3:ListBucket",
        ]
        effect = "Allow"
        resources = [
          "arn:aws:s3:::acme-ansible-files",
          "arn:aws:s3:::acme-ansible-files/*",
        ]
      }
    }
    
    resource "aws_iam_policy" "private_bucket_demo" {
      provider = "aws.demo"
      name = "acme-private-bucket"
      policy = "${data.aws_iam_policy_document.private_bucket.json}"
    }
    
    resource "aws_iam_role" "demo" {
      provider = "aws.demo"
      name = "demo"
      assume_role_policy = <<EOF
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "sts:AssumeRole",
                "Principal": {
                   "Service": "ec2.amazonaws.com"
                },
                "Effect": "Allow",
                "Sid": ""
            }
        ]
    }
    EOF
    }
    
    resource "aws_iam_instance_profile" "demo" {
      provider = "aws.demo"
      name = "demo"
      role = "${aws_iam_role.demo.name}"
    }
    
    resource "aws_iam_role_policy_attachment" "demo_private_bucket" {
      provider = "aws.demo"
      role = "${aws_iam_role.demo.name}"
      policy_arn = "${aws_iam_policy.private_bucket_demo.arn}"
    }
    
    resource "aws_iam_user" "demo" {
      name = "demo"
    }
    
    resource "aws_iam_user_policy_attachment" "demo_private_bucket" {
      user = "${aws_iam_user.demo.name}"
      policy_arn = "${aws_iam_policy.private_bucket_demo.arn}"
    }