├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── general-request.md ├── labels.yaml ├── pull_request_template.md ├── release-drafter-config.yaml └── workflows │ ├── label-synchronization.yaml │ ├── pr-validation.yaml │ ├── release-drafter.yaml │ ├── terraform-validation.yaml │ └── update-changelog.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── UPGRADING.md ├── account_audit.tf ├── account_logging.tf ├── account_management.tf ├── audit_manager.tf ├── cloudtrail.tf ├── config.tf ├── data.tf ├── datadog.tf ├── examples └── basic │ ├── README.md │ ├── main.tf │ └── terraform.tf ├── files ├── event_bridge │ └── security_hub_findings.json.tpl ├── iam │ ├── monitor_iam_access_policy.json.tpl │ └── service_assume_role.json.tpl ├── organizations │ ├── cloudtrail_log_stream.json │ ├── deny_disabling_security_hub.json.tpl │ ├── deny_leaving_org.json.tpl │ ├── deny_root_user.json │ └── require_use_of_imdsv2.json └── sns │ ├── iam_activity_topic_policy.json.tpl │ └── security_hub_topic_policy.json.tpl ├── guardduty.tf ├── iam_activity_logging.tf ├── images └── MCAF_landing_zone_tools_and_services_v040.png ├── inspector.tf ├── kms.tf ├── locals.tf ├── modules ├── aws-config-recorder │ ├── README.md │ ├── main.tf │ ├── terraform.tf │ └── variables.tf ├── permission-set │ ├── LICENSE │ ├── README.md │ ├── main.tf │ ├── terraform.tf │ └── variables.tf └── tag-policy-assignment │ ├── README.md │ ├── UPDATING.md │ ├── locals.tf │ ├── main.tf │ ├── variables.tf │ └── versions.tf ├── moved.tf ├── organizations_policy.tf ├── organizations_policy_allowed_regions.tf ├── outputs.tf ├── security_hub.tf ├── ses_accounts_mail_alias.tf ├── sso.tf ├── terraform.tf ├── tests ├── datadog.tftest.hcl └── setup │ └── main.tf └── variables.tf /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Template to report a bug 4 | title: 'bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **💡 Problem description** 11 | Enter summary of the problem here. 12 | 13 | **☹️ Current Behavior** 14 | Describe what is happening. More detail is better. When code is pasted, use correct formatting. 15 | 16 | **😀 Expected Behavior** 17 | Enter any other details such as examples, links to requirements, etc. Any criteria that might help with fixing the problem. Attach screenshots if possible. More detail is better. 18 | 19 | **❓Steps to Reproduce** 20 | Enter detailed steps to reproduce here. More detail is better. 21 | 22 | **🚧 Workaround** 23 | If there is a way to work around the problem, place that information here. 24 | 25 | **💻 Environment** 26 | Anything that will help triage the bug will help. For example: 27 | - Terraform version 28 | - Module version 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General Request 3 | about: A template for a general request on this repository 4 | title: '' 5 | labels: documentation, enhancement, chore 6 | assignees: '' 7 | 8 | --- 9 | 10 | **:thought_balloon: Description of the request or enhancement** 11 | A clear and concise description of what the request is about. Please add the fitting label to this issue: 12 | - Documentation 13 | - Enhancement 14 | - Chore (not covered by something else / question) 15 | 16 | **:bookmark: Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | 19 | **:100: Acceptance criteria** 20 | Enter the conditions of satisfaction here. That is, the conditions that will satisfy the user/persona that the goal/benefit/value has been achieved. 21 | -------------------------------------------------------------------------------- /.github/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: breaking 3 | color: "b60205" 4 | description: This change is not backwards compatible 5 | - name: bug 6 | color: "d93f0b" 7 | description: Something isn't working 8 | - name: documentation 9 | color: "0075ca" 10 | description: Improvements or additions to documentation 11 | - name: enhancement 12 | color: "0e8a16" 13 | description: New feature or request 14 | - name: feature 15 | color: "0e8a16" 16 | description: New feature or request 17 | - name: fix 18 | color: "d93f0b" 19 | description: Fixes a bug 20 | - name: chore 21 | color: "6b93d3" 22 | description: Task not covered by something else (e.g. refactor, CI changes, tests) 23 | - name: no-changelog 24 | color: "cccccc" 25 | description: No entry should be added to the release notes and changelog 26 | - name: security 27 | color: "5319e7" 28 | description: Solving a security issue 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **:hammer_and_wrench: Summary** 2 | 3 | 4 | 5 | **:rocket: Motivation** 6 | 7 | 8 | **:pencil: Additional Information** 9 | 10 | -------------------------------------------------------------------------------- /.github/release-drafter-config.yaml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | version-template: "$MAJOR.$MINOR.$PATCH" 4 | change-title-escapes: '\<*_&' 5 | 6 | categories: 7 | - title: "🚀 Features" 8 | labels: 9 | - "breaking" 10 | - "enhancement" 11 | - "feature" 12 | - title: "🐛 Bug Fixes" 13 | labels: 14 | - "bug" 15 | - "fix" 16 | - "security" 17 | - title: "📖 Documentation" 18 | labels: 19 | - "documentation" 20 | - title: "🧺 Miscellaneous" 21 | labels: 22 | - "chore" 23 | 24 | version-resolver: 25 | major: 26 | labels: 27 | - "breaking" 28 | minor: 29 | labels: 30 | - "enhancement" 31 | - "feature" 32 | patch: 33 | labels: 34 | - "bug" 35 | - "chore" 36 | - "documentation" 37 | - "fix" 38 | - "security" 39 | default: "minor" 40 | 41 | autolabeler: 42 | - label: "documentation" 43 | body: 44 | - "/documentation/" 45 | branch: 46 | - '/docs\/.+/' 47 | title: 48 | - "/documentation/i" 49 | - "/docs/i" 50 | - label: "bug" 51 | body: 52 | - "/bug/" 53 | branch: 54 | - '/bug\/.+/' 55 | - '/fix\/.+/' 56 | title: 57 | - "/bug/i" 58 | - "/fix/i" 59 | - label: "feature" 60 | branch: 61 | - '/feature\/.+/' 62 | - '/enhancement\/.+/' 63 | title: 64 | - "/feature/i" 65 | - "/feat/i" 66 | - "/enhancement/i" 67 | - label: "breaking" 68 | body: 69 | - "/breaking change/i" 70 | branch: 71 | - '/breaking\/.+/' 72 | title: 73 | - "/!:/" 74 | - "/breaking/i" 75 | - "/major/i" 76 | - label: "chore" 77 | branch: 78 | - '/chore\/.+/' 79 | title: 80 | - "/chore/i" 81 | 82 | exclude-contributors: 83 | - "github-actions[bot]" 84 | 85 | exclude-labels: 86 | - "no-changelog" 87 | 88 | template: | 89 | # What's Changed 90 | 91 | $CHANGES 92 | 93 | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 94 | -------------------------------------------------------------------------------- /.github/workflows/label-synchronization.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT CHANGE THIS FILE DIRECTLY 2 | # Source: https://github.com/schubergphilis/mcaf-github-workflows 3 | 4 | name: label-synchronization 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - main 10 | - master 11 | paths: 12 | - .github/labels.yaml 13 | - .github/workflows/label-sync.yaml 14 | 15 | permissions: 16 | # write permission is required to edit issue labels 17 | issues: write 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Synchronize labels 27 | uses: crazy-max/ghaction-github-labeler@v5 28 | with: 29 | dry-run: false 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | skip-delete: false 32 | yaml-file: .github/labels.yaml 33 | -------------------------------------------------------------------------------- /.github/workflows/pr-validation.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT CHANGE THIS FILE DIRECTLY 2 | # Source: https://github.com/schubergphilis/mcaf-github-workflows 3 | 4 | name: "pr-validation" 5 | 6 | on: 7 | pull_request: 8 | 9 | permissions: 10 | checks: write 11 | contents: read 12 | pull-requests: write 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.event.pull_request.number }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | autolabeler: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: release-drafter/release-drafter@v6 23 | with: 24 | config-name: release-drafter-config.yaml 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | title-checker: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: amannn/action-semantic-pull-request@v5 32 | id: lint_pr_title 33 | with: 34 | types: | 35 | breaking 36 | bug 37 | chore 38 | docs 39 | documentation 40 | enhancement 41 | feat 42 | feature 43 | fix 44 | security 45 | requireScope: false 46 | ignoreLabels: | 47 | skip-changelog 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - uses: marocchino/sticky-pull-request-comment@v2 52 | # When the previous steps fails, the workflow would stop. By adding this 53 | # condition you can continue the execution with the populated error message. 54 | if: always() && (steps.lint_pr_title.outputs.error_message != null) 55 | with: 56 | header: pr-title-lint-error 57 | message: | 58 | Hey there and thank you for opening this pull request! 👋🏼 59 | 60 | We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. 61 | 62 | Examples for valid PR titles: 63 | feat(ui): Add button component. 64 | fix: Correct typo. 65 | _type(scope): subject._ 66 | 67 | Adding a scope is optional 68 | 69 | Details: 70 | ``` 71 | ${{ steps.lint_pr_title.outputs.error_message }} 72 | ``` 73 | 74 | # Delete a previous comment when the issue has been resolved 75 | - if: ${{ steps.lint_pr_title.outputs.error_message == null }} 76 | uses: marocchino/sticky-pull-request-comment@v2 77 | with: 78 | header: pr-title-lint-error 79 | delete: true 80 | 81 | label-checker: 82 | needs: autolabeler 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: docker://agilepathway/pull-request-label-checker:v1.6.55 86 | id: lint_pr_labels 87 | with: 88 | any_of: breaking,bug,chore,documentation,enhancement,feature,fix,security 89 | repo_token: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - uses: marocchino/sticky-pull-request-comment@v2 92 | # When the previous steps fails, the workflow would stop. By adding this 93 | # condition you can continue the execution with the populated error message. 94 | if: always() && (steps.lint_pr_labels.outputs.label_check == 'failure') 95 | with: 96 | header: pr-labels-lint-error 97 | message: | 98 | Hey there and thank you for opening this pull request! 👋🏼 99 | 100 | The PR needs to have at least one of the following labels: 101 | 102 | - breaking 103 | - bug 104 | - chore 105 | - documentation 106 | - enhancement 107 | - feature 108 | - fix 109 | - security 110 | 111 | # Delete a previous comment when the issue has been resolved 112 | - if: ${{ steps.lint_pr_labels.outputs.label_check == 'success' }} 113 | uses: marocchino/sticky-pull-request-comment@v2 114 | with: 115 | header: pr-labels-lint-error 116 | delete: true 117 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT CHANGE THIS FILE DIRECTLY 2 | # Source: https://github.com/schubergphilis/mcaf-github-workflows 3 | 4 | name: "release-drafter" 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - master 11 | paths-ignore: 12 | - .github/** 13 | - .gitignore 14 | - .pre-commit-config.yaml 15 | - CHANGELOG.md 16 | - CONTRIBUTING.md 17 | - LICENSE 18 | 19 | permissions: 20 | # write permission is required to create a github release 21 | contents: write 22 | 23 | jobs: 24 | draft: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: release-drafter/release-drafter@v6 28 | with: 29 | publish: false 30 | prerelease: false 31 | config-name: release-drafter-config.yaml 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/terraform-validation.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT CHANGE THIS FILE DIRECTLY 2 | # Source: https://github.com/schubergphilis/mcaf-github-workflows 3 | 4 | name: "terraform" 5 | 6 | on: 7 | pull_request: 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | TF_IN_AUTOMATION: 1 16 | 17 | jobs: 18 | fmt-lint-validate: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Terraform 25 | uses: hashicorp/setup-terraform@v3 26 | 27 | - name: Setup Terraform Linters 28 | uses: terraform-linters/setup-tflint@v4 29 | with: 30 | github_token: ${{ github.token }} 31 | 32 | - name: Terraform Format 33 | id: fmt 34 | run: terraform fmt -check -recursive 35 | 36 | - name: Terraform Lint 37 | id: lint 38 | run: | 39 | echo "Checking ." 40 | tflint --format compact 41 | 42 | for d in examples/*/; do 43 | echo "Checking ${d} ..." 44 | tflint --chdir=$d --format compact 45 | done 46 | 47 | - name: Terraform Validate 48 | id: validate 49 | if: ${{ !vars.SKIP_TERRAFORM_VALIDATE }} 50 | run: | 51 | for d in examples/*/; do 52 | echo "Checking ${d} ..." 53 | terraform -chdir=$d init 54 | terraform -chdir=$d validate -no-color 55 | done 56 | env: 57 | AWS_DEFAULT_REGION: eu-west-1 58 | 59 | - name: Terraform Test 60 | id: test 61 | if: ${{ !vars.SKIP_TERRAFORM_TESTS }} 62 | run: | 63 | terraform init 64 | terraform test 65 | 66 | - uses: actions/github-script@v7 67 | if: github.event_name == 'pull_request' || always() 68 | with: 69 | github-token: ${{ secrets.GITHUB_TOKEN }} 70 | script: | 71 | // 1. Retrieve existing bot comments for the PR 72 | const { data: comments } = await github.rest.issues.listComments({ 73 | owner: context.repo.owner, 74 | repo: context.repo.repo, 75 | issue_number: context.issue.number, 76 | }) 77 | const botComment = comments.find(comment => { 78 | return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style') 79 | }) 80 | 81 | // 2. Prepare format of the comment 82 | const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` 83 | #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` 84 | #### Terraform Lint 📖\`${{ steps.lint.outcome }}\` 85 | #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\` 86 |
Validation Output 87 | 88 | \`\`\`\n 89 | ${{ steps.validate.outputs.stdout }} 90 | \`\`\` 91 | 92 |
`; 93 | 94 | // 3. If we have a comment, update it, otherwise create a new one 95 | if (botComment) { 96 | github.rest.issues.updateComment({ 97 | owner: context.repo.owner, 98 | repo: context.repo.repo, 99 | comment_id: botComment.id, 100 | body: output 101 | }) 102 | } else { 103 | github.rest.issues.createComment({ 104 | issue_number: context.issue.number, 105 | owner: context.repo.owner, 106 | repo: context.repo.repo, 107 | body: output 108 | }) 109 | } 110 | 111 | docs: 112 | runs-on: ubuntu-latest 113 | steps: 114 | - name: Checkout code 115 | uses: actions/checkout@v4 116 | with: 117 | ref: ${{ github.event.pull_request.head.ref }} 118 | 119 | - name: Render terraform docs inside the README.md and push changes back to PR branch 120 | uses: terraform-docs/gh-actions@v1.3.0 121 | with: 122 | args: --sort-by required 123 | git-commit-message: "docs(readme): update module usage" 124 | git-push: true 125 | output-file: README.md 126 | output-method: inject 127 | working-dir: . 128 | continue-on-error: true # added this to prevent a PR from a remote fork failing the workflow 129 | 130 | # If the recursive flag is set to true, the action will not update the main README.md file. 131 | # Therefore we need to run the action twice, once for the root module and once for the modules directory 132 | docs-modules: 133 | runs-on: ubuntu-latest 134 | steps: 135 | - name: Checkout code 136 | uses: actions/checkout@v4 137 | with: 138 | ref: ${{ github.event.pull_request.head.ref }} 139 | 140 | - name: Render terraform docs inside the README.md and push changes back to PR branch 141 | uses: terraform-docs/gh-actions@v1.3.0 142 | with: 143 | args: --sort-by required 144 | git-commit-message: "docs(readme): update module usage" 145 | git-push: true 146 | output-file: README.md 147 | output-method: inject 148 | recursive-path: modules 149 | recursive: true 150 | working-dir: . 151 | continue-on-error: true # added this to prevent a PR from a remote fork failing the workflow 152 | 153 | checkov: 154 | runs-on: ubuntu-latest 155 | steps: 156 | - name: Check out code 157 | uses: actions/checkout@v4 158 | 159 | - name: Run Checkov 160 | uses: bridgecrewio/checkov-action@v12 161 | with: 162 | container_user: 1000 163 | directory: "/" 164 | download_external_modules: false 165 | framework: terraform 166 | output_format: sarif 167 | quiet: true 168 | skip_check: "CKV_GIT_5,CKV_GLB_1,CKV_TF_1" 169 | soft_fail: false 170 | skip_path: "examples/" 171 | 172 | ### SKIP REASON ### 173 | # Check | Description | Reason 174 | 175 | # CKV_GIT_5 | Ensure GitHub pull requests have at least 2 approvals | We strive for at least 1 approval 176 | # CKV_GLB_1 | Ensure at least two approving reviews are required to merge a GitLab MR | We strive for at least 1 approval 177 | # CKV_TF_1 | Ensure Terraform module sources use a commit hash | We think this check is too restrictive and that versioning should be preferred over commit hash 178 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT CHANGE THIS FILE DIRECTLY 2 | # Source: https://github.com/schubergphilis/mcaf-github-workflows 3 | 4 | name: "update-changelog" 5 | 6 | on: 7 | release: 8 | types: 9 | - published 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | update: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | with: 21 | token: ${{ secrets.MCAF_GITHUB_TOKEN }} 22 | 23 | - name: Update Changelog 24 | uses: stefanzweifel/changelog-updater-action@v1 25 | with: 26 | latest-version: ${{ github.event.release.tag_name }} 27 | release-notes: ${{ github.event.release.body }} 28 | 29 | - name: Commit updated Changelog 30 | uses: stefanzweifel/git-auto-commit-action@v5 31 | with: 32 | branch: ${{ github.event.repository.default_branch }} 33 | commit_message: "docs(changelog): update changelog" 34 | file_pattern: CHANGELOG.md 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is managed by mcaf-github-workflows 2 | 3 | # CheckOv pre-commit external modules path 4 | **/.external_modules/* 5 | 6 | # Local .terraform directories 7 | **/.terraform/* 8 | 9 | # Local Lambda function package directories 10 | **/builds/* 11 | 12 | # Terraform locks 13 | # This is a module, not a stand-alone deployment 14 | .terraform.lock.hcl 15 | 16 | # Ignore CLI configuration files 17 | .terraformrc 18 | terraform.rc 19 | 20 | # Crash log files 21 | crash.log 22 | crash.*.log 23 | 24 | # .tfstate files 25 | *.tfstate 26 | *.tfstate.* 27 | 28 | # .tfvars files 29 | # These should not be part of version control as they are data points which are potentially sensitive and subject to change depending on the environment. 30 | *.tfvars 31 | *.tfvars.json 32 | 33 | # override files 34 | # These should not be part of version control as they are usually used to override resources locally 35 | override.tf 36 | override.tf.json 37 | *_override.tf 38 | *_override.tf.json 39 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT CHANGE THIS FILE DIRECTLY 2 | # Source: https://github.com/schubergphilis/mcaf-github-workflows 3 | default_stages: [pre-commit] 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: check-json 9 | - id: check-merge-conflict 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: check-yaml 13 | - id: check-added-large-files 14 | - id: pretty-format-json 15 | args: 16 | - --autofix 17 | - id: detect-aws-credentials 18 | args: 19 | - --allow-missing-credentials 20 | - id: detect-private-key 21 | - repo: https://github.com/antonbabenko/pre-commit-terraform 22 | rev: v1.98.1 23 | hooks: 24 | - id: terraform_fmt 25 | - id: terraform_tflint 26 | - id: terraform_docs 27 | - id: terraform_validate 28 | - repo: https://github.com/bridgecrewio/checkov.git 29 | rev: 3.2.388 30 | hooks: 31 | - id: checkov 32 | verbose: false 33 | args: 34 | - --download-external-modules 35 | - "true" 36 | - --quiet 37 | - --compact 38 | - --skip-check 39 | - CKV_GIT_5,CKV_GLB_1,CKV_TF_1 40 | - --skip-path 41 | - examples/* 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Coding Guidelines 4 | 5 | - The terraform language has some [style conventions](https://developer.hashicorp.com/terraform/language/syntax/style) which must be followed for consistency between files and modules written by different teams. 6 | 7 | ## Opening a pull request 8 | 9 | - We require pull request titles to follow the [conventional commits specification](https://www.conventionalcommits.org/en/v1.0.0/) 10 | 11 | - Labels are automatically added to your PR based on certain keywords in the `title`, `body`, and `branch` . You are able to manually add or remove labels from your PR, the following labels are allowed: `breaking`, `enhancement`, `feature`, `bug`, `fix`, `security`, `documentation`. 12 | 13 | ## Release flow 14 | 15 | 1. Every time a PR is merged, a draft release note is created or updated to add an entry for this PR. The release version is automatically incremented based on the labels specified. 16 | 17 | 2. When you are ready to publish the release, you can use the drafted release note to do so. `MCAF Contributors` are able to publish releases. If you are an `MCAF Contributor` and want to publish a drafted release: 18 | - Browse to the release page 19 | - Edit the release you want to publish (click on the pencil) 20 | - Click `Update release` (the green button at the bottom of the page) 21 | 22 | If a PR should not be added to the release notes and changelog, add the label `no-changelog` to your PR. 23 | 24 | ## Local Development 25 | 26 | To ease local development, [pre-commit](https://pre-commit.com/) configuration has been added to the repository. Pre-commit is useful for identifying simple issues before creating a PR: 27 | 28 | To use it, follow these steps: 29 | 30 | 1. Installation: 31 | - Using Brew: `brew install tflint` 32 | - Using Python: `pip3 install pre-commit --upgrade` 33 | - Using Conda: `conda install -c conda-forge pre-commit` 34 | 35 | 2. Run the pre-commit hooks against all the files (the first time run might take a few minutes): 36 | `pre-commit run -a` 37 | 38 | 3. (optional) Install the pre-commit hooks to run before each commit: 39 | `pre-commit install` 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading Notes 2 | 3 | This document captures required refactoring on your part when upgrading to a module version that contains breaking changes. 4 | 5 | ## Upgrading to v6.0.0 6 | 7 | ### Key Changes 8 | 9 | #### Refactored `permission-set` sub-module 10 | 11 | The previous version of this sub-module used the AWS account ID as a key in the `aws_ssoadmin_account_assignment.default` resource. This worked as expected when creating accounts in separate workspace, but when calling this sub-module directly in the same workspace that creates the account, the account ID is still to be computed which isn't allowed for key names. 12 | 13 | To fix this, the resource was refactored to use predictable values, so we swapped out the account ID for the account name: `aws_ssoadmin_account_assignment.default["${each.sso_group}:${each.aws_account_id}"]` becomes `aws_ssoadmin_account_assignment.default["${each.sso_group}:${each.aws_account_name}"]` 14 | 15 | Unfortunately this means that the state will be out of sync and the resources will be recreated as `moved` blocks do not yet support `for_each`. 16 | 17 | #### Variables 18 | 19 | - `aws_sso_permission_sets` in the root has had it's `assignments` field changed from a map to a list of objects. See the README for more detailed information. The same change has been implemented in the `assignments` variable of the `permission-set` sub-module. 20 | 21 | ### How to upgrade 22 | 23 | Change the `aws_sso_permission_sets` variable format to use a list objects, e.g. 24 | 25 | ```hcl 26 | assignments = [ 27 | { 28 | for account in [ 123456789012, 012456789012 ] : account => [ 29 | okta_group.aws["AWSPlatformAdmins"].name 30 | ] 31 | }, 32 | ] 33 | ``` 34 | 35 | Becomes: 36 | 37 | ```hcl 38 | assignments = [ 39 | for account in [ 40 | { id = "123456789012", name = "ProductionAccount" }, 41 | { id = "012456789012", name = "DevelopmentAccount" } 42 | ] : { 43 | account_id = account.id 44 | account_name = account.name 45 | sso_groups = [okta_group.aws["AWSPlatformAdmins"].name] 46 | } 47 | ] 48 | ``` 49 | 50 | ## Upgrading to v5.0.0 51 | 52 | ### Key Changes 53 | 54 | #### Transition to Centralized Security Hub Configuration 55 | 56 | This version transitions Security Hub configuration from **Local** to **Central**. Learn more in the [AWS Security Hub Documentation](https://docs.aws.amazon.com/securityhub/latest/userguide/central-configuration-intro.html). 57 | 58 | **Default Behavior:** 59 | 60 | - Security Hub Findings Aggregation is enabled for regions defined in: 61 | - `regions.home_region` 62 | - `regions.linked_regions`. `us-east-1` is automatically included for global services. 63 | 64 | #### Dropping Support for Local Configuration 65 | 66 | **Local configurations are no longer supported.** Centralized configuration aligns with AWS best practices and reduces complexity. 67 | 68 | ### Variables 69 | 70 | The following variables have been replaced: 71 | 72 | - `aws_service_control_policies.allowed_regions` → `regions.allowed_regions` 73 | - `aws_config.aggregator_regions` → the union of `regions.home_region` and `regions.linked_regions` 74 | 75 | The following variables have been introduced: 76 | 77 | - `aws_security_hub.aggregator_linking_mode`. Indicates whether to aggregate findings from all of the available Regions or from a specified list. 78 | - `aws_security_hub.disabled_control_identifiers`. List of Security Hub control IDs that are disabled in the organisation. 79 | - `aws_security_hub.enabled_control_identifiers`. List of Security Hub control IDs that are enabled in the organisation. 80 | 81 | The following variables have been removed: 82 | 83 | - `aws_security_hub.auto_enable_new_accounts`. This variable is not configurable anymore using security hub central configuration. 84 | - `aws_security_hub.auto_enable_default_standards`. This variable is not configurable anymore using security hub central configuration. 85 | 86 | ### How to upgrade 87 | 88 | 1. Verify Control Tower Governed Regions. 89 | 90 | Ensure your AWS Control Tower Landing Zone regions includes `us-east-1`. 91 | 92 | To check: 93 | 94 | 1. Log in to the **core-management account**. 95 | 2. Navigate to **AWS Control Tower** → **Landing Zone Settings**. 96 | 3. Confirm `us-east-1` is listed under **Landing Zone Regions**. 97 | 98 | If `us-east-1` is missing, update your AWS Control Tower settings **before upgrading**. 99 | 100 | > [!NOTE] 101 | > For more details on the `regions` variable, refer to the [Specifying the correct regions section in the readme](README.md). 102 | 103 | 2. Update the variables according to the variables section above. 104 | 105 | 3. Manually Removing Local Security Hub Standards 106 | 107 | Previous versions managed `aws_securityhub_standards_subscription` resources locally in core accounts. These are now centrally configured using `aws_securityhub_configuration_policy`. **Terraform will attempt to remove these resources from the state**. To prevent disabling them, the resources must be manually removed from the Terraform state. 108 | 109 | _Steps to Remove Resources:_ 110 | 111 | a. Generate Removal Commands. Run the following shell snippet: 112 | 113 | ```shell 114 | terraform init 115 | for local_standard in $(terraform state list | grep "module.landing_zone.aws_securityhub_standards_subscription"); do 116 | echo "terraform state rm '$local_standard'" 117 | done 118 | ``` 119 | 120 | b. Execute Commands: Evaluate and run the generated statements. They will look like: 121 | 122 | ```shell 123 | terraform state rm 'module.landing_zone.aws_securityhub_standards_subscription.logging["arn:aws:securityhub:eu-central-1::standards/pci-dss/v/3.2.1"]' 124 | ... 125 | ``` 126 | 127 | _Why Manual Removal is Required_ 128 | 129 | Terraform cannot handle `for_each` loops in `removed` statements ([HashiCorp Issue #34439](https://github.com/hashicorp/terraform/issues/34439)). Therefore the resources created with a `for_each` loop on `local.security_hub_standards_arns` must be manually removed from the Terraform state to prevent unintended deletions. 130 | 131 | 4. Upgrade your mcaf-landing-zone module to v5.x.x. 132 | 133 | 5. Upgrade your [mcaf-account-baseline](https://github.com/schubergphilis/terraform-aws-mcaf-account-baseline) deployments to v2.0.0 or higher. 134 | 135 | ### Troubleshooting 136 | 137 | #### Issue: AWS Security Hub control "AWS Config should be enabled and use the service-linked role for resource recording" fails for multiple accounts after upgrade 138 | 139 | #### Resolution Steps 140 | 141 | 1. **Verify `regions.linked_regions`:** 142 | 143 | - Ensure that `regions.linked_regions` matches the AWS Control Tower Landing Zone regions. 144 | - For guidance, refer to the [Specifying the correct regions section in the README](README.md). 145 | 146 | 2. **Check Organizational Units (OUs):** 147 | 148 | - Log in to the **core-management account**. 149 | - Navigate to **AWS Control Tower** → **Organization**. 150 | - Confirm all OUs have the **Baseline state** set to `Succeeded`. 151 | 152 | 3. **Check Account Baseline States:** 153 | 154 | - In **AWS Control Tower** → **Organization**, verify that all accounts show a **Baseline state** of `Succeeded`. 155 | - If any accounts display `Update available`: 156 | - Select the account. 157 | - Go to **Actions** → **Update**. 158 | 159 | 4. **Allow Time for Changes to Propagate:** 160 | - Wait up to **24 hours** for updates to propagate and resolve the Security Hub findings. 161 | 162 | If all steps are completed and the issue persists, review AWS Control Tower settings and logs for additional troubleshooting. 163 | 164 | ### Known Issues 165 | 166 | **Issue:** The AWS Security Hub control "AWS Config should be enabled and use the service-linked role for resource recording" fails for the core-management account after the upgrade. 167 | 168 | **Cause:** AWS Control Tower does not enable AWS Config in the core-management account. While this module enables AWS Config in the home region of the core-management account, it does not cover the linked regions. 169 | 170 | **Workaround:** Suppress these findings or enable AWS Config yourself in the linked regions for the core-management account. 171 | 172 | ## Upgrading to v4.0.0 173 | 174 | > [!WARNING] > **Read the diagram in [PR 210](https://github.com/schubergphilis/terraform-aws-mcaf-landing-zone/pull/210) and the guide below! If you currently have EKS Runtime Monitoring enabled, you need to perform MANUAL steps after you have migrated to this version.** 175 | 176 | ### Behaviour 177 | 178 | Using the default `aws_guardduty` values: 179 | 180 | - `EKS_RUNTIME_MONITORING` gets removed from the state (but not disabled) 181 | - `RUNTIME_MONITORING` is enabled including `ECS_FARGATE_AGENT_MANAGEMENT`, `EC2_AGENT_MANAGEMENT`, and `EKS_ADDON_MANAGEMENT`. 182 | - Minimum required AWS provider has been set to `v5.54.0`, and minimum required Terraform version has been set to `v1.6`. 183 | 184 | ### Variables 185 | 186 | The following variables have been replaced: 187 | 188 | - `aws_guardduty.eks_runtime_monitoring_status` -> `aws_guardduty.runtime_monitoring_status.enabled` 189 | - `aws_guardduty.eks_addon_management_status` -> `aws_guardduty.runtime_monitoring_status.eks_addon_management_status` 190 | 191 | The following variables have been introduced: 192 | 193 | - `aws_guardduty.runtime_monitoring_status.ecs_fargate_agent_management_status` 194 | - `aws_guardduty.runtime_monitoring_status.ec2_agent_management_status` 195 | 196 | ### EKS Runtime Monitoring to Runtime Monitoring migration 197 | 198 | #### The issue 199 | 200 | After you upgraded to this version. **RUNTIME_MONITORING is enabled. But EKS_RUNTIME_MONITORING is not disabled** as is written in the [guardduty_detector_feature documentation](https://registry.terraform.io/providers/hashicorp/aws/5.68.0/docs/resources/guardduty_detector_feature): _Deleting this resource does not disable the detector feature, the resource in simply removed from state instead._ 201 | 202 | To prevent duplicated costs please **disable** EKS_RUNTIME_MONITORING manually after upgrading. 203 | 204 | > [!IMPORTANT] 205 | > Run all the commands with valid credentials in the AWS account where guardduty is delegated administrator. By default this is the **control tower audit** account. 206 | > It's not possible to execute these steps from the AWS Console as the EKS Runtime Monitoring protection plan has already been removed from the GUI. The only way to control this feature is via the CLI. 207 | 208 | #### Step 1: get the GuardDuty detector id 209 | 210 | ``` 211 | aws guardduty list-detectors 212 | ``` 213 | 214 | Should display: 215 | 216 | ``` 217 | { 218 | "DetectorIds": [ 219 | "12abc34d567e8fa901bc2d34e56789f0" 220 | ] 221 | } 222 | ``` 223 | 224 | > [!IMPORTANT] 225 | > Ensure you run this command in the right region! If GuardDuty is enabled in multiple regions then execute all steps for all enabled regions. 226 | 227 | #### Step 2: update the GuardDuty detector 228 | 229 | _Replace 12abc34d567e8fa901bc2d34e56789f0 with your own regional detector-id. Execute these commands in the audit account:_ 230 | 231 | ``` 232 | aws guardduty update-detector --detector-id 12abc34d567e8fa901bc2d34e56789f0 --features '[{"Name" : "EKS_RUNTIME_MONITORING", "Status" : "DISABLED"}]' 233 | ``` 234 | 235 | #### Step 3: update the GuardDuty organization settings 236 | 237 | Replace the `<>` with your current configuration for auto-enabling GuardDuty. By default this should be set to `ALL`. 238 | 239 | ``` 240 | aws guardduty update-organization-configuration --detector-id 12abc34d567e8fa901bc2d34e56789f0 --auto-enable-organization-members <> --features '[{"Name" : "EKS_RUNTIME_MONITORING", "AutoEnable": "NONE"}]' 241 | ``` 242 | 243 | #### Step 4: update the GuardDuty member accounts 244 | 245 | Disable EKS Runtime Monitoring for **all** member accounts in your organization, for example: 246 | 247 | ``` 248 | aws guardduty update-member-detectors --detector-id 12abc34d567e8fa901bc2d34e56789f0 --account-ids 111122223333 --features '[{"Name" : "EKS_RUNTIME_MONITORING", "Status" : "DISABLED"}]' 249 | ``` 250 | 251 | #### Troubleshooting 252 | 253 | > An error occurred (BadRequestException) when calling the UpdateMemberDetectors operation: The request is rejected because a feature cannot be turned off for a member while organization has the feature flag set to 'All Accounts'. 254 | 255 | Change these options on the AWS console by following the steps below: 256 | 257 | 1. Go to the GuardDuty Console. 258 | 2. On left navigation bar, under protection plans, select `Runtime Monitoring`. 259 | 3. Under the `Configuration` tab, in `Runtime Monitoring configuration` click `Edit` and here you need to select the option `Configure accounts manually` for `Automated agent configuration - Amazon EKS`. 260 | 261 | Once complete, please allow a minute for the changes to update, you should now be able to execute the command from step 3. When you have executed this command for all AWS accounts, set this option back to `Enable for all accounts`. 262 | 263 | > Even after following all steps I still see the message `Your organization has auto-enable preferences set for EKS Runtime Monitoring. This feature has been removed from console experience and can now be managed as part of the Runtime Monitoring feature. Learn more`. 264 | 265 | We have checked in with AWS and this behaviour is expected, this is a static message that is displayed currently on the AWS Management Console. AWS could not confirm how to hide this message or how long it will be visible. 266 | 267 | #### Verification 268 | 269 | Review the GuardDuty organization settings: 270 | 271 | ``` 272 | aws guardduty describe-organization-configuration --detector-id 12abc34d567e8fa901bc2d34e56789f0 273 | ``` 274 | 275 | Should display: 276 | 277 | ``` 278 | ... 279 | "Features": [ 280 | ... 281 | { 282 | "Name": "EKS_RUNTIME_MONITORING", 283 | "AutoEnable": "NONE", 284 | "AdditionalConfiguration": [ 285 | { 286 | "Name": "EKS_ADDON_MANAGEMENT", 287 | "AutoEnable": "ALL" 288 | } 289 | ] 290 | }, 291 | ... 292 | ``` 293 | 294 | Review the GuardDuty detector settings: 295 | 296 | ``` 297 | aws guardduty get-detector --detector-id 12abc34d567e8fa901bc2d34e56789f0 298 | ``` 299 | 300 | Should display: 301 | 302 | ``` 303 | ... 304 | "Features": [ 305 | ... 306 | { 307 | "Name": "EKS_RUNTIME_MONITORING", 308 | "Status": "DISABLED", 309 | "UpdatedAt": "2024-10-16T14:12:31+02:00", 310 | "AdditionalConfiguration": [ 311 | { 312 | "Name": "EKS_ADDON_MANAGEMENT", 313 | "Status": "ENABLED", 314 | "UpdatedAt": "2024-10-16T14:24:43+02:00" 315 | } 316 | ] 317 | }, 318 | ... 319 | ``` 320 | 321 | > [!NOTE] 322 | > If you want to be really sure all member accounts have the right settings you can run the `aws guardduty get-detector` for member accounts as well. Ensure you have valid credentials for the member account and replace the `detector-id` with the GuardDuty `detector-id` of the member account. 323 | 324 | ## Upgrading to v3.0.0 325 | 326 | ### Behaviour 327 | 328 | This version add Control Tower 3.x support. Upgrade to Control Tower 3.x before upgrading to this version. 329 | 330 | ## Upgrading to v2.0.0 331 | 332 | ### Behaviour 333 | 334 | This version sets the minimum required aws provider version from v4 to v5. 335 | 336 | ### Variables 337 | 338 | The following variables have been replaced: 339 | 340 | - `aws_guardduty.datasources.malware_protection` -> `aws_guardduty.ebs_malware_protection_status` 341 | - `aws_guardduty.datasources.kubernetes` -> `aws_guardduty.eks_audit_logs_status` 342 | - `aws_guardduty.datasources.s3_logs` -> `aws_guardduty.s3_data_events_status` 343 | 344 | The following variables have been introduced: 345 | 346 | - `aws_guardduty.eks_addon_management_status` 347 | - `aws_guardduty.eks_runtime_monitoring_status` 348 | - `aws_guardduty.lambda_network_logs_status` 349 | - `aws_guardduty.rds_login_events_status` 350 | 351 | ## Upgrading to v1.0.0 352 | 353 | ### Behaviour 354 | 355 | In previous versions of this module, `auto-enable default standards` was enabled by default. From v1.0.0 this behaviour has been changed to disabled by default (controlled via `var.aws_security_hub.auto_enable_default_standards`) since the default standards are not updated regularly enough. 356 | 357 | At time of writing only the `AWS Foundational Security Best Practices v1.0.0 standard` and the `CIS AWS Foundations Benchmark v1.2.0` are enabled by [by default](https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-standards-enable-disable.html) while this module enables the following standards: 358 | 359 | - `AWS Foundational Security Best Practices v1.0.0` 360 | - `CIS AWS Foundations Benchmark v1.4.0` 361 | - `PCI DSS v3.2.1` 362 | 363 | The enabling of the standards in all member account is now controlled via [mcaf-account-baseline](https://github.com/schubergphilis/terraform-aws-mcaf-account-baseline). 364 | 365 | ### Variables 366 | 367 | The following variables have been replaced by a new variable `aws_security_hub`: 368 | 369 | - `aws_security_hub_product_arns` -> `aws_security_hub.product_arns` 370 | - `security_hub_standards_arns` -> `aws_security_hub.standards_arns` 371 | - `security_hub_create_cis_metric_filters` -> `aws_security_hub.create_cis_metric_filters` 372 | 373 | ## Upgrading to v0.25.x 374 | 375 | Version `0.25.x` has added support for specifying a kms_key_id in the `var.additional_auditing_trail`. This variable is mandatory, if you already have additional cloudtrail configurations created using this variable encryption is now mandatory. 376 | 377 | ```hcl 378 | module "landing_zone" 379 | ... 380 | additional_auditing_trail = { 381 | name = "audit-trail-name" 382 | bucket = "audit-trail-s3-bucket-name" 383 | kms_key_id = "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" 384 | } 385 | ... 386 | } 387 | ``` 388 | 389 | ## Upgrading to v0.24.x 390 | 391 | Version `0.24.x` changes the AWS nested providers to provider aliases. Define the providers outside the module and reference them when calling this module. For an example, see `examples/basic`. 392 | 393 | ## Upgrading to v0.23.x 394 | 395 | Version `0.23.x` introduces a change in behaviour of AWS Config: 396 | 397 | - By default the `aggregator_regions` were set to eu-west-1 and eu-central-1, this has been changed to only enable the current region. Provide a list of regions to `var.aws_config.aggregator_regions` if you want to enable AWS Config in multiple regions. 398 | - Previously the `aws-controltower-logs` bucket was used to store CloudTrail and AWS Config logs, this version introduces a separate bucket for AWS Config. You are able to override the bucket name by setting `var.aws_config.delivery_channel_s3_bucket_name`. 399 | 400 | ## Upgrading to v0.21.x 401 | 402 | Version `0.21.x` introduces exceptions for IAM entities on the `DenyDisablingSecurityHub` and `DenyLeavingOrg` SCP. The following variables have been merged into a new variable `aws_service_control_policies`: 403 | 404 | - `aws_deny_disabling_security_hub` 405 | - `aws_deny_leaving_org` 406 | - `aws_deny_root_user_ous` 407 | - `aws_region_restrictions` 408 | - `aws_require_imdsv2` 409 | 410 | ## Upgrading to v0.20.x 411 | 412 | Resources managing permission sets in AWS IAM Identity Center have been moved to a sub-module, meaning you will need to create `moved` blocks to update the state. The user interface remains unchanged. 413 | 414 | To move the resources to their new locations in the state, create a `moved.tf` in your workspace and add the following for each managed permission set (assuming your module is called `landing_zone`): 415 | 416 | ```hcl 417 | moved { 418 | from = module.landing_zone.aws_ssoadmin_permission_set.default["<< PERMISSION SET NAME >>"] 419 | to = module.landing_zone.module.aws_sso_permission_sets["<< PERMISSION SET NAME >>"].aws_ssoadmin_permission_set.default[0] 420 | } 421 | 422 | moved { 423 | from = module.landing_zone.aws_ssoadmin_permission_set_inline_policy.default["<< PERMISSION SET NAME >>"] 424 | to = module.landing_zone.module.aws_sso_permission_sets["<< PERMISSION SET NAME >>"].aws_ssoadmin_permission_set_inline_policy.default[0] 425 | } 426 | ``` 427 | 428 | Example, if you have a "PlatformAdmin" permission set: 429 | 430 | ```hcl 431 | moved { 432 | from = module.landing_zone.aws_ssoadmin_permission_set.default["PlatformAdmin"] 433 | to = module.landing_zone.module.aws_sso_permission_sets["PlatformAdmin"].aws_ssoadmin_permission_set.default[0] 434 | } 435 | 436 | moved { 437 | from = module.landing_zone.aws_ssoadmin_permission_set_inline_policy.default["PlatformAdmin"] 438 | to = module.landing_zone.module.aws_sso_permission_sets["PlatformAdmin"].aws_ssoadmin_permission_set_inline_policy.default[0] 439 | } 440 | ``` 441 | 442 | For each permission set assignment, add the following block and substitute the placeholders: 443 | 444 | ```hcl 445 | moved { 446 | from = module.landing_zone.aws_ssoadmin_account_assignment.default["<< SSO GROUP NAME >>-<< AWS ACCOUNT ID >>-<< PERMISSION SET NAME >>" 447 | to = module.landing_zone.module.aws_sso_permission_sets["PlatformAdmin"].aws_ssoadmin_account_assignment.default["<< SSO GROUP NAME >>:<< AWS ACCOUNT ID >>"] 448 | } 449 | ``` 450 | 451 | Example: 452 | 453 | ```hcl 454 | moved { 455 | from = module.landing_zone.aws_ssoadmin_account_assignment.default["PlatformAdminTeam-123456789012-PlatformAdmin"] 456 | to = module.landing_zone.module.aws_sso_permission_sets["PlatformAdmin"].aws_ssoadmin_account_assignment.default["PlatformAdminTeam:123456789012"] 457 | } 458 | ``` 459 | 460 | Repeat adding these `moved` blocks until `terraform plan` doesn't report any planned changed. 461 | 462 | This version requires Terraform 1.3 or newer. 463 | 464 | ## Upgrading to v0.19.x 465 | 466 | Be aware that all tag policies will be recreated since they are now created per tag policy instead of per OU. 467 | 468 | ## Upgrading to v0.18.x 469 | 470 | Version 0.18.x allows Tag Policies on nested Organizational units. Therefore the variable `aws_required_tags` needs the Organizational unit paths including 'Root', e.g.: 471 | 472 | ```hcl 473 | module "landing_zone" { 474 | ... 475 | 476 | aws_required_tags = { 477 | "Root/Production" = [ 478 | { 479 | name = "Tag1" 480 | values = ["A", "B"] 481 | } 482 | ] 483 | "Root/Environments/Non-Production" = [ 484 | { 485 | name = "Tag2" 486 | } 487 | ] 488 | } 489 | ``` 490 | 491 | ## Upgrading to v0.17.x 492 | 493 | The following variables are now typed from string to list(string): 494 | 495 | - `kms_key_policy` 496 | - `kms_key_policy_audit` 497 | - `kms_key_policy_logging` 498 | 499 | The following default key policy has been removed from the audit KMS key and a more secure default has been provided: 500 | 501 | ```shell 502 | { 503 | "Sid": "Enable IAM User Permissions", 504 | "Effect": "Allow", 505 | "Principal": { 506 | "AWS": [ 507 | "arn:aws:iam::${audit_account_id}:root", 508 | "arn:aws:iam::${master_account_id}:root" 509 | ] 510 | }, 511 | "Action": "kms:*", 512 | "Resource": "*" 513 | } 514 | ``` 515 | 516 | If this new key policy is too restrictive for your deployment add extra key policies statements using the `kms_key_policy_audit` variable. 517 | 518 | ## Upgrading to v0.16.x 519 | 520 | Version `0.16` adds support for [AWS provider version 4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade) 521 | 522 | Many parameters are removed from the `aws_s3_bucket` resource configuration, Terraform will not pick up on these changes on a subsequent terraform plan or terraform apply. 523 | 524 | Please run the following commands before migrating to this version (assuming you have called the module `landing_zone`): 525 | 526 | ```shell 527 | terraform import 'module.landing_zone.module.ses-root-accounts-mail-forward[0].module.s3_bucket.aws_s3_bucket_server_side_encryption_configuration.default' 528 | 529 | terraform import 'module.landing_zone.module.ses-root-accounts-mail-forward[0].module.s3_bucket.aws_s3_bucket_versioning.default' 530 | 531 | terraform import 'module.landing_zone.module.ses-root-accounts-mail-forward[0].module.s3_bucket.aws_s3_bucket_acl.default' 532 | 533 | terraform import 'module.landing_zone.module.ses-root-accounts-mail-forward[0].module.s3_bucket.aws_s3_bucket_policy.default' 534 | 535 | terraform import 'module.landing_zone.module.ses-root-accounts-mail-forward[0].module.s3_bucket.aws_s3_bucket_lifecycle_configuration.default[0]' 536 | 537 | ``` 538 | 539 | ## Upgrading to v0.15.x 540 | 541 | Version `0.15` adds an optional mail forwarder using Amazon SES. Adding the `ses_root_accounts_mail_forward` variable creates the necessary resources to accept mail sent to a verified email address and forward it to an external recipient or recipients. Due to the usage of `configuration_aliases` in the provider configurations of some submodules, this module now requires to use Terraform version 1.0.0 or higher. 542 | 543 | ## Upgrading to v0.14.x 544 | 545 | Version `0.14.x` introduces an account level S3 public access policy that blocks public access to all S3 buckets in the landing zone core accounts. Please make sure you have no S3 buckets that require public access in any of the landing zone core accounts before upgrading. 546 | 547 | ## Upgrading to v0.13.x 548 | 549 | Version `0.13.x` adds support for managed policies. This required changing the variable `aws_sso_permission_sets` where each permission set now requires an additional field called `managed_policy_arns` which must be a list of strings or can be an empty list. 550 | 551 | ## Upgrading to v0.12.x 552 | 553 | Version `0.12.x` automatically sets the audit account as security hub administrator account for the organization and automatically enables Security Hub for new accounts in the organization. In case you already configured this manually please import these resources: 554 | 555 | ```shell 556 | terraform import aws_securityhub_organization_admin_account.default 557 | terraform import aws_securityhub_organization_configuration.default 558 | ``` 559 | 560 | ## Upgrading to v0.11.x 561 | 562 | Version `0.11.x` adds additional IAM activity monitors, these will be created automatically if you have the cis-aws-foundations-benchmark standard enabled. To disable the creation of these monitors set the variable `security_hub_create_cis_metric_filters` to false. 563 | 564 | ## Upgrading to v0.10.x 565 | 566 | Version `0.10.x` adds the possibility of assigning the same SSO Permission Set to different groups of accounts and SSO Groups. For example, the permission set `Administrator` can be assigned to group A for account 123 and for group B for account 456. 567 | 568 | This required changing the variable `aws_sso_permission_sets` where the `accounts` attribute was renamed to `assignments` and changed to a list. 569 | 570 | ## Upgrading to v0.9.x 571 | 572 | Removal of the local AVM module. Modify the source to the new [MCAF Account Vending Machine (AVM) module](https://github.com/schubergphilis/terraform-aws-mcaf-avm). 573 | 574 | The following variables have been renamed: 575 | 576 | - `sns_aws_config_subscription` -> `aws_config_sns_subscription` 577 | - `security_hub_product_arns` -> `aws_security_hub_product_arns` 578 | - `sns_aws_security_hub_subscription` -> `aws_security_hub_sns_subscription` 579 | - `sns_monitor_iam_activity_subscription` -> `monitor_iam_activity_sns_subscription` 580 | 581 | The following variable has been removed: 582 | 583 | - `aws_create_account_password_policy`, if you do not want to enable the password policy set the `aws_account_password_policy` variable to `null` 584 | 585 | The provider alias has changed. Change the following occurence for all accounts, as shown below for the `sandbox` AVM module instance. 586 | 587 | ```shell 588 | module.sandbox.provider[\"registry.terraform.io/hashicorp/aws\"].managed_by_inception => module.sandbox.provider[\"registry.terraform.io/hashicorp/aws\"].account 589 | ``` 590 | 591 | Moreover, resources in the AVM module are now stored under `module.tfe_workspace[0]`, resulting in a plan wanting to destroy and recreate the existing Terraform Cloud workspace and IAM user used by the workspace which is undesirable. 592 | 593 | To prevent this happening, simply move the resources in the state to their new location as shown below for the `sandbox` AVM module instance: 594 | 595 | ```shell 596 | terraform state mv 'module.sandbox.module.workspace[0]' 'module.sandbox.module.tfe_workspace[0]' 597 | ``` 598 | 599 | Finally, if you are migrating to the [MCAF Account Baseline module](https://github.com/schubergphilis/terraform-aws-mcaf-account-baseline) as well. Then remove the following resources from the state and let these resource be managed by the baseline workspaces. Command shown below for the `sandbox` AVM module instance 600 | 601 | ```shell 602 | terraform state mv -state-out=baseline-sandbox.tfstate 'module.sandbox.aws_cloudwatch_log_metric_filter.iam_activity' 'module.account_baseline.aws_cloudwatch_log_metric_filter.iam_activity' 603 | terraform state mv -state-out=baseline-sandbox.tfstate 'module.sandbox.aws_cloudwatch_metric_alarm.iam_activity' 'module.account_baseline.aws_cloudwatch_metric_alarm.iam_activity' 604 | terraform state mv -state-out=baseline-sandbox.tfstate 'module.sandbox.aws_iam_account_password_policy.default' 'module.account_baseline.aws_iam_account_password_policy.default' 605 | terraform state mv -state-out=baseline-sandbox.tfstate 'module.sandbox.aws_ebs_encryption_by_default.default' 'module.account_baseline.aws_ebs_encryption_by_default.default' 606 | ``` 607 | 608 | ## Upgrading to v0.8.x 609 | 610 | Version `0.8.x` introduces the possibility of managing AWS SSO resources using this module. To avoid a race condition between Okta pushing groups to AWS SSO and Terraform trying to read them using data sources, the `okta_app_saml` resource has been removed from the module. 611 | 612 | With this change, all Okta configuration can be managed in the way that best suits the user. It also makes it possible to use this module with any other identity provider that is able to create groups on AWS SSO. 613 | 614 | ## Upgrading to v0.7.x 615 | 616 | From version `0.7.0`, the monitoring of IAM entities has changed from Event Bridge Rules to CloudWatch Alarms. This means that passing a list of IAM identities to the variable `monitor_iam_access` is no longer supported. 617 | 618 | The name of the SNS Topic used for notifications has also changed from `LandingZone-MonitorIAMAccess` to `LandingZone-IAMActivity`. Since this is a new Topic, all pre-existing SNS Subscriptions should be configured again using the variable `sns_monitor_iam_activity_subscription`. 619 | 620 | ## Upgrading to v0.5.x 621 | 622 | Since the `create_workspace` variable was added to the AVM module, resources in the included [terraform-aws-mcaf-workspace](https://github.com/schubergphilis/terraform-aws-mcaf-workspace) module are now stored under `module.workspace[0]`, resulting in a plan wanting to destroy and recreate the existing Terraform Cloud workspace and IAM user used by the workspace which is undesirable. 623 | 624 | To prevent this happening, simply move the resources in the state to their new location as shown below for the `sandbox` AVM module instance: 625 | 626 | ```shell 627 | terraform state mv 'module.sandbox.module.workspace' 'module.sandbox.module.workspace[0]' 628 | ``` 629 | 630 | ## Upgrading from v0.1.x to v0.2.x 631 | 632 | This section describes changes to be aware of when upgrading from v0.1.x to v0.2.x. 633 | 634 | ### Enhancements 635 | 636 | #### AWS Config Aggregator Accounts 637 | 638 | Since version `0.2.x` supports multiple account IDs when configuring AWS Config Aggregator accounts, the identifier given to the multiple `aws_config_aggregate_authorization` resources had to change from `region_name` to `account_id-region_name`. This causes the authorizations created by version `0.1.x` to be destroyed and recreated with the new identifiers. 639 | 640 | #### AWS GuardDuty 641 | 642 | In order to enable GuardDuty for the entire organization, all existing accounts except for the `master` and `logging` accounts have to be add as members in the `audit` account like explained [here](https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_organizations.html#guardduty_add_orgs_accounts). If this step is not taken, only the core accounts will have GuardDuty enabled. 643 | 644 | #### TFE Workspaces 645 | 646 | TFE Workspaces use version [0.3.0 of the terraform-aws-mcaf-workspace](https://github.com/schubergphilis/terraform-aws-mcaf-workspace/tree/v0.3.0) module which by default creates a Terraform backend file in the repository associated with the workspace. 647 | -------------------------------------------------------------------------------- /account_audit.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_account_password_policy" "audit" { 2 | count = var.aws_account_password_policy != null ? 1 : 0 3 | provider = aws.audit 4 | 5 | allow_users_to_change_password = var.aws_account_password_policy.allow_users_to_change 6 | max_password_age = var.aws_account_password_policy.max_age 7 | minimum_password_length = var.aws_account_password_policy.minimum_length 8 | password_reuse_prevention = var.aws_account_password_policy.reuse_prevention_history 9 | require_lowercase_characters = var.aws_account_password_policy.require_lowercase_characters 10 | require_numbers = var.aws_account_password_policy.require_numbers 11 | require_symbols = var.aws_account_password_policy.require_symbols 12 | require_uppercase_characters = var.aws_account_password_policy.require_uppercase_characters 13 | } 14 | 15 | resource "aws_ebs_encryption_by_default" "audit" { 16 | provider = aws.audit 17 | 18 | enabled = var.aws_ebs_encryption_by_default 19 | } 20 | 21 | resource "aws_s3_account_public_access_block" "audit" { 22 | provider = aws.audit 23 | 24 | block_public_acls = true 25 | block_public_policy = true 26 | ignore_public_acls = true 27 | restrict_public_buckets = true 28 | } 29 | -------------------------------------------------------------------------------- /account_logging.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_account_password_policy" "logging" { 2 | count = var.aws_account_password_policy != null ? 1 : 0 3 | provider = aws.logging 4 | 5 | allow_users_to_change_password = var.aws_account_password_policy.allow_users_to_change 6 | max_password_age = var.aws_account_password_policy.max_age 7 | minimum_password_length = var.aws_account_password_policy.minimum_length 8 | password_reuse_prevention = var.aws_account_password_policy.reuse_prevention_history 9 | require_lowercase_characters = var.aws_account_password_policy.require_lowercase_characters 10 | require_numbers = var.aws_account_password_policy.require_numbers 11 | require_symbols = var.aws_account_password_policy.require_symbols 12 | require_uppercase_characters = var.aws_account_password_policy.require_uppercase_characters 13 | } 14 | 15 | resource "aws_ebs_encryption_by_default" "logging" { 16 | provider = aws.logging 17 | 18 | enabled = var.aws_ebs_encryption_by_default 19 | } 20 | 21 | resource "aws_s3_account_public_access_block" "logging" { 22 | provider = aws.logging 23 | 24 | block_public_acls = true 25 | block_public_policy = true 26 | ignore_public_acls = true 27 | restrict_public_buckets = true 28 | } 29 | -------------------------------------------------------------------------------- /account_management.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_metric_filter" "iam_activity_master" { 2 | for_each = var.monitor_iam_activity ? merge(local.iam_activity, local.cloudtrail_activity_cis_aws_foundations) : {} 3 | 4 | name = "LandingZone-IAMActivity-${each.key}" 5 | pattern = each.value 6 | log_group_name = data.aws_cloudwatch_log_group.cloudtrail_master[0].name 7 | 8 | metric_transformation { 9 | name = "LandingZone-IAMActivity-${each.key}" 10 | namespace = "LandingZone-IAMActivity" 11 | value = "1" 12 | } 13 | } 14 | 15 | resource "aws_cloudwatch_metric_alarm" "iam_activity_master" { 16 | for_each = aws_cloudwatch_log_metric_filter.iam_activity_master 17 | 18 | alarm_name = each.value.name 19 | comparison_operator = "GreaterThanOrEqualToThreshold" 20 | evaluation_periods = "1" 21 | metric_name = each.value.name 22 | namespace = each.value.metric_transformation[0].namespace 23 | period = "300" 24 | statistic = "Sum" 25 | threshold = "1" 26 | alarm_description = "Monitors IAM activity for ${each.key}" 27 | alarm_actions = [aws_sns_topic.iam_activity[0].arn] 28 | insufficient_data_actions = [] 29 | tags = var.tags 30 | } 31 | 32 | resource "aws_iam_account_password_policy" "master" { 33 | count = var.aws_account_password_policy != null ? 1 : 0 34 | 35 | allow_users_to_change_password = var.aws_account_password_policy.allow_users_to_change 36 | max_password_age = var.aws_account_password_policy.max_age 37 | minimum_password_length = var.aws_account_password_policy.minimum_length 38 | password_reuse_prevention = var.aws_account_password_policy.reuse_prevention_history 39 | require_lowercase_characters = var.aws_account_password_policy.require_lowercase_characters 40 | require_numbers = var.aws_account_password_policy.require_numbers 41 | require_symbols = var.aws_account_password_policy.require_symbols 42 | require_uppercase_characters = var.aws_account_password_policy.require_uppercase_characters 43 | } 44 | 45 | resource "aws_ebs_encryption_by_default" "master" { 46 | enabled = var.aws_ebs_encryption_by_default 47 | } 48 | 49 | resource "aws_s3_account_public_access_block" "master" { 50 | block_public_acls = true 51 | block_public_policy = true 52 | ignore_public_acls = true 53 | restrict_public_buckets = true 54 | } 55 | -------------------------------------------------------------------------------- /audit_manager.tf: -------------------------------------------------------------------------------- 1 | resource "aws_auditmanager_account_registration" "default" { 2 | count = var.aws_auditmanager.enabled == true ? 1 : 0 3 | 4 | delegated_admin_account = data.aws_caller_identity.audit.account_id 5 | deregister_on_destroy = true 6 | kms_key = module.kms_key_audit.arn 7 | } 8 | 9 | module "audit_manager_reports" { 10 | count = var.aws_auditmanager.enabled == true ? 1 : 0 11 | providers = { aws = aws.audit } 12 | 13 | source = "schubergphilis/mcaf-s3/aws" 14 | version = "~> 0.14.1" 15 | 16 | kms_key_arn = module.kms_key_audit.arn 17 | name_prefix = var.aws_auditmanager.reports_bucket_prefix 18 | versioning = true 19 | 20 | lifecycle_rule = [ 21 | { 22 | id = "retention" 23 | enabled = true 24 | 25 | abort_incomplete_multipart_upload = { 26 | days_after_initiation = 7 27 | } 28 | 29 | noncurrent_version_expiration = { 30 | noncurrent_days = 90 31 | } 32 | 33 | noncurrent_version_transition = { 34 | noncurrent_days = 30 35 | storage_class = "ONEZONE_IA" 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /cloudtrail.tf: -------------------------------------------------------------------------------- 1 | #tfsec:ignore:AWS065 2 | resource "aws_cloudtrail" "additional_auditing_trail" { 3 | #checkov:skip=CKV_AWS_252: "Ensure CloudTrail defines an SNS Topic" 4 | #checkov:skip=CKV2_AWS_10: "Ensure CloudTrail trails are integrated with CloudWatch Logs" 5 | count = var.additional_auditing_trail != null ? 1 : 0 6 | 7 | name = var.additional_auditing_trail.name 8 | enable_log_file_validation = true 9 | is_multi_region_trail = true 10 | is_organization_trail = true 11 | s3_bucket_name = var.additional_auditing_trail.bucket 12 | kms_key_id = var.additional_auditing_trail.kms_key_id 13 | tags = var.tags 14 | 15 | dynamic "event_selector" { 16 | for_each = var.additional_auditing_trail.event_selector != null ? { create = true } : {} 17 | 18 | content { 19 | dynamic "data_resource" { 20 | for_each = var.additional_auditing_trail.event_selector.data_resource != null ? { create = true } : {} 21 | 22 | content { 23 | type = var.additional_auditing_trail.event_selector.data_resource.type 24 | values = var.additional_auditing_trail.event_selector.data_resource.values 25 | } 26 | } 27 | 28 | include_management_events = var.additional_auditing_trail.event_selector.include_management_events 29 | exclude_management_event_sources = var.additional_auditing_trail.event_selector.exclude_management_event_sources 30 | read_write_type = var.additional_auditing_trail.event_selector.read_write_type 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | aws_config_aggregators = flatten([ 3 | for account in toset(try(var.aws_config.aggregator_account_ids, [])) : [ 4 | for region in toset(try(local.all_organisation_regions, [])) : { 5 | account_id = account 6 | region = region 7 | } 8 | ] 9 | ]) 10 | 11 | aws_config_rules = setunion( 12 | try(var.aws_config.rule_identifiers, []), 13 | [ 14 | "CLOUD_TRAIL_ENABLED", 15 | "ENCRYPTED_VOLUMES", 16 | "ROOT_ACCOUNT_MFA_ENABLED", 17 | "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED" 18 | ] 19 | ) 20 | 21 | aws_config_s3_name = coalesce( 22 | var.aws_config.delivery_channel_s3_bucket_name, 23 | "aws-config-configuration-history-${var.control_tower_account_ids.logging}-${data.aws_region.current.name}" 24 | ) 25 | } 26 | 27 | // AWS Config - Management account configuration 28 | resource "aws_config_aggregate_authorization" "master" { 29 | for_each = { for aggregator in local.aws_config_aggregators : "${aggregator.account_id}-${aggregator.region}" => aggregator if aggregator.account_id != var.control_tower_account_ids.audit } 30 | 31 | account_id = each.value.account_id 32 | region = each.value.region 33 | tags = var.tags 34 | } 35 | 36 | resource "aws_config_aggregate_authorization" "master_to_audit" { 37 | for_each = local.all_organisation_regions 38 | 39 | account_id = var.control_tower_account_ids.audit 40 | region = each.value 41 | tags = var.tags 42 | } 43 | 44 | resource "aws_iam_service_linked_role" "config" { 45 | aws_service_name = "config.amazonaws.com" 46 | } 47 | 48 | resource "aws_config_organization_managed_rule" "default" { 49 | for_each = toset(local.aws_config_rules) 50 | 51 | name = each.value 52 | rule_identifier = each.value 53 | } 54 | 55 | // AWS Config - Audit account configuration 56 | resource "aws_config_configuration_aggregator" "audit" { 57 | provider = aws.audit 58 | 59 | name = "audit" 60 | tags = var.tags 61 | 62 | account_aggregation_source { 63 | account_ids = [ 64 | for account in data.aws_organizations_organization.default.accounts : account.id if account.id != var.control_tower_account_ids.audit 65 | ] 66 | all_regions = true 67 | } 68 | } 69 | 70 | resource "aws_config_aggregate_authorization" "audit" { 71 | for_each = { for aggregator in local.aws_config_aggregators : "${aggregator.account_id}-${aggregator.region}" => aggregator if aggregator.account_id != var.control_tower_account_ids.audit } 72 | provider = aws.audit 73 | 74 | account_id = each.value.account_id 75 | region = each.value.region 76 | tags = var.tags 77 | } 78 | 79 | resource "aws_sns_topic_subscription" "aws_config" { 80 | for_each = var.aws_config_sns_subscription 81 | provider = aws.audit 82 | 83 | endpoint = each.value.endpoint 84 | endpoint_auto_confirms = length(regexall("http", each.value.protocol)) > 0 85 | protocol = each.value.protocol 86 | topic_arn = "arn:aws:sns:${data.aws_region.current.name}:${var.control_tower_account_ids.audit}:aws-controltower-AggregateSecurityNotifications" 87 | } 88 | 89 | // AWS Config - Logging account configuration 90 | resource "aws_config_aggregate_authorization" "logging" { 91 | for_each = { for aggregator in local.aws_config_aggregators : "${aggregator.account_id}-${aggregator.region}" => aggregator if aggregator.account_id != var.control_tower_account_ids.audit } 92 | provider = aws.logging 93 | 94 | account_id = each.value.account_id 95 | region = each.value.region 96 | tags = var.tags 97 | } 98 | 99 | data "aws_iam_policy_document" "aws_config_s3" { 100 | statement { 101 | sid = "AWSConfigBucketPermissionsCheck" 102 | actions = ["s3:GetBucketAcl"] 103 | resources = ["arn:aws:s3:::${local.aws_config_s3_name}"] 104 | 105 | principals { 106 | type = "Service" 107 | identifiers = ["config.amazonaws.com"] 108 | } 109 | } 110 | 111 | statement { 112 | sid = "AWSConfigBucketExistenceCheck" 113 | actions = ["s3:ListBucket"] 114 | resources = ["arn:aws:s3:::${local.aws_config_s3_name}"] 115 | 116 | principals { 117 | type = "Service" 118 | identifiers = ["config.amazonaws.com"] 119 | } 120 | } 121 | 122 | statement { 123 | sid = "AllowConfigWriteAccess" 124 | actions = ["s3:PutObject"] 125 | resources = ["arn:aws:s3:::${local.aws_config_s3_name}/*"] 126 | 127 | principals { 128 | type = "Service" 129 | identifiers = ["config.amazonaws.com"] 130 | } 131 | 132 | condition { 133 | test = "StringEquals" 134 | variable = "s3:x-amz-acl" 135 | values = ["bucket-owner-full-control"] 136 | } 137 | } 138 | } 139 | 140 | module "aws_config_s3" { 141 | #checkov:skip=CKV_AWS_19: False positive, KMS key is used by default https://github.com/bridgecrewio/checkov/issues/3847 142 | #checkov:skip=CKV_AWS_145: False positive, KMS key is used by default https://github.com/bridgecrewio/checkov/issues/3847 143 | providers = { aws = aws.logging } 144 | 145 | source = "schubergphilis/mcaf-s3/aws" 146 | version = "~> 0.14.1" 147 | 148 | name = local.aws_config_s3_name 149 | kms_key_arn = module.kms_key_logging.arn 150 | policy = data.aws_iam_policy_document.aws_config_s3.json 151 | versioning = true 152 | tags = var.tags 153 | 154 | lifecycle_rule = [ 155 | { 156 | id = "retention" 157 | enabled = true 158 | 159 | abort_incomplete_multipart_upload = { 160 | days_after_initiation = 7 161 | } 162 | 163 | expiration = { 164 | days = 365 165 | } 166 | 167 | noncurrent_version_expiration = { 168 | noncurrent_days = 365 169 | } 170 | 171 | noncurrent_version_transition = { 172 | noncurrent_days = 90 173 | storage_class = "STANDARD_IA" 174 | } 175 | 176 | transition = { 177 | days = 90 178 | storage_class = "STANDARD_IA" 179 | } 180 | } 181 | ] 182 | } 183 | 184 | module "aws_config_recorder" { 185 | source = "./modules/aws-config-recorder" 186 | 187 | delivery_frequency = var.aws_config.delivery_frequency 188 | iam_service_linked_role_arn = aws_iam_service_linked_role.config.arn 189 | kms_key_arn = module.kms_key_logging.arn 190 | s3_bucket_name = module.aws_config_s3.name 191 | s3_key_prefix = var.aws_config.delivery_channel_s3_key_prefix 192 | sns_topic_arn = data.aws_sns_topic.all_config_notifications.arn 193 | } 194 | -------------------------------------------------------------------------------- /data.tf: -------------------------------------------------------------------------------- 1 | data "aws_caller_identity" "audit" { 2 | provider = aws.audit 3 | } 4 | 5 | data "aws_caller_identity" "logging" { 6 | provider = aws.logging 7 | } 8 | 9 | data "aws_caller_identity" "management" {} 10 | 11 | data "aws_cloudwatch_log_group" "cloudtrail_master" { 12 | count = var.monitor_iam_activity ? 1 : 0 13 | 14 | name = "aws-controltower/CloudTrailLogs" 15 | } 16 | 17 | data "aws_organizations_organization" "default" {} 18 | 19 | data "aws_organizations_organizational_units" "default" { 20 | parent_id = data.aws_organizations_organization.default.roots[0].id 21 | } 22 | 23 | data "aws_region" "current" {} 24 | 25 | data "aws_sns_topic" "all_config_notifications" { 26 | provider = aws.audit 27 | 28 | name = "aws-controltower-AllConfigNotifications" 29 | } 30 | 31 | data "mcaf_aws_all_organizational_units" "default" {} 32 | -------------------------------------------------------------------------------- /datadog.tf: -------------------------------------------------------------------------------- 1 | module "datadog_audit" { 2 | #checkov:skip=CKV_AWS_124: since this is managed by terraform, we reason that this already provides feedback and a seperate SNS topic is therefore not required 3 | count = try(var.datadog.enable_integration, false) == true ? 1 : 0 4 | providers = { aws = aws.audit } 5 | 6 | source = "schubergphilis/mcaf-datadog/aws" 7 | version = "~> 0.9.0" 8 | 9 | api_key = try(var.datadog.api_key, null) 10 | api_key_name = var.datadog.create_api_key ? "${var.datadog.api_key_name_prefix}${data.aws_caller_identity.audit.account_id}" : null 11 | create_api_key = var.datadog.create_api_key 12 | cspm_resource_collection_enabled = var.datadog.cspm_resource_collection_enabled 13 | excluded_regions = var.datadog_excluded_regions 14 | extended_resource_collection_enabled = var.datadog.extended_resource_collection_enabled 15 | install_log_forwarder = var.datadog.install_log_forwarder 16 | log_collection_services = var.datadog.log_collection_services 17 | log_forwarder_version = var.datadog.log_forwarder_version 18 | metric_tag_filters = var.datadog.metric_tag_filters 19 | namespace_rules = var.datadog.namespace_rules 20 | site_url = try(var.datadog.site_url, null) 21 | tags = var.tags 22 | } 23 | 24 | module "datadog_master" { 25 | #checkov:skip=CKV_AWS_124: since this is managed by terraform, we reason that this already provides feedback and a seperate SNS topic is therefore not required 26 | count = try(var.datadog.enable_integration, false) == true ? 1 : 0 27 | 28 | source = "schubergphilis/mcaf-datadog/aws" 29 | version = "~> 0.9.0" 30 | 31 | api_key = try(var.datadog.api_key, null) 32 | api_key_name = var.datadog.create_api_key ? "${var.datadog.api_key_name_prefix}${data.aws_caller_identity.management.account_id}" : null 33 | create_api_key = var.datadog.create_api_key 34 | cspm_resource_collection_enabled = var.datadog.cspm_resource_collection_enabled 35 | excluded_regions = var.datadog_excluded_regions 36 | extended_resource_collection_enabled = var.datadog.extended_resource_collection_enabled 37 | install_log_forwarder = var.datadog.install_log_forwarder 38 | log_collection_services = var.datadog.log_collection_services 39 | log_forwarder_version = var.datadog.log_forwarder_version 40 | metric_tag_filters = var.datadog.metric_tag_filters 41 | namespace_rules = var.datadog.namespace_rules 42 | site_url = try(var.datadog.site_url, null) 43 | tags = var.tags 44 | } 45 | 46 | module "datadog_logging" { 47 | #checkov:skip=CKV_AWS_124: since this is managed by terraform, we reason that this already provides feedback and a seperate SNS topic is therefore not required 48 | count = try(var.datadog.enable_integration, false) == true ? 1 : 0 49 | providers = { aws = aws.logging } 50 | 51 | source = "schubergphilis/mcaf-datadog/aws" 52 | version = "~> 0.9.0" 53 | 54 | api_key = try(var.datadog.api_key, null) 55 | api_key_name = var.datadog.create_api_key ? "${var.datadog.api_key_name_prefix}${data.aws_caller_identity.logging.account_id}" : null 56 | create_api_key = var.datadog.create_api_key 57 | cspm_resource_collection_enabled = var.datadog.cspm_resource_collection_enabled 58 | excluded_regions = var.datadog_excluded_regions 59 | extended_resource_collection_enabled = var.datadog.extended_resource_collection_enabled 60 | install_log_forwarder = var.datadog.install_log_forwarder 61 | log_collection_services = var.datadog.log_collection_services 62 | log_forwarder_version = var.datadog.log_forwarder_version 63 | metric_tag_filters = var.datadog.metric_tag_filters 64 | namespace_rules = var.datadog.namespace_rules 65 | site_url = try(var.datadog.site_url, null) 66 | tags = var.tags 67 | } 68 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # basic 2 | 3 | Terraform module to test basic functionality of `terraform-aws-mcaf-landing-zone` module. 4 | 5 | 6 | ## Requirements 7 | 8 | | Name | Version | 9 | |------|---------| 10 | | [terraform](#requirement\_terraform) | >= 1.3 | 11 | | [aws](#requirement\_aws) | >= 4.40.0 | 12 | | [datadog](#requirement\_datadog) | > 3.0.0 | 13 | | [mcaf](#requirement\_mcaf) | >= 0.4.2 | 14 | 15 | ## Providers 16 | 17 | No providers. 18 | 19 | ## Modules 20 | 21 | | Name | Source | Version | 22 | |------|--------|---------| 23 | | [landing\_zone](#module\_landing\_zone) | ../../ | n/a | 24 | 25 | ## Resources 26 | 27 | No resources. 28 | 29 | ## Inputs 30 | 31 | No inputs. 32 | 33 | ## Outputs 34 | 35 | No outputs. 36 | 37 | -------------------------------------------------------------------------------- /examples/basic/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | control_tower_account_ids = { 3 | audit = "012345678902" 4 | logging = "012345678903" 5 | } 6 | } 7 | 8 | provider "aws" { 9 | region = "eu-central-1" 10 | } 11 | 12 | provider "aws" { 13 | alias = "audit" 14 | region = "eu-central-1" 15 | 16 | assume_role { 17 | role_arn = "arn:aws:iam::${local.control_tower_account_ids.audit}:role/AWSControlTowerExecution" 18 | } 19 | } 20 | 21 | provider "aws" { 22 | alias = "logging" 23 | region = "eu-central-1" 24 | 25 | assume_role { 26 | role_arn = "arn:aws:iam::${local.control_tower_account_ids.logging}:role/AWSControlTowerExecution" 27 | } 28 | } 29 | 30 | provider "datadog" { 31 | validate = false 32 | } 33 | 34 | provider "mcaf" { 35 | aws {} 36 | } 37 | 38 | module "landing_zone" { 39 | providers = { aws = aws, aws.audit = aws.audit, aws.logging = aws.logging } 40 | 41 | source = "../../" 42 | 43 | control_tower_account_ids = local.control_tower_account_ids 44 | 45 | regions = { 46 | allowed_regions = ["eu-central-1"] 47 | home_region = "eu-central-1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/basic/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 5.54.0" 6 | configuration_aliases = [aws.audit, aws.logging] 7 | } 8 | datadog = { 9 | source = "datadog/datadog" 10 | version = ">= 3.39" 11 | } 12 | mcaf = { 13 | source = "schubergphilis/mcaf" 14 | version = ">= 0.4.2" 15 | } 16 | } 17 | required_version = ">= 1.9.0" 18 | } 19 | -------------------------------------------------------------------------------- /files/event_bridge/security_hub_findings.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "detail-type": ["Security Hub Findings - Imported"], 3 | "source": ["aws.securityhub"], 4 | "detail": { 5 | "findings": { 6 | "Severity": { 7 | "Label": [ 8 | "HIGH", 9 | "CRITICAL", 10 | "MEDIUM" 11 | ] 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /files/iam/monitor_iam_access_policy.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": "events:PutEvents", 7 | "Resource": "${event_bus_arn}" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /files/iam/service_assume_role.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Action": "sts:AssumeRole", 6 | "Principal": { 7 | "Service": "${service}" 8 | }, 9 | "Effect": "Allow", 10 | "Sid": "" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /files/organizations/cloudtrail_log_stream.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": { 4 | "Sid": "DenyDeletingCloudTrailLogStream", 5 | "Effect": "Deny", 6 | "Action": [ 7 | "logs:DeleteLogStream" 8 | ], 9 | "Resource": [ 10 | "arn:aws:logs:*:*:log-group:aws-controltower/CloudTrailLogs:*" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /files/organizations/deny_disabling_security_hub.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": { 4 | "Sid": "DenyDisablingSecurityHub", 5 | "Effect": "Deny", 6 | "Action": [ 7 | "securityhub:DeleteInvitations", 8 | "securityhub:DisableSecurityHub", 9 | "securityhub:DisassociateFromMasterAccount", 10 | "securityhub:DeleteMembers", 11 | "securityhub:DisassociateMembers", 12 | "securityhub:BatchDisableStandards" 13 | ], 14 | "Resource": "*", 15 | "Condition": { 16 | %{ if length(exceptions) > 0 ~} 17 | "ArnNotLike": { 18 | "aws:PrincipalARN": ${jsonencode(exceptions)} 19 | } 20 | %{ endif ~} 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /files/organizations/deny_leaving_org.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": { 4 | "Sid": "DenyLeavingOrg", 5 | "Effect": "Deny", 6 | "Action": "organizations:LeaveOrganization", 7 | "Resource": "*", 8 | "Condition": { 9 | %{ if length(exceptions) > 0 ~} 10 | "ArnNotLike": { 11 | "aws:PrincipalARN": ${jsonencode(exceptions)} 12 | } 13 | %{ endif ~} 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /files/organizations/deny_root_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "DenyRootUser", 6 | "Effect": "Deny", 7 | "Action": "*", 8 | "Resource": "*", 9 | "Condition": { 10 | "StringLike": { 11 | "aws:PrincipalArn": "arn:aws:iam::*:root" 12 | } 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /files/organizations/require_use_of_imdsv2.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "RequireAllEc2RolesToUseV2", 6 | "Effect": "Deny", 7 | "Action": "*", 8 | "Resource": "*", 9 | "Condition": { 10 | "NumericLessThan": { 11 | "ec2:RoleDelivery": "2.0" 12 | } 13 | } 14 | }, 15 | { 16 | "Sid": "RequireImdsV2", 17 | "Effect": "Deny", 18 | "Action": "ec2:RunInstances", 19 | "Resource": "arn:aws:ec2:*:*:instance/*", 20 | "Condition": { 21 | "StringNotEquals": { 22 | "ec2:MetadataHttpTokens": "required" 23 | } 24 | } 25 | }, 26 | { 27 | "Effect": "Deny", 28 | "Action": "ec2:ModifyInstanceMetadataOptions", 29 | "Resource": "*" 30 | }, 31 | { 32 | "Sid": "MaxImdsHopLimit", 33 | "Effect": "Deny", 34 | "Action": "ec2:RunInstances", 35 | "Resource": "arn:aws:ec2:*:*:instance/*", 36 | "Condition": { 37 | "NumericGreaterThan": { 38 | "ec2:MetadataHttpPutResponseHopLimit": "3" 39 | } 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /files/sns/iam_activity_topic_policy.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "__default_statement_ID", 6 | "Effect": "Allow", 7 | "Principal": { 8 | "AWS": "*" 9 | }, 10 | "Action": [ 11 | "SNS:Subscribe", 12 | "SNS:SetTopicAttributes", 13 | "SNS:RemovePermission", 14 | "SNS:Receive", 15 | "SNS:Publish", 16 | "SNS:ListSubscriptionsByTopic", 17 | "SNS:GetTopicAttributes", 18 | "SNS:DeleteTopic", 19 | "SNS:AddPermission" 20 | ], 21 | "Resource": "${sns_topic}", 22 | "Condition": { 23 | "StringEquals": { 24 | "AWS:SourceAccount": "${audit_account_id}" 25 | } 26 | } 27 | }, 28 | { 29 | "Sid": "AllowServicesToPublishFromMgmtAccount", 30 | "Effect": "Allow", 31 | "Principal": { 32 | "Service": ${services_allowed_publish} 33 | }, 34 | "Action": "sns:Publish", 35 | "Resource": "${sns_topic}", 36 | "Condition": { 37 | "StringEquals": { 38 | "AWS:SourceAccount": "${mgmt_account_id}" 39 | } 40 | } 41 | }, 42 | { 43 | "Sid": "AllowMgmtMasterToListSubcriptions", 44 | "Effect": "Allow", 45 | "Principal": { 46 | "AWS": "arn:aws:iam::${mgmt_account_id}:root" 47 | }, 48 | "Action": "sns:ListSubscriptionsByTopic", 49 | "Resource": "${sns_topic}" 50 | } 51 | %{ if length(security_hub_roles) > 0 ~} 52 | , 53 | { 54 | "Sid": "AllowListSubscribersBySecurityHub", 55 | "Effect": "Allow", 56 | "Principal": { 57 | "AWS": [ ${join(", ", security_hub_roles)} ] 58 | }, 59 | "Action": "sns:ListSubscriptionsByTopic", 60 | "Resource": "${sns_topic}" 61 | } 62 | %{ endif ~} 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /files/sns/security_hub_topic_policy.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "__default_statement_ID", 6 | "Effect": "Allow", 7 | "Principal": { 8 | "AWS": "*" 9 | }, 10 | "Action": [ 11 | "SNS:Subscribe", 12 | "SNS:SetTopicAttributes", 13 | "SNS:RemovePermission", 14 | "SNS:Receive", 15 | "SNS:Publish", 16 | "SNS:ListSubscriptionsByTopic", 17 | "SNS:GetTopicAttributes", 18 | "SNS:DeleteTopic", 19 | "SNS:AddPermission" 20 | ], 21 | "Resource": "${sns_topic}", 22 | "Condition": { 23 | "StringEquals": { 24 | "AWS:SourceAccount": "${account_id}" 25 | } 26 | } 27 | }, 28 | { 29 | "Sid": "__services_allowed_publish", 30 | "Effect": "Allow", 31 | "Principal": { 32 | "Service": ${services_allowed_publish} 33 | }, 34 | "Action": "sns:Publish", 35 | "Resource": "${sns_topic}" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /guardduty.tf: -------------------------------------------------------------------------------- 1 | // AWS GuardDuty - Management account configuration 2 | resource "aws_guardduty_organization_admin_account" "audit" { 3 | count = var.aws_guardduty.enabled == true ? 1 : 0 4 | 5 | admin_account_id = var.control_tower_account_ids.audit 6 | } 7 | 8 | // AWS GuardDuty - Audit account configuration 9 | resource "aws_guardduty_detector" "audit" { 10 | #checkov:skip=CKV_AWS_238: "Ensure that GuardDuty detector is enabled" - False positive, GuardDuty is enabled by default. 11 | #checkov:skip=CKV2_AWS_3: "Ensure GuardDuty is enabled to specific org/region" - False positive, GuardDuty is enabled by default. 12 | provider = aws.audit 13 | 14 | enable = var.aws_guardduty.enabled 15 | finding_publishing_frequency = var.aws_guardduty.finding_publishing_frequency 16 | tags = var.tags 17 | } 18 | 19 | resource "aws_guardduty_organization_configuration" "default" { 20 | count = var.aws_guardduty.enabled == true ? 1 : 0 21 | provider = aws.audit 22 | 23 | auto_enable_organization_members = var.aws_guardduty.enabled ? "ALL" : "NONE" 24 | detector_id = aws_guardduty_detector.audit.id 25 | 26 | depends_on = [aws_guardduty_organization_admin_account.audit] 27 | } 28 | 29 | resource "aws_guardduty_organization_configuration_feature" "ebs_malware_protection" { 30 | provider = aws.audit 31 | 32 | detector_id = aws_guardduty_detector.audit.id 33 | name = "EBS_MALWARE_PROTECTION" 34 | auto_enable = var.aws_guardduty.ebs_malware_protection_status == true ? "ALL" : "NONE" 35 | } 36 | 37 | resource "aws_guardduty_organization_configuration_feature" "eks_audit_logs" { 38 | provider = aws.audit 39 | 40 | detector_id = aws_guardduty_detector.audit.id 41 | name = "EKS_AUDIT_LOGS" 42 | auto_enable = var.aws_guardduty.eks_audit_logs_status == true ? "ALL" : "NONE" 43 | } 44 | 45 | resource "aws_guardduty_organization_configuration_feature" "lambda_network_logs" { 46 | provider = aws.audit 47 | 48 | detector_id = aws_guardduty_detector.audit.id 49 | name = "LAMBDA_NETWORK_LOGS" 50 | auto_enable = var.aws_guardduty.lambda_network_logs_status == true ? "ALL" : "NONE" 51 | } 52 | 53 | resource "aws_guardduty_organization_configuration_feature" "rds_login_events" { 54 | provider = aws.audit 55 | 56 | detector_id = aws_guardduty_detector.audit.id 57 | name = "RDS_LOGIN_EVENTS" 58 | auto_enable = var.aws_guardduty.rds_login_events_status == true ? "ALL" : "NONE" 59 | } 60 | 61 | resource "aws_guardduty_organization_configuration_feature" "s3_data_events" { 62 | provider = aws.audit 63 | 64 | detector_id = aws_guardduty_detector.audit.id 65 | name = "S3_DATA_EVENTS" 66 | auto_enable = var.aws_guardduty.s3_data_events_status == true ? "ALL" : "NONE" 67 | } 68 | 69 | resource "aws_guardduty_organization_configuration_feature" "runtime_monitoring" { 70 | provider = aws.audit 71 | 72 | detector_id = aws_guardduty_detector.audit.id 73 | name = "RUNTIME_MONITORING" 74 | auto_enable = var.aws_guardduty.runtime_monitoring_status.enabled == true ? "ALL" : "NONE" 75 | 76 | additional_configuration { 77 | name = "ECS_FARGATE_AGENT_MANAGEMENT" 78 | auto_enable = var.aws_guardduty.runtime_monitoring_status.ecs_fargate_agent_management_status == true ? "ALL" : "NONE" 79 | } 80 | 81 | additional_configuration { 82 | name = "EC2_AGENT_MANAGEMENT" 83 | auto_enable = var.aws_guardduty.runtime_monitoring_status.ec2_agent_management_status == true ? "ALL" : "NONE" 84 | } 85 | 86 | additional_configuration { 87 | name = "EKS_ADDON_MANAGEMENT" 88 | auto_enable = var.aws_guardduty.runtime_monitoring_status.eks_addon_management_status == true ? "ALL" : "NONE" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /iam_activity_logging.tf: -------------------------------------------------------------------------------- 1 | resource "aws_sns_topic" "iam_activity" { 2 | count = var.monitor_iam_activity ? 1 : 0 3 | provider = aws.audit 4 | 5 | name = "LandingZone-IAMActivity" 6 | http_success_feedback_role_arn = aws_iam_role.sns_feedback.arn 7 | http_failure_feedback_role_arn = aws_iam_role.sns_feedback.arn 8 | kms_master_key_id = module.kms_key_audit.id 9 | tags = var.tags 10 | } 11 | 12 | resource "aws_sns_topic_policy" "iam_activity" { 13 | count = var.monitor_iam_activity ? 1 : 0 14 | provider = aws.audit 15 | 16 | arn = aws_sns_topic.iam_activity[0].arn 17 | 18 | policy = templatefile("${path.module}/files/sns/iam_activity_topic_policy.json.tpl", { 19 | audit_account_id = data.aws_caller_identity.audit.account_id 20 | mgmt_account_id = data.aws_caller_identity.management.account_id 21 | services_allowed_publish = jsonencode("cloudwatch.amazonaws.com") 22 | sns_topic = aws_sns_topic.iam_activity[0].arn 23 | 24 | security_hub_roles = local.security_hub_has_cis_aws_foundations_enabled ? sort([ 25 | for account_id, _ in local.aws_account_emails : "\"arn:aws:sts::${account_id}:assumed-role/AWSServiceRoleForSecurityHub/securityhub\"" 26 | if account_id != var.control_tower_account_ids.audit 27 | ]) : [] 28 | }) 29 | } 30 | 31 | resource "aws_sns_topic_subscription" "iam_activity" { 32 | for_each = var.monitor_iam_activity ? var.monitor_iam_activity_sns_subscription : {} 33 | provider = aws.audit 34 | 35 | endpoint = each.value.endpoint 36 | endpoint_auto_confirms = length(regexall("http", each.value.protocol)) > 0 37 | protocol = each.value.protocol 38 | topic_arn = aws_sns_topic.iam_activity[0].arn 39 | } 40 | 41 | resource "aws_iam_role" "sns_feedback" { 42 | provider = aws.audit 43 | 44 | name = "LandingZone-SNSFeedback" 45 | path = var.path 46 | tags = var.tags 47 | 48 | assume_role_policy = templatefile("${path.module}/files/iam/service_assume_role.json.tpl", { 49 | service = "sns.amazonaws.com" 50 | }) 51 | } 52 | 53 | data "aws_iam_policy_document" "sns_feedback" { 54 | statement { 55 | sid = "SNSFeedbackPolicy" 56 | 57 | actions = [ 58 | "logs:CreateLogGroup", 59 | "logs:CreateLogStream", 60 | "logs:PutLogEvents", 61 | "logs:PutMetricFilter", 62 | "logs:PutRetentionPolicy" 63 | ] 64 | 65 | resources = compact([aws_sns_topic.security_hub_findings.arn, var.monitor_iam_activity ? aws_sns_topic.iam_activity[0].arn : null]) 66 | 67 | condition { 68 | test = "StringEquals" 69 | variable = "AWS:SourceAccount" 70 | values = [data.aws_caller_identity.audit.account_id] 71 | } 72 | } 73 | } 74 | 75 | resource "aws_iam_role_policy" "sns_feedback_policy" { 76 | provider = aws.audit 77 | 78 | name = "LandingZone-SNSFeedbackPolicy" 79 | policy = data.aws_iam_policy_document.sns_feedback.json 80 | role = aws_iam_role.sns_feedback.id 81 | } 82 | -------------------------------------------------------------------------------- /images/MCAF_landing_zone_tools_and_services_v040.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schubergphilis/terraform-aws-mcaf-landing-zone/d19518fcd8f94576aef0008f60f17f1a40d28f8f/images/MCAF_landing_zone_tools_and_services_v040.png -------------------------------------------------------------------------------- /inspector.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | inspector_members_account_ids = var.aws_inspector.enabled ? [ 3 | for account in data.aws_organizations_organization.default.accounts : account.id 4 | if account.id != var.control_tower_account_ids.audit 5 | && !contains(var.aws_inspector.excluded_member_account_ids, account.id) 6 | ] : [] 7 | 8 | inspector_enabled_resource_types = var.aws_inspector.enabled ? compact([ 9 | var.aws_inspector.enable_scan_ec2 ? "EC2" : "", 10 | var.aws_inspector.enable_scan_ecr ? "ECR" : "", 11 | var.aws_inspector.enable_scan_lambda ? "LAMBDA" : "", 12 | var.aws_inspector.enable_scan_lambda_code ? "LAMBDA_CODE" : "", 13 | ]) : [] 14 | } 15 | 16 | // Delegate the admin account to the audit account 17 | resource "aws_inspector2_delegated_admin_account" "default" { 18 | count = var.aws_inspector.enabled == true ? 1 : 0 19 | 20 | account_id = var.control_tower_account_ids.audit 21 | } 22 | 23 | // Activate Inspector in the audit account 24 | resource "aws_inspector2_enabler" "audit_account" { 25 | count = var.aws_inspector.enabled == true ? 1 : 0 26 | provider = aws.audit 27 | 28 | account_ids = [var.control_tower_account_ids.audit] 29 | resource_types = local.inspector_enabled_resource_types 30 | 31 | depends_on = [aws_inspector2_delegated_admin_account.default] 32 | } 33 | 34 | // Associate the member accounts with the audit account 35 | resource "aws_inspector2_member_association" "default" { 36 | for_each = toset(local.inspector_members_account_ids) 37 | provider = aws.audit 38 | 39 | account_id = each.value 40 | 41 | depends_on = [aws_inspector2_enabler.audit_account] 42 | } 43 | 44 | // Activate Inspector in the member accounts 45 | resource "aws_inspector2_enabler" "member_accounts" { 46 | count = var.aws_inspector.enabled == true ? 1 : 0 47 | provider = aws.audit 48 | 49 | account_ids = toset(local.inspector_members_account_ids) 50 | resource_types = local.inspector_enabled_resource_types 51 | 52 | timeouts { 53 | create = var.aws_inspector.resource_create_timeout 54 | } 55 | 56 | depends_on = [aws_inspector2_member_association.default] 57 | } 58 | 59 | // Auto-enable Inspector in the new member accounts 60 | resource "aws_inspector2_organization_configuration" "default" { 61 | count = var.aws_inspector.enabled == true ? 1 : 0 62 | provider = aws.audit 63 | 64 | auto_enable { 65 | ec2 = var.aws_inspector.enable_scan_ec2 66 | ecr = var.aws_inspector.enable_scan_ecr 67 | lambda = var.aws_inspector.enable_scan_lambda 68 | lambda_code = var.aws_inspector.enable_scan_lambda_code 69 | } 70 | 71 | depends_on = [aws_inspector2_enabler.member_accounts] 72 | } 73 | -------------------------------------------------------------------------------- /kms.tf: -------------------------------------------------------------------------------- 1 | # Management Account 2 | module "kms_key" { 3 | source = "schubergphilis/mcaf-kms/aws" 4 | version = "~> 0.3.0" 5 | 6 | name = "inception" 7 | description = "KMS key used in the master account" 8 | enable_key_rotation = true 9 | policy = data.aws_iam_policy_document.kms_key.json 10 | tags = var.tags 11 | } 12 | 13 | data "aws_iam_policy_document" "kms_key" { 14 | override_policy_documents = var.kms_key_policy 15 | 16 | statement { 17 | sid = "Base Permissions" 18 | actions = ["kms:*"] 19 | effect = "Allow" 20 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.management.account_id}:key/*"] 21 | 22 | principals { 23 | type = "AWS" 24 | identifiers = [ 25 | "arn:aws:iam::${data.aws_caller_identity.management.account_id}:root" 26 | ] 27 | } 28 | } 29 | 30 | dynamic "statement" { 31 | for_each = var.ses_root_accounts_mail_forward != null ? ["allow_ses"] : [] 32 | content { 33 | sid = "Allow SES Decrypt" 34 | effect = "Allow" 35 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.management.account_id}:key/*"] 36 | 37 | actions = [ 38 | "kms:Decrypt", 39 | "kms:GenerateDataKey*" 40 | ] 41 | 42 | principals { 43 | type = "Service" 44 | identifiers = [ 45 | "ses.amazonaws.com" 46 | ] 47 | } 48 | } 49 | } 50 | 51 | dynamic "statement" { 52 | for_each = var.ses_root_accounts_mail_forward != null ? ["allow_cw_loggroup_email_forwarder"] : [] 53 | content { 54 | sid = "Allow EmailForwarder CloudWatch Log Group" 55 | effect = "Allow" 56 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.management.account_id}:key/*"] 57 | 58 | actions = [ 59 | "kms:Decrypt", 60 | "kms:Describe*", 61 | "kms:Encrypt", 62 | "kms:GenerateDataKey*", 63 | "kms:ReEncrypt*" 64 | ] 65 | 66 | condition { 67 | test = "ArnLike" 68 | variable = "kms:EncryptionContext:aws:logs:arn" 69 | 70 | values = [ 71 | "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.management.account_id}:log-group:/aws/lambda/EmailForwarder" 72 | ] 73 | } 74 | 75 | principals { 76 | type = "Service" 77 | identifiers = [ 78 | "logs.${data.aws_region.current.name}.amazonaws.com" 79 | ] 80 | } 81 | } 82 | } 83 | } 84 | 85 | # Audit Account 86 | module "kms_key_audit" { 87 | providers = { aws = aws.audit } 88 | 89 | source = "schubergphilis/mcaf-kms/aws" 90 | version = "~> 0.3.0" 91 | 92 | name = "audit" 93 | description = "KMS key used for encrypting audit-related data" 94 | enable_key_rotation = true 95 | policy = data.aws_iam_policy_document.kms_key_audit.json 96 | tags = var.tags 97 | } 98 | 99 | data "aws_iam_policy_document" "kms_key_audit" { 100 | source_policy_documents = var.kms_key_policy_audit 101 | 102 | statement { 103 | sid = "Full permissions for the root user only" 104 | actions = ["kms:*"] 105 | effect = "Allow" 106 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.audit.account_id}:key/*"] 107 | 108 | condition { 109 | test = "StringEquals" 110 | variable = "aws:PrincipalType" 111 | values = ["Account"] 112 | } 113 | 114 | principals { 115 | type = "AWS" 116 | identifiers = [ 117 | "arn:aws:iam::${data.aws_caller_identity.audit.account_id}:root" 118 | ] 119 | } 120 | } 121 | 122 | statement { 123 | sid = "Administrative permissions for pipeline" 124 | effect = "Allow" 125 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.audit.account_id}:key/*"] 126 | 127 | actions = [ 128 | "kms:Create*", 129 | "kms:Describe*", 130 | "kms:Enable*", 131 | "kms:GenerateDataKey*", 132 | "kms:Get*", 133 | "kms:List*", 134 | "kms:Put*", 135 | "kms:Revoke*", 136 | "kms:TagResource", 137 | "kms:UntagResource", 138 | "kms:Update*" 139 | ] 140 | 141 | principals { 142 | type = "AWS" 143 | identifiers = [ 144 | "arn:aws:iam::${data.aws_caller_identity.audit.account_id}:role/AWSControlTowerExecution" 145 | ] 146 | } 147 | } 148 | 149 | statement { 150 | sid = "List KMS keys permissions for all IAM users" 151 | effect = "Allow" 152 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.audit.account_id}:key/*"] 153 | 154 | actions = [ 155 | "kms:Describe*", 156 | "kms:Get*", 157 | "kms:List*" 158 | ] 159 | 160 | principals { 161 | type = "AWS" 162 | identifiers = [ 163 | "arn:aws:iam::${data.aws_caller_identity.audit.account_id}:root" 164 | ] 165 | } 166 | } 167 | 168 | statement { 169 | sid = "Allow CloudWatch Decrypt" 170 | effect = "Allow" 171 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.audit.account_id}:key/*"] 172 | 173 | actions = [ 174 | "kms:Decrypt", 175 | "kms:GenerateDataKey" 176 | ] 177 | 178 | principals { 179 | type = "Service" 180 | identifiers = [ 181 | "cloudwatch.amazonaws.com", 182 | "events.amazonaws.com" 183 | ] 184 | } 185 | } 186 | 187 | statement { 188 | sid = "Allow SNS Decrypt" 189 | effect = "Allow" 190 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.audit.account_id}:key/*"] 191 | 192 | actions = [ 193 | "kms:Decrypt", 194 | "kms:GenerateDataKey" 195 | ] 196 | 197 | principals { 198 | type = "Service" 199 | identifiers = [ 200 | "sns.amazonaws.com" 201 | ] 202 | } 203 | } 204 | 205 | dynamic "statement" { 206 | for_each = var.aws_auditmanager.enabled ? ["allow_audit_manager"] : [] 207 | 208 | content { 209 | sid = "Allow Audit Manager from management to describe and grant" 210 | effect = "Allow" 211 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.audit.account_id}:key/*"] 212 | 213 | actions = [ 214 | "kms:CreateGrant", 215 | "kms:DescribeKey" 216 | ] 217 | 218 | principals { 219 | type = "AWS" 220 | identifiers = [ 221 | "arn:aws:iam::${data.aws_caller_identity.management.account_id}:root" 222 | ] 223 | } 224 | 225 | condition { 226 | test = "Bool" 227 | variable = "kms:ViaService" 228 | 229 | values = [ 230 | "auditmanager.amazonaws.com" 231 | ] 232 | } 233 | } 234 | } 235 | 236 | dynamic "statement" { 237 | for_each = var.aws_auditmanager.enabled ? ["allow_audit_manager"] : [] 238 | content { 239 | sid = "Encrypt and Decrypt permissions for S3" 240 | effect = "Allow" 241 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.management.account_id}:key/*"] 242 | 243 | actions = [ 244 | "kms:Encrypt", 245 | "kms:Decrypt", 246 | "kms:ReEncrypt*", 247 | "kms:GenerateDataKey*" 248 | ] 249 | 250 | principals { 251 | type = "AWS" 252 | identifiers = [ 253 | "arn:aws:iam::${data.aws_caller_identity.management.account_id}:root" 254 | ] 255 | } 256 | 257 | condition { 258 | test = "StringLike" 259 | variable = "kms:ViaService" 260 | values = [ 261 | "s3.${data.aws_region.current.name}.amazonaws.com", 262 | ] 263 | } 264 | } 265 | } 266 | } 267 | 268 | # Logging Account 269 | module "kms_key_logging" { 270 | providers = { aws = aws.logging } 271 | 272 | source = "schubergphilis/mcaf-kms/aws" 273 | version = "~> 0.3.0" 274 | 275 | name = "logging" 276 | description = "KMS key to use with logging account" 277 | enable_key_rotation = true 278 | policy = data.aws_iam_policy_document.kms_key_logging.json 279 | tags = var.tags 280 | } 281 | 282 | data "aws_iam_policy_document" "kms_key_logging" { 283 | source_policy_documents = var.kms_key_policy_logging 284 | 285 | statement { 286 | sid = "Full permissions for the root user only" 287 | actions = ["kms:*"] 288 | effect = "Allow" 289 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.logging.account_id}:key/*"] 290 | 291 | condition { 292 | test = "StringEquals" 293 | variable = "aws:PrincipalType" 294 | values = ["Account"] 295 | } 296 | 297 | principals { 298 | type = "AWS" 299 | identifiers = [ 300 | "arn:aws:iam::${data.aws_caller_identity.logging.account_id}:root" 301 | ] 302 | } 303 | } 304 | 305 | statement { 306 | sid = "Administrative permissions for pipeline" 307 | effect = "Allow" 308 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.logging.account_id}:key/*"] 309 | 310 | actions = [ 311 | "kms:Create*", 312 | "kms:Describe*", 313 | "kms:Enable*", 314 | "kms:Get*", 315 | "kms:List*", 316 | "kms:Put*", 317 | "kms:Revoke*", 318 | "kms:TagResource", 319 | "kms:UntagResource", 320 | "kms:Update*" 321 | ] 322 | 323 | principals { 324 | type = "AWS" 325 | identifiers = [ 326 | "arn:aws:iam::${data.aws_caller_identity.logging.account_id}:role/AWSControlTowerExecution" 327 | ] 328 | } 329 | } 330 | 331 | statement { 332 | sid = "List KMS keys permissions for all IAM users" 333 | effect = "Allow" 334 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.logging.account_id}:key/*"] 335 | 336 | actions = [ 337 | "kms:Describe*", 338 | "kms:Get*", 339 | "kms:List*" 340 | ] 341 | 342 | principals { 343 | type = "AWS" 344 | identifiers = [ 345 | "arn:aws:iam::${data.aws_caller_identity.logging.account_id}:root" 346 | ] 347 | } 348 | } 349 | 350 | statement { 351 | sid = "KMS permissions for AWS logs service" 352 | effect = "Allow" 353 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.logging.account_id}:key/*"] 354 | 355 | actions = [ 356 | "kms:Encrypt", 357 | "kms:Decrypt", 358 | "kms:ReEncrypt*", 359 | "kms:GenerateDataKey*", 360 | "kms:DescribeKey", 361 | ] 362 | 363 | principals { 364 | type = "Service" 365 | identifiers = ["logs.${data.aws_region.current.name}.amazonaws.com"] 366 | } 367 | } 368 | 369 | statement { 370 | sid = "AllowAWSConfigToEncryptDecryptLogs" 371 | effect = "Allow" 372 | resources = ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.logging.account_id}:key/*"] 373 | 374 | actions = [ 375 | "kms:Decrypt", 376 | "kms:GenerateDataKey*" 377 | ] 378 | 379 | principals { 380 | type = "Service" 381 | identifiers = [ 382 | "config.amazonaws.com" 383 | ] 384 | } 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | aws_account_emails = { for account in data.aws_organizations_organization.default.accounts : account.id => account.email } 3 | 4 | iam_activity = { 5 | SSO = "{$.readOnly IS FALSE && $.userIdentity.sessionContext.sessionIssuer.userName = \"AWSReservedSSO_*\" && $.eventName != \"ConsoleLogin\"}" 6 | } 7 | 8 | cloudtrail_activity_cis_aws_foundations = (local.security_hub_has_cis_aws_foundations_enabled && var.aws_security_hub.create_cis_metric_filters) ? { 9 | RootActivity = "{$.userIdentity.type=\"Root\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != \"AwsServiceEvent\"}" 10 | UnauthorizedApiCalls = "{($.errorCode=\"*UnauthorizedOperation\") || ($.errorCode=\"AccessDenied*\")}" 11 | IamPolicyChanges = "{($.eventName=DeleteGroupPolicy) || ($.eventName=DeleteRolePolicy) || ($.eventName=DeleteUserPolicy) || ($.eventName=PutGroupPolicy) || ($.eventName=PutRolePolicy) || ($.eventName=PutUserPolicy) || ($.eventName=CreatePolicy) || ($.eventName=DeletePolicy) || ($.eventName=CreatePolicyVersion) || ($.eventName=DeletePolicyVersion) || ($.eventName=AttachRolePolicy) || ($.eventName=DetachRolePolicy) || ($.eventName=AttachUserPolicy) || ($.eventName=DetachUserPolicy) || ($.eventName=AttachGroupPolicy) || ($.eventName=DetachGroupPolicy)}" 12 | CloudTrailConfigChange = "{($.eventName=CreateTrail) || ($.eventName=UpdateTrail) || ($.eventName=DeleteTrail) || ($.eventName=StartLogging) || ($.eventName=StopLogging)}" 13 | ManagementConsoleAuthFailure = "{($.eventName=ConsoleLogin) && ($.errorMessage=\"Failed authentication\")}" 14 | KmsKeyDisableOrDeletion = "{($.eventSource=kms.amazonaws.com) && (($.eventName=DisableKey) || ($.eventName=ScheduleKeyDeletion))}" 15 | S3BucketPolicyChange = "{($.eventSource=s3.amazonaws.com) && (($.eventName=PutBucketAcl) || ($.eventName=PutBucketPolicy) || ($.eventName=PutBucketCors) || ($.eventName=PutBucketLifecycle) || ($.eventName=PutBucketReplication) || ($.eventName=DeleteBucketPolicy) || ($.eventName=DeleteBucketCors) || ($.eventName=DeleteBucketLifecycle) || ($.eventName=DeleteBucketReplication))}" 16 | AwsConfigConfigChange = "{($.eventSource=config.amazonaws.com) && (($.eventName=StopConfigurationRecorder) || ($.eventName=DeleteDeliveryChannel) || ($.eventName=PutDeliveryChannel) || ($.eventName=PutConfigurationRecorder))}" 17 | SecurityGroupChange = "{($.eventName=AuthorizeSecurityGroupIngress) || ($.eventName=AuthorizeSecurityGroupEgress) || ($.eventName=RevokeSecurityGroupIngress) || ($.eventName=RevokeSecurityGroupEgress) || ($.eventName=CreateSecurityGroup) || ($.eventName=DeleteSecurityGroup)}" 18 | NaclChange = "{($.eventName=CreateNetworkAcl) || ($.eventName=CreateNetworkAclEntry) || ($.eventName=DeleteNetworkAcl) || ($.eventName=DeleteNetworkAclEntry) || ($.eventName=ReplaceNetworkAclEntry) || ($.eventName=ReplaceNetworkAclAssociation)}" 19 | NetworkGatewayChange = "{($.eventName=CreateCustomerGateway) || ($.eventName=DeleteCustomerGateway) || ($.eventName=AttachInternetGateway) || ($.eventName=CreateInternetGateway) || ($.eventName=DeleteInternetGateway) || ($.eventName=DetachInternetGateway)}" 20 | RouteTableChange = "{($.eventName=CreateRoute) || ($.eventName=CreateRouteTable) || ($.eventName=ReplaceRoute) || ($.eventName=ReplaceRouteTableAssociation) || ($.eventName=DeleteRouteTable) || ($.eventName=DeleteRoute) || ($.eventName=DisassociateRouteTable)}" 21 | VpcChange = "{($.eventName=CreateVpc) || ($.eventName=DeleteVpc) || ($.eventName=ModifyVpcAttribute) || ($.eventName=AcceptVpcPeeringConnection) || ($.eventName=CreateVpcPeeringConnection) || ($.eventName=DeleteVpcPeeringConnection) || ($.eventName=RejectVpcPeeringConnection) || ($.eventName=AttachClassicLinkVpc) || ($.eventName=DetachClassicLinkVpc) || ($.eventName=DisableVpcClassicLink) || ($.eventName=EnableVpcClassicLink)}" 22 | } : {} 23 | 24 | aws_service_control_policies_principal_exceptions = distinct(concat(var.aws_service_control_policies.principal_exceptions, ["arn:aws:iam::*:role/AWSControlTowerExecution"])) 25 | 26 | security_hub_standards_arns_default = [ 27 | "arn:aws:securityhub:${data.aws_region.current.name}::standards/aws-foundational-security-best-practices/v/1.0.0", 28 | "arn:aws:securityhub:${data.aws_region.current.name}::standards/cis-aws-foundations-benchmark/v/1.4.0", 29 | "arn:aws:securityhub:${data.aws_region.current.name}::standards/pci-dss/v/3.2.1" 30 | ] 31 | 32 | security_hub_standards_arns = var.aws_security_hub.standards_arns != null ? var.aws_security_hub.standards_arns : local.security_hub_standards_arns_default 33 | 34 | security_hub_has_cis_aws_foundations_enabled = length(regexall( 35 | "cis-aws-foundations-benchmark/v", join(",", local.security_hub_standards_arns) 36 | )) > 0 ? true : false 37 | all_organisation_regions = toset(distinct(concat([var.regions.home_region], var.regions.linked_regions, var.regions.allowed_regions, [data.aws_region.current.name]))) 38 | } 39 | -------------------------------------------------------------------------------- /modules/aws-config-recorder/README.md: -------------------------------------------------------------------------------- 1 | # AWS Config Recorder 2 | 3 | Terraform module to create an AWS Config Recorder. 4 | 5 | 6 | ## Requirements 7 | 8 | | Name | Version | 9 | |------|---------| 10 | | [terraform](#requirement\_terraform) | >= 1.3 | 11 | | [aws](#requirement\_aws) | >= 4.9.0 | 12 | 13 | ## Providers 14 | 15 | | Name | Version | 16 | |------|---------| 17 | | [aws](#provider\_aws) | >= 4.9.0 | 18 | 19 | ## Modules 20 | 21 | No modules. 22 | 23 | ## Resources 24 | 25 | | Name | Type | 26 | |------|------| 27 | | [aws_config_configuration_recorder.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/config_configuration_recorder) | resource | 28 | | [aws_config_configuration_recorder_status.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/config_configuration_recorder_status) | resource | 29 | | [aws_config_delivery_channel.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/config_delivery_channel) | resource | 30 | 31 | ## Inputs 32 | 33 | | Name | Description | Type | Default | Required | 34 | |------|-------------|------|---------|:--------:| 35 | | [iam\_service\_linked\_role\_arn](#input\_iam\_service\_linked\_role\_arn) | The ARN for the AWS Config IAM Service Linked Role | `string` | n/a | yes | 36 | | [kms\_key\_arn](#input\_kms\_key\_arn) | The ARN of the KMS key for AWS Config | `string` | n/a | yes | 37 | | [s3\_bucket\_name](#input\_s3\_bucket\_name) | The name of the S3 bucket for AWS Config | `string` | n/a | yes | 38 | | [s3\_key\_prefix](#input\_s3\_key\_prefix) | The S3 key prefix for AWS Config | `string` | n/a | yes | 39 | | [sns\_topic\_arn](#input\_sns\_topic\_arn) | The ARN of the SNS topic that AWS Config delivers notifications to. | `string` | n/a | yes | 40 | | [delivery\_frequency](#input\_delivery\_frequency) | The frequency with which AWS Config recurringly delivers configuration snapshots | `string` | `"TwentyFour_Hours"` | no | 41 | 42 | ## Outputs 43 | 44 | No outputs. 45 | -------------------------------------------------------------------------------- /modules/aws-config-recorder/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_config_configuration_recorder" "default" { 2 | name = "default" 3 | role_arn = var.iam_service_linked_role_arn 4 | 5 | recording_group { 6 | all_supported = true 7 | include_global_resource_types = true 8 | } 9 | } 10 | 11 | resource "aws_config_delivery_channel" "default" { 12 | name = "default" 13 | s3_bucket_name = var.s3_bucket_name 14 | s3_key_prefix = var.s3_key_prefix 15 | s3_kms_key_arn = var.kms_key_arn 16 | sns_topic_arn = var.sns_topic_arn 17 | 18 | snapshot_delivery_properties { 19 | delivery_frequency = var.delivery_frequency 20 | } 21 | 22 | depends_on = [aws_config_configuration_recorder.default] 23 | } 24 | 25 | resource "aws_config_configuration_recorder_status" "default" { 26 | name = aws_config_configuration_recorder.default.name 27 | is_enabled = true 28 | depends_on = [aws_config_delivery_channel.default] 29 | } 30 | -------------------------------------------------------------------------------- /modules/aws-config-recorder/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 4.9.0" 6 | } 7 | } 8 | required_version = ">= 1.3" 9 | } 10 | -------------------------------------------------------------------------------- /modules/aws-config-recorder/variables.tf: -------------------------------------------------------------------------------- 1 | variable "delivery_frequency" { 2 | type = string 3 | description = "The frequency with which AWS Config recurringly delivers configuration snapshots" 4 | default = "TwentyFour_Hours" 5 | } 6 | 7 | variable "iam_service_linked_role_arn" { 8 | type = string 9 | description = "The ARN for the AWS Config IAM Service Linked Role" 10 | } 11 | 12 | variable "kms_key_arn" { 13 | type = string 14 | description = "The ARN of the KMS key for AWS Config" 15 | } 16 | 17 | variable "s3_bucket_name" { 18 | type = string 19 | description = "The name of the S3 bucket for AWS Config" 20 | } 21 | 22 | variable "s3_key_prefix" { 23 | type = string 24 | description = "The S3 key prefix for AWS Config" 25 | } 26 | 27 | variable "sns_topic_arn" { 28 | type = string 29 | description = "The ARN of the SNS topic that AWS Config delivers notifications to." 30 | } 31 | -------------------------------------------------------------------------------- /modules/permission-set/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /modules/permission-set/README.md: -------------------------------------------------------------------------------- 1 | # AWS IAM Identity Center Permission Set 2 | 3 | Terraform module to create and/or manage a permission set in AWS IAM Identity Center (previously known as AWS SSO). 4 | 5 | ## Usage 6 | 7 | This module creates a permission set, or uses an existing permission set, and manages its account and group assignments. 8 | 9 | To just create a permissions set for it to be managed elsewhere: 10 | 11 | ```hcl 12 | module "permission_set" { 13 | source = "https://github.com/schubergphilis/terraform-aws-mcaf-landing-zone//modules/permission-set" 14 | 15 | name = "PlatformAdmin" 16 | managed_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"] 17 | } 18 | ``` 19 | 20 | Use `assignments` to assign this accounts and groups combinations to the created permission set: 21 | 22 | ```hcl 23 | module "permission_set" { 24 | source = "https://github.com/schubergphilis/terraform-aws-mcaf-landing-zone//modules/permission-set" 25 | 26 | name = "PlatformAdmin" 27 | managed_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"] 28 | 29 | assignments = [ 30 | { 31 | for account in ["11111111111", "22222222222", "33333333333"] : 32 | account => ["SSOgroup1"] 33 | }, 34 | ] 35 | } 36 | ``` 37 | 38 | To manage an existing permission set, set `create` to `false`: 39 | 40 | ```hcl 41 | module "permission_set" { 42 | source = "https://github.com/schubergphilis/terraform-aws-mcaf-landing-zone//modules/permission-set" 43 | 44 | name = "PlatformAdmin" 45 | create = false 46 | managed_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"] 47 | 48 | assignments = [ 49 | { 50 | for account in ["11111111111", "22222222222", "33333333333"] : 51 | account => ["SSOgroup1"] 52 | }, 53 | ] 54 | } 55 | ``` 56 | 57 | Add additional assignments to reuse the permission set across different account and SSO group combinations: 58 | 59 | ```hcl 60 | module "permission_set" { 61 | source = "https://github.com/schubergphilis/terraform-aws-mcaf-landing-zone//modules/permission-set" 62 | 63 | name = "PlatformAdmin" 64 | managed_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"] 65 | 66 | assignments = [ 67 | { 68 | for account in ["11111111111", "22222222222", "33333333333"] : 69 | account => ["SSOgroup1"] 70 | }, 71 | { 72 | for account in ["33333333333", "44444444444"] : 73 | account => ["SSOgroup2"] 74 | }, 75 | ] 76 | } 77 | ``` 78 | 79 | It's also possible to create your own inline policy instead if more fine grained access is required: 80 | 81 | ```hcl 82 | module "permission_set" { 83 | source = "https://github.com/schubergphilis/terraform-aws-mcaf-landing-zone//modules/permission-set" 84 | 85 | name = "PlatformAdmin" 86 | managed_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"] 87 | 88 | assignments = [ 89 | { 90 | for account in ["11111111111", "22222222222", "33333333333"] : 91 | account => ["SSOgroup1"] 92 | }, 93 | ] 94 | 95 | inline_policy = jsonencode({ 96 | "Version" = "2012-10-17" 97 | "Statement" = jsondecode(file("${path.module}/templates/sso_platform_admin.json.tftpl")) 98 | }) 99 | } 100 | ``` 101 | 102 | 103 | ## Requirements 104 | 105 | | Name | Version | 106 | |------|---------| 107 | | terraform | >= 1.3 | 108 | | aws | >= 4.9.0 | 109 | 110 | ## Providers 111 | 112 | | Name | Version | 113 | |------|---------| 114 | | aws | >= 4.9.0 | 115 | 116 | ## Inputs 117 | 118 | | Name | Description | Type | Default | Required | 119 | |------|-------------|------|---------|:--------:| 120 | | name | Name of the permission set | `string` | n/a | yes | 121 | | assignments | List of account IDs and Identity Center groups to assign to the permission set | `list(map(list(string)))` | `[]` | no | 122 | | create | Set to false to only manage assignments when the permission set already exists | `bool` | `true` | no | 123 | | inline\_policy | The IAM inline policy to attach to a permission set | `string` | `null` | no | 124 | | managed\_policy\_arns | List of IAM managed policy ARNs to be attached to the permission set | `list(string)` | `[]` | no | 125 | | module\_depends\_on | A list of external resources the module depends\_on | `any` | `[]` | no | 126 | | session\_duration | The length of time that the application user sessions are valid in the ISO-8601 standard | `string` | `"PT4H"` | no | 127 | 128 | ## Outputs 129 | 130 | No output. 131 | 132 | 133 | 134 | ## License 135 | 136 | **Copyright:** Schuberg Philis 137 | 138 | ```text 139 | Licensed under the Apache License, Version 2.0 (the "License"); 140 | you may not use this file except in compliance with the License. 141 | You may obtain a copy of the License at 142 | http://www.apache.org/licenses/LICENSE-2.0 143 | Unless required by applicable law or agreed to in writing, software 144 | distributed under the License is distributed on an "AS IS" BASIS, 145 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 146 | See the License for the specific language governing permissions and 147 | limitations under the License. 148 | ``` 149 | 150 | 151 | ## Requirements 152 | 153 | | Name | Version | 154 | |------|---------| 155 | | [terraform](#requirement\_terraform) | >= 1.3 | 156 | | [aws](#requirement\_aws) | >= 4.9.0 | 157 | 158 | ## Providers 159 | 160 | | Name | Version | 161 | |------|---------| 162 | | [aws](#provider\_aws) | >= 4.9.0 | 163 | 164 | ## Modules 165 | 166 | No modules. 167 | 168 | ## Resources 169 | 170 | | Name | Type | 171 | |------|------| 172 | | [aws_ssoadmin_account_assignment.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssoadmin_account_assignment) | resource | 173 | | [aws_ssoadmin_managed_policy_attachment.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssoadmin_managed_policy_attachment) | resource | 174 | | [aws_ssoadmin_permission_set.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssoadmin_permission_set) | resource | 175 | | [aws_ssoadmin_permission_set_inline_policy.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssoadmin_permission_set_inline_policy) | resource | 176 | | [aws_identitystore_group.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/identitystore_group) | data source | 177 | | [aws_ssoadmin_instances.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssoadmin_instances) | data source | 178 | | [aws_ssoadmin_permission_set.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssoadmin_permission_set) | data source | 179 | 180 | ## Inputs 181 | 182 | | Name | Description | Type | Default | Required | 183 | |------|-------------|------|---------|:--------:| 184 | | [name](#input\_name) | Name of the permission set | `string` | n/a | yes | 185 | | [assignments](#input\_assignments) | List of account names and IDs and Identity Center groups to assign to the permission set |
list(object({
account_id = string
account_name = string
sso_groups = list(string)
}))
| `[]` | no | 186 | | [create](#input\_create) | Set to false to only manage assignments when the permission set already exists | `bool` | `true` | no | 187 | | [inline\_policy](#input\_inline\_policy) | The IAM inline policy to attach to a permission set | `string` | `null` | no | 188 | | [managed\_policy\_arns](#input\_managed\_policy\_arns) | List of IAM managed policy ARNs to be attached to the permission set | `list(string)` | `[]` | no | 189 | | [module\_depends\_on](#input\_module\_depends\_on) | A list of external resources the module depends\_on | `any` | `[]` | no | 190 | | [session\_duration](#input\_session\_duration) | The length of time that the application user sessions are valid in the ISO-8601 standard | `string` | `"PT4H"` | no | 191 | 192 | ## Outputs 193 | 194 | No outputs. 195 | -------------------------------------------------------------------------------- /modules/permission-set/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | aws_sso_account_assignments = flatten([ 3 | for assignment in var.assignments : [ 4 | for sso_group in assignment.sso_groups : { 5 | aws_account_id = assignment.account_id 6 | aws_account_name = assignment.account_name 7 | sso_group = sso_group 8 | } 9 | ] 10 | ]) 11 | } 12 | 13 | data "aws_ssoadmin_instances" "default" {} 14 | 15 | data "aws_identitystore_group" "default" { 16 | for_each = toset(flatten([ 17 | for assignment in var.assignments : assignment.sso_groups 18 | ])) 19 | 20 | identity_store_id = tolist(data.aws_ssoadmin_instances.default.identity_store_ids)[0] 21 | 22 | alternate_identifier { 23 | unique_attribute { 24 | attribute_path = "DisplayName" 25 | attribute_value = each.value 26 | } 27 | } 28 | 29 | depends_on = [ 30 | var.module_depends_on 31 | ] 32 | } 33 | 34 | data "aws_ssoadmin_permission_set" "default" { 35 | count = var.create ? 0 : 1 36 | 37 | instance_arn = tolist(data.aws_ssoadmin_instances.default.arns)[0] 38 | name = var.name 39 | 40 | depends_on = [ 41 | var.module_depends_on 42 | ] 43 | } 44 | 45 | resource "aws_ssoadmin_permission_set" "default" { 46 | count = var.create ? 1 : 0 47 | 48 | name = var.name 49 | instance_arn = tolist(data.aws_ssoadmin_instances.default.arns)[0] 50 | session_duration = var.session_duration 51 | 52 | depends_on = [ 53 | var.module_depends_on 54 | ] 55 | } 56 | 57 | resource "aws_ssoadmin_account_assignment" "default" { 58 | for_each = { 59 | for assignment in local.aws_sso_account_assignments : 60 | "${assignment.sso_group}:${assignment.aws_account_name}" => assignment 61 | } 62 | 63 | instance_arn = var.create ? aws_ssoadmin_permission_set.default[0].instance_arn : data.aws_ssoadmin_permission_set.default[0].instance_arn 64 | permission_set_arn = var.create ? aws_ssoadmin_permission_set.default[0].arn : data.aws_ssoadmin_permission_set.default[0].arn 65 | principal_id = data.aws_identitystore_group.default[each.value.sso_group].group_id 66 | principal_type = "GROUP" 67 | target_id = each.value.aws_account_id 68 | target_type = "AWS_ACCOUNT" 69 | 70 | depends_on = [ 71 | var.module_depends_on 72 | ] 73 | } 74 | 75 | resource "aws_ssoadmin_permission_set_inline_policy" "default" { 76 | count = var.inline_policy != null ? 1 : 0 77 | 78 | inline_policy = var.inline_policy 79 | instance_arn = aws_ssoadmin_permission_set.default[0].instance_arn 80 | permission_set_arn = aws_ssoadmin_permission_set.default[0].arn 81 | 82 | depends_on = [ 83 | var.module_depends_on 84 | ] 85 | } 86 | 87 | resource "aws_ssoadmin_managed_policy_attachment" "default" { 88 | for_each = toset(var.managed_policy_arns) 89 | 90 | instance_arn = aws_ssoadmin_permission_set.default[0].instance_arn 91 | managed_policy_arn = each.value 92 | permission_set_arn = aws_ssoadmin_permission_set.default[0].arn 93 | 94 | depends_on = [ 95 | var.module_depends_on 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /modules/permission-set/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 4.9.0" 6 | } 7 | } 8 | required_version = ">= 1.3" 9 | } 10 | -------------------------------------------------------------------------------- /modules/permission-set/variables.tf: -------------------------------------------------------------------------------- 1 | variable "name" { 2 | type = string 3 | description = "Name of the permission set" 4 | } 5 | 6 | variable "assignments" { 7 | type = list(object({ 8 | account_id = string 9 | account_name = string 10 | sso_groups = list(string) 11 | })) 12 | default = [] 13 | description = "List of account names and IDs and Identity Center groups to assign to the permission set" 14 | } 15 | 16 | variable "create" { 17 | type = bool 18 | default = true 19 | description = "Set to false to only manage assignments when the permission set already exists" 20 | } 21 | 22 | variable "inline_policy" { 23 | type = string 24 | default = null 25 | description = "The IAM inline policy to attach to a permission set" 26 | } 27 | 28 | variable "module_depends_on" { 29 | type = any 30 | description = "A list of external resources the module depends_on" 31 | default = [] 32 | } 33 | 34 | variable "managed_policy_arns" { 35 | type = list(string) 36 | default = [] 37 | description = "List of IAM managed policy ARNs to be attached to the permission set" 38 | } 39 | 40 | variable "session_duration" { 41 | type = string 42 | default = "PT4H" 43 | description = "The length of time that the application user sessions are valid in the ISO-8601 standard" 44 | } 45 | -------------------------------------------------------------------------------- /modules/tag-policy-assignment/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Requirements 4 | 5 | | Name | Version | 6 | |------|---------| 7 | | terraform | >= 1.3 | 8 | | aws | >= 4.9.0 | 9 | 10 | ## Providers 11 | 12 | | Name | Version | 13 | |------|---------| 14 | | aws | >= 4.9.0 | 15 | 16 | ## Inputs 17 | 18 | | Name | Description | Type | Default | Required | 19 | |------|-------------|------|---------|:--------:| 20 | | aws\_ou\_tags | Map of AWS OU names and their tag policies |
map(object({
values = optional(list(string))
enforced_for = optional(list(string))
}))
| n/a | yes | 21 | | ou\_path | Path of the organizational unit (OU) | `string` | n/a | yes | 22 | | target\_id | The unique identifier (ID) organizational unit (OU) that you want to attach the policy to. | `string` | n/a | yes | 23 | | tags | Map of AWS resource tags | `map(string)` | `{}` | no | 24 | 25 | ## Outputs 26 | 27 | No output. 28 | 29 | 30 | 31 | 32 | ## Requirements 33 | 34 | | Name | Version | 35 | |------|---------| 36 | | [terraform](#requirement\_terraform) | >= 1.3 | 37 | | [aws](#requirement\_aws) | >= 4.9.0 | 38 | 39 | ## Providers 40 | 41 | | Name | Version | 42 | |------|---------| 43 | | [aws](#provider\_aws) | >= 4.9.0 | 44 | 45 | ## Modules 46 | 47 | No modules. 48 | 49 | ## Resources 50 | 51 | | Name | Type | 52 | |------|------| 53 | | [aws_organizations_policy.required_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/organizations_policy) | resource | 54 | | [aws_organizations_policy_attachment.required_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/organizations_policy_attachment) | resource | 55 | 56 | ## Inputs 57 | 58 | | Name | Description | Type | Default | Required | 59 | |------|-------------|------|---------|:--------:| 60 | | [aws\_ou\_tags](#input\_aws\_ou\_tags) | Map of AWS OU names and their tag policies |
map(object({
values = optional(list(string))
enforced_for = optional(list(string))
}))
| n/a | yes | 61 | | [ou\_path](#input\_ou\_path) | Path of the organizational unit (OU) | `string` | n/a | yes | 62 | | [target\_id](#input\_target\_id) | The unique identifier (ID) organizational unit (OU) that you want to attach the policy to. | `string` | n/a | yes | 63 | | [tags](#input\_tags) | Map of AWS resource tags | `map(string)` | `{}` | no | 64 | 65 | ## Outputs 66 | 67 | No outputs. 68 | -------------------------------------------------------------------------------- /modules/tag-policy-assignment/UPDATING.md: -------------------------------------------------------------------------------- 1 | # Updating 2 | 3 | To update the `all_enforced_services` local variable, copy all services from the [services and resource types that support enforcement](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_supported-resources-enforcement.html) page into a file called `all-services-page.txt` and run the following: 4 | 5 | ```shell 6 | grep '^\"' all-services-page.txt | awk -F':' '{ print $1":*\"\," }' | sort -n | uniq 7 | ``` 8 | -------------------------------------------------------------------------------- /modules/tag-policy-assignment/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | all_enforced_services = [ 3 | "acm-pca:certificate-authority", 4 | "acm:*", 5 | "amplifyuibuilder:app/environment/components", 6 | "amplifyuibuilder:app/environment/themes", 7 | "aoss:collection", 8 | "apigateway:apikeys", 9 | "apigateway:domainnames", 10 | "apigateway:restapis", 11 | "apigateway:restapis/stages", 12 | "appconfig:application", 13 | "appconfig:application/configurationprofile", 14 | "appconfig:application/environment", 15 | "appconfig:application/environment/deployment", 16 | "appconfig:deploymentstrategy", 17 | "appmesh:*", 18 | "athena:*", 19 | "auditmanager:assessment", 20 | "auditmanager:assessmentFramework", 21 | "auditmanager:control", 22 | "backup-gateway:gateway", 23 | "backup-gateway:hypervisor", 24 | "backup-gateway:vm", 25 | "backup:backup-plan", 26 | "backup:backup-vault", 27 | "batch:job", 28 | "batch:job-definition", 29 | "batch:job-queue", 30 | "bugbust:event", 31 | "catalog:portfolio", 32 | "catalog:product", 33 | "chime:app-instance", 34 | "chime:app-instance/channel", 35 | "chime:app-instance/user", 36 | "chime:media-pipeline", 37 | "chime:meeting", 38 | "cleanrooms:collaboration", 39 | "cleanrooms:configuredtable", 40 | "cleanrooms:membership", 41 | "cleanrooms:membership/configuredtableassociation", 42 | "cloud9:environment", 43 | "cloudfront:*", 44 | "cloudtrail:*", 45 | "cloudwatch:*", 46 | "codebuild:*", 47 | "codecatalyst:connections", 48 | "codecommit:*", 49 | "codeguru-reviewer:association", 50 | "codepipeline:*", 51 | "codestar-connections:connection", 52 | "codestar-connections:host", 53 | "cognito-identity:*", 54 | "cognito-idp:*", 55 | "comprehend:*", 56 | "config:*", 57 | "connect:instance/agent", 58 | "connect:instance/contact-flow", 59 | "connect:instance/integration-association", 60 | "connect:instance/queue", 61 | "connect:instance/routing-profile", 62 | "connect:instance/transfer-destination", 63 | "diode-messaging:mapping", 64 | "directconnect:*", 65 | "dlm:policy", 66 | "dms:*", 67 | "dynamodb:*", 68 | "ec2:capacity-reservation", 69 | "ec2:client-vpn-endpoint", 70 | "ec2:customer-gateway", 71 | "ec2:dhcp-options", 72 | "ec2:elastic-ip", 73 | "ec2:fleet", 74 | "ec2:fpga-image", 75 | "ec2:host-reservation", 76 | "ec2:image", 77 | "ec2:instance", 78 | "ec2:internet-gateway", 79 | "ec2:launch-template", 80 | "ec2:natgateway", 81 | "ec2:network-acl", 82 | "ec2:network-interface", 83 | "ec2:reserved-instances", 84 | "ec2:route-table", 85 | "ec2:security-group", 86 | "ec2:snapshot", 87 | "ec2:spot-instances-request", 88 | "ec2:subnet", 89 | "ec2:traffic-mirror-filter", 90 | "ec2:traffic-mirror-session", 91 | "ec2:traffic-mirror-target", 92 | "ec2:volume", 93 | "ec2:vpc", 94 | "ec2:vpc-endpoint", 95 | "ec2:vpc-endpoint-service", 96 | "ec2:vpc-peering-connection", 97 | "ec2:vpn-connection", 98 | "ec2:vpn-gateway", 99 | "ecr:repository", 100 | "ecs:cluster", 101 | "ecs:service", 102 | "ecs:task-set", 103 | "eks:cluster", 104 | "elastic-inference:elastic-inference-accelerator", 105 | "elasticache:cluster", 106 | "elasticbeanstalk:application", 107 | "elasticbeanstalk:applicationversion", 108 | "elasticbeanstalk:configurationtemplate", 109 | "elasticbeanstalk:platform", 110 | "elasticfilesystem:*", 111 | "elasticloadbalancing:*", 112 | "elasticmapreduce:cluster", 113 | "elasticmapreduce:editor", 114 | "emr-serverless:applications", 115 | "es:domain", 116 | "events:*", 117 | "firehose:*", 118 | "frauddetector:detector", 119 | "frauddetector:detector-version", 120 | "frauddetector:model", 121 | "frauddetector:rule", 122 | "frauddetector:variable", 123 | "fsx:*", 124 | "globalaccelerator:accelerator", 125 | "greengrass:bulk", 126 | "greengrass:connectorsDefinition", 127 | "greengrass:coresDefinition", 128 | "greengrass:devicesDefinition", 129 | "greengrass:functionsDefinition", 130 | "greengrass:loggersDefinition", 131 | "greengrass:resourcesDefinition", 132 | "greengrass:subscriptionsDefinition", 133 | "guardduty:detector", 134 | "guardduty:detector/filter", 135 | "guardduty:detector/ipset", 136 | "guardduty:detector/threatintelset", 137 | "healthlake:datastore", 138 | "iam:instance-profile", 139 | "iam:mfa", 140 | "iam:oidc-provider", 141 | "iam:policy", 142 | "iam:saml-provider", 143 | "iam:server-certificate", 144 | "inspector2:filter", 145 | "internetmonitor:monitor", 146 | "iotanalytics:*", 147 | "iotevents:*", 148 | "iotfleethub:application", 149 | "iotroborunner:site", 150 | "iotroborunner:site/destination", 151 | "iotroborunner:site/worker-fleet", 152 | "iotroborunner:site/worker-fleet/worker", 153 | "iotsitewise:asset", 154 | "iotsitewise:asset-model", 155 | "kinesisanalytics:*", 156 | "kms:*", 157 | "lambda:*", 158 | "logs:log-group", 159 | "macie2:custom-data-identifier", 160 | "mediastore:container", 161 | "mq:broker", 162 | "mq:configuration", 163 | "network-firewall:firewall", 164 | "network-firewall:firewall-policy", 165 | "network-firewall:stateful-rulegroup", 166 | "network-firewall:stateless-rulegroup", 167 | "oam:link", 168 | "oam:sink", 169 | "omics:annotationStore", 170 | "omics:referenceStore", 171 | "omics:referenceStore/reference", 172 | "omics:run", 173 | "omics:runGroup", 174 | "omics:sequenceStore", 175 | "omics:sequenceStore/readSet", 176 | "omics:variantStore", 177 | "omics:workflow", 178 | "organizations:account", 179 | "organizations:ou", 180 | "organizations:policy", 181 | "organizations:root", 182 | "pipes:pipe", 183 | "ram:*", 184 | "rbin:rule", 185 | "rds:cluster-endpoint", 186 | "rds:cluster-pg", 187 | "rds:db-proxy", 188 | "rds:db-proxy-endpoint", 189 | "rds:es", 190 | "rds:og", 191 | "rds:pg", 192 | "rds:ri", 193 | "rds:secgrp", 194 | "rds:subgrp", 195 | "rds:target-group", 196 | "redshift-serverless:namespace", 197 | "redshift-serverless:workgroup", 198 | "redshift:*", 199 | "resource-groups:*", 200 | "route53:hostedzone", 201 | "route53resolver:*", 202 | "s3:bucket", 203 | "sagemaker:action", 204 | "sagemaker:app-image-config", 205 | "sagemaker:artifact", 206 | "sagemaker:context", 207 | "sagemaker:experiment", 208 | "sagemaker:flow-definition", 209 | "sagemaker:human-task-ui", 210 | "sagemaker:model-package", 211 | "sagemaker:model-package-group", 212 | "sagemaker:pipeline", 213 | "sagemaker:processing-job", 214 | "sagemaker:project", 215 | "sagemaker:training-job", 216 | "scheduler:schedule-group", 217 | "secretsmanager:*", 218 | "servicecatalog:applications", 219 | "servicecatalog:attribute-groups", 220 | "sms-voice:configuration-set", 221 | "sms-voice:opt-out-list", 222 | "sms-voice:phone-number", 223 | "sms-voice:pool", 224 | "sms-voice:sender-id", 225 | "sns:topic", 226 | "sqs:queue", 227 | "ssm-contacts:contact", 228 | "ssm:automation-execution", 229 | "ssm:document", 230 | "ssm:managed-instance", 231 | "ssm:opsitem", 232 | "ssm:patchbaseline", 233 | "ssm:session", 234 | "states:*", 235 | "storagegateway:*", 236 | "transfer:server", 237 | "transfer:user", 238 | "transfer:workflow", 239 | "wellarchitected:workload", 240 | "wickr:network", 241 | "wisdom:assistant", 242 | "wisdom:association", 243 | "wisdom:content", 244 | "wisdom:knowledge-base", 245 | "wisdom:session", 246 | "worklink:fleet", 247 | "workspaces:*" 248 | ] 249 | } 250 | -------------------------------------------------------------------------------- /modules/tag-policy-assignment/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | ou_path = replace(var.ou_path, "/", "-") 3 | } 4 | 5 | // https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_supported-resources-enforcement.html 6 | resource "aws_organizations_policy" "required_tags" { 7 | for_each = var.aws_ou_tags 8 | 9 | name = "LandingZone-RequiredTags-${local.ou_path}-${each.key}" 10 | type = "TAG_POLICY" 11 | tags = var.tags 12 | 13 | content = jsonencode( 14 | { 15 | tags = { 16 | (each.key) = merge( 17 | { 18 | tag_key = { 19 | "@@assign" = each.key, 20 | "@@operators_allowed_for_child_policies" = ["@@none"] 21 | } 22 | }, 23 | try(each.value["values"] != null, false) ? 24 | { 25 | tag_value = { "@@assign" = each.value["values"] } 26 | } : {}, 27 | try(each.value["enforced_for"] != null, false) ? 28 | { 29 | enforced_for = { 30 | "@@assign" = (each.value["enforced_for"][0] == "all" ? 31 | local.all_enforced_services : each.value["enforced_for"]) 32 | } 33 | } : {}, 34 | ) 35 | } 36 | } 37 | ) 38 | } 39 | 40 | resource "aws_organizations_policy_attachment" "required_tags" { 41 | for_each = var.aws_ou_tags 42 | 43 | policy_id = aws_organizations_policy.required_tags[each.key].id 44 | target_id = var.target_id 45 | } 46 | -------------------------------------------------------------------------------- /modules/tag-policy-assignment/variables.tf: -------------------------------------------------------------------------------- 1 | variable "aws_ou_tags" { 2 | type = map(object({ 3 | values = optional(list(string)) 4 | enforced_for = optional(list(string)) 5 | })) 6 | description = "Map of AWS OU names and their tag policies" 7 | } 8 | 9 | variable "tags" { 10 | type = map(string) 11 | description = "Map of AWS resource tags" 12 | default = {} 13 | } 14 | 15 | variable "target_id" { 16 | type = string 17 | description = "The unique identifier (ID) organizational unit (OU) that you want to attach the policy to." 18 | } 19 | 20 | variable "ou_path" { 21 | type = string 22 | description = "Path of the organizational unit (OU)" 23 | } 24 | -------------------------------------------------------------------------------- /modules/tag-policy-assignment/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 4.9.0" 6 | } 7 | } 8 | required_version = ">= 1.3" 9 | } 10 | -------------------------------------------------------------------------------- /moved.tf: -------------------------------------------------------------------------------- 1 | moved { 2 | from = aws_config_configuration_recorder.default 3 | to = module.aws_config_recorder.aws_config_configuration_recorder.default 4 | } 5 | 6 | moved { 7 | from = aws_config_delivery_channel.default 8 | to = module.aws_config_recorder.aws_config_delivery_channel.default 9 | } 10 | 11 | moved { 12 | from = aws_config_configuration_recorder_status.default 13 | to = module.aws_config_recorder.aws_config_configuration_recorder_status.default 14 | } 15 | -------------------------------------------------------------------------------- /organizations_policy.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | enabled_root_policies = { 3 | cloudtrail_log_stream = { 4 | enable = true // This is not configurable and will be applied all the time. 5 | policy = file("${path.module}/files/organizations/cloudtrail_log_stream.json") 6 | } 7 | deny_disabling_security_hub = { 8 | enable = var.aws_service_control_policies.aws_deny_disabling_security_hub 9 | policy = var.aws_service_control_policies.aws_deny_disabling_security_hub != false ? templatefile("${path.module}/files/organizations/deny_disabling_security_hub.json.tpl", { 10 | exceptions = local.aws_service_control_policies_principal_exceptions 11 | }) : null 12 | } 13 | deny_leaving_org = { 14 | enable = var.aws_service_control_policies.aws_deny_leaving_org 15 | policy = var.aws_service_control_policies.aws_deny_leaving_org != false ? templatefile("${path.module}/files/organizations/deny_leaving_org.json.tpl", { 16 | exceptions = local.aws_service_control_policies_principal_exceptions 17 | }) : null 18 | } 19 | // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ExamplePolicies_EC2.html#iam-example-instance-metadata-requireIMDSv2 20 | require_use_of_imdsv2 = { 21 | enable = var.aws_service_control_policies.aws_require_imdsv2 22 | policy = file("${path.module}/files/organizations/require_use_of_imdsv2.json") 23 | } 24 | } 25 | 26 | root_policies_to_merge = [for key, value in local.enabled_root_policies : jsondecode( 27 | value.enable == true ? value.policy : "{\"Statement\": []}" 28 | )] 29 | 30 | root_policies_merged = flatten([ 31 | for policy in local.root_policies_to_merge : policy.Statement 32 | ]) 33 | } 34 | 35 | resource "aws_organizations_policy" "lz_root_policies" { 36 | name = "LandingZone-RootPolicies" 37 | content = jsonencode({ 38 | Version = "2012-10-17" 39 | Statement = local.root_policies_merged 40 | }) 41 | description = "LandingZone enabled Root OU policies" 42 | tags = var.tags 43 | } 44 | 45 | resource "aws_organizations_policy_attachment" "lz_root_policies" { 46 | policy_id = aws_organizations_policy.lz_root_policies.id 47 | target_id = data.aws_organizations_organization.default.roots[0].id 48 | } 49 | 50 | // https://summitroute.com/blog/2020/03/25/aws_scp_best_practices/#deny-ability-to-leave-organization 51 | resource "aws_organizations_policy" "deny_root_user" { 52 | count = length(var.aws_service_control_policies.aws_deny_root_user_ous) > 0 ? 1 : 0 53 | 54 | name = "LandingZone-DenyRootUser" 55 | content = file("${path.module}/files/organizations/deny_root_user.json") 56 | tags = var.tags 57 | } 58 | 59 | resource "aws_organizations_policy_attachment" "deny_root_user" { 60 | for_each = { 61 | for ou in data.aws_organizations_organizational_units.default.children : ou.name => ou if contains(var.aws_service_control_policies.aws_deny_root_user_ous, ou.name) 62 | } 63 | 64 | policy_id = aws_organizations_policy.deny_root_user[0].id 65 | target_id = each.value.id 66 | } 67 | 68 | module "tag_policy_assignment" { 69 | for_each = { 70 | for ou in data.mcaf_aws_all_organizational_units.default.organizational_units : ou.path => ou if contains(keys(coalesce(var.aws_required_tags, {})), ou.path) 71 | } 72 | 73 | source = "./modules/tag-policy-assignment" 74 | aws_ou_tags = { for k, v in var.aws_required_tags[each.key] : v.name => v } 75 | target_id = each.value.id 76 | ou_path = each.key 77 | tags = var.tags 78 | } 79 | -------------------------------------------------------------------------------- /organizations_policy_allowed_regions.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | ################################################################################ 3 | # 1) Core service lists 4 | ################################################################################ 5 | 6 | # AWS services that need to be allowed in the global (us-east-1) region. 7 | # These services are typically used for account management, billing, and other global operations. 8 | global_service_actions = [ 9 | "a4b:*", 10 | "access-analyzer:*", 11 | "account:*", 12 | "acm:*", 13 | "activate:*", 14 | "artifact:*", 15 | "aws-marketplace-management:*", 16 | "aws-marketplace:*", 17 | "aws-portal:*", 18 | "billing:*", 19 | "billingconductor:*", 20 | "budgets:*", 21 | "ce:*", 22 | "chatbot:*", 23 | "chime:*", 24 | "cloudfront:*", 25 | "cloudtrail:Describe*", 26 | "cloudtrail:Get*", 27 | "cloudtrail:List*", 28 | "cloudtrail:LookupEvents", 29 | "cloudwatch:Describe*", 30 | "cloudwatch:Get*", 31 | "cloudwatch:List*", 32 | "compute-optimizer:*", 33 | "config:*", 34 | "consoleapp:*", 35 | "consolidatedbilling:*", 36 | "cur:*", 37 | "datapipeline:GetAccountLimits", 38 | "devicefarm:*", 39 | "directconnect:*", 40 | "ec2:DescribeRegions", 41 | "ec2:DescribeTransitGateways", 42 | "ec2:DescribeVpnGateways", 43 | "ecr-public:*", 44 | "fms:*", 45 | "freetier:*", 46 | "globalaccelerator:*", 47 | "health:*", 48 | "iam:*", 49 | "importexport:*", 50 | "invoicing:*", 51 | "iq:*", 52 | "kms:*", 53 | "license-manager:ListReceivedLicenses", 54 | "lightsail:Get*", 55 | "logs:*", 56 | "mobileanalytics:*", 57 | "networkmanager:*", 58 | "notifications-contacts:*", 59 | "notifications:*", 60 | "organizations:*", 61 | "payments:*", 62 | "pricing:*", 63 | "quicksight:DescribeAccountSubscription", 64 | "quicksight:DescribeTemplate", 65 | "resource-explorer-2:*", 66 | "route53-recovery-cluster:*", 67 | "route53-recovery-control-config:*", 68 | "route53-recovery-readiness:*", 69 | "route53:*", 70 | "route53domains:*", 71 | "s3:CreateMultiRegionAccessPoint", 72 | "s3:DeleteMultiRegionAccessPoint", 73 | "s3:DescribeMultiRegionAccessPointOperation", 74 | "s3:GetAccountPublicAccessBlock", 75 | "s3:GetBucketLocation", 76 | "s3:GetBucketPolicy", 77 | "s3:GetBucketPolicyStatus", 78 | "s3:GetBucketPublicAccessBlock", 79 | "s3:GetMultiRegionAccessPoint", 80 | "s3:GetMultiRegionAccessPointPolicy", 81 | "s3:GetMultiRegionAccessPointPolicyStatus", 82 | "s3:GetStorageLensConfiguration", 83 | "s3:GetStorageLensDashboard", 84 | "s3:ListAllMyBuckets", 85 | "s3:ListMultiRegionAccessPoints", 86 | "s3:ListStorageLensConfigurations", 87 | "s3:PutAccountPublicAccessBlock", 88 | "s3:PutMultiRegionAccessPointPolicy", 89 | "savingsplans:*", 90 | "servicequotas:*", 91 | "shield:*", 92 | "sso:*", 93 | "sts:*", 94 | "support:*", 95 | "supportapp:*", 96 | "supportplans:*", 97 | "sustainability:*", 98 | "tag:GetResources", 99 | "tax:*", 100 | "trustedadvisor:*", 101 | "vendor-insights:ListEntitledSecurityProfiles", 102 | "waf-regional:*", 103 | "waf:*", 104 | "wafv2:*", 105 | "wellarchitected:*", 106 | ] 107 | 108 | # AWS services that are inherently multi-region, meaning they can operate across multiple regions. 109 | multi_region_service_actions = [ 110 | "supportplans:*" 111 | ] 112 | 113 | ################################################################################ 114 | # 2) Build the regions & exemption sets used in the SCP Statements 115 | ################################################################################ 116 | 117 | # List of regions that have extra whitelisted actions 118 | regions_with_whitelist_exceptions = keys(var.regions.additional_allowed_service_actions_per_region) 119 | 120 | # Statement #1: 121 | allowed_plus_exception_regions = var.regions.allowed_regions != null ? distinct(concat( 122 | var.regions.allowed_regions, 123 | local.regions_with_whitelist_exceptions 124 | )) : [] 125 | 126 | exempted_actions_global = distinct(concat( 127 | local.global_service_actions, 128 | local.multi_region_service_actions, 129 | )) 130 | 131 | # Statement #2: 132 | exempted_actions_per_region = { 133 | for region, service_action in var.regions.additional_allowed_service_actions_per_region : 134 | region => distinct(concat( 135 | local.multi_region_service_actions, 136 | service_action 137 | )) 138 | } 139 | 140 | # To keep the SCP as small as possible and avoid duplication, regions with exactly the same allowed service actions are grouped together. 141 | exempted_actions_per_region_grouped = [ 142 | for actions in distinct(values(local.exempted_actions_per_region)) : { 143 | actions = actions 144 | regions = [ 145 | for region, service_action in local.exempted_actions_per_region : 146 | region if service_action == actions 147 | ] 148 | } 149 | ] 150 | 151 | # Statement #3: 152 | allowed_plus_linked_plus_exception_plus_global_regions = var.regions.allowed_regions != null ? distinct(concat( 153 | var.regions.allowed_regions, 154 | var.regions.linked_regions, 155 | local.regions_with_whitelist_exceptions, 156 | ["us-east-1"] # (us-east-1 is the default region for the global services, so we need to allow it) 157 | )) : [] 158 | 159 | ################################################################################ 160 | # 3) Assemble the 3 SCP statements 161 | ################################################################################ 162 | 163 | allowed_regions_policy_statements = concat( 164 | # Statement (1) explanation: 165 | # Allow all services in your `allowed_regions` & regions listed in the `additional_allowed_service_actions_per_region`, 166 | # For all other regions, every service action is denied except for global & multi-region service actions. 167 | [ 168 | { 169 | Sid = "DenyAllRegionsOutsideAllowedList" 170 | Effect = "Deny" 171 | NotAction = local.exempted_actions_global 172 | Resource = "*" 173 | Condition = { 174 | StringNotEquals = { 175 | "aws:RequestedRegion" = local.allowed_plus_exception_regions 176 | } 177 | ArnNotLike = { 178 | "aws:PrincipalARN" = local.aws_service_control_policies_principal_exceptions 179 | } 180 | } 181 | } 182 | ], 183 | 184 | # Statement (2) explanation: 185 | # In each `additional_allowed_service_actions_per_region` region, 186 | # only allow the actions listed in the `additional_allowed_service_actions_per_region` map & the `multi_region_service_actions`. 187 | [ 188 | for group in local.exempted_actions_per_region_grouped : { 189 | Sid = "DenyOutsideAllowedList${replace(replace(join("_", group.regions), "-", ""), "_", "")}" 190 | Effect = "Deny" 191 | NotAction = group.actions 192 | Resource = "*" 193 | Condition = { 194 | StringEquals = { 195 | "aws:RequestedRegion" = group.regions 196 | } 197 | ArnNotLike = { 198 | "aws:PrincipalARN" = local.aws_service_control_policies_principal_exceptions 199 | } 200 | } 201 | } 202 | ], 203 | 204 | # Statement (3) explanation: 205 | # Deny all service actions except for the `multi_region_service_actions` in any region not in your allowed + linked + exception + [us-east-1] set. 206 | # This statement is for leak prevention: It explicitly denies any per-region-only services within your core allowed regions so they can’t slip in where you don’t want them; 207 | # as some services like acm & logs are both global and regional specific services. 208 | [ 209 | { 210 | Sid = "DenyAllOtherRegions" 211 | Effect = "Deny" 212 | NotAction = local.multi_region_service_actions 213 | Resource = "*" 214 | Condition = { 215 | StringNotEquals = { 216 | "aws:RequestedRegion" = local.allowed_plus_linked_plus_exception_plus_global_regions 217 | } 218 | ArnNotLike = { 219 | "aws:PrincipalARN" = local.aws_service_control_policies_principal_exceptions 220 | } 221 | } 222 | } 223 | ] 224 | ) 225 | 226 | allowed_regions_policy = jsonencode({ 227 | Version = "2012-10-17" 228 | Statement = local.allowed_regions_policy_statements 229 | }) 230 | } 231 | 232 | resource "aws_organizations_policy" "allowed_regions" { 233 | count = var.regions.allowed_regions != null ? 1 : 0 234 | 235 | name = "LandingZone-AllowedRegions" 236 | content = local.allowed_regions_policy 237 | description = "LandingZone allowed regions" 238 | tags = var.tags 239 | } 240 | 241 | resource "aws_organizations_policy_attachment" "allowed_regions" { 242 | count = var.regions.allowed_regions != null ? 1 : 0 243 | 244 | policy_id = aws_organizations_policy.allowed_regions[0].id 245 | target_id = data.aws_organizations_organization.default.roots[0].id 246 | } 247 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "aws_config_s3_bucket_arn" { 2 | description = "ARN of the AWS Config S3 bucket in the logging account" 3 | value = module.aws_config_s3.arn 4 | } 5 | 6 | output "aws_config_iam_service_linked_role_arn" { 7 | description = "IAM Service Linked Role ARN for AWS Config in the management account" 8 | value = aws_iam_service_linked_role.config.arn 9 | } 10 | 11 | output "kms_key_arn" { 12 | description = "ARN of KMS key for the management account" 13 | value = module.kms_key.arn 14 | } 15 | 16 | output "kms_key_id" { 17 | description = "ID of KMS key for the management account" 18 | value = module.kms_key.id 19 | } 20 | 21 | output "kms_key_audit_arn" { 22 | description = "ARN of KMS key for the audit account" 23 | value = module.kms_key_audit.arn 24 | } 25 | 26 | output "kms_key_audit_id" { 27 | description = "ID of KMS key for the audit account" 28 | value = module.kms_key_audit.id 29 | } 30 | 31 | output "kms_key_logging_arn" { 32 | description = "ARN of KMS key for the logging account" 33 | value = module.kms_key_logging.arn 34 | } 35 | 36 | output "kms_key_logging_id" { 37 | description = "ID of KMS key for the logging account" 38 | value = module.kms_key_logging.id 39 | } 40 | 41 | output "monitor_iam_activity_sns_topic_arn" { 42 | description = "ARN of the SNS Topic in the audit account for IAM activity monitoring notifications" 43 | value = var.monitor_iam_activity ? aws_sns_topic.iam_activity[0].arn : "" 44 | } 45 | -------------------------------------------------------------------------------- /security_hub.tf: -------------------------------------------------------------------------------- 1 | // AWS Security Hub - Management account configuration and enrollment 2 | resource "aws_securityhub_organization_admin_account" "default" { 3 | admin_account_id = data.aws_caller_identity.audit.account_id 4 | 5 | depends_on = [aws_securityhub_account.default] 6 | } 7 | 8 | resource "aws_securityhub_account" "management" { 9 | control_finding_generator = var.aws_security_hub.control_finding_generator 10 | 11 | depends_on = [aws_securityhub_organization_configuration.default] 12 | } 13 | 14 | resource "aws_securityhub_member" "management" { 15 | provider = aws.audit 16 | 17 | account_id = data.aws_caller_identity.management.account_id 18 | 19 | depends_on = [aws_securityhub_account.management] 20 | 21 | lifecycle { 22 | ignore_changes = [invite] 23 | } 24 | } 25 | 26 | // AWS Security Hub - Audit account configuration and enrollment 27 | resource "aws_securityhub_account" "default" { 28 | provider = aws.audit 29 | 30 | control_finding_generator = var.aws_security_hub.control_finding_generator 31 | } 32 | 33 | resource "aws_securityhub_finding_aggregator" "default" { 34 | provider = aws.audit 35 | 36 | linking_mode = var.aws_security_hub.aggregator_linking_mode 37 | specified_regions = var.aws_security_hub.aggregator_linking_mode == "SPECIFIED_REGIONS" ? var.regions.linked_regions : null 38 | 39 | depends_on = [aws_securityhub_account.default] 40 | } 41 | 42 | resource "aws_securityhub_organization_configuration" "default" { 43 | provider = aws.audit 44 | 45 | auto_enable = false 46 | auto_enable_standards = "NONE" 47 | 48 | organization_configuration { 49 | configuration_type = "CENTRAL" 50 | } 51 | 52 | depends_on = [aws_securityhub_organization_admin_account.default, aws_securityhub_finding_aggregator.default] 53 | } 54 | 55 | resource "aws_securityhub_configuration_policy" "default" { 56 | provider = aws.audit 57 | 58 | name = "mcaf-lz" 59 | description = "MCAF Landing Zone default configuration policy" 60 | 61 | configuration_policy { 62 | service_enabled = true 63 | enabled_standard_arns = local.security_hub_standards_arns 64 | 65 | security_controls_configuration { 66 | disabled_control_identifiers = var.aws_security_hub.disabled_control_identifiers 67 | enabled_control_identifiers = var.aws_security_hub.enabled_control_identifiers 68 | } 69 | } 70 | 71 | depends_on = [aws_securityhub_organization_configuration.default] 72 | } 73 | 74 | resource "aws_securityhub_configuration_policy_association" "root" { 75 | provider = aws.audit 76 | 77 | target_id = data.aws_organizations_organization.default.roots[0].id 78 | policy_id = aws_securityhub_configuration_policy.default.id 79 | } 80 | 81 | resource "aws_cloudwatch_event_rule" "security_hub_findings" { 82 | provider = aws.audit 83 | 84 | name = "LandingZone-SecurityHubFindings" 85 | description = "Rule for getting SecurityHub findings" 86 | event_pattern = file("${path.module}/files/event_bridge/security_hub_findings.json.tpl") 87 | tags = var.tags 88 | } 89 | 90 | resource "aws_cloudwatch_event_target" "security_hub_findings" { 91 | provider = aws.audit 92 | 93 | arn = aws_sns_topic.security_hub_findings.arn 94 | rule = aws_cloudwatch_event_rule.security_hub_findings.name 95 | target_id = "SendToSNS" 96 | } 97 | 98 | resource "aws_sns_topic" "security_hub_findings" { 99 | provider = aws.audit 100 | 101 | name = "LandingZone-SecurityHubFindings" 102 | http_success_feedback_role_arn = aws_iam_role.sns_feedback.arn 103 | http_failure_feedback_role_arn = aws_iam_role.sns_feedback.arn 104 | kms_master_key_id = module.kms_key_audit.id 105 | tags = var.tags 106 | } 107 | 108 | resource "aws_sns_topic_policy" "security_hub_findings" { 109 | provider = aws.audit 110 | 111 | arn = aws_sns_topic.security_hub_findings.arn 112 | policy = templatefile("${path.module}/files/sns/security_hub_topic_policy.json.tpl", { 113 | account_id = data.aws_caller_identity.audit.account_id 114 | services_allowed_publish = jsonencode("events.amazonaws.com") 115 | sns_topic = aws_sns_topic.security_hub_findings.arn 116 | }) 117 | } 118 | 119 | resource "aws_sns_topic_subscription" "security_hub_findings" { 120 | for_each = var.aws_security_hub_sns_subscription 121 | provider = aws.audit 122 | 123 | endpoint = each.value.endpoint 124 | endpoint_auto_confirms = length(regexall("http", each.value.protocol)) > 0 125 | protocol = each.value.protocol 126 | topic_arn = aws_sns_topic.security_hub_findings.arn 127 | } 128 | 129 | // AWS Security Hub - Logging account enrollment 130 | resource "aws_securityhub_member" "logging" { 131 | provider = aws.audit 132 | 133 | account_id = data.aws_caller_identity.logging.account_id 134 | 135 | lifecycle { 136 | ignore_changes = [invite] 137 | } 138 | 139 | depends_on = [aws_securityhub_organization_configuration.default] 140 | } 141 | -------------------------------------------------------------------------------- /ses_accounts_mail_alias.tf: -------------------------------------------------------------------------------- 1 | module "ses-root-accounts-mail-alias" { 2 | # checkov:skip=CKV_AWS_273: IAM user is the only option for SMTP auth 3 | 4 | count = var.ses_root_accounts_mail_forward != null ? 1 : 0 5 | providers = { aws = aws, aws.route53 = aws } 6 | 7 | source = "schubergphilis/mcaf-ses/aws" 8 | version = "~> 0.1.4" 9 | 10 | dmarc = var.ses_root_accounts_mail_forward.dmarc 11 | domain = var.ses_root_accounts_mail_forward.domain 12 | kms_key_id = module.kms_key.id 13 | tags = var.tags 14 | } 15 | 16 | module "ses-root-accounts-mail-forward" { 17 | # checkov:skip=CKV_AWS_19: False positive: https://github.com/bridgecrewio/checkov/issues/3847. The S3 bucket created by this module is encrypted with KMS. 18 | # checkov:skip=CKV_AWS_145: False positive: https://github.com/bridgecrewio/checkov/issues/3847. The S3 bucket created by this module is encrypted with KMS. 19 | # checkov:skip=CKV_AWS_272: This module does not support lambda code signing at the moment 20 | 21 | count = var.ses_root_accounts_mail_forward != null ? 1 : 0 22 | 23 | source = "schubergphilis/mcaf-ses-forwarder/aws" 24 | version = "~> 0.3.0" 25 | 26 | bucket_name = "ses-forwarder-${replace(var.ses_root_accounts_mail_forward.domain, ".", "-")}" 27 | from_email = var.ses_root_accounts_mail_forward.from_email 28 | kms_key_arn = module.kms_key.arn 29 | recipient_mapping = var.ses_root_accounts_mail_forward.recipient_mapping 30 | tags = var.tags 31 | } 32 | -------------------------------------------------------------------------------- /sso.tf: -------------------------------------------------------------------------------- 1 | module "aws_sso_permission_sets" { 2 | for_each = var.aws_sso_permission_sets 3 | 4 | source = "./modules/permission-set" 5 | name = each.key 6 | session_duration = each.value.session_duration 7 | assignments = each.value.assignments 8 | inline_policy = each.value.inline_policy 9 | managed_policy_arns = each.value.managed_policy_arns 10 | } 11 | -------------------------------------------------------------------------------- /terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 5.54.0" 6 | configuration_aliases = [aws.audit, aws.logging] 7 | } 8 | datadog = { 9 | source = "datadog/datadog" 10 | version = ">= 3.39" 11 | } 12 | mcaf = { 13 | source = "schubergphilis/mcaf" 14 | version = ">= 0.4.2" 15 | } 16 | } 17 | required_version = ">= 1.9.0" 18 | } 19 | -------------------------------------------------------------------------------- /tests/datadog.tftest.hcl: -------------------------------------------------------------------------------- 1 | variables { 2 | 3 | control_tower_account_ids = { 4 | audit = "012345678902" 5 | logging = "012345678903" 6 | } 7 | 8 | regions = { 9 | allowed_regions = ["eu-central-1"] 10 | home_region = "eu-central-1" 11 | } 12 | } 13 | 14 | run "setup" { 15 | module { 16 | source = "./tests/setup" 17 | } 18 | } 19 | 20 | mock_provider "datadog" {} 21 | 22 | mock_provider "aws" { 23 | mock_data "aws_region" { 24 | defaults = { 25 | name = "eu-central-1" 26 | } 27 | } 28 | 29 | mock_data "aws_caller_identity" { 30 | defaults = { 31 | account_id = "012345678901" 32 | } 33 | } 34 | 35 | mock_data "aws_organizations_organization" { 36 | defaults = { 37 | accounts = [ 38 | { 39 | "arn" : "arn:aws:organizations::012345678901:account/o-ab1234cdef/012345678901", 40 | "email" : "core-master@example.com", 41 | "id" : "012345678901", 42 | "name" : "core-master", 43 | "status" : "ACTIVE" 44 | }, 45 | { 46 | "arn" : "arn:aws:organizations::012345678901:account/o-ab1234cdef/012345678902", 47 | "email" : "core-audit@example.com", 48 | "id" : "012345678902", 49 | "name" : "core-audit", 50 | "status" : "ACTIVE" 51 | }, 52 | { 53 | "arn" : "arn:aws:organizations::012345678901:account/o-ab1234cdef/012345678903", 54 | "email" : "core-logging@example.com", 55 | "id" : "012345678903", 56 | "name" : "core-logging", 57 | "status" : "ACTIVE" 58 | } 59 | ] 60 | 61 | roots = [ 62 | { 63 | "arn" : "arn:aws:organizations::012345678901:root/o-ab1234cdef/r-12ac", 64 | "id" : "r-12ac", 65 | "name" : "Root", 66 | "policy_types" : [ 67 | { 68 | "status" : "ENABLED", 69 | "type" : "SERVICE_CONTROL_POLICY" 70 | }, 71 | { 72 | "status" : "ENABLED", 73 | "type" : "TAG_POLICY" 74 | } 75 | ] 76 | } 77 | ] 78 | } 79 | } 80 | 81 | mock_data "aws_iam_policy_document" { 82 | defaults = { 83 | json = "{\"Version\": \"2012-10-17\",\"Statement\": [{\"Sid\": \"Base Permissions\",\"Effect\": \"Allow\",\"Action\": \"kms:*\",\"Resource\": \"*\",\"Principal\": {\"AWS\": \"arn:aws:iam::012345678901:root\"}}]}" 84 | } 85 | } 86 | } 87 | 88 | mock_provider "aws" { 89 | alias = "audit" 90 | 91 | mock_data "aws_region" { 92 | defaults = { 93 | name = "eu-central-1" 94 | } 95 | } 96 | 97 | mock_data "aws_caller_identity" { 98 | defaults = { 99 | account_id = "012345678902" 100 | } 101 | } 102 | 103 | mock_data "aws_iam_policy_document" { 104 | defaults = { 105 | json = "{\"Version\": \"2012-10-17\",\"Statement\": [{\"Sid\": \"Base Permissions\",\"Effect\": \"Allow\",\"Action\": \"kms:*\",\"Resource\": \"*\",\"Principal\": {\"AWS\": \"arn:aws:iam::012345678902:root\"}}]}" 106 | } 107 | } 108 | 109 | mock_data "aws_sns_topic" { 110 | defaults = { 111 | arn = "arn:aws:sns:eu-central-1:012345678902:aws-controltower-AllConfigNotifications" 112 | } 113 | } 114 | } 115 | 116 | mock_provider "aws" { 117 | alias = "logging" 118 | 119 | mock_data "aws_region" { 120 | defaults = { 121 | name = "eu-central-1" 122 | } 123 | } 124 | 125 | mock_data "aws_caller_identity" { 126 | defaults = { 127 | account_id = "012345678903" 128 | } 129 | } 130 | 131 | mock_data "aws_iam_policy_document" { 132 | defaults = { 133 | json = "{\"Version\": \"2012-10-17\",\"Statement\": [{\"Sid\": \"Base Permissions\",\"Effect\": \"Allow\",\"Action\": \"kms:*\",\"Resource\": \"*\",\"Principal\": {\"AWS\": \"arn:aws:iam::012345678903:root\"}}]}" 134 | } 135 | } 136 | } 137 | 138 | mock_provider "mcaf" { 139 | mock_data "mcaf_aws_all_organizational_units" { 140 | defaults = { 141 | organizational_units = [ 142 | { 143 | "id" : "ou-1234", 144 | "name" : "OU1" 145 | }, 146 | { 147 | "id" : "ou-5678", 148 | "name" : "OU2" 149 | } 150 | ] 151 | } 152 | } 153 | } 154 | 155 | run "datadog_integration_disabled" { 156 | module { 157 | source = "./" 158 | } 159 | 160 | command = plan 161 | 162 | assert { 163 | condition = length(module.datadog_master) == 0 164 | error_message = "The Datadog integration should be disabled" 165 | } 166 | 167 | assert { 168 | condition = length(module.datadog_audit) == 0 169 | error_message = "The Datadog integration should be disabled" 170 | } 171 | 172 | assert { 173 | condition = length(module.datadog_logging) == 0 174 | error_message = "The Datadog integration should be disabled" 175 | } 176 | } 177 | 178 | run "datadog_integration_disabled_by_boolean" { 179 | module { 180 | source = "./" 181 | } 182 | 183 | variables { 184 | datadog = { 185 | enable_integration = false 186 | site_url = "datadoghq.eu" 187 | } 188 | } 189 | 190 | command = plan 191 | 192 | assert { 193 | condition = length(module.datadog_master) == 0 194 | error_message = "The Datadog integration should be disabled" 195 | } 196 | 197 | assert { 198 | condition = length(module.datadog_audit) == 0 199 | error_message = "The Datadog integration should be disabled" 200 | } 201 | 202 | assert { 203 | condition = length(module.datadog_logging) == 0 204 | error_message = "The Datadog integration should be disabled" 205 | } 206 | } 207 | 208 | run "datadog_integration_enabled_api_key" { 209 | module { 210 | source = "./" 211 | } 212 | 213 | variables { 214 | datadog = { 215 | api_key = "12345678901234567890123456789012" 216 | enable_integration = true 217 | install_log_forwarder = true 218 | log_forwarder_version = "latest" 219 | site_url = "datadoghq.eu" 220 | } 221 | } 222 | 223 | command = plan 224 | 225 | assert { 226 | condition = length(module.datadog_master) == 1 227 | error_message = "The Datadog integration should be enabled" 228 | } 229 | 230 | assert { 231 | condition = length(module.datadog_audit) == 1 232 | error_message = "The Datadog integration should be enabled" 233 | } 234 | 235 | assert { 236 | condition = length(module.datadog_logging) == 1 237 | error_message = "The Datadog integration should be enabled" 238 | } 239 | } 240 | 241 | run "datadog_integration_enabled_create_api_key" { 242 | module { 243 | source = "./" 244 | } 245 | 246 | variables { 247 | datadog = { 248 | api_key = null 249 | create_api_key = true 250 | enable_integration = true 251 | install_log_forwarder = true 252 | log_forwarder_version = "latest" 253 | site_url = "datadoghq.eu" 254 | } 255 | } 256 | 257 | command = plan 258 | 259 | assert { 260 | condition = length(module.datadog_master) == 1 261 | error_message = "The Datadog integration should be enabled" 262 | } 263 | 264 | assert { 265 | condition = length(module.datadog_audit) == 1 266 | error_message = "The Datadog integration should be enabled" 267 | } 268 | 269 | assert { 270 | condition = length(module.datadog_logging) == 1 271 | error_message = "The Datadog integration should be enabled" 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /tests/setup/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 5.54.0" 6 | configuration_aliases = [aws.audit, aws.logging] 7 | } 8 | datadog = { 9 | source = "datadog/datadog" 10 | version = ">= 3.39" 11 | } 12 | mcaf = { 13 | source = "schubergphilis/mcaf" 14 | version = ">= 0.4.2" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "additional_auditing_trail" { 2 | type = object({ 3 | name = string 4 | bucket = string 5 | kms_key_id = string 6 | 7 | event_selector = optional(object({ 8 | data_resource = optional(object({ 9 | type = string 10 | values = list(string) 11 | })) 12 | exclude_management_event_sources = optional(set(string), null) 13 | include_management_events = optional(bool, true) 14 | read_write_type = optional(string, "All") 15 | })) 16 | }) 17 | default = null 18 | description = "CloudTrail configuration for additional auditing trail" 19 | } 20 | 21 | variable "aws_account_password_policy" { 22 | type = object({ 23 | allow_users_to_change = bool 24 | max_age = number 25 | minimum_length = number 26 | require_lowercase_characters = bool 27 | require_numbers = bool 28 | require_symbols = bool 29 | require_uppercase_characters = bool 30 | reuse_prevention_history = number 31 | }) 32 | default = { 33 | allow_users_to_change = true 34 | max_age = 90 35 | minimum_length = 14 36 | require_lowercase_characters = true 37 | require_numbers = true 38 | require_symbols = true 39 | require_uppercase_characters = true 40 | reuse_prevention_history = 24 41 | } 42 | description = "AWS account password policy parameters for the audit, logging and master account" 43 | } 44 | 45 | variable "aws_auditmanager" { 46 | type = object({ 47 | enabled = bool 48 | reports_bucket_prefix = string 49 | }) 50 | default = { 51 | enabled = true 52 | reports_bucket_prefix = "audit-manager-reports" 53 | } 54 | description = "AWS Audit Manager config settings" 55 | } 56 | 57 | variable "aws_config" { 58 | type = object({ 59 | aggregator_account_ids = optional(list(string), []) 60 | delivery_channel_s3_bucket_name = optional(string, null) 61 | delivery_channel_s3_key_prefix = optional(string, null) 62 | delivery_frequency = optional(string, "TwentyFour_Hours") 63 | rule_identifiers = optional(list(string), []) 64 | }) 65 | default = { 66 | aggregator_account_ids = [] 67 | delivery_channel_s3_bucket_name = null 68 | delivery_channel_s3_key_prefix = null 69 | delivery_frequency = "TwentyFour_Hours" 70 | rule_identifiers = [] 71 | } 72 | description = "AWS Config settings" 73 | 74 | validation { 75 | condition = contains(["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], var.aws_config.delivery_frequency) 76 | error_message = "The delivery frequency must be set to \"One_Hour\", \"Three_Hours\", \"Six_Hours\", \"Twelve_Hours\", or \"TwentyFour_Hours\"." 77 | } 78 | } 79 | 80 | variable "aws_config_sns_subscription" { 81 | type = map(object({ 82 | endpoint = string 83 | protocol = string 84 | })) 85 | default = {} 86 | description = "Subscription options for the aws-controltower-AggregateSecurityNotifications (AWS Config) SNS topic" 87 | } 88 | 89 | variable "aws_ebs_encryption_by_default" { 90 | type = bool 91 | default = true 92 | description = "Set to true to enable AWS Elastic Block Store encryption by default" 93 | } 94 | 95 | variable "aws_guardduty" { 96 | type = object({ 97 | enabled = optional(bool, true) 98 | finding_publishing_frequency = optional(string, "FIFTEEN_MINUTES") 99 | ebs_malware_protection_status = optional(bool, true) 100 | eks_audit_logs_status = optional(bool, true) 101 | lambda_network_logs_status = optional(bool, true) 102 | rds_login_events_status = optional(bool, true) 103 | s3_data_events_status = optional(bool, true) 104 | runtime_monitoring_status = optional(object({ 105 | enabled = optional(bool, true) 106 | eks_addon_management_status = optional(bool, true) 107 | ecs_fargate_agent_management_status = optional(bool, true) 108 | ec2_agent_management_status = optional(bool, true) 109 | }), {}) 110 | }) 111 | default = {} 112 | description = "AWS GuardDuty settings" 113 | } 114 | 115 | variable "aws_inspector" { 116 | type = object({ 117 | enabled = optional(bool, false) 118 | enable_scan_ec2 = optional(bool, true) 119 | enable_scan_ecr = optional(bool, true) 120 | enable_scan_lambda = optional(bool, true) 121 | enable_scan_lambda_code = optional(bool, true) 122 | excluded_member_account_ids = optional(list(string), []) 123 | resource_create_timeout = optional(string, "15m") 124 | }) 125 | default = {} 126 | description = "AWS Inspector settings, at least one of the scan options must be enabled" 127 | } 128 | 129 | variable "aws_required_tags" { 130 | type = map(list(object({ 131 | name = string 132 | values = optional(list(string)) 133 | enforced_for = optional(list(string)) 134 | }))) 135 | default = null 136 | description = "AWS Required tags settings" 137 | 138 | validation { 139 | condition = var.aws_required_tags != null ? alltrue([for taglist in var.aws_required_tags : length(taglist) <= 10]) : true 140 | error_message = "A maximum of 10 tag keys can be supplied to stay within the maximum policy length." 141 | } 142 | } 143 | 144 | variable "aws_security_hub" { 145 | type = object({ 146 | aggregator_linking_mode = optional(string, "SPECIFIED_REGIONS") 147 | auto_enable_controls = optional(bool, true) 148 | control_finding_generator = optional(string, "SECURITY_CONTROL") 149 | create_cis_metric_filters = optional(bool, true) 150 | disabled_control_identifiers = optional(list(string), null) 151 | enabled_control_identifiers = optional(list(string), null) 152 | product_arns = optional(list(string), []) 153 | standards_arns = optional(list(string), null) 154 | }) 155 | default = {} 156 | description = "AWS Security Hub settings" 157 | 158 | validation { 159 | condition = contains(["SECURITY_CONTROL", "STANDARD_CONTROL"], var.aws_security_hub.control_finding_generator) 160 | error_message = "The \"control_finding_generator\" variable must be set to either \"SECURITY_CONTROL\" or \"STANDARD_CONTROL\"." 161 | } 162 | 163 | validation { 164 | condition = contains(["SPECIFIED_REGIONS", "ALL_REGIONS"], var.aws_security_hub.aggregator_linking_mode) 165 | error_message = "The \"aggregator_linking_mode\" variable must be set to either \"SPECIFIED_REGIONS\" or \"ALL_REGIONS\"." 166 | } 167 | 168 | validation { 169 | condition = try(length(var.aws_security_hub.enabled_control_identifiers), 0) == 0 || try(length(var.aws_security_hub.disabled_control_identifiers), 0) == 0 170 | error_message = "Only one of \"enabled_control_identifiers\" or \"disabled_control_identifiers\" variable can be set." 171 | } 172 | } 173 | 174 | variable "aws_security_hub_sns_subscription" { 175 | type = map(object({ 176 | endpoint = string 177 | protocol = string 178 | })) 179 | default = {} 180 | description = "Subscription options for the LandingZone-SecurityHubFindings SNS topic" 181 | } 182 | 183 | variable "aws_service_control_policies" { 184 | type = object({ 185 | aws_deny_disabling_security_hub = optional(bool, true) 186 | aws_deny_leaving_org = optional(bool, true) 187 | aws_deny_root_user_ous = optional(list(string), []) 188 | aws_require_imdsv2 = optional(bool, true) 189 | principal_exceptions = optional(list(string), []) 190 | }) 191 | default = {} 192 | description = "AWS SCP's parameters to disable required/denied policies, set a list of allowed AWS regions, and set principals that are exempt from the restriction" 193 | } 194 | 195 | variable "aws_sso_permission_sets" { 196 | type = map(object({ 197 | assignments = list(object({ 198 | account_id = string 199 | account_name = string 200 | sso_groups = list(string) 201 | })) 202 | inline_policy = optional(string, null) 203 | managed_policy_arns = optional(list(string), []) 204 | session_duration = optional(string, "PT4H") 205 | })) 206 | default = {} 207 | description = "Map of AWS IAM Identity Center permission sets with AWS accounts and group names that should be granted access to each account" 208 | } 209 | 210 | variable "control_tower_account_ids" { 211 | type = object({ 212 | audit = string 213 | logging = string 214 | }) 215 | description = "Control Tower core account IDs" 216 | } 217 | 218 | variable "datadog" { 219 | type = object({ 220 | api_key = optional(string, null) 221 | api_key_name_prefix = optional(string, "aws-landing-zone-") 222 | create_api_key = optional(bool, false) 223 | cspm_resource_collection_enabled = optional(bool, false) 224 | enable_integration = bool 225 | extended_resource_collection_enabled = optional(bool, false) 226 | install_log_forwarder = optional(bool, false) 227 | log_collection_services = optional(list(string), []) 228 | log_forwarder_version = optional(string) 229 | metric_tag_filters = optional(map(string), {}) 230 | namespace_rules = optional(list(string), []) 231 | site_url = string 232 | }) 233 | default = null 234 | description = "Datadog integration options for the core accounts" 235 | 236 | validation { 237 | condition = ( 238 | # Either Datadog integration config is not supplied = disabled 239 | var.datadog == null || 240 | # Or it's directly disabled 241 | try(var.datadog.enable_integration, false) == false || 242 | # Or it's enabled but the log forwarder is disabled (API key not needed) 243 | (try(var.datadog.enable_integration, false) && try(var.datadog.install_log_forwarder, false) == false) || 244 | # Or the API key is supplied 245 | try(length(var.datadog.api_key), 0) > 0 || 246 | # Or the API key will be created 247 | try(var.datadog.create_api_key, false) 248 | ) 249 | 250 | error_message = "If Datadog integration is enabled, either an API key must be provided or the 'create_api_key' option must be set to true." 251 | } 252 | } 253 | 254 | variable "datadog_excluded_regions" { 255 | type = list(string) 256 | description = "List of regions where metrics collection will be disabled." 257 | default = [] 258 | } 259 | 260 | variable "kms_key_policy" { 261 | type = list(string) 262 | default = [] 263 | description = "A list of valid KMS key policy JSON documents" 264 | } 265 | 266 | variable "kms_key_policy_audit" { 267 | type = list(string) 268 | default = [] 269 | description = "A list of valid KMS key policy JSON document for use with audit KMS key" 270 | } 271 | 272 | variable "kms_key_policy_logging" { 273 | type = list(string) 274 | default = [] 275 | description = "A list of valid KMS key policy JSON document for use with logging KMS key" 276 | } 277 | 278 | variable "monitor_iam_activity" { 279 | type = bool 280 | default = true 281 | description = "Whether IAM activity should be monitored" 282 | } 283 | 284 | variable "monitor_iam_activity_sns_subscription" { 285 | type = map(object({ 286 | endpoint = string 287 | protocol = string 288 | })) 289 | default = {} 290 | description = "Subscription options for the LandingZone-IAMActivity SNS topic" 291 | } 292 | 293 | variable "regions" { 294 | type = object({ 295 | additional_allowed_service_actions_per_region = optional(map(list(string)), {}) 296 | allowed_regions = list(string) 297 | home_region = string 298 | linked_regions = optional(list(string), ["us-east-1"]) 299 | }) 300 | description = "Region configuration, plus global and per-region service SCP exceptions. See the README for more information on the configuration options." 301 | 302 | validation { 303 | condition = length(var.regions.linked_regions) > 0 304 | error_message = "The 'linked_regions' list must include at least one region. By default, 'us-east-1' is specified to ensure the tracking of global resources. Please specify at least one region if overriding the default." 305 | } 306 | } 307 | 308 | variable "path" { 309 | type = string 310 | default = "/" 311 | description = "Optional path for all IAM users, user groups, roles, and customer managed policies created by this module" 312 | } 313 | 314 | variable "ses_root_accounts_mail_forward" { 315 | type = object({ 316 | domain = string 317 | from_email = string 318 | recipient_mapping = map(any) 319 | 320 | dmarc = object({ 321 | policy = optional(string) 322 | rua = optional(string) 323 | ruf = optional(string) 324 | }) 325 | }) 326 | default = null 327 | description = "SES config to receive and forward root account emails" 328 | } 329 | 330 | variable "tags" { 331 | type = map(string) 332 | default = {} 333 | description = "Map of tags" 334 | } 335 | --------------------------------------------------------------------------------