diff --git a/.github/terraform-backend.drawio.png b/.github/terraform-backend.drawio.png new file mode 100644 index 0000000..dcad379 Binary files /dev/null and b/.github/terraform-backend.drawio.png differ diff --git a/.github/workflows/misspell.yaml b/.github/workflows/misspell.yaml new file mode 100644 index 0000000..6574e3d --- /dev/null +++ b/.github/workflows/misspell.yaml @@ -0,0 +1,23 @@ +--- +name: misspell +on: + push: + branches: + - main + - master + - prod + - develop + +jobs: + misspell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: misspell + uses: reviewdog/action-misspell@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + locale: "US" + reporter: github-check + filter_mode: nofilter + level: error diff --git a/.github/workflows/pre-commit-check.yaml b/.github/workflows/pre-commit-check.yaml deleted file mode 100644 index a0459a5..0000000 --- a/.github/workflows/pre-commit-check.yaml +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: pre-commit-check -on: - push: - branches: - - master - - develop - pull_request: - -jobs: - build: - runs-on: macOS-latest - steps: - - uses: actions/checkout@v2 - with: - ref: ${{ github.head_ref }} - - name: Install prerequisites - run: ./bin/install-macos.sh - - name: initialize Terraform - run: terraform init --backend=false - - uses: actions/cache@v1 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }} - restore-keys: | - pre-commit - - name: pre-commit run all - run: | - pre-commit run -a - env: - AWS_DEFAULT_REGION: us-east-1 - SKIP: terraform_tflint_deep,no-commit-to-branch - - uses: stefanzweifel/git-auto-commit-action@v4 - if: ${{ failure() }} - with: - commit_message: Apply automatic changes - commit_options: "--no-verify" - # Optional commit user and author settings - commit_user_name: Linter Bot - commit_user_email: noreply@rhythmictech.com - commit_author: Linter Bot diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..398767c --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,30 @@ +--- +name: pre-commit-check +on: + push: + branches: + - master + - prod + - develop + +jobs: + pre-commit-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: initialize Terraform + run: terraform init --backend=false + - name: pre-commit + uses: pre-commit/action@v2.0.3 + env: + AWS_DEFAULT_REGION: us-east-1 + # many of these are covered by better reviewdog linters below + SKIP: >- + terraform_tflint_deep, + no-commit-to-branch, + terraform_tflint_nocreds, + terraform_tfsec diff --git a/.github/workflows/pullRequest.yaml b/.github/workflows/pullRequest.yaml new file mode 100644 index 0000000..8f1eba5 --- /dev/null +++ b/.github/workflows/pullRequest.yaml @@ -0,0 +1,81 @@ +--- +name: pull request +on: + pull_request: + +jobs: + # TODO: #22 add job using https://github.com/reviewdog/action-alex + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: initialize Terraform + run: terraform init --backend=false + - name: pre-commit + uses: pre-commit/action@v2.0.3 + env: + AWS_DEFAULT_REGION: us-east-1 + # many of these are covered by better reviewdog linters below + SKIP: >- + terraform_tflint_deep, + no-commit-to-branch, + terraform_tflint_nocreds, + terraform_tfsec + tflint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: Terraform init + run: terraform init --backend=false + - name: tflint + uses: reviewdog/action-tflint@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-check + filter_mode: added + flags: --module + level: error + tfsec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: Terraform init + run: terraform init --backend=false + - name: tfsec + uses: reviewdog/action-tfsec@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-check + filter_mode: added + level: warning + misspell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: misspell + uses: reviewdog/action-misspell@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + locale: "US" + reporter: github-pr-check + filter_mode: added + level: error + yamllint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: yamllint + uses: reviewdog/action-yamllint@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-check + filter_mode: added + level: error diff --git a/.github/workflows/tflint.yaml b/.github/workflows/tflint.yaml new file mode 100644 index 0000000..10457b5 --- /dev/null +++ b/.github/workflows/tflint.yaml @@ -0,0 +1,27 @@ +--- +name: tflint +on: + push: + branches: + - main + - master + - prod + - develop + +jobs: + tflint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: Terraform init + run: terraform init --backend=false + - name: tflint + uses: reviewdog/action-tflint@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-check + filter_mode: nofilter + flags: --module + level: error diff --git a/.github/workflows/tfsec.yaml b/.github/workflows/tfsec.yaml new file mode 100644 index 0000000..2f75a3e --- /dev/null +++ b/.github/workflows/tfsec.yaml @@ -0,0 +1,26 @@ +--- +name: tfsec +on: + push: + branches: + - main + - master + - prod + - develop + +jobs: + tfsec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: Terraform init + run: terraform init --backend=false + - name: tfsec + uses: reviewdog/action-tfsec@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-check + filter_mode: nofilter + level: error diff --git a/.github/workflows/yamllint.yaml b/.github/workflows/yamllint.yaml new file mode 100644 index 0000000..0b6f93a --- /dev/null +++ b/.github/workflows/yamllint.yaml @@ -0,0 +1,22 @@ +--- +name: yamllint +on: + push: + branches: + - main + - master + - prod + - develop + +jobs: + yamllint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: yamllint + uses: reviewdog/action-yamllint@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-check + filter_mode: nofilter + level: error diff --git a/.gitignore b/.gitignore index 428217a..b948591 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ # .tfstate files *.tfstate *.tfstate.* + +assumerole +assumerole/* + +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eafd12d..5ba9f36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,11 @@ +exclude: ".terraform" repos: - repo: https://github.com/antonbabenko/pre-commit-terraform - rev: v1.31.0 + rev: v1.74.1 hooks: - id: terraform_docs always_run: true - args: - - --args=--sort-by-required - id: terraform_fmt - - id: terraform_tflint - alias: terraform_tflint_deep - name: terraform_tflint_deep - args: - - --args=--deep - id: terraform_tflint alias: terraform_tflint_nocreds name: terraform_tflint_nocreds @@ -33,6 +27,28 @@ repos: cd $(dirname "$FILE") terraform init --backend=false terraform validate . + cd .. + done + ' + language: system + verbose: true + files: \.tf(vars)?$ + exclude: examples + - id: tflock + name: provider_locks + entry: | + bash -c ' + AWS_DEFAULT_REGION=us-east-1 + declare -a DIRS + for FILE in "$@" + do + DIRS+=($(dirname "$FILE")) + done + for DIR in $(printf "%s\n" "${DIRS[@]}" | sort -u) + do + cd $(dirname "$FILE") + terraform providers lock -platform=linux_amd64 + cd .. done ' language: system @@ -40,7 +56,7 @@ repos: files: \.tf(vars)?$ exclude: examples - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.0.0 + rev: v4.2.0 hooks: - id: check-case-conflict - id: check-json @@ -50,11 +66,17 @@ repos: args: - --unsafe - id: end-of-file-fixer - - id: trailing-whitespace - id: mixed-line-ending args: - --fix=lf - id: no-commit-to-branch + args: + - --branch + - main + - --branch + - master + - --branch + - prod - id: pretty-format-json args: - --autofix @@ -63,3 +85,5 @@ repos: args: - --markdown-linebreak-ext=md exclude: README.md +ci: + skip: [terraform_docs, terraform_fmt, terraform_tflint, terraform_tfsec, tflock] diff --git a/.terraform-version b/.terraform-version index c37136a..4f20ea7 100644 --- a/.terraform-version +++ b/.terraform-version @@ -1 +1 @@ -0.13.5 +latest:^1.1 diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..3d75310 --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,71 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "4.28.0" + constraints = ">= 4.0.0" + hashes = [ + "h1:OsPtt0JGl70xbY2+V/ai0d+Jk2p7pvkW8h9IKhIr288=", + "h1:TXCUuuaf2q54C43bxSNiF9g+cxTr8zqEZem0pW15cjE=", + "h1:Z7sun0kcsON43rhvjmlv0nzkL2XMoBUTGLdCIFTmJ4I=", + "h1:tiM/l5kEY7c2m4W5d98gPB9zZauX1mMB45olCglVnBk=", + "zh:1d4806e50971d2cd565273cedf3206e38931677a6f546cf2b9fb140b52b80604", + "zh:3f076791002b8afa5ba2d2038f1e1db5956022327eb5242152723ed410ae4571", + "zh:40e5944a9df0d083dbd316bcc6ac9ceada5c00dab70c21897e62b68c4c936bc9", + "zh:68b78d0c1866aa0bcbbadb1cf51349c9af697f8789f5778b7e7e2912a9c4845d", + "zh:72d6e66136841c0e5ae264e03555cf59751ddae1b9784eafcb877c624332c70a", + "zh:902c8f89dc10d321b87c09270c27a31a42d4e74e4da1608e55b7f241cd010a62", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bf54c9f55d420b4e1fe68db81a759c40b9f9747159dea3061212a1c9768dcdfd", + "zh:bfbe7e745c420a4ebd27ca35dfe5c2acc7cdd05092e1daf60f5ae29a1130d752", + "zh:d271a30b16f0861f020e423d120d1458cf1757e740e016ace22084c39dc13550", + "zh:f1e4672d1625fd1f1268d4b807cb90e28150d46fb2d0dd0836de65db29c8d5e6", + "zh:f5cee910b4db2da3c2a28dae9055cbca4273eb774c362bb7bb5bde04deff4557", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.2.3" + constraints = ">= 2.0.0" + hashes = [ + "h1:3bH88Z7tlWvcoubm6hQUBk3s9bSIJC8bVHQz749B87E=", + "h1:FvRIEgCmAezgZUqb2F+PZ9WnSSnR5zbEM2ZI+GLmbMk=", + "h1:KmHz81iYgw9Xn2L3Carc2uAzvFZ1XsE7Js3qlVeC77k=", + "h1:aWp5iSUxBGgPv1UnV5yag9Pb0N+U1I0sZb38AXBFO8A=", + "zh:04f0978bb3e052707b8e82e46780c371ac1c66b689b4a23bbc2f58865ab7d5c0", + "zh:6484f1b3e9e3771eb7cc8e8bab8b35f939a55d550b3f4fb2ab141a24269ee6aa", + "zh:78a56d59a013cb0f7eb1c92815d6eb5cf07f8b5f0ae20b96d049e73db915b238", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8aa9950f4c4db37239bcb62e19910c49e47043f6c8587e5b0396619923657797", + "zh:996beea85f9084a725ff0e6473a4594deb5266727c5f56e9c1c7c62ded6addbb", + "zh:9a7ef7a21f48fabfd145b2e2a4240ca57517ad155017e86a30860d7c0c109de3", + "zh:a63e70ac052aa25120113bcddd50c1f3cfe61f681a93a50cea5595a4b2cc3e1c", + "zh:a6e8d46f94108e049ad85dbed60354236dc0b9b5ec8eabe01c4580280a43d3b8", + "zh:bb112ce7efbfcfa0e65ed97fa245ef348e0fd5bfa5a7e4ab2091a9bd469f0a9e", + "zh:d7bec0da5c094c6955efed100f3fe22fca8866859f87c025be1760feb174d6d9", + "zh:fb9f271b72094d07cef8154cd3d50e9aa818a0ea39130bc193132ad7b23076fd", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.4.1" + constraints = ">= 3.0.0" + hashes = [ + "h1:NfxR2vBvEEfI0nA7FiL+QMuWFnC6rl2avzQ9+ykoSo4=", + "h1:Q+1ZwDtRYtFZLrPfpe9wnz7ulNvotLfM6WmwXFCGnSA=", + "h1:gveAzKTj7/ji9kYyW9BoxmJ8TUHulmwhHaodm5dLfr4=", + "h1:tHwxkMmT5lV0jFsUaEDJav+S1KJXUF20UL3ympHE5UY=", + "zh:081d91c6f2602f76acef4a8f18d6bf392e104fe02a7885d167b06fe60adf7277", + "zh:0cbde9a961a3d4581edbf3af8137eac11e52b9b8b6117a6bda916150b68f7281", + "zh:1ad33b85a6e7400c438d33acd7c8a43c74d79711f11c7b8fd715bf94379a30ac", + "zh:5bfa3c71d28c9f961d1c46cdfa583b3a82a59d7298f4afe2c89081ebdf8863b8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a34a4286aff007a3834e13e70d28e7ce9b8ae162c5bef9412cae89df6226fe2d", + "zh:a6adefa0199b60cdc1accc617b24851c1f7da501891bc97d039d819749ead537", + "zh:c0ad3b665de7b7124d3159eefabcaae29f0bd8758847bfef6204afbd7e083bba", + "zh:d8a086da281e36949a70e8fce7aef449db34d65e12195e5856ddfb4e1b5747f2", + "zh:e1fcc67afc6b808a616bf53da6da18420fad7594cadca1b3a6d2a447f52dc8c5", + "zh:ed1877c6850c2d7101044fb3cf352ce81e5c3743aa78b1087bcf557b5163e887", + "zh:f7992c6fa4a639b1464dfdd7648a12e1ae6f05c6b8958c90c9705f09fd0b5bb5", + ] +} diff --git a/.tflint.hcl b/.tflint.hcl index 5cba22a..854fb92 100644 --- a/.tflint.hcl +++ b/.tflint.hcl @@ -1,6 +1,5 @@ config { module = true - deep_check = false } rule "terraform_deprecated_interpolation" { @@ -37,7 +36,7 @@ rule "terraform_naming_convention" { } rule "terraform_required_version" { - enabled = true + enabled = false } rule "terraform_required_providers" { diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..e1a518a --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,2 @@ +truthy: + check-keys: false diff --git a/LICENSE b/LICENSE index 3fe5c34..5fe0374 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Rhythmic Technologies, Inc. +Copyright (c) 2022 Rhythmic Technologies, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 372cd7c..9117714 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,161 @@ # terraform-aws-backend -[![](https://github.com/rhythmictech/terraform-aws-backend/workflows/pre-commit-check/badge.svg)](https://github.com/rhythmictech/terraform-aws-backend/actions) follow on Twitter -Creates a backend S3 bucket and DynamoDB table for managing Terraform state. Useful for bootstrapping a new -environment. This module supports cross-account state management, using a centralized account that holds the S3 bucket and KMS key. +[![tflint](https://github.com/rhythmictech/terraform-aws-backend/workflows/tflint/badge.svg?branch=master&event=push)](https://github.com/rhythmictech/terraform-aws-backend/actions?query=workflow%3Atflint+event%3Apush+branch%3Amaster) +[![tfsec](https://github.com/rhythmictech/terraform-aws-backend/workflows/tfsec/badge.svg?branch=master&event=push)](https://github.com/rhythmictech/terraform-aws-backend/actions?query=workflow%3Atfsec+event%3Apush+branch%3Amaster) +[![yamllint](https://github.com/rhythmictech/terraform-aws-backend/workflows/yamllint/badge.svg?branch=master&event=push)](https://github.com/rhythmictech/terraform-aws-backend/actions?query=workflow%3Ayamllint+event%3Apush+branch%3Amaster) +[![misspell](https://github.com/rhythmictech/terraform-aws-backend/workflows/misspell/badge.svg?branch=master&event=push)](https://github.com/rhythmictech/terraform-aws-backend/actions?query=workflow%3Amisspell+event%3Apush+branch%3Amaster) +[![pre-commit-check](https://github.com/rhythmictech/terraform-aws-backend/workflows/pre-commit-check/badge.svg?branch=master&event=push)](https://github.com/rhythmictech/terraform-aws-backend/actions?query=workflow%3Apre-commit-check+event%3Apush+branch%3Amaster) +follow on Twitter -_Note: A centralized DynamoDB locking table is not supported because terraform cannot assume more than one IAM role per execution._ +Creates an S3 bucket and DynamoDB table for managing Terraform state. Note that when bootstrapping a new environment, it is typically easier to use a separate method for creating the bucket and lock table, like [a CloudFormation Stack](https://github.com/rhythmictech/AWS-CFN-Terraform-Bootstrap). This module is intended to create a backend in an AWS account that is already Terraform-managed. This is useful to store the state for other accounts externally. -## Usage -``` -module "backend" { - source = "git::ssh://git@github.com/rhythmictech/terraform-aws-backend" - bucket = "project-tfstate" - region = "us-east-1" - table = "tf-locktable" -} +This module will create a CloudFormation stack and an optional wrapper script to deploy it. This stack is suitable to run in any account that will store its Terraform state in the bucket created by this module. It creates an IAM role with the AdministratorAccess policy attached and with an External ID which can then be assumed by terraform to create resources in the child account(s). -``` +![visualization](.github/terraform-backend.drawio.png) -## Cross Account State Management -Managing state across accounts requires additional configuration to ensure that the S3 bucket is appropriately accessible and the KMS key is usable. +*Breaking Changes* + +Previous versions of this module had support for cross-account management in a way that proved awkward for many uses cases and made it more difficult than it should've to fully secure the tfstate between accounts. Version 4.x and later eliminates support for this and refocuses the module on using centralized tfstate buckets with cross-account role assumption for execution of terraform. As a result, many variable names have changed and functionality has been dropped. Upgrade to this version at your own peril. -The following module declaration will create an S3 bucket and KMS key that are accessible to the root account (and users with the AdministratorAccess managed role) in the target account: +## Multi-Account Usage +These instructions assume two AWS accounts; a "Parent" account which holds the terraform state and IAM users, and a "Child" account. -```yaml +1) In the parent account create this module. The below code is a serving suggestion. +``` module "backend" { - source = "git::ssh://git@github.com/rhythmictech/terraform-aws-backend" - allowed_account_ids = ["123456789012"] - bucket = "012345678901-us-east-1-tfstate" - region = "us-east-1" + source = "rhythmictech/backend/aws" + version = "4.1.0" + + bucket_name = "${local.account_id}-${var.region}-terraform-state" + create_assumerole_template = true + logging_target_bucket = module.s3logging-bucket.s3_bucket_name + logging_target_prefix = "${local.account_id}-${var.region}-tf-state" + tags = module.tags.tags_no_name } ``` -In the target account, use this declaration to import the module: +It will create a folder with a shell script and a CloudFormation stack in it. -```yaml -module "backend" { - source = "git::ssh://git@github.com/rhythmictech/terraform-aws-backend" - kms_key_id = "arn:aws:kms:us-east-1:012345678901:key/59381274-af42-8521-04af-ab0acfe3d521" - region = "us-east-1" - remote_bucket = "012345678901-us-east-1-tfstate" +2) Log into the child account and run the shell script, `assumerole/addrole.sh`. This will create a CloudFormation stack in that child account. + +3) In the terraform code for the child account create the provider and backend sections like below, substituting `PARENT_ACCT_ID` and `PARENT_REGION`, `CHILD_ACCT_ID`, AND `EXTERNAL_ID`. + +terraform backend config: +``` +bucket = "PARENT_ACCT_ID-PARENT_REGION-terraform-state" +dynamodb_table = "tf-locktable" +key = "account.tfstate" +region = "PARENT_REGION" +``` + +provider config: +``` +provider "aws" { + assume_role { + role_arn = "arn:aws:iam::CHILD_ACCT_ID:role/Terraform" + session_name = "terraform-network" + external_id = "EXTERNAL_ID" + } } ``` -The module will automatically write to the source account S3 bucket using the KMS key with cross-account access. +4) Log in to the master account and run terraform using this backend and provider config. The state will be stored in the parent account but terraform will assume the child account role. + +## Cross Account State Management + +See [Use AssumeRole to Provision AWS Resources Across Accounts](https://learn.hashicorp.com/tutorials/terraform/aws-assumerole) for more information on this pattern. -Access to the source S3 bucket is done based on a prefix that matches the AWS Account ID. Therefore, target accounts must use a `workspace_key_prefix` that matches the account ID, such as in the following sample backend-config values: +This module is not intended to hold the state for the account in which it is created. If the account itself is also Terraform managed, it is recommended to create a separate bucket for its own state manually or via a different IaC method (e.g., CloudFormation) to avoid the chicken-and-egg problem. See [this CloudFormation template](https://github.com/rhythmictech/AWS-CFN-Terraform-Bootstrap) to create terraform backend for this or any other single account. +You can test the ability to assume a role in the child account by logging in with the parent account and running this ``` -bucket = "012345678901-us-east-1-tf-state" -key = "project.tfstate" -workspace_key_prefix = "123456789012" -region = "us-east-1" + +export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" \ +$(aws sts assume-role \ +--external-id EXTERNAL_ID \ +--role-arn arn:aws:iam::CHILD_ACCT_ID:role/Terraform \ +--role-session-name testme \ +--query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]" \ +--output text))% + +AWS_SECURITY_TOKEN= ``` +Then `aws sts get-caller-identity` should reveal you to be in the child account. + ## Requirements | Name | Version | |------|---------| -| terraform | >= 0.13 | -| aws | ~> 3.15.0 | +| [terraform](#requirement\_terraform) | >= 0.14 | +| [aws](#requirement\_aws) | >= 4.0 | +| [local](#requirement\_local) | >= 2.0 | +| [random](#requirement\_random) | >= 3.0 | ## Providers | Name | Version | |------|---------| -| aws | ~> 3.15.0 | +| [aws](#provider\_aws) | 4.28.0 | +| [local](#provider\_local) | 2.2.3 | +| [random](#provider\_random) | 3.4.1 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_dynamodb_table.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_kms_alias.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kms_alias) | resource | +| [aws_kms_key.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kms_key) | resource | +| [aws_s3_bucket.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_lifecycle_configuration.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_lifecycle_configuration) | resource | +| [aws_s3_bucket_logging.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_logging) | resource | +| [aws_s3_bucket_ownership_controls.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_ownership_controls) | resource | +| [aws_s3_bucket_public_access_block.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block) | resource | +| [aws_s3_bucket_server_side_encryption_configuration.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | +| [aws_s3_bucket_versioning.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_versioning) | resource | +| [local_file.assumerole_addrole](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file) | resource | +| [local_sensitive_file.assumerole_tfassumerole](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/sensitive_file) | resource | +| [random_password.external_id](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| allowed\_account\_ids | Account IDs that are allowed to access the bucket/KMS key | `list(string)` | `[]` | no | -| bucket | Name of bucket to create (do not provide if using `remote_bucket`) | `string` | `""` | no | -| kms\_alias\_name | Name of KMS Alias | `string` | `""` | no | -| kms\_key\_id | ARN for KMS key for all encryption operations. | `string` | `""` | no | -| logging\_target\_bucket | The name of the bucket that will receive the log objects | `string` | `null` | no | -| logging\_target\_prefix | A key prefix for log objects | `string` | `"TFStateLogs/"` | no | -| remote\_bucket | If specified, the remote bucket will be used for the backend. A new bucket will not be created | `string` | `""` | no | -| table | Name of Dynamo Table to create | `string` | `"tf-locktable"` | no | -| tags | Mapping of any extra tags you want added to resources | `map(string)` | `{}` | no | +| [assumerole\_role\_attach\_policies](#input\_assumerole\_role\_attach\_policies) | Policy ARNs to attach to role (can be managed or custom but must exist) | `list(string)` |
[
"arn:aws:iam::aws:policy/AdministratorAccess"
]
| no | +| [assumerole\_role\_external\_id](#input\_assumerole\_role\_external\_id) | External ID to attach to role (this is required, a random ID will be generated if not specified here) | `string` | `null` | no | +| [assumerole\_role\_name](#input\_assumerole\_role\_name) | Name of role to create in assumerole template | `string` | `"Terraform"` | no | +| [assumerole\_stack\_name](#input\_assumerole\_stack\_name) | Name of CloudFormation stack | `string` | `"tf-assumerole"` | no | +| [assumerole\_template\_name](#input\_assumerole\_template\_name) | File name of assumerole cloudformation template | `string` | `"assumerole/tfassumerole.cfn.yml"` | no | +| [bucket\_name](#input\_bucket\_name) | Name of bucket to hold tf state | `string` | n/a | yes | +| [create\_assumerole\_template](#input\_create\_assumerole\_template) | If true, create a CloudFormation template that can be run against accounts to create an assumable role | `bool` | `false` | no | +| [dynamo\_locktable\_name](#input\_dynamo\_locktable\_name) | Name of lock table for terraform | `string` | `"tf-locktable"` | no | +| [dynamodb\_kms\_key\_arn](#input\_dynamodb\_kms\_key\_arn) | KMS key arn to enable encryption on dynamodb table. Defaults to `alias/aws/dynamodb` | `string` | `null` | no | +| [dynamodb\_point\_in\_time\_recovery](#input\_dynamodb\_point\_in\_time\_recovery) | DynamoDB point-in-time recovery. | `bool` | `true` | no | +| [dynamodb\_server\_side\_encryption](#input\_dynamodb\_server\_side\_encryption) | Bool to enable encryption on dynamodb table | `bool` | `true` | no | +| [kms\_alias\_name](#input\_kms\_alias\_name) | Name of KMS Alias | `string` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | ARN for KMS key for all encryption operations (a key will be created if this is not provided) | `string` | `null` | no | +| [lifecycle\_rules](#input\_lifecycle\_rules) | lifecycle rules to apply to the bucket (set to null to skip lifecycle rules) |
list(object(
{
id = string
enabled = bool
prefix = string
expiration = number
noncurrent_version_expiration = number
}))
| `null` | no | +| [logging\_target\_bucket](#input\_logging\_target\_bucket) | The name of the bucket that will receive the log objects (logging will be disabled if null) | `string` | `null` | no | +| [logging\_target\_prefix](#input\_logging\_target\_prefix) | A key prefix for log objects | `string` | `null` | no | +| [tags](#input\_tags) | Mapping of any extra tags you want added to resources | `map(string)` | `{}` | no | ## Outputs | Name | Description | |------|-------------| -| kms\_key\_arn | ARN of KMS Key for S3 bucket | -| s3\_bucket\_backend | S3 bucket | - +| [backend\_config\_stub](#output\_backend\_config\_stub) | Backend config stub to be used in child account(s) | +| [external\_id](#output\_external\_id) | External ID attached to IAM role in managed accounts | +| [kms\_key\_arn](#output\_kms\_key\_arn) | ARN of KMS Key for S3 bucket | +| [provider\_config\_stub](#output\_provider\_config\_stub) | Provider config stub to be used in child account(s) | +| [s3\_bucket\_backend](#output\_s3\_bucket\_backend) | S3 bucket used to store TF state | diff --git a/assumerole.tf b/assumerole.tf new file mode 100644 index 0000000..9fcede2 --- /dev/null +++ b/assumerole.tf @@ -0,0 +1,37 @@ +locals { + external_id = coalesce(var.assumerole_role_external_id, random_password.external_id.result) +} + +resource "local_file" "assumerole_addrole" { + count = var.create_assumerole_template ? 1 : 0 + + filename = "assumerole/addrole.sh" + + content = templatefile("${path.module}/template/addrole.sh.tftpl", { + stack_name = var.assumerole_stack_name + assumerole_template_name = var.assumerole_template_name + }) + +} + +resource "local_sensitive_file" "assumerole_tfassumerole" { + count = var.create_assumerole_template ? 1 : 0 + + filename = var.assumerole_template_name + + content = templatefile("${path.module}/template/tfassumerole.cfn.yml.tftpl", { + external_id = local.external_id + parent_account_id = local.account_id + partition = local.partition + policy_arns = var.assumerole_role_attach_policies + role_name = var.assumerole_role_name + }) + +} + +# not used if an external id is specified +resource "random_password" "external_id" { + length = 16 + special = true + override_special = "-_=+" +} diff --git a/bin/install-macos.sh b/bin/install-macos.sh index 4bc710b..3800165 100755 --- a/bin/install-macos.sh +++ b/bin/install-macos.sh @@ -14,5 +14,4 @@ git config --global init.templateDir ~/.git-template pre-commit init-templatedir ~/.git-template echo 'installing terraform with tfenv' -tfenv install min-required -tfenv use min-required +tfenv install diff --git a/bin/install-ubuntu.sh b/bin/install-ubuntu.sh new file mode 100755 index 0000000..3390308 --- /dev/null +++ b/bin/install-ubuntu.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +echo 'installing dependencies' +sudo apt install python3-pip gawk &&\ +pip3 install pre-commit + +# terraform docs +mkdir tmp +cd tmp +curl -Lo ./terraform-docs.tar.gz https://github.com/terraform-docs/terraform-docs/releases/download/v0.16.0/terraform-docs-v0.16.0-$(uname)-amd64.tar.gz +tar -xzf terraform-docs.tar.gz +chmod +x terraform-docs +sudo mv terraform-docs /usr/bin/ +cd .. +rm -rf tmp + +curl -L "$(curl -sL https://api.github.com/repos/terraform-linters/tflint/releases/latest | grep -o -E "https://.+?_linux_amd64.zip")" > tflint.zip && unzip tflint.zip && rm tflint.zip && sudo mv tflint /usr/bin/ +env GO111MODULE=on go get -u github.com/liamg/tfsec/cmd/tfsec +git clone https://github.com/tfutils/tfenv.git ~/.tfenv || true +mkdir -p ~/.local/bin/ +. ~/.profile +ln -s ~/.tfenv/bin/* ~/.local/bin + +echo 'installing pre-commit hooks' +pre-commit install + +echo 'setting pre-commit hooks to auto-install on clone in the future' +git config --global init.templateDir ~/.git-template +pre-commit init-templatedir ~/.git-template + +echo 'installing terraform with tfenv' +tfenv install diff --git a/examples/basic/README.md b/examples/basic/README.md index 04d1618..22e2bf0 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -1,5 +1,17 @@ -# Basic Backend Example -Creates resources for a secure backend in AWS +# Basic Backend Example +Creates resources for a secure backend in AWS to support separate AWS accounts. To use this backend, use the following provider definition: + +``` +provider "aws" { + region = var.region + + assume_role { + role_arn = "arn:aws-us-gov:iam::012345678901:role/Terraform" + external_id = "YourExternalID" + } +} + +You will need to run Terraform with IAM credentials of the account that holds the state rather than the accounts that you are working on. ## Requirements @@ -8,16 +20,23 @@ No requirements. ## Providers -| Name | Version | -|------|---------| -| aws | n/a | +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [tfstate](#module\_tfstate) | ../.. | n/a | + +## Resources + +No resources. ## Inputs -No input. +No inputs. ## Outputs -No output. - +No outputs. diff --git a/examples/basic/main.tf b/examples/basic/main.tf index e6907b2..154befc 100644 --- a/examples/basic/main.tf +++ b/examples/basic/main.tf @@ -1,35 +1,7 @@ -data "aws_caller_identity" "current" {} -data "aws_region" "current" {} - -locals { - env = "sandbox" - name = "example" - namespace = "aws-rhythmic-sandbox" - owner = "Rhythmictech Engineering" - - extra_tags = { - delete_me = "please" - purpose = "testing" - } -} - -module "tags" { - source = "rhythmictech/tags/terraform" - version = "1.0.0" - - names = [local.name, local.env, local.namespace] - - tags = merge({ - "Env" = local.env, - "Namespace" = local.namespace, - "Owner" = local.owner - }, local.extra_tags) -} - -module "backend" { +module "tfstate" { source = "../.." - bucket = "${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}-${module.tags.name}" - kms_alias_name = "${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}-${module.tags.name}" - tags = module.tags.tags + bucket_name = "tf-state-remote" + create_assumerole_template = true + dynamo_locktable_name = "tf-locktable-remote" } diff --git a/examples/external-logging/README.md b/examples/external-logging/README.md deleted file mode 100644 index 7f087d1..0000000 --- a/examples/external-logging/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Backend Example with external Logging -Creates resources for a secure backend in AWS - - - -## Requirements - -No requirements. - -## Providers - -No provider. - -## Inputs - -No input. - -## Outputs - -No output. - - diff --git a/examples/external-logging/main.tf b/examples/external-logging/main.tf deleted file mode 100644 index 6790598..0000000 --- a/examples/external-logging/main.tf +++ /dev/null @@ -1,37 +0,0 @@ - -locals { - env = "sandbox" - name = "example" - namespace = "aws-rhythmic-sandbox" - owner = "Rhythmictech Engineering" - region = "us-east-1" - - extra_tags = { - delete_me = "please" - purpose = "testing" - } -} - -module "tags" { - source = "rhythmictech/tags/terraform" - version = "1.0.0" - - names = [local.name, local.env, local.namespace] - - tags = merge({ - "Env" = local.env, - "Namespace" = local.namespace, - "Owner" = local.owner - }, local.extra_tags) -} - -module "bucket" { - source = "rhythmictech/s3logging-bucket/aws" - version = "2.0.0" - bucket_suffix = "tfstate-logging" -} - -module "backend" { - source = "../.." - logging_target_bucket = module.bucket.s3_bucket_name -} diff --git a/examples/workspaces/README.md b/examples/workspaces/README.md new file mode 100644 index 0000000..103bf7c --- /dev/null +++ b/examples/workspaces/README.md @@ -0,0 +1,68 @@ +# Workspaces Backend Example +Creates resources for a secure backend in AWS to support multiple AWS accounts. To use this backend with accounts managed by Terraform workspaces, use the following provider definition and variable: + +``` +provider "aws" { + region = var.region + + assume_role { + role_arn = var.workspace_iam_roles[terraform.workspace].role_arn + external_id = var.workspace_iam_roles[terraform.workspace].external_id + } +} + +variable "workspace_iam_roles" { + description = "IAM roles to assume" + type = any +} + +``` + +Then define variable entries for each account: + +``` +workspace_iam_roles = { + dev = { + role_arn = "arn:aws-us-gov:iam::012345678901:role/Terraform" + external_id = "YourExternalID" + } + test = { + role_arn = "arn:aws:iam::012345678902:role/Terraform" + external_id = "YourExternalID" + } + prod = { + role_arn = "arn:aws-us-gov:iam::012345678903:role/Terraform" + external_id = "YourExternalID" + } +} +``` + +You will need to run Terraform with IAM credentials of the account that holds the state rather than the accounts that you are working on. + + +## Requirements + +No requirements. + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [tfstate](#module\_tfstate) | ../.. | n/a | + +## Resources + +No resources. + +## Inputs + +No inputs. + +## Outputs + +No outputs. + diff --git a/examples/workspaces/main.tf b/examples/workspaces/main.tf new file mode 100644 index 0000000..154befc --- /dev/null +++ b/examples/workspaces/main.tf @@ -0,0 +1,7 @@ +module "tfstate" { + source = "../.." + + bucket_name = "tf-state-remote" + create_assumerole_template = true + dynamo_locktable_name = "tf-locktable-remote" +} diff --git a/kms.tf b/kms.tf index 2664e51..f41ff24 100644 --- a/kms.tf +++ b/kms.tf @@ -3,49 +3,30 @@ data "aws_iam_policy_document" "key" { actions = ["kms:*"] effect = "Allow" resources = ["*"] + principals { type = "AWS" identifiers = ["arn:aws:iam::${local.account_id}:root"] } } - - dynamic "statement" { - for_each = var.allowed_account_ids - - content { - effect = "Allow" - resources = ["*"] - actions = [ - "kms:Encrypt*", - "kms:Decrypt*", - "kms:ReEncrypt*", - "kms:GenerateDataKey*", - "kms:DescribeKey", - ] - principals { - type = "AWS" - identifiers = ["arn:aws:iam::${statement.value}:root"] - } - } - } } resource "aws_kms_key" "this" { - count = var.kms_key_id == "" ? 1 : 0 + count = var.kms_key_id == "" ? 1 : 0 + deletion_window_in_days = 7 description = "Terraform State KMS key" enable_key_rotation = true policy = data.aws_iam_policy_document.key.json - tags = merge( - { - "Name" = var.kms_alias_name != "" ? var.kms_alias_name : "tf_backend_key" - }, - var.tags - ) + + tags = merge(var.tags, { + "Name" = var.kms_alias_name != null ? var.kms_alias_name : "tf_backend_key" + }) } resource "aws_kms_alias" "this" { - count = var.kms_key_id == "" ? 1 : 0 + count = var.kms_key_id == "" ? 1 : 0 + name = "alias/${var.kms_alias_name != "" ? var.kms_alias_name : "tf_backend_key"}" target_key_id = aws_kms_key.this[0].id } diff --git a/main.tf b/main.tf index e226817..c86f8d0 100644 --- a/main.tf +++ b/main.tf @@ -1,62 +1,96 @@ data "aws_caller_identity" "current" {} -locals { - account_id = data.aws_caller_identity.current.account_id +data "aws_partition" "current" {} - # Account IDs that will have access to stream CloudTrail logs - account_ids = concat([local.account_id], var.allowed_account_ids) +data "aws_region" "current" {} - # Format account IDs into necessary resource lists. - iam_account_principals = formatlist("arn:aws:iam::%s:root", local.account_ids) +locals { + account_id = data.aws_caller_identity.current.account_id + partition = data.aws_partition.current.partition + region = data.aws_region.current.name # Resolve resource names - bucket = try(aws_s3_bucket.this[0].id, var.remote_bucket) - kms_key_id = try(aws_kms_key.this[0].arn, var.kms_key_id) + bucket_name = try(var.bucket_name, "${local.account_id}-${local.region}-tfstate") + kms_key_id = try(aws_kms_key.this[0].arn, var.kms_key_id) } +# tfsec is not yet smart enough to know new tf syntax for crypto/logging +#tfsec:ignore:AWS017 #tfsec:ignore:AWS002 resource "aws_s3_bucket" "this" { - count = var.remote_bucket == "" ? 1 : 0 - bucket = var.bucket - acl = "log-delivery-write" - tags = merge( - var.tags, - { - "Name" = var.bucket - }, - ) - - versioning { - enabled = true + bucket = local.bucket_name + + tags = merge(var.tags, { + "Name" = local.bucket_name + }) +} + +resource "aws_s3_bucket_ownership_controls" "this" { + bucket = aws_s3_bucket.this.id + + rule { + object_ownership = "BucketOwnerEnforced" } +} + +resource "aws_s3_bucket_lifecycle_configuration" "this" { + count = var.lifecycle_rules == null ? 0 : 1 - lifecycle_rule { - id = "expire" - enabled = true + bucket = aws_s3_bucket.this.id - noncurrent_version_expiration { - days = 90 + dynamic "rule" { + iterator = rule + for_each = var.lifecycle_rules + + content { + id = rule.value.id + status = rule.value.enabled ? "Enabled" : "Disabled" + + filter { + prefix = lookup(rule.value, "prefix", null) + } + + expiration { + days = lookup(rule.value, "expiration", 2147483647) + } + + noncurrent_version_expiration { + noncurrent_days = lookup(rule.value, "noncurrent_version_expiration", 2147483647) + } } } - server_side_encryption_configuration { - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "aws:kms" - kms_master_key_id = local.kms_key_id - } + depends_on = [aws_s3_bucket_versioning.this] +} + +resource "aws_s3_bucket_logging" "this" { + count = var.logging_target_bucket == null ? 0 : 1 + + bucket = aws_s3_bucket.this.id + target_bucket = var.logging_target_bucket + target_prefix = var.logging_target_prefix +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "this" { + bucket = aws_s3_bucket.this.bucket + + rule { + apply_server_side_encryption_by_default { + kms_master_key_id = local.kms_key_id + sse_algorithm = "aws:kms" } } +} + +resource "aws_s3_bucket_versioning" "this" { + bucket = aws_s3_bucket.this.id - logging { - target_bucket = coalesce(var.logging_target_bucket, var.bucket) - target_prefix = var.logging_target_prefix + versioning_configuration { + status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "this" { - depends_on = [aws_s3_bucket_policy.this] - count = var.remote_bucket == "" ? 1 : 0 - bucket = aws_s3_bucket.this[0].id + bucket = aws_s3_bucket.this.id block_public_acls = true block_public_policy = true ignore_public_acls = true @@ -64,63 +98,25 @@ resource "aws_s3_bucket_public_access_block" "this" { } resource "aws_dynamodb_table" "this" { - name = var.table + name = var.dynamo_locktable_name billing_mode = "PAY_PER_REQUEST" hash_key = "LockID" + tags = merge(var.tags, { + "Name" = var.dynamo_locktable_name + }) + attribute { name = "LockID" type = "S" } - tags = merge( - var.tags, - { - "Name" = "tf-locktable" - }, - ) -} - -data "aws_iam_policy_document" "this" { - statement { - effect = "Allow" - - actions = [ - "s3:ListBucket" - ] - - resources = [ - "arn:aws:s3:::${local.bucket}" - ] - - principals { - type = "AWS" - identifiers = local.iam_account_principals - } + point_in_time_recovery { + enabled = var.dynamodb_point_in_time_recovery } - dynamic "statement" { - for_each = var.allowed_account_ids - - content { - effect = "Allow" - resources = ["arn:aws:s3:::${local.bucket}/${statement.value}/*"] - actions = [ - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject" - ] - - principals { - type = "AWS" - identifiers = ["arn:aws:iam::${statement.value}:root"] - } - } + server_side_encryption { + enabled = var.dynamodb_server_side_encryption + kms_key_arn = var.dynamodb_kms_key_arn } } - -resource "aws_s3_bucket_policy" "this" { - count = var.remote_bucket == "" ? 1 : 0 - bucket = aws_s3_bucket.this[0].id - policy = data.aws_iam_policy_document.this.json -} diff --git a/outputs.tf b/outputs.tf index e3dde8a..c7abb70 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,9 +1,41 @@ -output "s3_bucket_backend" { - description = "S3 bucket" - value = try(aws_s3_bucket.this[0].bucket, var.remote_bucket) + +output "external_id" { + description = "External ID attached to IAM role in managed accounts" + value = local.external_id } output "kms_key_arn" { description = "ARN of KMS Key for S3 bucket" value = try(aws_kms_key.this[0].arn, var.kms_key_id) } + +output "s3_bucket_backend" { + description = "S3 bucket used to store TF state" + value = aws_s3_bucket.this.bucket +} + +########################################## +# stubs +########################################## + +output "backend_config_stub" { + description = "Backend config stub to be used in child account(s)" + value = <