From 46ef9fa07758d5318c55bad185789e9ec02e7ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?= <52226147+Atom-oh@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:53:50 +0900 Subject: [PATCH] Public-repo log hygiene: secrets for role ARNs, account-id masking, admin creds via TF_VAR secrets (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * public-repo log hygiene: role ARNs to secrets, account-id masking, admin creds out of tfvars Actions logs on a public repo are publicly readable; repo VARIABLES are not masked there. Role ARNs (account-id-bearing) move to same-named SECRETS, every configure-aws-credentials step sets mask-aws-account-id: true, and admin_email/admin_password leave the tfvars blobs — terraform.yml exports them from TF_VAR_ADMIN_* secrets as TF_VAR_ env. * close the plan-artifact channel + guard un-stripped tfvars (review follow-up) A tfplan embeds all variable values in plaintext and public-repo artifacts are world-downloadable: the plan job now encrypts the artifact with the TF_PLAN_ENC_KEY secret (fail-closed) and apply decrypts it. Both restore steps hard-fail if the restored tfvars still carries admin_email/ admin_password (tfvars outranks TF_VAR_ env — a stale blob would silently bypass the secrets channel). Apply's missing-role error now says secret. * demo user per stack, admin user gated off, IAM views admin-only Every stack now creates one regular Cognito user (demo_email, default demo@awsops.local; password via the TF_VAR_DEMO_PASSWORD secret on the plan step only). The admin user is no longer created by default (create_admin_user=false) — enabling it is a per-stack decision with per-stack credentials, resolving the shared-admin-credential review MAJOR. An 'admins' Cognito group backs web/lib/admin.ts's group check, and /api/security now withholds iam_no_mfa findings from non-admins (same convention as the admin-gated iam_user/iam_role inventory types); the security page renders only the checks the API returns. The apply job also cleans decrypted plan/config files off the persistent runner (if: always()) and no longer receives TF_VAR_* env a saved plan never reads. * fix: address review feedback — plan-job runner cleanup + admin provisioning docs The plan job gets the same if: always() sensitive-file cleanup as apply (an encrypt-step failure would otherwise strand a plaintext tfplan on the shared persistent runner), and the runbook's Korean text now states how a per-stack admin is actually provisioned (local apply with TF_VAR_admin_* env). * fix: address review feedback — demo user gated for production, k8sgpt admin_email path restored create_demo_user (default true) lets a stack — production foremost — refuse the shared demo credential, and a per-stack demo_password tfvars override is now the sanctioned path (the blob is itself a secret). The tfvars guard narrows to admin_password only: admin_email is not a secret and k8sgpt.tf needs it in tfvars when that flag is on, now enforced by a precondition on the budget resource. Runbook secrets matrix gains the six role-ARN secrets. * fix: address review feedback — demo user opt-in (default false), runner cleanup in deploy workflows create_demo_user now defaults to false, so the shared demo credential can never reach a stack by omission — dev-tier stacks opt in via their tfvars blob (atomoh's re-registered with create_demo_user = true). deploy-web and deploy-agentcore get the same if: always() cleanup of restored terraform config off the persistent runner. * fix: address review feedback — admin user leaves Terraform management entirely A TF-managed admin needs a password channel through CI plans (forbidden by the hygiene policy), and a locally-applied one ping-pongs into a destroy on the next CI plan via shared remote state — so aws_cognito_user.admin (and create_admin_user/admin_password) are removed outright. Admins are provisioned per stack out-of-band (admin-create-user + admins group; runbook carries the commands). admin_email stays solely as the k8sgpt notification address. Existing stacks' TF-managed admin is intentionally destroyed on the next apply. --- .github/workflows/deploy-agentcore.yml | 13 ++++- .github/workflows/deploy-web.yml | 24 +++++++-- .github/workflows/pr-review.yml | 3 +- .github/workflows/terraform.yml | 67 +++++++++++++++++++++++--- docs/runbooks/dev-repo-setup.md | 47 ++++++++++++++++++ terraform/foundation/auth.tf | 33 +++++++++++-- terraform/foundation/data.tf | 18 +++---- terraform/foundation/k8sgpt.tf | 7 +++ terraform/foundation/steampipe.tf | 6 +-- terraform/foundation/variables.tf | 20 ++++++-- terraform/foundation/workers.tf | 46 +++++++++--------- web/app/api/security/route.test.ts | 18 ++++++- web/app/api/security/route.ts | 13 +++-- web/app/security/page.tsx | 15 ++++-- 14 files changed, 266 insertions(+), 64 deletions(-) diff --git a/.github/workflows/deploy-agentcore.yml b/.github/workflows/deploy-agentcore.yml index fb91c0611..23c5fa6df 100644 --- a/.github/workflows/deploy-agentcore.yml +++ b/.github/workflows/deploy-agentcore.yml @@ -25,8 +25,8 @@ jobs: environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} env: TARGET: ${{ github.ref_name }} - MAIN_ROLE: ${{ vars.AWS_CI_DEPLOYER_ROLE_ARN }} - DEV_ROLE: ${{ vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + MAIN_ROLE: ${{ secrets.AWS_CI_DEPLOYER_ROLE_ARN }} + DEV_ROLE: ${{ secrets.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} @@ -50,6 +50,7 @@ jobs: - name: Configure AWS credentials (OIDC -> ci-deployer) uses: aws-actions/configure-aws-credentials@v4 with: + mask-aws-account-id: true role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 @@ -87,3 +88,11 @@ jobs: # SMOKE just needs to be non-empty to enable Makefile's `$(if $(SMOKE),...)` # — "false" is non-empty too, so only export it when actually true. run: make agentcore ${{ inputs.smoke && 'SMOKE=1' || '' }} + + # The runner is persistent and shared — never leave the restored stack + # config behind, whatever the outcome. + - name: Clean restored terraform config off the runner + if: always() + working-directory: terraform/foundation + run: rm -f terraform.tfvars backend.hcl + diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 9b284e064..3ac0556d1 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -57,8 +57,8 @@ jobs: cancel-in-progress: ${{ github.ref_name != 'main' }} env: BRANCH: ${{ github.ref_name }} - MAIN_ROLE: ${{ vars.AWS_CI_BUILD_ROLE_ARN }} - DEV_ROLE: ${{ vars.AWS_CI_BUILD_DEV_ROLE_ARN }} + MAIN_ROLE: ${{ secrets.AWS_CI_BUILD_ROLE_ARN }} + DEV_ROLE: ${{ secrets.AWS_CI_BUILD_DEV_ROLE_ARN }} MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} @@ -82,6 +82,7 @@ jobs: - name: Configure AWS credentials (OIDC -> ci-build) uses: aws-actions/configure-aws-credentials@v4 with: + mask-aws-account-id: true role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 @@ -107,6 +108,13 @@ jobs: working-directory: terraform/foundation run: echo "ecr_web_uri=$(terraform output -raw ecr_web_uri)" >> "$GITHUB_OUTPUT" + # The runner is persistent and shared — never leave the restored stack + # config behind, whatever the outcome. + - name: Clean restored terraform config off the runner + if: always() + working-directory: terraform/foundation + run: rm -f terraform.tfvars backend.hcl + - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 @@ -144,8 +152,8 @@ jobs: cancel-in-progress: ${{ github.ref_name != 'main' }} env: BRANCH: ${{ github.ref_name }} - MAIN_ROLE: ${{ vars.AWS_CI_DEPLOYER_ROLE_ARN }} - DEV_ROLE: ${{ vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + MAIN_ROLE: ${{ secrets.AWS_CI_DEPLOYER_ROLE_ARN }} + DEV_ROLE: ${{ secrets.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} @@ -169,6 +177,7 @@ jobs: - name: Configure AWS credentials (OIDC -> ci-deployer) uses: aws-actions/configure-aws-credentials@v4 with: + mask-aws-account-id: true role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 @@ -198,6 +207,13 @@ jobs: echo "url=$(terraform output -raw public_url)" >> "$GITHUB_OUTPUT" echo "ecr_repo=$(terraform output -raw ecr_web_uri | sed 's|^[^/]*/||')" >> "$GITHUB_OUTPUT" + # The runner is persistent and shared — never leave the restored stack + # config behind, whatever the outcome. + - name: Clean restored terraform config off the runner + if: always() + working-directory: terraform/foundation + run: rm -f terraform.tfvars backend.hcl + - name: Pin web-latest to the approved image # The task definition references :web-latest, and this step is that # tag's ONLY writer (build never touches it). Point it at the EXACT diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index e8377e6f5..f9fd0b9ef 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -53,7 +53,8 @@ jobs: - name: Configure AWS credentials (OIDC -> ci-review) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ vars.AWS_CI_REVIEW_ROLE_ARN }} + mask-aws-account-id: true + role-to-assume: ${{ secrets.AWS_CI_REVIEW_ROLE_ARN }} aws-region: us-east-1 # Security (M1): pull_request_target runs in a secrets/write-permission context -> diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 8a8c59650..103f808d2 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -64,7 +64,8 @@ jobs: - name: Configure AWS credentials (OIDC -> ci-terraform-plan) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ vars.AWS_CI_TERRAFORM_PLAN_ROLE_ARN }} + mask-aws-account-id: true + role-to-assume: ${{ secrets.AWS_CI_TERRAFORM_PLAN_ROLE_ARN }} aws-region: ap-northeast-2 - name: Restore backend.hcl / terraform.tfvars @@ -85,6 +86,13 @@ jobs: fi echo "$B" | base64 -d > backend.hcl echo "$V" | base64 -d > terraform.tfvars + # Guard the PASSWORD only: admin_email is not a secret (and k8sgpt.tf needs it + # in tfvars when that flag is on); a per-stack demo_password override in the + # stack's own tfvars blob is the sanctioned path, so it is not guarded either. + if grep -Eq '^[[:space:]]*admin_password[[:space:]]*=' terraform.tfvars; then + echo "::error::restored terraform.tfvars still contains admin_password — strip it and re-register the secret. Admins are not Terraform-managed: provision per stack with admin-create-user (docs/runbooks/dev-repo-setup.md)." + exit 1 + fi - name: terraform init if: steps.restore.outputs.skip != '1' @@ -92,16 +100,42 @@ jobs: - name: terraform plan if: steps.restore.outputs.skip != '1' + env: + # Sensitive inputs ride as TF_VAR_ env from masked secrets, never inside the + # tfvars blob (public-repo logs are public; secrets are auto-masked). Scoped to + # this one step so no other step (or PR-authored hook) sees them. The demo user + # is deliberately one shared credential across stacks; admin users are per-stack + # and NOT created by CI (create_admin_user defaults to false). + TF_VAR_demo_password: ${{ secrets.TF_VAR_DEMO_PASSWORD }} run: terraform plan -out=tfplan -input=false - - name: Upload plan artifact + # A plan file embeds every input variable value in plaintext (sensitive + # ones included), and artifacts on a public repo are downloadable by any + # GitHub account — encrypt before upload, fail-closed without the key. + - name: Encrypt plan artifact + if: steps.restore.outputs.skip != '1' + env: + TF_PLAN_ENC_KEY: ${{ secrets.TF_PLAN_ENC_KEY }} + run: | + [ -n "$TF_PLAN_ENC_KEY" ] || { echo "::error::TF_PLAN_ENC_KEY secret is not set — refusing to upload a plaintext plan."; exit 1; } + openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -in tfplan -out tfplan.enc -pass env:TF_PLAN_ENC_KEY + rm -f tfplan + + - name: Upload plan artifact (encrypted) if: steps.restore.outputs.skip != '1' uses: actions/upload-artifact@v4 with: name: tfplan - path: terraform/foundation/tfplan + path: terraform/foundation/tfplan.enc retention-days: 5 + # Same principle as the apply job: the runner is persistent and shared — + # never leave the plaintext plan (an encrypt-step failure strands it) or + # the restored config behind, whatever the outcome. + - name: Clean sensitive files off the runner + if: always() + run: rm -f tfplan tfplan.enc terraform.tfvars backend.hcl + apply: name: Apply (saved plan) if: github.event_name == 'workflow_dispatch' @@ -109,8 +143,8 @@ jobs: environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} env: TARGET: ${{ github.ref_name }} - MAIN_ROLE: ${{ vars.AWS_CI_DEPLOYER_ROLE_ARN }} - DEV_ROLE: ${{ vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + MAIN_ROLE: ${{ secrets.AWS_CI_DEPLOYER_ROLE_ARN }} + DEV_ROLE: ${{ secrets.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} @@ -128,12 +162,13 @@ jobs: dev|atomoh|ssminji|whchoi) ROLE="$DEV_ROLE";; *) echo "::error::unexpected dispatch target '$TARGET'"; exit 1;; esac - [ -n "$ROLE" ] || { echo "::error::deployer role variable for '$TARGET' is not set"; exit 1; } + [ -n "$ROLE" ] || { echo "::error::deployer role secret for '$TARGET' is not set"; exit 1; } echo "role=$ROLE" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials (OIDC -> ci-deployer) uses: aws-actions/configure-aws-credentials@v4 with: + mask-aws-account-id: true role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 @@ -151,6 +186,13 @@ jobs: fi echo "$B" | base64 -d > backend.hcl echo "$V" | base64 -d > terraform.tfvars + # Guard the PASSWORD only: admin_email is not a secret (and k8sgpt.tf needs it + # in tfvars when that flag is on); a per-stack demo_password override in the + # stack's own tfvars blob is the sanctioned path, so it is not guarded either. + if grep -Eq '^[[:space:]]*admin_password[[:space:]]*=' terraform.tfvars; then + echo "::error::restored terraform.tfvars still contains admin_password — strip it and re-register the secret. Admins are not Terraform-managed: provision per stack with admin-create-user (docs/runbooks/dev-repo-setup.md)." + exit 1 + fi - name: terraform init run: terraform init -backend-config=backend.hcl -input=false @@ -161,5 +203,18 @@ jobs: run: | gh run download "${{ inputs.plan_run_id }}" --repo "${{ github.repository }}" --name tfplan --dir . + - name: Decrypt the approved plan + env: + TF_PLAN_ENC_KEY: ${{ secrets.TF_PLAN_ENC_KEY }} + run: | + [ -n "$TF_PLAN_ENC_KEY" ] || { echo "::error::TF_PLAN_ENC_KEY secret is not set."; exit 1; } + openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -in tfplan.enc -out tfplan -pass env:TF_PLAN_ENC_KEY + - name: terraform apply (exact saved plan — never re-planned) run: terraform apply -input=false tfplan + + # The runner is persistent — never leave the decrypted plan (embeds sensitive + # variable values in plaintext) or the restored config behind. + - name: Clean sensitive files off the runner + if: always() + run: rm -f tfplan tfplan.enc terraform.tfvars backend.hcl diff --git a/docs/runbooks/dev-repo-setup.md b/docs/runbooks/dev-repo-setup.md index c1b29d3d4..e898eeec3 100644 --- a/docs/runbooks/dev-repo-setup.md +++ b/docs/runbooks/dev-repo-setup.md @@ -82,10 +82,57 @@ terraform -chdir=terraform/foundation plan -out tfplan # review the plan terraform -chdir=terraform/foundation apply tfplan # apply EXACTLY that plan ``` +Sensitive-value policy (public repo — Actions LOGS are public): role ARNs and +anything carrying the account id live in repo **secrets** (auto-masked in +logs), never variables; every credentials step sets `mask-aws-account-id`. +Cognito users: dev/preview stacks get the shared regular **demo user** +(`demo_email` defaults to `demo@awsops.local`; its password rides as the +`TF_VAR_DEMO_PASSWORD` repo secret, exported by terraform.yml as +`TF_VAR_demo_password` on the plan step only). `create_demo_user` defaults to +**false** (fail-closed): a dev-tier stack opts in with `create_demo_user = +true` in its tfvars blob, so the shared credential can never reach a stack — +production foremost — by omission. A stack may instead override +`demo_password` in its own blob (the blob is itself a secret; tfvars outranks +env, so the override is the sanctioned per-stack path). **Admin users are not +Terraform-managed at all** (a TF-managed admin would need a password channel +through CI plans, and a locally-applied one would ping-pong into a destroy on +the next CI plan via the shared remote state). Provision an admin per stack +out-of-band, with per-stack credentials — never a repo-wide shared pair: + +```bash +aws cognito-idp admin-create-user --user-pool-id \ + --username --user-attributes Name=email,Value= Name=email_verified,Value=true \ + --message-action SUPPRESS +aws cognito-idp admin-set-user-password --user-pool-id \ + --username --password '' --permanent +aws cognito-idp admin-add-user-to-group --user-pool-id \ + --username --group-name admins +``` + +Only admins (the Cognito `admins` group, or the SSM email allowlist) see +IAM-related views. `admin_password` must NOT sit in any registered tfvars +blob — the restore step hard-fails on it (`admin_email` alone is fine: it is +not a secret, and `k8sgpt_enabled` stacks need it in tfvars for the budget +alarm subscriber). Stacks provisioned before this policy carried a TF-managed +admin user: the first post-merge plan proposes destroying it — that removal +is intentional (recreate via the CLI above when the stack actually needs an +admin). +The plan artifact is a covered channel too: a tfplan embeds every variable +value in plaintext and public-repo artifacts are downloadable by anyone, so +the plan job encrypts it with the `TF_PLAN_ENC_KEY` secret (fail-closed) and +the apply job decrypts before applying. +(공개 리포는 Actions 로그도 공개 — 역할 ARN 등 계정 ID 포함 값은 변수 금지·시크릿 +전용. demo 사용자 비밀번호는 `TF_VAR_DEMO_PASSWORD` 시크릿으로 공급하되 production은 +`create_demo_user=false` 또는 자체 tfvars 블롭의 `demo_password` override로 공유 +자격을 거부합니다. admin 사용자는 Terraform 관리 밖입니다 — 스택별로 위 +`admin-create-user` CLI 3종으로 만들고 `admins` 그룹에 넣습니다. 기존 스택의 +TF-관리 admin은 머지 후 첫 plan에서 삭제로 표시되며, 이는 의도된 제거입니다.) + Then register the generated files (base64) as repo secrets: | Stack | Secrets | |---|---| +| all stacks (repo-wide) | `TF_PLAN_ENC_KEY` (plan-artifact encryption) / `TF_VAR_DEMO_PASSWORD` (demo user) / role-ARN secrets `AWS_CI_BUILD_ROLE_ARN` · `AWS_CI_BUILD_DEV_ROLE_ARN` · `AWS_CI_DEPLOYER_ROLE_ARN` · `AWS_CI_DEPLOYER_DEV_ROLE_ARN` · `AWS_CI_TERRAFORM_PLAN_ROLE_ARN` · `AWS_CI_REVIEW_ROLE_ARN` (moved from repo variables — public-repo logs never mask variables) | | production (`main`) | `TF_BACKEND_HCL` / `TF_TFVARS` | | dev (`awsops-dev.whchoi.net`) | `TF_BACKEND_HCL_DEV` / `TF_TFVARS_DEV` | | user branch `atomoh`/`ssminji`/`whchoi` (`.awsops-dev.whchoi.net`) | `TF_BACKEND_HCL_PREVIEW_` / `TF_TFVARS_PREVIEW_` (uppercased branch name) | diff --git a/terraform/foundation/auth.tf b/terraform/foundation/auth.tf index beac2c3da..67d9eccd6 100644 --- a/terraform/foundation/auth.tf +++ b/terraform/foundation/auth.tf @@ -92,16 +92,41 @@ resource "aws_cognito_user_pool_client" "main" { } } -resource "aws_cognito_user" "admin" { +# Admin gate group — web/lib/admin.ts checks cognito:groups for ADMIN_GROUP (default 'admins'); +# IAM-related views (iam_user/iam_role inventory, iam_no_mfa findings) are admin-only. +resource "aws_cognito_user_group" "admins" { + name = "admins" user_pool_id = aws_cognito_user_pool.main.id - username = var.admin_email - password = var.admin_password + description = "Admins — IAM-related views are visible only to this group" +} + +# Regular demo user — carries no group, so IAM views stay hidden. Gated so a stack +# (e.g. production) can refuse the shared demo credential entirely. +resource "aws_cognito_user" "demo" { + count = var.create_demo_user ? 1 : 0 + user_pool_id = aws_cognito_user_pool.main.id + username = var.demo_email + password = var.demo_password attributes = { - email = var.admin_email + email = var.demo_email email_verified = true } + lifecycle { + precondition { + condition = var.demo_password != "" + error_message = "create_demo_user=true requires demo_password (TF_VAR_DEMO_PASSWORD secret in CI, or a per-stack tfvars override)." + } + } } +# Admin users are deliberately NOT managed by Terraform. A TF-managed admin would need a +# password channel through CI (plan evaluates variables), which the public-repo hygiene +# policy forbids — and a locally-applied one would ping-pong into a destroy on the next CI +# plan (shared remote state). Admins are provisioned out-of-band per stack +# (admin-create-user + admin-add-user-to-group into the "admins" group above) — see +# docs/runbooks/dev-repo-setup.md. The previously TF-managed admin user is intentionally +# removed from state on the next apply. + data "aws_iam_policy_document" "edge_assume" { statement { actions = ["sts:AssumeRole"] diff --git a/terraform/foundation/data.tf b/terraform/foundation/data.tf index 44984e053..468b59608 100644 --- a/terraform/foundation/data.tf +++ b/terraform/foundation/data.tf @@ -67,7 +67,7 @@ resource "aws_rds_cluster" "aurora" { manage_master_user_password = true # RDS Data API (HTTP endpoint): lets the read-only inventory-read MCP Lambda query the synced # inventory without a VPC attachment or pg8000 bundling. In-place enable (no reboot/replace). - enable_http_endpoint = true + enable_http_endpoint = true # IAM database authentication: lets long-running Fargate tasks connect via a short-lived # STS-signed auth token (rds-db:connect) instead of the Aurora master secret. The master secret # is RDS-managed and auto-rotates every 7 days; a task that only reads it once at container @@ -77,14 +77,14 @@ resource "aws_rds_cluster" "aurora" { # Steampipe's boot-time generator (`steampipe_reader`, M1 fix) and the web BFF (`awsops_web`). # In-place enable, no reboot/replace. Unconditional now that web depends on it too. iam_database_authentication_enabled = true - master_user_secret_kms_key_id = aws_kms_key.aurora.key_id - storage_encrypted = true - kms_key_id = aws_kms_key.aurora.arn - db_subnet_group_name = aws_db_subnet_group.aurora.name - vpc_security_group_ids = [aws_security_group.aurora.id] - backup_retention_period = 7 - deletion_protection = false - skip_final_snapshot = true + master_user_secret_kms_key_id = aws_kms_key.aurora.key_id + storage_encrypted = true + kms_key_id = aws_kms_key.aurora.arn + db_subnet_group_name = aws_db_subnet_group.aurora.name + vpc_security_group_ids = [aws_security_group.aurora.id] + backup_retention_period = 7 + deletion_protection = false + skip_final_snapshot = true serverlessv2_scaling_configuration { min_capacity = var.aurora_min_acu diff --git a/terraform/foundation/k8sgpt.tf b/terraform/foundation/k8sgpt.tf index 3c0dfe31e..24cb3cff9 100644 --- a/terraform/foundation/k8sgpt.tf +++ b/terraform/foundation/k8sgpt.tf @@ -34,6 +34,13 @@ resource "aws_budgets_budget" "k8sgpt_bedrock" { values = ["Amazon Bedrock"] } + lifecycle { + precondition { + condition = var.admin_email != "" + error_message = "k8sgpt_enabled=true requires admin_email (budget alarm subscriber) — admin_email now defaults to \"\"." + } + } + notification { comparison_operator = "GREATER_THAN" threshold = 80 diff --git a/terraform/foundation/steampipe.tf b/terraform/foundation/steampipe.tf index 21fc060cb..c75d94636 100644 --- a/terraform/foundation/steampipe.tf +++ b/terraform/foundation/steampipe.tf @@ -34,7 +34,7 @@ resource "aws_iam_role_policy" "execution_steampipe_secret" { name = "${var.project}-exec-steampipe-secret" role = aws_iam_role.execution.id policy = jsonencode({ - Version = "2012-10-17" + Version = "2012-10-17" Statement = [{ Effect = "Allow", Action = ["secretsmanager:GetSecretValue"], Resource = [aws_secretsmanager_secret.steampipe[0].arn] }] }) } @@ -134,7 +134,7 @@ resource "aws_iam_role_policy" "steampipe_task" { "elasticache:Describe*", "elasticache:ListTagsForResource", "es:Describe*", "es:List*", "kafka:Describe*", "kafka:List*", - "eks:Describe*", "eks:List*", # aws-data/eks-optimize 콜렉터: aws_eks_cluster 등 EKS 테이블 + "eks:Describe*", "eks:List*", # aws-data/eks-optimize 콜렉터: aws_eks_cluster 등 EKS 테이블 "wafv2:List*", "wafv2:Get*", "cloudwatch:Describe*", "cloudtrail:Describe*", "cloudtrail:List*", "cloudtrail:GetTrailStatus", "cloudtrail:GetEventSelectors", "cloudtrail:GetInsightSelectors", @@ -195,7 +195,7 @@ resource "aws_ecs_task_definition" "steampipe" { { name = "STEAMPIPE_DATABASE_PASSWORD", valueFrom = aws_secretsmanager_secret.steampipe[0].arn }, ] healthCheck = { - command = ["CMD-SHELL", "steampipe query \"select 1\" >/dev/null 2>&1 || exit 1"] + command = ["CMD-SHELL", "steampipe query \"select 1\" >/dev/null 2>&1 || exit 1"] interval = 30 timeout = 10 # retries=5 (150s consecutive-failure tolerance) — NOT just the initial startup case. diff --git a/terraform/foundation/variables.tf b/terraform/foundation/variables.tf index ea29b2074..446d60282 100644 --- a/terraform/foundation/variables.tf +++ b/terraform/foundation/variables.tf @@ -80,13 +80,27 @@ variable "cognito_domain_prefix" { variable "admin_email" { type = string - description = "Initial Cognito admin user email" + description = "Notification email reused by k8sgpt.tf SNS (required when k8sgpt_enabled). The Cognito admin USER is not Terraform-managed — see auth.tf / docs/runbooks/dev-repo-setup.md." + default = "" +} + +variable "create_demo_user" { + type = bool + description = "Create the shared regular demo user. Default FALSE (fail-closed — the shared credential must never reach a stack, production foremost, by omission): dev-tier stacks opt in explicitly in their tfvars." + default = false } -variable "admin_password" { +variable "demo_email" { type = string - description = "Initial admin permanent password (>=8, upper+lower+number)" + description = "Regular (non-admin) demo user email — created in every stack" + default = "demo@awsops.local" +} + +variable "demo_password" { + type = string + description = "Demo user permanent password (>=8, upper+lower+number). CI supplies the shared default via the TF_VAR_DEMO_PASSWORD secret; a stack may override it in its own tfvars blob (tfvars outranks env) or disable the user via create_demo_user." sensitive = true + default = "" } variable "k8sgpt_enabled" { diff --git a/terraform/foundation/workers.tf b/terraform/foundation/workers.tf index 1f1a61442..74af1b04e 100644 --- a/terraform/foundation/workers.tf +++ b/terraform/foundation/workers.tf @@ -586,14 +586,14 @@ resource "aws_lambda_function" "worker" { } environment { variables = merge({ - AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint - AURORA_DATABASE = aws_rds_cluster.aurora.database_name - AURORA_USER = "awsops_worker" + AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint + AURORA_DATABASE = aws_rds_cluster.aurora.database_name + AURORA_USER = "awsops_worker" # AI Diagnosis (Task 1b): report worker uploads here + invokes a global.* Bedrock profile from var.region. ARTIFACT_BUCKET = aws_s3_bucket.diagnosis_artifacts[0].bucket BEDROCK_REGION = var.region # + gated DIAGNOSIS_SNS_TOPIC_ARN/APP_DOMAIN (notify) — empty map when diagnosis_notify_enabled=false → no env diff. - }, local.notify_worker_env_map, local.ds_env_map, local.insight_env_map, local.gqg_env_map, + }, local.notify_worker_env_map, local.ds_env_map, local.insight_env_map, local.gqg_env_map, local.dsqg_env_map) # + gated datasource env; + AI_INSIGHTS_ENABLED/ONBOARD_EKS_CLUSTERS when ai_insights_enabled; # + GRAPH_QUERYGEN_ENABLED when graph_querygen_enabled @@ -619,9 +619,9 @@ resource "aws_lambda_function" "status_updater" { } environment { variables = { - AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint - AURORA_DATABASE = aws_rds_cluster.aurora.database_name - AURORA_USER = "awsops_worker" + AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint + AURORA_DATABASE = aws_rds_cluster.aurora.database_name + AURORA_USER = "awsops_worker" } } depends_on = [aws_cloudwatch_log_group.status_updater, aws_iam_role_policy_attachment.worker_lambda_vpc] @@ -1086,8 +1086,8 @@ resource "aws_iam_role_policy" "worker_lambda_graph_querygen" { Resource = "arn:aws:ssm:${var.region}:${data.aws_caller_identity.current.account_id}:parameter/ops/${var.project}/agentcore/interpreter_id" }, { - Sid = "CodeInterpreterSandboxCheck" - Effect = "Allow" + Sid = "CodeInterpreterSandboxCheck" + Effect = "Allow" Action = [ "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", @@ -1296,11 +1296,11 @@ resource "aws_lambda_function" "schedule_dispatcher" { } environment { variables = { - AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint - AURORA_DATABASE = aws_rds_cluster.aurora.database_name - AURORA_USER = "awsops_worker" - AWS_ACCOUNT_ID = local.acct - JOBS_QUEUE_URL = aws_sqs_queue.jobs[0].url + AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint + AURORA_DATABASE = aws_rds_cluster.aurora.database_name + AURORA_USER = "awsops_worker" + AWS_ACCOUNT_ID = local.acct + JOBS_QUEUE_URL = aws_sqs_queue.jobs[0].url } } depends_on = [aws_cloudwatch_log_group.schedule_dispatcher, aws_iam_role_policy_attachment.worker_lambda_vpc] @@ -1500,11 +1500,11 @@ resource "aws_lambda_function" "dsindex_dispatcher" { } environment { variables = { - AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint - AURORA_DATABASE = aws_rds_cluster.aurora.database_name - AURORA_USER = "awsops_worker" - AWS_ACCOUNT_ID = local.acct - JOBS_QUEUE_URL = aws_sqs_queue.jobs[0].url + AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint + AURORA_DATABASE = aws_rds_cluster.aurora.database_name + AURORA_USER = "awsops_worker" + AWS_ACCOUNT_ID = local.acct + JOBS_QUEUE_URL = aws_sqs_queue.jobs[0].url } } depends_on = [aws_cloudwatch_log_group.dsindex_dispatcher, aws_iam_role_policy_attachment.worker_lambda_vpc] @@ -1599,10 +1599,10 @@ resource "aws_lambda_function" "insight_dispatcher" { } environment { variables = { - AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint - AURORA_DATABASE = aws_rds_cluster.aurora.database_name - AURORA_USER = "awsops_worker" - JOBS_QUEUE_URL = aws_sqs_queue.jobs[0].url + AURORA_ENDPOINT = aws_rds_cluster.aurora.endpoint + AURORA_DATABASE = aws_rds_cluster.aurora.database_name + AURORA_USER = "awsops_worker" + JOBS_QUEUE_URL = aws_sqs_queue.jobs[0].url } } depends_on = [aws_cloudwatch_log_group.insight_dispatcher, aws_iam_role_policy_attachment.worker_lambda_vpc] diff --git a/web/app/api/security/route.test.ts b/web/app/api/security/route.test.ts index 91bb9181d..6503aba57 100644 --- a/web/app/api/security/route.test.ts +++ b/web/app/api/security/route.test.ts @@ -19,8 +19,8 @@ describe('GET /api/security', () => { const body = await (await GET(req())).json(); expect(body.enabled).toBe(false); }); - it('200 returns summary + findings per check', async () => { - verifyUser.mockResolvedValue({ sub: 'u' }); + it('200 returns summary + findings per check (admin sees iam_no_mfa)', async () => { + verifyUser.mockResolvedValue({ sub: 'u', groups: ['admins'] }); query .mockResolvedValueOnce({ rows: [{ n: 4 }] }) // presence probe .mockResolvedValueOnce({ rows: [{ resource_id: 'b1', region: 'us-east-1', detail: { bucket_policy_is_public: true } }] }) // public_s3 @@ -35,6 +35,20 @@ describe('GET /api/security', () => { expect(body.summary).toEqual({ public_s3: 1, open_sg: 1, unencrypted_ebs: 0, ecr_cve: 0, iam_no_mfa: 1 }); expect(body.findings.public_s3[0]).toMatchObject({ check: 'public_s3', resource_id: 'b1', severity: 'high' }); + expect(body.checks).toContain('iam_no_mfa'); + }); + it('non-admin: iam_no_mfa withheld from checks/summary/findings', async () => { + verifyUser.mockResolvedValue({ sub: 'u' }); // no groups, no SSM allowlist → not admin + query + .mockResolvedValueOnce({ rows: [{ n: 4 }] }) // presence probe + .mockResolvedValueOnce({ rows: [] }) // public_s3 + .mockResolvedValueOnce({ rows: [] }) // open_sg + .mockResolvedValueOnce({ rows: [] }); // unencrypted_ebs — iam_no_mfa never queried + const { GET } = await import('./route'); + const body = await (await GET(req())).json(); + expect(body.checks).not.toContain('iam_no_mfa'); + expect(body.summary).not.toHaveProperty('iam_no_mfa'); + expect(body.findings).not.toHaveProperty('iam_no_mfa'); }); it('500 on db error', async () => { verifyUser.mockResolvedValue({ sub: 'u' }); diff --git a/web/app/api/security/route.ts b/web/app/api/security/route.ts index fc0c6a330..a6fcc922f 100644 --- a/web/app/api/security/route.ts +++ b/web/app/api/security/route.ts @@ -1,4 +1,5 @@ import { verifyUser } from '@/lib/auth'; +import { isAdmin } from '@/lib/admin'; import { getPool } from '@/lib/db'; import { FINDING_SQL, rowToFinding, CHECK_META, type CheckKey, type Finding } from '@/lib/security-findings'; import { ecrCveFindings } from '@/lib/ecr-cve'; @@ -27,9 +28,13 @@ async function resolveAccounts(raw: string | null): Promise { } export async function GET(request: Request) { - if (!(await verifyUser(request.headers.get('cookie')))) { + const user = await verifyUser(request.headers.get('cookie')); + if (!user) { return Response.json({ status: 'error', message: 'unauthenticated' }, { status: 401 }); } + // IAM identity data is admin-only (same convention as lib/inventory.ts ADMIN_ONLY_TYPES — + // iam_no_mfa findings are rows of the admin-gated iam_user inventory type). + const checks = (await isAdmin(user)) ? CHECKS : CHECKS.filter((k) => k !== 'iam_no_mfa'); try { const pool = getPool(); const accounts = await resolveAccounts(new URL(request.url).searchParams.get('accounts')); @@ -44,11 +49,11 @@ export async function GET(request: Request) { [accounts], ); if (Number(probe.rows[0]?.n ?? 0) === 0) { - return Response.json({ enabled: false, summary: {}, findings: {} }); + return Response.json({ enabled: false, summary: {}, findings: {}, checks }); } const summary = {} as Record; const findings = {} as Record; - for (const check of CHECKS) { + for (const check of checks) { if (!(check in FINDING_SQL)) continue; // live-SDK checks handled below const r = await pool.query<{ resource_id: string; region: string; account_id?: string; detail: unknown }>( FINDING_SQL[check as keyof typeof FINDING_SQL], [accounts], @@ -63,7 +68,7 @@ export async function GET(request: Request) { findings.ecr_cve = []; } summary.ecr_cve = findings.ecr_cve.length; - return Response.json({ enabled: true, summary, findings, accounts }); + return Response.json({ enabled: true, summary, findings, accounts, checks }); } catch (e) { return Response.json({ status: 'error', message: e instanceof Error ? e.message : String(e) }, { status: 500 }); } diff --git a/web/app/security/page.tsx b/web/app/security/page.tsx index 5654dee47..d37b51bdb 100644 --- a/web/app/security/page.tsx +++ b/web/app/security/page.tsx @@ -25,6 +25,9 @@ interface ApiResp { enabled: boolean; summary: Partial>; findings: Partial>; + // Checks visible to this caller — the API omits admin-only ones (iam_no_mfa) for + // non-admins; absent (older API) means all checks. + checks?: CheckKey[]; } // Fixed CVE-severity slice colors (v1 parity: red/orange/purple/cyan; v2 chart-palette hues). @@ -114,14 +117,20 @@ export default function SecurityPage() { }, [load]); const enabled = data?.enabled ?? true; + const visibleChecks = data?.checks ?? CHECKS; const summary = data?.summary ?? {}; + + // The active tab can reference a check the API withheld (admin-only) — snap to the first visible. + useEffect(() => { + if (visibleChecks.length > 0 && !visibleChecks.includes(active)) setActive(visibleChecks[0]); + }, [visibleChecks, active]); const findings = data?.findings ?? {}; // Severity rollup for the donut (high vs medium). const sevData = (() => { let high = 0; let medium = 0; - for (const k of CHECKS) { + for (const k of visibleChecks) { const n = summary[k] ?? 0; if (CHECK_META[k].severity === 'high') high += n; else if (CHECK_META[k].severity === 'medium') medium += n; @@ -174,7 +183,7 @@ export default function SecurityPage() { <> {/* Per-check counts */}
- {CHECKS.map((k) => ( + {visibleChecks.map((k) => ( ({ value: k, label: `${CHECK_META[k].label} (${summary[k] ?? 0})` }))} + options={visibleChecks.map((k) => ({ value: k, label: `${CHECK_META[k].label} (${summary[k] ?? 0})` }))} value={active} onChange={(v) => setActive(v as CheckKey)} />