diff --git a/.github/workflows/run-ci-cd.yaml b/.github/workflows/run-ci-cd.yaml index 416fa356c7..8817469077 100644 --- a/.github/workflows/run-ci-cd.yaml +++ b/.github/workflows/run-ci-cd.yaml @@ -476,6 +476,11 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-region: ${{ vars.AWS_REGION }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-duration-seconds: 3600 + role-external-id: ${{ secrets.AWS_ROLE_EXTERNAL_ID }} + role-session-name: GitHubActions-BuildStagingImages + role-skip-session-tagging: true + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/nest-staging-terraform - name: Login to Amazon ECR uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 @@ -618,6 +623,87 @@ jobs: frontend-sbom-${{ env.RELEASE_VERSION }}.cdx.json timeout-minutes: 5 + bootstrap-staging-nest: + name: Bootstrap Nest Staging + env: + TF_INPUT: false + TF_IN_AUTOMATION: true + environment: staging + if: | + github.repository == 'OWASP/Nest' && + github.ref == 'refs/heads/main' + needs: + - scan-staging-images + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + aws-access-key-id: ${{ secrets.BOOTSTRAP_AWS_ACCESS_KEY_ID }} + aws-region: ${{ vars.AWS_REGION }} + aws-secret-access-key: ${{ secrets.BOOTSTRAP_AWS_SECRET_ACCESS_KEY }} + + - name: Install Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd + with: + terraform_version: 1.14.0 + + - name: Prepare terraform backend + env: + AWS_REGION: ${{ vars.AWS_REGION }} + TF_STATE_BUCKET_NAME: ${{ secrets.BOOTSTRAP_TF_STATE_BUCKET_NAME }} + TF_STATE_DYNAMODB_TABLE_NAME: ${{ secrets.BOOTSTRAP_TF_STATE_DYNAMODB_TABLE_NAME }} + run: | + umask 377 + cat > infrastructure/bootstrap/terraform.tfbackend <<-EOF + bucket="$TF_STATE_BUCKET_NAME" + dynamodb_table="$TF_STATE_DYNAMODB_TABLE_NAME" + region="$AWS_REGION" + EOF + + - name: Prepare terraform variables + env: + AWS_REGION: ${{ vars.AWS_REGION }} + PROJECT_NAME: 'nest' + AWS_ROLE_EXTERNAL_ID: ${{ secrets.AWS_ROLE_EXTERNAL_ID }} + run: | + umask 377 + cat > infrastructure/bootstrap/terraform.tfvars <<-EOF + aws_region="$AWS_REGION" + project_name="$PROJECT_NAME" + aws_role_external_id="$AWS_ROLE_EXTERNAL_ID" + EOF + + - name: Terraform Init + working-directory: infrastructure/bootstrap + run: terraform init -backend-config=terraform.tfbackend + + - name: Terraform Validate + working-directory: infrastructure/bootstrap + run: terraform validate + + - name: Terraform Plan + working-directory: infrastructure/bootstrap + run: terraform plan -out=tfplan + + - name: Show plan in summary + working-directory: infrastructure/bootstrap + run: | + echo "## Bootstrap Terraform Plan Output" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + terraform show -no-color tfplan >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Terraform Apply + working-directory: infrastructure/bootstrap + run: terraform apply -auto-approve tfplan + timeout-minutes: 10 + plan-staging-nest: name: Plan Nest Staging env: @@ -628,6 +714,7 @@ jobs: github.repository == 'OWASP/Nest' && github.ref == 'refs/heads/main' needs: + - bootstrap-staging-nest - scan-staging-images - set-release-version permissions: @@ -643,6 +730,11 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-region: ${{ vars.AWS_REGION }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-duration-seconds: 3600 + role-external-id: ${{ secrets.AWS_ROLE_EXTERNAL_ID }} + role-session-name: GitHubActions-StagingPlan + role-skip-session-tagging: true + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/nest-staging-terraform - name: Install Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd @@ -671,7 +763,7 @@ jobs: ECS_USE_FARGATE_SPOT: true ENVIRONMENT: 'staging' FRONTEND_USE_FARGATE_SPOT: true - LAMBDA_FUNCTION_NAME: ${{ secrets.ZAPPA_LAMBDA_FUNCTION_NAME }} + LAMBDA_FUNCTION_NAME: 'nest-staging' PROJECT_NAME: 'nest' run: | umask 377 @@ -740,6 +832,11 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-region: ${{ vars.AWS_REGION }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-duration-seconds: 3600 + role-external-id: ${{ secrets.AWS_ROLE_EXTERNAL_ID }} + role-session-name: GitHubActions-StagingDeploy + role-skip-session-tagging: true + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/nest-staging-terraform - name: Install Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd @@ -800,7 +897,7 @@ jobs: - name: Install backend dependencies working-directory: backend - run: poetry sync --no-root --without dev --without test --without video + run: poetry sync --no-root --without test --without video - name: Prepare Zappa settings env: diff --git a/.gitignore b/.gitignore index 5506760f1f..2400fade1e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,10 +38,10 @@ backend/fuzzing_results/ !*.tfvars.example **/.terraform/ backend-sbom-local.cdx.json -backend/*nest-backend-dev*.tar.gz -backend/*nest-backend-dev*.zip -backend/*nest-backend-staging*.tar.gz -backend/*nest-backend-staging*.zip +backend/*nest-dev*.tar.gz +backend/*nest-dev*.zip +backend/*nest-staging*.tar.gz +backend/*nest-staging*.zip backend/data/backup* backend/generated_videos/ backend/staticfiles diff --git a/backend/zappa_settings.template.json b/backend/zappa_settings.template.json index 858ee682f8..09b32a29a4 100644 --- a/backend/zappa_settings.template.json +++ b/backend/zappa_settings.template.json @@ -25,7 +25,7 @@ ], "manage_roles": true, "memory_size": 3008, - "project_name": "nest-backend", + "project_name": "nest", "regex_excludes": [ "/boto3/", "/boto3-", diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 5d8808d9bf..0faa5c95af 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -169,6 +169,7 @@ pyyaml quasis rediss relativedelta +replicationgroup repositorycontributor requirepass rqworker @@ -184,6 +185,7 @@ skillstruck slackbot slideshare speakerdeck +subgrp superfences tfbackend tgz diff --git a/infrastructure/README.md b/infrastructure/README.md index f4a6551201..c99c2f02a8 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -9,13 +9,15 @@ Ensure you have the following setup/installed: - Setup Project: [CONTRIBUTING.md](https://github.com/OWASP/Nest/blob/main/CONTRIBUTING.md) - Terraform: [Terraform Documentation](https://developer.hashicorp.com/terraform/docs) - AWS CLI: [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) -- An AWS account with credentials configured locally. +- An AWS account. +Note: Refer to the respective `README.md` files for more information. ## Setting up the infrastructure Follow these steps to set up the infrastructure: 1. **Setup Backend (one-time setup)**: + - Prerequisite: Create a `nest-backend` IAM user with the policies defined in `infrastructure/backend/README.md`. - Navigate to the backend directory: @@ -44,7 +46,56 @@ Follow these steps to set up the infrastructure: > [!NOTE] > It is recommended to not destroy the backend resources unless absolutely necessary. -2. **Setup Main Infrastructure (staging)**: +2. **Bootstrap IAM Role**: + - Prerequisite: Create a `nest-bootstrap` IAM user with the policies defined in `infrastructure/bootstrap/README.md`. + + - Navigate to the bootstrap directory: + + ```bash + cd infrastructure/bootstrap/ + ``` + + - Create a local terraform variables file: + + ```bash + touch terraform.tfvars + ``` + + - Copy the contents from the example file: + + ```bash + cat terraform.tfvars.example > terraform.tfvars + ``` + + - Create a local backend configuration file: + + ```bash + touch terraform.tfbackend + ``` + + - Copy the contents from the example file: + + ```bash + cat terraform.tfbackend.example > terraform.tfbackend + ``` + + > [!NOTE] + > Update the state bucket name in `terraform.tfbackend` with the name of the state bucket (bootstrap) created in the previous step. + + - Initialize Terraform if needed: + + ```bash + terraform init -backend-config=terraform.tfbackend + ``` + + - Apply the changes to create the bootstrap resources: + + ```bash + terraform apply + ``` + +3. **Setup Main Infrastructure (staging)**: + - Prerequisite: Create a `nest-staging` IAM user with the policies defined in `infrastructure/staging/README.md` - Navigate to the main infrastructure directory. If you are in `infrastructure/backend`, you can use: @@ -94,7 +145,7 @@ Follow these steps to set up the infrastructure: terraform apply ``` -3. **Populate Secrets** +4. **Populate Secrets** - Visit the AWS Console > Systems Manager > Parameter Store. - Populate all `DJANGO_*` secrets that have `to-be-set-in-aws-console` value. @@ -156,7 +207,7 @@ The Django backend deployment is managed by Zappa. This includes the IAM roles, - Update `terraform.tfvars` with the Lambda details: ```hcl - lambda_function_name = "nest-backend-staging" + lambda_function_name = "nest-staging" ``` - Apply the changes to create ALB routing: @@ -299,8 +350,8 @@ Migrate and load data into the new database. ```bash aws ecs update-service \ - --cluster owasp-nest-staging-frontend-cluster \ - --service owasp-nest-staging-frontend-service \ + --cluster nest-staging-frontend-cluster \ + --service nest-staging-frontend-service \ --force-new-deployment \ --region us-east-2 ``` diff --git a/infrastructure/backend/README.md b/infrastructure/backend/README.md new file mode 100644 index 0000000000..86f31f1b25 --- /dev/null +++ b/infrastructure/backend/README.md @@ -0,0 +1,99 @@ +## Inline Permissions +Use the following inline permissions for the `nest-backend` IAM User +*Note*: replace ${AWS_ACCOUNT_ID} and ${AWS_BACKEND_KMS_KEY_ARN} with appropriate values. +*Note*: use "*" instead of `AWS_BACKEND_KMS_KEY_ARN` on first `terraform apply`. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3Management", + "Effect": "Allow", + "Action": [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:DeleteBucketPolicy", + "s3:GetAccelerateConfiguration", + "s3:GetBucketAcl", + "s3:GetBucketCors", + "s3:GetBucketLogging", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketPolicy", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketRequestPayment", + "s3:GetBucketTagging", + "s3:GetBucketVersioning", + "s3:GetBucketWebsite", + "s3:GetEncryptionConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetObject", + "s3:GetReplicationConfiguration", + "s3:ListBucket", + "s3:PutBucketLogging", + "s3:PutBucketObjectLockConfiguration", + "s3:PutBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketTagging", + "s3:PutBucketVersioning", + "s3:PutEncryptionConfiguration", + "s3:PutLifecycleConfiguration", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::nest-*-terraform-state*", + "arn:aws:s3:::nest-*-terraform-state*/*" + ] + }, + { + "Sid": "DynamoDBManagement", + "Effect": "Allow", + "Action": [ + "dynamodb:CreateTable", + "dynamodb:DeleteTable", + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeTable", + "dynamodb:DescribeTimeToLive", + "dynamodb:ListTagsOfResource", + "dynamodb:TagResource", + "dynamodb:UntagResource", + "dynamodb:UpdateContinuousBackups", + "dynamodb:UpdateTable" + ], + "Resource": "arn:aws:dynamodb:*:${AWS_ACCOUNT_ID}:table/nest-*-terraform-state-lock" + }, + { + "Sid": "KMSCreateManagement", + "Effect": "Allow", + "Action": [ + "kms:CreateKey", + "kms:ListAliases", + "kms:ListKeys" + ], + "Resource": "*" + }, + { + "Sid": "KMSManagement", + "Effect": "Allow", + "Action": [ + "kms:CreateAlias", + "kms:CreateGrant", + "kms:DeleteAlias", + "kms:DescribeKey", + "kms:DisableKeyRotation", + "kms:EnableKeyRotation", + "kms:GetKeyPolicy", + "kms:GetKeyRotationStatus", + "kms:ListResourceTags", + "kms:PutKeyPolicy", + "kms:ScheduleKeyDeletion", + "kms:TagResource", + "kms:UntagResource", + "kms:UpdateAlias", + "kms:UpdateKeyDescription" + ], + "Resource": "${AWS_BACKEND_KMS_KEY_ARN}" + } + ] +} +``` diff --git a/infrastructure/backend/main.tf b/infrastructure/backend/main.tf index d3a0b76456..e16b5a71ad 100644 --- a/infrastructure/backend/main.tf +++ b/infrastructure/backend/main.tf @@ -18,29 +18,50 @@ locals { ManagedBy = "Terraform" Project = var.project_name } + state_environments = toset(var.state_environments) +} + +module "kms" { + source = "../modules/kms" + + common_tags = local.common_tags + environment = "backend" + project_name = var.project_name +} + +resource "random_id" "suffix" { + byte_length = 4 } data "aws_iam_policy_document" "logs" { + for_each = local.state_environments + statement { actions = ["s3:PutObject"] effect = "Allow" - resources = ["${aws_s3_bucket.logs.arn}/*"] + resources = ["${aws_s3_bucket.logs[each.key].arn}/*"] sid = "s3-log-delivery" principals { - type = "Service" identifiers = ["logging.s3.amazonaws.com"] + type = "Service" } } } data "aws_iam_policy_document" "state_https_only" { + for_each = local.state_environments policy_id = "ForceHTTPS" statement { actions = ["s3:*"] - sid = "HTTPSOnly" effect = "Deny" + sid = "HTTPSOnly" + + resources = [ + aws_s3_bucket.state[each.key].arn, + "${aws_s3_bucket.state[each.key].arn}/*", + ] condition { test = "Bool" @@ -51,31 +72,18 @@ data "aws_iam_policy_document" "state_https_only" { identifiers = ["*"] type = "AWS" } - resources = [ - aws_s3_bucket.state.arn, - "${aws_s3_bucket.state.arn}/*", - ] } } -module "kms" { - source = "../modules/kms" - - common_tags = local.common_tags - environment = "backend" - project_name = var.project_name -} - -resource "random_id" "suffix" { - byte_length = 4 -} - resource "aws_dynamodb_table" "state_lock" { - name = "${var.project_name}-terraform-state-lock" + for_each = local.state_environments + billing_mode = "PAY_PER_REQUEST" hash_key = "LockID" + name = "${var.project_name}-${each.key}-terraform-state-lock" tags = merge(local.common_tags, { - Name = "${var.project_name}-terraform-state-lock" + Environment = each.key + Name = "${var.project_name}-${each.key}-terraform-state-lock" }) attribute { @@ -95,33 +103,26 @@ resource "aws_dynamodb_table" "state_lock" { } resource "aws_s3_bucket" "logs" { # NOSONAR - bucket = "${var.project_name}-terraform-state-logs-${random_id.suffix.hex}" + for_each = local.state_environments - lifecycle { - prevent_destroy = true - } + bucket = "${var.project_name}-${each.key}-terraform-state-logs-${random_id.suffix.hex}" tags = merge(local.common_tags, { - Name = "${var.project_name}-terraform-state-logs" + Environment = each.key + Name = "${var.project_name}-${each.key}-terraform-state-logs" }) -} - -resource "aws_s3_bucket" "state" { # NOSONAR - bucket = "${var.project_name}-terraform-state-${random_id.suffix.hex}" - object_lock_enabled = true lifecycle { prevent_destroy = true } - tags = merge(local.common_tags, { - Name = "${var.project_name}-terraform-state" - }) } -resource "aws_s3_bucket_lifecycle_configuration" "state" { - bucket = aws_s3_bucket.state.id +resource "aws_s3_bucket_lifecycle_configuration" "logs" { + for_each = local.state_environments + + bucket = aws_s3_bucket.logs[each.key].id rule { - id = "delete-old-versions" + id = "expire-logs" status = "Enabled" abort_incomplete_multipart_upload { @@ -130,33 +131,97 @@ resource "aws_s3_bucket_lifecycle_configuration" "state" { noncurrent_version_expiration { noncurrent_days = var.noncurrent_version_expiration_days } + expiration { + days = var.expire_log_days + } } } -resource "aws_s3_bucket_lifecycle_configuration" "logs" { - bucket = aws_s3_bucket.logs.id +resource "aws_s3_bucket_policy" "logs" { + for_each = local.state_environments + + bucket = aws_s3_bucket.logs[each.key].id + policy = data.aws_iam_policy_document.logs[each.key].json +} + +resource "aws_s3_bucket_public_access_block" "logs" { + for_each = local.state_environments + + block_public_acls = true + block_public_policy = true + bucket = aws_s3_bucket.logs[each.key].id + ignore_public_acls = true + restrict_public_buckets = true +} + +#trivy:ignore:AVD-AWS-0132 +resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { + for_each = local.state_environments + + bucket = aws_s3_bucket.logs[each.key].id rule { - id = "expire-logs" + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "logs" { + for_each = local.state_environments + + bucket = aws_s3_bucket.logs[each.key].id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket" "state" { # NOSONAR + for_each = local.state_environments + + bucket = "${var.project_name}-${each.key}-terraform-state-${random_id.suffix.hex}" + object_lock_enabled = true + tags = merge(local.common_tags, { + Environment = each.key + Name = "${var.project_name}-${each.key}-terraform-state" + }) + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "state" { + for_each = local.state_environments + + bucket = aws_s3_bucket.state[each.key].id + + rule { + id = "delete-old-versions" status = "Enabled" abort_incomplete_multipart_upload { days_after_initiation = var.abort_incomplete_multipart_upload_days } - expiration { - days = var.expire_log_days + noncurrent_version_expiration { + noncurrent_days = var.noncurrent_version_expiration_days } } } resource "aws_s3_bucket_logging" "state" { - bucket = aws_s3_bucket.state.id - target_bucket = aws_s3_bucket.logs.id + for_each = local.state_environments + + bucket = aws_s3_bucket.state[each.key].id + target_bucket = aws_s3_bucket.logs[each.key].id target_prefix = "s3/" } resource "aws_s3_bucket_object_lock_configuration" "state" { - bucket = aws_s3_bucket.state.id + for_each = local.state_environments + + bucket = aws_s3_bucket.state[each.key].id rule { default_retention { @@ -166,45 +231,29 @@ resource "aws_s3_bucket_object_lock_configuration" "state" { } } -resource "aws_s3_bucket_policy" "logs" { - bucket = aws_s3_bucket.logs.id - policy = data.aws_iam_policy_document.logs.json -} - resource "aws_s3_bucket_policy" "state" { - bucket = aws_s3_bucket.state.id - policy = data.aws_iam_policy_document.state_https_only.json -} + for_each = local.state_environments -resource "aws_s3_bucket_public_access_block" "logs" { - block_public_acls = true - block_public_policy = true - bucket = aws_s3_bucket.logs.id - ignore_public_acls = true - restrict_public_buckets = true + bucket = aws_s3_bucket.state[each.key].id + policy = data.aws_iam_policy_document.state_https_only[each.key].json } resource "aws_s3_bucket_public_access_block" "state" { + for_each = local.state_environments + block_public_acls = true block_public_policy = true - bucket = aws_s3_bucket.state.id + bucket = aws_s3_bucket.state[each.key].id ignore_public_acls = true restrict_public_buckets = true } -#trivy:ignore:AVD-AWS-0132 -resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { - bucket = aws_s3_bucket.logs.id - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} - #trivy:ignore:AVD-AWS-0132 resource "aws_s3_bucket_server_side_encryption_configuration" "state" { - bucket = aws_s3_bucket.state.id + for_each = local.state_environments + + bucket = aws_s3_bucket.state[each.key].id + rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" @@ -213,14 +262,10 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "state" { } resource "aws_s3_bucket_versioning" "state" { - bucket = aws_s3_bucket.state.id - versioning_configuration { - status = "Enabled" - } -} + for_each = local.state_environments + + bucket = aws_s3_bucket.state[each.key].id -resource "aws_s3_bucket_versioning" "logs" { - bucket = aws_s3_bucket.logs.id versioning_configuration { status = "Enabled" } diff --git a/infrastructure/backend/outputs.tf b/infrastructure/backend/outputs.tf index 46ecb4e598..98e73812ca 100644 --- a/infrastructure/backend/outputs.tf +++ b/infrastructure/backend/outputs.tf @@ -1,9 +1,9 @@ -output "dynamodb_table_name" { - description = "The name of the DynamoDB table for Terraform state locking" - value = aws_dynamodb_table.state_lock.name +output "dynamodb_table_names" { + description = "The names of the per-environment DynamoDB tables for Terraform state locking." + value = { for env, table in aws_dynamodb_table.state_lock : env => table.name } } -output "s3_bucket_name" { - description = "The name of the S3 bucket for Terraform state" - value = aws_s3_bucket.state.bucket +output "state_bucket_names" { + description = "The names of the per-environment S3 buckets for Terraform state." + value = { for env, bucket in aws_s3_bucket.state : env => bucket.bucket } } diff --git a/infrastructure/backend/variables.tf b/infrastructure/backend/variables.tf index a0365669a4..6fef5536a9 100644 --- a/infrastructure/backend/variables.tf +++ b/infrastructure/backend/variables.tf @@ -27,3 +27,16 @@ variable "project_name" { type = string default = "nest" } + +variable "state_environments" { + description = "A list of environments to create separate state buckets for." + type = list(string) + default = ["bootstrap", "staging"] + + validation { + condition = alltrue([ + for env in var.state_environments : contains(["bootstrap", "staging"], env) + ]) + error_message = "Each environment must be 'bootstrap' or 'staging'." + } +} diff --git a/infrastructure/bootstrap/.terraform.lock.hcl b/infrastructure/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000000..55057fd2e7 --- /dev/null +++ b/infrastructure/bootstrap/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.22.0" + constraints = "6.22.0" + hashes = [ + "h1:TV1UZ7DzioV1EUY/lMS+eIInU379DA1Q2QwnEGGZMks=", + "zh:0ed7ceb13bade9076021a14f995d07346d3063f4a419a904d5804d76e372bbda", + "zh:195dcde5a4b0def82bc3379053edc13941ff94ea5905808fe575f7c7bbd66693", + "zh:4047c4dba121d29859b72d2155c47f969b41d3c5768f73dff5d8a0cc55f74e52", + "zh:5694f37d6ea69b6f96dfb30d53e66f7a41c1aad214c212b6ffa54bdd799e3b27", + "zh:6cf8bb7d984b1fae9fd10d6ce1e62f6c10751a1040734b75a1f7286609782e49", + "zh:737d0e600dfe2626b4d6fc5dd2b24c0997fd983228a7a607b9176a1894a281a0", + "zh:7d328a195ce36b1170afe6758cf88223c8765620211f5cc0451bdd6899243b4e", + "zh:7edb4bc34baeba92889bd9ed50b34c04b3eeb3d8faa8bb72699c6335a2e95bab", + "zh:8e71836814e95454b00c51f3cb3e10fd78a59f7dc4c5362af64233fee989790d", + "zh:9367f63b23d9ddfab590b2247a8ff5ccf83410cbeca43c6e441c488c45efff4c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a007de80ffde8539a73ee39fcfbe7ed12e025c98cd29b2110a7383b41a4aad39", + "zh:aae7b7aed8bf3a4bea80a9a2f08fef1adeb748beff236c4a54af93bb6c09a56c", + "zh:b5a16b59d4210c1eaf35c8c027ecdab9e074dd081d602f5112eecdebf2e1866d", + "zh:d479bad0a004e4893bf0ba6c6cd867fefd14000051bbe3de5b44a925e3d46cd5", + ] +} diff --git a/infrastructure/bootstrap/README.md b/infrastructure/bootstrap/README.md new file mode 100644 index 0000000000..ae6a1abc5b --- /dev/null +++ b/infrastructure/bootstrap/README.md @@ -0,0 +1,85 @@ +## Users +`bootstrap` creates a role for each environment that IAM users can assume. +These users are listed in the `var.environments` variable. +Ensure your IAM Users follow the naming convention: +- nest-${var.environment} + +Example: `nest-staging`, `nest-bootstrap`, etc. + +## Inline Permissions +Use the following inline permissions for the `nest-bootstrap` IAM User +*Note*: replace ${AWS_ACCOUNT_ID} and ${AWS_BACKEND_KMS_KEY_ARN} with appropriate values. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3StateAccess", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::nest-bootstrap-terraform-state-*", + "arn:aws:s3:::nest-bootstrap-terraform-state-*/*" + ] + }, + { + "Sid": "DynamoDBStateLocking", + "Effect": "Allow", + "Action": [ + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem" + ], + "Resource": "arn:aws:dynamodb:*:${AWS_ACCOUNT_ID}:table/nest-bootstrap-terraform-state-lock" + }, + { + "Sid": "IAMManagement", + "Effect": "Allow", + "Action": [ + "iam:AttachRolePolicy", + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:CreateRole", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListPolicyVersions", + "iam:ListRolePolicies", + "iam:PutRolePolicy", + "iam:TagPolicy", + "iam:TagRole", + "iam:UntagPolicy", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole" + ], + "Resource": [ + "arn:aws:iam::${AWS_ACCOUNT_ID}:role/nest-*-terraform", + "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/nest-*-terraform" + ] + }, + { + "Sid": "KMSManagement", + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "${AWS_BACKEND_KMS_KEY_ARN}" + } + ] +} +``` diff --git a/infrastructure/bootstrap/backend.tf b/infrastructure/bootstrap/backend.tf new file mode 100644 index 0000000000..864fb653b9 --- /dev/null +++ b/infrastructure/bootstrap/backend.tf @@ -0,0 +1,6 @@ +terraform { + backend "s3" { + encrypt = true + key = "bootstrap/terraform.tfstate" + } +} diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf new file mode 100644 index 0000000000..3d26c6a472 --- /dev/null +++ b/infrastructure/bootstrap/main.tf @@ -0,0 +1,642 @@ +terraform { + required_version = "1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.22.0" + } + } +} + +locals { + common_tags = { + Environment = "bootstrap" + ManagedBy = "Terraform" + Project = var.project_name + } + environments = toset(var.environments) +} + +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "part_one" { + for_each = local.environments + + statement { + sid = "GlobalDiscovery" + effect = "Allow" + actions = [ + "acm:DescribeCertificate", + "application-autoscaling:DescribeScalableTargets", + "application-autoscaling:DescribeScalingPolicies", + "ec2:Describe*", + "ec2:DescribeFlowLogs", + "ec2:DescribeNetworkAcls", + "ecr:DescribeRepositories", + "ecs:DescribeTaskDefinition", + "elasticache:DescribeCacheClusters", + "elasticache:DescribeCacheSubnetGroups", + "elasticache:DescribeReplicationGroups", + "elasticloadbalancing:Describe*", + "events:ListRuleNamesByTarget", + "kms:DescribeKey", + "lambda:ListFunctions", + "lambda:ListVersionsByFunction", + "logs:DescribeLogGroups", + "rds:DescribeDBInstances", + "rds:DescribeDBSubnetGroups", + "secretsmanager:DescribeSecret", + "ssm:DescribeParameters", + ] + resources = ["*"] + } + + statement { + sid = "ACMManagement" + effect = "Allow" + actions = [ + "acm:AddTagsToCertificate", + "acm:DeleteCertificate", + "acm:ListCertificates", + "acm:ListTagsForCertificate", + "acm:RemoveTagsFromCertificate", + "acm:RequestCertificate", + "acm:ResendValidationEmail", + "acm:UpdateCertificateOptions", + ] + resources = ["arn:aws:acm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:certificate/*"] + } + + statement { + sid = "AppAutoscalingManagement" + effect = "Allow" + actions = [ + "application-autoscaling:DeleteScalingPolicy", + "application-autoscaling:DeregisterScalableTarget", + "application-autoscaling:ListTagsForResource", + "application-autoscaling:PutScalingPolicy", + "application-autoscaling:RegisterScalableTarget", + "application-autoscaling:TagResource", + "application-autoscaling:UntagResource", + ] + resources = ["*"] + } + + statement { + sid = "CloudWatchLogsManagement" + effect = "Allow" + actions = [ + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:ListTagsForResource", + "logs:ListTagsLogGroup", + "logs:PutRetentionPolicy", + "logs:TagLogGroup", + "logs:TagResource", + "logs:UntagLogGroup", + "logs:UntagResource", + ] + resources = ["arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*"] + } + + statement { + sid = "DynamoDBStateLocking" + effect = "Allow" + actions = [ + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + ] + resources = [ + "arn:aws:dynamodb:*:${data.aws_caller_identity.current.account_id}:table/${var.project_name}-${each.key}-terraform-state-lock", + ] + } + + statement { + sid = "ElastiCacheManagement" + effect = "Allow" + actions = [ + "elasticache:AddTagsToResource", + "elasticache:CreateCacheSubnetGroup", + "elasticache:CreateReplicationGroup", + "elasticache:DeleteCacheSubnetGroup", + "elasticache:DeleteReplicationGroup", + "elasticache:ListTagsForResource", + "elasticache:ModifyCacheSubnetGroup", + "elasticache:ModifyReplicationGroup", + "elasticache:RemoveTagsFromResource", + ] + resources = [ + "arn:aws:elasticache:${var.aws_region}:${data.aws_caller_identity.current.account_id}:cluster:${var.project_name}-${each.key}-*", + "arn:aws:elasticache:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parametergroup:*", + "arn:aws:elasticache:${var.aws_region}:${data.aws_caller_identity.current.account_id}:replicationgroup:${var.project_name}-${each.key}-*", + "arn:aws:elasticache:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnetgroup:${var.project_name}-${each.key}-*", + ] + } + + statement { + sid = "EC2Management" + effect = "Allow" + actions = [ + "ec2:AllocateAddress", + "ec2:AssociateRouteTable", + "ec2:AttachInternetGateway", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:CreateFlowLogs", + "ec2:CreateInternetGateway", + "ec2:CreateNatGateway", + "ec2:CreateNetworkAcl", + "ec2:CreateNetworkAclEntry", + "ec2:CreateRoute", + "ec2:CreateRouteTable", + "ec2:CreateSecurityGroup", + "ec2:CreateSubnet", + "ec2:CreateTags", + "ec2:CreateVpc", + "ec2:CreateVpcEndpoint", + "ec2:DeleteFlowLogs", + "ec2:DeleteInternetGateway", + "ec2:DeleteNatGateway", + "ec2:DeleteNetworkAcl", + "ec2:DeleteNetworkAclEntry", + "ec2:DeleteNetworkInterface", + "ec2:DeleteRoute", + "ec2:DeleteRouteTable", + "ec2:DeleteSecurityGroup", + "ec2:DeleteSubnet", + "ec2:DeleteTags", + "ec2:DeleteVpc", + "ec2:DeleteVpcEndpoints", + "ec2:DetachInternetGateway", + "ec2:DisassociateAddress", + "ec2:DisassociateRouteTable", + "ec2:ModifySubnetAttribute", + "ec2:ModifyVpcAttribute", + "ec2:ModifyVpcEndpoint", + "ec2:ReleaseAddress", + "ec2:ReplaceNetworkAclAssociation", + "ec2:ReplaceNetworkAclEntry", + "ec2:ReplaceRoute", + "ec2:ReplaceRouteTableAssociation", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + ] + resources = ["*"] + } + + statement { + sid = "ECRAuth" + effect = "Allow" + actions = [ + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + } + + statement { + sid = "ECRManagement" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:CreateRepository", + "ecr:DeleteLifecyclePolicy", + "ecr:DeleteRepository", + "ecr:GetDownloadUrlForLayer", + "ecr:GetLifecyclePolicy", + "ecr:GetRepositoryPolicy", + "ecr:InitiateLayerUpload", + "ecr:ListImages", + "ecr:ListTagsForResource", + "ecr:PutImage", + "ecr:PutImageScanningConfiguration", + "ecr:PutLifecyclePolicy", + "ecr:SetRepositoryPolicy", + "ecr:TagResource", + "ecr:UntagResource", + "ecr:UploadLayerPart", + ] + resources = [ + "arn:aws:ecr:*:${data.aws_caller_identity.current.account_id}:repository/${var.project_name}-${each.key}-*", + ] + } + + statement { + sid = "ECSClusterManagement" + effect = "Allow" + actions = [ + "ecs:CreateCluster", + "ecs:DeleteCluster", + "ecs:DescribeClusters", + "ecs:ListTagsForResource", + "ecs:PutClusterCapacityProviders", + "ecs:TagResource", + "ecs:UntagResource", + "ecs:UpdateCluster", + ] + resources = ["arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:cluster/${var.project_name}-${each.key}-*"] + } + + statement { + sid = "ECSGlobal" + effect = "Allow" + actions = [ + "ecs:DeregisterTaskDefinition", + "ecs:ListClusters", + "ecs:ListTaskDefinitions", + "ecs:RegisterTaskDefinition", + "ecs:TagResource", + ] + resources = ["*"] + } + + statement { + sid = "ECSOrchestration" + effect = "Allow" + actions = [ + "ecs:RunTask", + "ecs:DescribeTasks", + "ecs:StopTask", + "ecs:DescribeServices", + "ecs:UpdateService" + ] + resources = [ + "arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task-definition/${var.project_name}-*:*", + "arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:service/${var.project_name}-*/*", + "arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:cluster/${var.project_name}-*", + "arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task/${var.project_name}-*/*" + ] + } + + statement { + sid = "ECSServiceManagement" + effect = "Allow" + actions = [ + "ecs:CreateService", + "ecs:DeleteService", + "ecs:DescribeServices", + "ecs:UpdateService", + ] + resources = ["arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:service/${var.project_name}-${each.key}-*/*"] + } + + statement { + sid = "ECSTaskDefinition" + effect = "Allow" + actions = [ + "ecs:DescribeTaskDefinition", + "ecs:TagResource", + ] + resources = ["arn:aws:ecs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:task-definition/${var.project_name}-${each.key}-*:*"] + } +} + +data "aws_iam_policy_document" "part_two" { + for_each = local.environments + + statement { + sid = "ELBManagement" + effect = "Allow" + actions = [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:CreateTargetGroup", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:DeleteRule", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:DeregisterTargets", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:ModifyRule", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:RemoveTags", + "elasticloadbalancing:SetRulePriorities", + "elasticloadbalancing:SetSecurityGroups", + ] + resources = ["*"] + } + + statement { + sid = "EventBridgeManagement" + effect = "Allow" + actions = [ + "events:DeleteRule", + "events:DescribeRule", + "events:ListTagsForResource", + "events:ListTargetsByRule", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + "events:TagResource", + "events:UntagResource", + ] + resources = [ + "arn:aws:events:*:${data.aws_caller_identity.current.account_id}:rule/${var.project_name}-${each.key}-*", + ] + } + + statement { + sid = "IAMManagement" + effect = "Allow" + actions = [ + "iam:AttachRolePolicy", + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:CreateRole", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:DeleteRole", + "iam:DetachRolePolicy", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListPolicyVersions", + "iam:ListRolePolicies", + "iam:PutRolePolicy", + "iam:TagPolicy", + "iam:TagRole", + "iam:UntagPolicy", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${var.project_name}-${each.key}-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${var.project_name}-*-${each.key}-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.project_name}-${each.key}-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.project_name}-*-${each.key}-*", + ] + } + + statement { + sid = "IAMPassRole" + effect = "Allow" + actions = [ + "iam:PassRole", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.project_name}-${each.key}-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.project_name}-*-${each.key}-*", + ] + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = [ + "ecs-tasks.amazonaws.com", + "events.amazonaws.com", + "lambda.amazonaws.com", + "rds.amazonaws.com", + "vpc-flow-logs.amazonaws.com" + ] + } + } + + statement { + sid = "KMSManagement" + effect = "Allow" + actions = [ + "kms:CreateKey", + "kms:DisableKeyRotation", + "kms:EnableKeyRotation", + "kms:GetKeyPolicy", + "kms:GetKeyRotationStatus", + "kms:ListAliases", + "kms:ListResourceTags", + "kms:PutKeyPolicy", + "kms:ScheduleKeyDeletion", + "kms:TagResource", + "kms:UntagResource" + ] + resources = ["*"] + } + + statement { + sid = "KMSKeyUsageAndPolicy" + effect = "Allow" + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:UpdateKeyDescription", + ] + resources = ["*"] + + condition { + test = "ForAnyValue:StringEquals" + variable = "kms:ResourceAliases" + values = [ + "alias/${var.project_name}-backend", + "alias/${var.project_name}-${each.key}" + ] + } + } + + statement { + sid = "KMSAliasManagement" + effect = "Allow" + actions = [ + "kms:CreateAlias", + "kms:DeleteAlias", + "kms:UpdateAlias" + ] + resources = [ + "arn:aws:kms:${var.aws_region}:${data.aws_caller_identity.current.account_id}:alias/${var.project_name}-*", + "arn:aws:kms:${var.aws_region}:${data.aws_caller_identity.current.account_id}:key/*" + ] + } + + statement { + sid = "LambdaManagement" + effect = "Allow" + actions = [ + "lambda:AddPermission", + "lambda:CreateAlias", + "lambda:CreateFunction", + "lambda:DeleteAlias", + "lambda:DeleteFunction", + "lambda:DeleteFunctionConcurrency", + "lambda:GetAlias", + "lambda:GetFunction", + "lambda:GetFunctionCodeSigningConfig", + "lambda:GetFunctionConcurrency", + "lambda:GetFunctionConfiguration", + "lambda:GetFunctionUrlConfig", + "lambda:GetPolicy", + "lambda:InvokeFunction", + "lambda:ListFunctionUrlConfigs", + "lambda:ListTags", + "lambda:ListVersionsByFunction", + "lambda:PublishVersion", + "lambda:PutFunctionConcurrency", + "lambda:RemovePermission", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:UpdateAlias", + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration", + ] + resources = [ + "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:${var.project_name}-${each.key}", + "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:${var.project_name}-${each.key}:*", + ] + } + + statement { + sid = "RDSManagement" + effect = "Allow" + actions = [ + "rds:AddTagsToResource", + "rds:CreateDBInstance", + "rds:CreateDBSubnetGroup", + "rds:DeleteDBInstance", + "rds:DeleteDBSubnetGroup", + "rds:ListTagsForResource", + "rds:ModifyDBInstance", + "rds:ModifyDBSubnetGroup", + "rds:RemoveTagsFromResource", + ] + resources = [ + "arn:aws:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:db:${var.project_name}-${each.key}-*", + "arn:aws:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subgrp:${var.project_name}-${each.key}-*" + ] + } + + statement { + sid = "S3Management" + effect = "Allow" + actions = [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:DeleteBucketPolicy", + "s3:DeleteObject", + "s3:GetAccelerateConfiguration", + "s3:GetBucketAcl", + "s3:GetBucketCors", + "s3:GetBucketLogging", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketOwnershipControls", + "s3:GetBucketPolicy", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketRequestPayment", + "s3:GetBucketTagging", + "s3:GetBucketVersioning", + "s3:GetBucketWebsite", + "s3:GetEncryptionConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetObject", + "s3:GetReplicationConfiguration", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:PutBucketAcl", + "s3:PutBucketLogging", + "s3:PutBucketObjectLockConfiguration", + "s3:PutBucketOwnershipControls", + "s3:PutBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketTagging", + "s3:PutBucketVersioning", + "s3:PutEncryptionConfiguration", + "s3:PutLifecycleConfiguration", + "s3:PutObject", + ] + resources = [ + "arn:aws:s3:::${var.project_name}-${each.key}-*", + "arn:aws:s3:::${var.project_name}-${each.key}-*/*", + ] + } + + statement { + sid = "SecretsManagerManagement" + effect = "Allow" + actions = [ + "secretsmanager:CreateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:GetResourcePolicy", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:RestoreSecret", + "secretsmanager:RotateSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:UpdateSecret", + ] + resources = ["arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:${var.project_name}-${each.key}-*"] + } + + statement { + sid = "SSMManagement" + effect = "Allow" + actions = [ + "ssm:AddTagsToResource", + "ssm:DeleteParameter", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:ListTagsForResource", + "ssm:PutParameter", + "ssm:RemoveTagsFromResource", + ] + resources = ["arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${var.project_name}/${each.key}/*"] + } +} + +resource "aws_iam_role" "terraform" { + for_each = local.environments + + name = "${var.project_name}-${each.key}-terraform" + tags = merge(local.common_tags, { + Environment = each.key + Name = "${var.project_name}-${each.key}-terraform" + }) + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole"] + Condition = { + StringEquals = { + "sts:ExternalId" = var.aws_role_external_id + } + } + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/${var.project_name}-${each.key}" + } + }, + ] + }) +} + +resource "aws_iam_policy" "part_one" { + for_each = local.environments + name = "${var.project_name}-${each.key}-part-one-terraform" + policy = data.aws_iam_policy_document.part_one[each.key].json +} + +resource "aws_iam_policy" "part_two" { + for_each = local.environments + name = "${var.project_name}-${each.key}-part-two-terraform" + policy = data.aws_iam_policy_document.part_two[each.key].json +} + +resource "aws_iam_role_policy_attachment" "attach_part_one" { + for_each = local.environments + role = aws_iam_role.terraform[each.key].name + policy_arn = aws_iam_policy.part_one[each.key].arn +} + +resource "aws_iam_role_policy_attachment" "attach_part_two" { + for_each = local.environments + role = aws_iam_role.terraform[each.key].name + policy_arn = aws_iam_policy.part_two[each.key].arn +} diff --git a/infrastructure/bootstrap/outputs.tf b/infrastructure/bootstrap/outputs.tf new file mode 100644 index 0000000000..904936b111 --- /dev/null +++ b/infrastructure/bootstrap/outputs.tf @@ -0,0 +1,4 @@ +output "terraform_role_arns" { + description = "The ARNs of the Terraform IAM roles, keyed by environment." + value = { for env in local.environments : env => aws_iam_role.terraform[env].arn } +} diff --git a/infrastructure/bootstrap/providers.tf b/infrastructure/bootstrap/providers.tf new file mode 100644 index 0000000000..c9d7ccbdea --- /dev/null +++ b/infrastructure/bootstrap/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = var.aws_region +} diff --git a/infrastructure/bootstrap/terraform.tfbackend.example b/infrastructure/bootstrap/terraform.tfbackend.example new file mode 100644 index 0000000000..4141551d28 --- /dev/null +++ b/infrastructure/bootstrap/terraform.tfbackend.example @@ -0,0 +1,3 @@ +bucket = "${STATE_BUCKET_NAME}" +dynamodb_table = "nest-bootstrap-terraform-state-lock" +region = "us-east-2" diff --git a/infrastructure/bootstrap/terraform.tfvars.example b/infrastructure/bootstrap/terraform.tfvars.example new file mode 100644 index 0000000000..5e9fafb7d0 --- /dev/null +++ b/infrastructure/bootstrap/terraform.tfvars.example @@ -0,0 +1,3 @@ +aws_region = "us-east-2" +aws_role_external_id = ${AWS_ROLE_EXTERNAL_ID} +project_name = "nest" diff --git a/infrastructure/bootstrap/variables.tf b/infrastructure/bootstrap/variables.tf new file mode 100644 index 0000000000..e31de7854f --- /dev/null +++ b/infrastructure/bootstrap/variables.tf @@ -0,0 +1,23 @@ +variable "aws_region" { + description = "The AWS region to deploy resources in." + type = string + default = "us-east-2" +} + +variable "aws_role_external_id" { + description = "The external ID for role assumption." + sensitive = true + type = string +} + +variable "environments" { + description = "The environments to create Terraform roles for." + type = list(string) + default = ["staging"] +} + +variable "project_name" { + description = "The name of the project." + type = string + default = "nest" +} diff --git a/infrastructure/staging/README.md b/infrastructure/staging/README.md new file mode 100644 index 0000000000..e4938aa2cc --- /dev/null +++ b/infrastructure/staging/README.md @@ -0,0 +1,51 @@ +## Inline Permissions +Use the following inline permissions for the `nest-staging` IAM User +*Note*: replace ${...} with appropriate values. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3StateManagement", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::nest-staging-terraform-state-*", + "arn:aws:s3:::nest-staging-terraform-state-*/*" + ] + }, + { + "Sid": "DynamoDBStateManagement", + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem" + ], + "Resource": "arn:aws:dynamodb:${AWS_REGION}:${AWS_ACCOUNT_ID}:table/nest-staging-terraform-state-lock" + }, + { + "Sid": "KMSStateManagement", + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "${AWS_BACKEND_KMS_KEY_ARN}" + }, + { + "Sid": "STSStateManagement", + "Effect": "Allow", + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ], + "Resource": "arn:aws:iam::${AWS_ACCOUNT_ID}:role/nest-staging-terraform" + } + ] +} +``` diff --git a/infrastructure/staging/main.tf b/infrastructure/staging/main.tf index e13f117f1b..4a49e135b3 100644 --- a/infrastructure/staging/main.tf +++ b/infrastructure/staging/main.tf @@ -15,8 +15,8 @@ locals { ManagedBy = "Terraform" Project = var.project_name } - fixtures_bucket_name = coalesce(var.fixtures_bucket_name, "${var.project_name}-fixtures") - zappa_bucket_name = coalesce(var.zappa_bucket_name, "${var.project_name}-zappa-deployments") + fixtures_bucket_name = coalesce(var.fixtures_bucket_name, "${var.project_name}-${var.environment}-fixtures") + zappa_bucket_name = coalesce(var.zappa_bucket_name, "${var.project_name}-${var.environment}-zappa-deployments") } module "alb" { diff --git a/infrastructure/staging/terraform.tfbackend.example b/infrastructure/staging/terraform.tfbackend.example index 957bf8a97b..f42bcce998 100644 --- a/infrastructure/staging/terraform.tfbackend.example +++ b/infrastructure/staging/terraform.tfbackend.example @@ -1,3 +1,3 @@ bucket = "${STATE_BUCKET_NAME}" -dynamodb_table = "nest-terraform-state-lock" +dynamodb_table = "nest-staging-terraform-state-lock" region = "us-east-2"