Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/deploy-agentcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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

Expand Down Expand Up @@ -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

24 changes: 20 additions & 4 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 }}
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
67 changes: 61 additions & 6 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -85,32 +86,65 @@ 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'
run: terraform init -backend-config=backend.hcl -input=false

- 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'
runs-on: sample-awsops
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 }}
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
47 changes: 47 additions & 0 deletions docs/runbooks/dev-repo-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pool-id> \
--username <email> --user-attributes Name=email,Value=<email> Name=email_verified,Value=true \
--message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id <pool-id> \
--username <email> --password '<per-stack password>' --permanent
aws cognito-idp admin-add-user-to-group --user-pool-id <pool-id> \
--username <email> --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` (`<user>.awsops-dev.whchoi.net`) | `TF_BACKEND_HCL_PREVIEW_<USER>` / `TF_TFVARS_PREVIEW_<USER>` (uppercased branch name) |
Expand Down
33 changes: 29 additions & 4 deletions terraform/foundation/auth.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
18 changes: 9 additions & 9 deletions terraform/foundation/data.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions terraform/foundation/k8sgpt.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading