From 6f32f3bfcd76f3545c8ed5b9bee3bfaa8e3eb083 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 16 Sep 2026 13:41:47 -0400 Subject: [PATCH] feat(infra): migrate payments-dashboard to HCP Terraform (PLAT-79) Replace the mgmt SAM stack with a prod-only HCP workspace using the afterhours stub-plus-zip-CD seam so GitHub Actions owns function code and Terraform owns infrastructure. --- .github/workflows/ci.yaml | 82 +- .github/workflows/deploy.yaml | 144 ++- .gitignore | 3 + README.md | 23 +- SETUP.md | 74 ++ samconfig.toml.example | 10 - scripts/package_lambdas.mjs | 92 ++ template.yaml | 936 ------------------ terraform/.terraform.lock.hcl | 47 + terraform/alarms.tf | 161 +++ terraform/apigateway.tf | 73 ++ terraform/bootstrap/stub/package.json | 3 + .../bootstrap/stub/src/expenseProcessor.js | 7 + .../bootstrap/stub/src/expenseReceiver.js | 7 + .../stub/src/fetchBoaTransactions.js | 7 + .../bootstrap/stub/src/processPaymentCsv.js | 7 + terraform/bootstrap/stub/src/slackAppHome.js | 7 + terraform/data.tf | 56 ++ terraform/events.tf | 47 + terraform/hcp_iam.tf | 906 +++++++++++++++++ terraform/iam_github_deploy.tf | 103 ++ terraform/lambda.tf | 250 +++++ terraform/lambda_boundary.tf | 147 +++ terraform/locals.tf | 77 ++ terraform/logs.tf | 11 + terraform/outputs.tf | 54 + terraform/providers.tf | 12 + terraform/s3.tf | 275 +++++ terraform/secrets.tf | 8 + terraform/ssm.tf | 15 + terraform/variables.tf | 29 + terraform/versions.tf | 22 + terraform/vpc.tf | 145 +++ tests/infra/hcpContract.test.js | 96 ++ 34 files changed, 2961 insertions(+), 975 deletions(-) create mode 100644 SETUP.md delete mode 100644 samconfig.toml.example create mode 100644 scripts/package_lambdas.mjs delete mode 100644 template.yaml create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/alarms.tf create mode 100644 terraform/apigateway.tf create mode 100644 terraform/bootstrap/stub/package.json create mode 100644 terraform/bootstrap/stub/src/expenseProcessor.js create mode 100644 terraform/bootstrap/stub/src/expenseReceiver.js create mode 100644 terraform/bootstrap/stub/src/fetchBoaTransactions.js create mode 100644 terraform/bootstrap/stub/src/processPaymentCsv.js create mode 100644 terraform/bootstrap/stub/src/slackAppHome.js create mode 100644 terraform/data.tf create mode 100644 terraform/events.tf create mode 100644 terraform/hcp_iam.tf create mode 100644 terraform/iam_github_deploy.tf create mode 100644 terraform/lambda.tf create mode 100644 terraform/lambda_boundary.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/logs.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/s3.tf create mode 100644 terraform/secrets.tf create mode 100644 terraform/ssm.tf create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf create mode 100644 terraform/vpc.tf create mode 100644 tests/infra/hcpContract.test.js diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f066adf..f13558e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,5 @@ name: CI + on: pull_request: branches: [main] @@ -8,10 +9,79 @@ permissions: contents: read jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Install + run: npm ci + + - name: Test + run: npm test + + terraform: + name: Terraform + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate + ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 - with: - run-typecheck: false - run-tests: true - run-cdk-synth: false - run-sam-validate: true + name: ci / ci + needs: [test, terraform] + if: ${{ always() && !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check jobs + env: + TEST_RESULT: ${{ needs.test.result }} + TERRAFORM_RESULT: ${{ needs.terraform.result }} + run: | + set -euo pipefail + fail=0 + check() { + local name="$1" + local result="$2" + case "${result}" in + success) + echo "${name}: ${result}" + ;; + *) + echo "${name}: ${result}" >&2 + fail=1 + ;; + esac + } + check test "${TEST_RESULT}" + check terraform "${TERRAFORM_RESULT}" + exit "${fail}" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4644e56..057f260 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,21 +1,143 @@ name: Deploy + +# Terraform owns Lambda skeletons. This workflow ships zips to prod and calls +# update-function-code. It never creates an HCP run. No GitHub Releases and no +# tagging in this workflow. + on: push: branches: [main] + paths-ignore: + - "terraform/**" + - "docs/**" + - "README.md" + - "SETUP.md" + - "AGENTS.md" + workflow_dispatch: + inputs: + ref: + description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref." + required: false + type: string + default: "" permissions: - id-token: write contents: read -concurrency: - group: deploy - cancel-in-progress: false - jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 - with: - stack-name: payments-dashboard - cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role - secrets: - deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + name: Deploy to prod + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: prod + concurrency: + group: deploy-payments-dashboard-prod + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + persist-credentials: false + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + echo "Building ${sha}" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build function zips + env: + GIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + node scripts/package_lambdas.mjs --git-sha "${GIT_SHA}" --out-dir build/packages + python3 - <<'PY' + import os, zipfile + from pathlib import Path + sha = os.environ["GIT_SHA"] + names = [ + "process_csv", + "slack_app_home", + "fetch_boa", + "expense_receiver", + "expense_processor", + ] + for name in names: + path = Path("build/packages") / f"{name}.zip" + if not path.is_file(): + raise SystemExit(f"missing {path}") + with zipfile.ZipFile(path) as zf: + info = zf.read("src/buildInfo.js").decode() + if sha not in info: + raise SystemExit(f"{path} missing GIT_SHA {sha}") + if "src/processPaymentCsv.js" not in zf.namelist(): + raise SystemExit(f"{path} missing src/") + print("zips ok") + PY + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + run: | + set -euo pipefail + prefix=/payments-dashboard/deploy + ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text) + { + echo "artifacts_bucket=${ARTIFACTS_BUCKET}" + echo "process_csv=$(aws ssm get-parameter --name "${prefix}/process_csv-function-name" --query Parameter.Value --output text)" + echo "slack_app_home=$(aws ssm get-parameter --name "${prefix}/slack_app_home-function-name" --query Parameter.Value --output text)" + echo "fetch_boa=$(aws ssm get-parameter --name "${prefix}/fetch_boa-function-name" --query Parameter.Value --output text)" + echo "expense_receiver=$(aws ssm get-parameter --name "${prefix}/expense_receiver-function-name" --query Parameter.Value --output text)" + echo "expense_processor=$(aws ssm get-parameter --name "${prefix}/expense_processor-function-name" --query Parameter.Value --output text)" + } >> "${GITHUB_OUTPUT}" + + - name: Upload zips and update function code + env: + ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + PROCESS_CSV: ${{ steps.deploy.outputs.process_csv }} + SLACK_APP_HOME: ${{ steps.deploy.outputs.slack_app_home }} + FETCH_BOA: ${{ steps.deploy.outputs.fetch_boa }} + EXPENSE_RECEIVER: ${{ steps.deploy.outputs.expense_receiver }} + EXPENSE_PROCESSOR: ${{ steps.deploy.outputs.expense_processor }} + run: | + set -euo pipefail + keys=( + process_csv:"${PROCESS_CSV}" + slack_app_home:"${SLACK_APP_HOME}" + fetch_boa:"${FETCH_BOA}" + expense_receiver:"${EXPENSE_RECEIVER}" + expense_processor:"${EXPENSE_PROCESSOR}" + ) + for pair in "${keys[@]}"; do + name="${pair%%:*}" + fn="${pair#*:}" + key="functions/${name}/${GIT_SHA}.zip" + aws s3 cp "build/packages/${name}.zip" "s3://${ARTIFACTS_BUCKET}/${key}" + aws lambda update-function-code \ + --function-name "${fn}" \ + --s3-bucket "${ARTIFACTS_BUCKET}" \ + --s3-key "${key}" \ + --query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \ + --output table + aws lambda wait function-updated-v2 --function-name "${fn}" + done diff --git a/.gitignore b/.gitignore index 1412c9c..0f8e012 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ .aws-sam/ samconfig.toml data/ +build/ +terraform/.terraform/ +terraform/build/ .DS_Store BofA API Resources/ *.csv diff --git a/README.md b/README.md index d4392a5..d19f1ad 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # Payments Dashboard ![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?logo=javascript&logoColor=black) -![AWS SAM](https://img.shields.io/badge/AWS-SAM-FF9900?logo=amazonaws&logoColor=white) +![AWS](https://img.shields.io/badge/AWS-HCP%20Terraform-FF9900?logo=amazonaws&logoColor=white) ![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white) ![CI](https://github.com/Sea-Haven-Industries/payments-dashboard/actions/workflows/ci.yaml/badge.svg) -AWS SAM application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, surfaces an outstanding-payments dashboard in Slack, and routes expense approvals through a Slack reaction-driven workflow. +HCP Terraform application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, surfaces an outstanding-payments dashboard in Slack, and routes expense approvals through a Slack reaction-driven workflow. Prod workspace: `payments-dashboard-prod` (trigger prefix `terraform/**`). Zip CD is GitHub Actions Environment `prod`. ## Architecture - **ProcessPaymentCsv** — Lambda triggered by S3 CSV upload. Parses Stampli payment exports, upserts to DynamoDB, and submits new/cancelled checks to the CashPro Check Management API. -- **FetchBoaTransactions** — Scheduled Lambda. Weekdays 9am ET it calls the CashPro **previous-day** Transaction Inquiry (authoritative sweep, trailing 7 days); weekdays at 16:00/19:00/22:00 UTC (~12/3/6pm ET, fixed-UTC so it drifts an hour in winter) it calls the **current-day** inquiry for same-day visibility (EventBridge `Input: {"endpoint":"current-day"}`, today-only, staleness sweep skipped). Every run archives the exact raw response to the `seahaven-payments-boa-raw-*` bucket (`raw//_/.json`, SSE-S3, 730-day lifecycle, PutObject-only grant; Retain-protected — decommission goes through the CFN decommission runbook) and upserts per-date `boa_balance##` snapshots (latest-wins on `run_at`, no TTL) from the Summary rows. Classifies each transaction and reconciles onto DynamoDB payment records. Event payload: `{fromDate?, toDate?, endpoint?}` (endpoint allowlisted and validated; unknown fields ignored). Intraday runs are disable-able as a unit via the `IntradaySchedule` rule. See [Bank reconciliation](#bank-reconciliation-fetchboatransactions). +- **FetchBoaTransactions** — Scheduled Lambda. Weekdays 9am ET it calls the CashPro **previous-day** Transaction Inquiry (authoritative sweep, trailing 7 days); weekdays at 16:00/19:00/22:00 UTC (~12/3/6pm ET, fixed-UTC so it drifts an hour in winter) it calls the **current-day** inquiry for same-day visibility (EventBridge `Input: {"endpoint":"current-day"}`, today-only, staleness sweep skipped). Every run archives the exact raw response to the `seahaven-payments-boa-raw-*` bucket (`raw//_/.json`, SSE-S3, 730-day lifecycle, PutObject-only grant; Retain-protected in Terraform) and upserts per-date `boa_balance##` snapshots (latest-wins on `run_at`, no TTL) from the Summary rows. Classifies each transaction and reconciles onto DynamoDB payment records. Event payload: `{fromDate?, toDate?, endpoint?}` (endpoint allowlisted and validated; unknown fields ignored). Intraday runs are disable-able as a unit via the `IntradaySchedule` rule. See [Bank reconciliation](#bank-reconciliation-fetchboatransactions). - **SlackAppHome** — Lambda behind API Gateway (`POST /slack/events`). Verifies the Slack signing secret (HMAC-SHA256, 5-minute replay window) before processing, then renders the payments dashboard on the Slack App Home tab with outstanding aging buckets, drill-down modals, and an always-visible "Returned — Needs Action" queue (bank-returned payments awaiting a reissue/void decision, sorted oldest return first). Returned records are excluded from Outstanding totals; terminal voided-and-bounced records appear in neither (audit trail only). - **ExpenseReceiver** — Lambda behind API Gateway (`POST /slack/expense-events`). Verifies the Slack signing secret (HMAC-SHA256), handles URL verification challenges, and async-invokes ExpenseProcessor. Runs outside VPC. - **ExpenseProcessor** — Async Lambda invoked by ExpenseReceiver. Processes `:white_check_mark:` reactions to advance expense messages through a four-stage Slack channel pipeline: Submitted → Processed → Authorized → Matched. Runs outside VPC. -ProcessPaymentCsv, FetchBoaTransactions, and SlackAppHome run inside a VPC with a NAT Gateway for a static outbound IP (required by BoA IP whitelisting). ExpenseReceiver, and ExpenseProcessor run outside the VPC. +ProcessPaymentCsv, FetchBoaTransactions, and SlackAppHome run inside a VPC with a NAT Gateway for a static outbound IP (required by BoA IP whitelisting). ExpenseReceiver and ExpenseProcessor run outside the VPC. ## Expense Approval Bot @@ -119,19 +119,21 @@ All BoA and Slack credentials are stored in AWS Secrets Manager (per `engineerin The `PaymentsDashboard` DynamoDB table (`AWS::DynamoDB::Table`, `TableName: PaymentsDashboard`, PK `pk` (S), CMK-encrypted) is **owned by this stack**, which is the sole authoritative writer. -**Consumer (read-only):** `seahaven-slack-bot` imports this table via `Table.fromTableName(...)` and reads it read-only (`grantReadData` plus an explicit `kms:Decrypt` grant on the shared CMK) from its `wo-po-lookup` Lambda, which backs the Bedrock agent's payment-lookup action group. The bot depends on: +**Former consumer:** `seahaven-slack-bot` (decommissioned 2026-07-23) imported this table by name. No live consumer remains. The table stays owned by this stack. + +The decommissioned bot depended on: - **Key schema:** PK `pk` (S) with the item format `payment#`. It does `GetItem` by `pk` and a full-table `Scan` filtered `begins_with(pk, "payment#")`. - **Attributes:** `check_number`, `payee`, `amount_usd`, `method`, `status`, `send_payment_on`, `clear_status`, `cleared_date`, `invoice_numbers`, `company_subsidiary`, `bank_reference`. - **Encryption:** the shared customer-managed CMK (`/seahaven/dynamodb/cmk-arn`). Because the consumer imports the table by name, `grantReadData` does not carry KMS access; a change of CMK requires re-granting on the consumer side or every read fails with `kms:Decrypt AccessDenied` (INFRA-95 / M-3 precedent). -The table is imported by name, so there is no compile-time link between the stacks: any change to the table name, `pk` format, these attribute names, the encryption key, or the table's lifecycle policy will silently break the Bedrock agent at runtime. Coordinate such changes with `seahaven-slack-bot` before shipping (INFRA-138). +No live stack imports this table. Keep the `pk` format and `payment#` prefix stable for Slack App Home and bank reconciliation. **Key prefixes in this table** (all owned by this stack): `payment#` (payment records), `metadata` (ingest metadata), `boa_txn##` (BoA submission journal, 90d TTL), `boa_recon#_#` (reconciliation run summaries, 90d TTL), `boa_balance##` (daily balance snapshots, latest-wins, no TTL). New prefixes are invisible to `seahaven-slack-bot`'s `begins_with(pk, "payment#")` scan — no consumer coordination needed when adding one. ## Monitoring & Alarms -All CloudWatch alarms publish to the shared `site-alerts` SNS topic (`arn:aws:sns:us-east-1:328440206208:site-alerts`). Alarms are ALARM-only by convention (no OK/recovery action) and treat missing data as `notBreaching`. Each alarm evaluates a single 5-minute period. +All CloudWatch alarms publish to the shared `site-alerts` SNS topic (`arn:aws:sns:us-east-1:011934824531:site-alerts` in seahaven-prod). Alarms are ALARM-only by convention (no OK/recovery action) and treat missing data as `notBreaching`. Each alarm evaluates a single 5-minute period. **SQS dead-letter queues** (messages-present, Maximum > 0): @@ -163,9 +165,6 @@ Duration thresholds (ms): processPaymentCsv 96000, fetchBoaTransactions 48000, s ## Deployment -```bash -sam build -sam deploy --guided -``` +See [SETUP.md](SETUP.md). Terraform owns infrastructure in workspace `payments-dashboard-prod`. GitHub Actions Environment `prod` ships function zips via `update-function-code`. Do not run `sam deploy`. -The `BOA_BASE_URL` environment variable in `template.yaml` controls whether Lambdas hit production (`https://api.bofa.com`) or sandbox (`https://api-sb.bofa.com`). All other BoA config is read from Secrets Manager at runtime. +The `boa_base_url` Terraform variable controls whether Lambdas hit production (`https://api.bofa.com`) or sandbox (`https://api-sb.bofa.com`). All other BoA config is read from Secrets Manager at runtime. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..dfebd56 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,74 @@ +# Payments Dashboard — Setup Guide + +Prod only. Workspace `payments-dashboard-prod` in project `seahaven-prod` +(account `011934824531`). No seahaven-dev workspace. + +## 1. Secrets + +Six Secrets Manager names already exist in seahaven-prod (copied from mgmt +with trailing newlines stripped). Terraform reads them by name; values stay +out of state. + +| Name | Used by | +|------|---------| +| `payments-dashboard/slack-bot-token` | slackAppHome | +| `payments-dashboard/slack-signing-secret` | slackAppHome | +| `payments-dashboard/boa-check-mgmt` | processPaymentCsv | +| `payments-dashboard/boa-reporting` | fetchBoaTransactions | +| `payments-dashboard/expense-slack-token` | expenseProcessor | +| `payments-dashboard/expense-slack-signing-secret` | expenseReceiver | + +## 2. HCP Terraform and GitHub Environment + +First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never +`StringLike`): + +1. Create the HCP workspace. Auto-apply off. No project-level variable set. + Working directory `terraform`. File trigger prefix `terraform/**` only. + Speculative plans on. VCS on `main`. +2. From `seahaven-org-baseline`: + `scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace payments-dashboard-prod` +3. Point workspace `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at + `hcptf-bootstrap` / `hcptf-bootstrap-plan`. Set `TFC_AWS_PROVIDER_AUTH=true`. +4. One manual apply with `schedules_enabled=false`. This creates the scoped + `hcptf-*` roles, the Lambda boundary, VPC/NAT, and the rest of the stack. +5. Retarget `TFC_AWS_*` to `hcptf-payments-dashboard` / + `hcptf-payments-dashboard-plan`. Re-run the create script with no + `--allow-workspace`. +6. Second manual apply as the scoped role. Then seal auto-apply on after + live-path proof. + +GitHub Environment `prod`: reviewers, branch policy `main` only, Environment +variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`. + +Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`. +Keep `schedules_enabled=false` until Slack Request URLs and the Stampli +uploader point at this stack. + +HCP outputs to copy: `slack_request_url`, `expense_slack_events_url`, +`csv_bucket_name`, `static_outbound_ip`, `github_deploy_role_arn`. + +## 3. Bank of America IP whitelist + +Submit `static_outbound_ip` to CashPro before any real Check Management or +Reporting call. The NAT EIP is new in seahaven-prod; mgmt `52.86.95.107` stays +until cutover. + +## 4. Prod cutover (PLAT-79) + +Avoid weekday 9am ET and the 16:00/19:00/22:00 UTC intraday slots. + +1. Merge this repo's PR (SAM CD is gone). First HCP apply is the bootstrap + window above with `schedules_enabled=false`. +2. Copy DynamoDB `PaymentsDashboard` mgmt → prod. Verify item counts for + `payment#`, `boa_recon#`, and `boa_balance#`. Do not copy + `seahaven-payments-boa-raw-*`. +3. GHA `workflow_dispatch` (or the merge deploy; re-run if it raced apply) to + overwrite stubs. +4. Instant cut: Slack App Home and Expense bot Request URLs → prod; + Stampli uploader bucket → `seahaven-payments-csv-011934824531`; + `schedules_enabled=true` via a terraform-only merge; disable mgmt + EventBridge. +5. After soak, delete mgmt stack `payments-dashboard`. Expect VPC ENI drain. + Leave mgmt raw bucket as Retain cold archive. Sweep mgmt secrets last. + Leave orphan `githubdeploy-payments-dashboard`. diff --git a/samconfig.toml.example b/samconfig.toml.example deleted file mode 100644 index d701b78..0000000 --- a/samconfig.toml.example +++ /dev/null @@ -1,10 +0,0 @@ -# Copy this file to samconfig.toml (gitignored) and adjust as needed for local deploys. -# CI/CD deploys via the reusable cd-sam.yaml workflow and does not use this file. -version = 0.1 - -[default.deploy.parameters] -stack_name = "payments-dashboard" -region = "us-east-1" -resolve_s3 = true -capabilities = "CAPABILITY_IAM" -confirm_changeset = true diff --git a/scripts/package_lambdas.mjs b/scripts/package_lambdas.mjs new file mode 100644 index 0000000..9045676 --- /dev/null +++ b/scripts/package_lambdas.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Build one Node zip per Lambda key. Used by deploy.yaml. + * Each zip is functions//.zip on S3. GIT_SHA is written to + * src/buildInfo.js inside the zip so a deploy is identifiable without a + * Terraform-owned env var. + */ +import { spawn, spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +const FUNCTIONS = [ + "process_csv", + "slack_app_home", + "fetch_boa", + "expense_receiver", + "expense_processor", +]; + +function parseArgs(argv) { + const out = { gitSha: "", outDir: join(ROOT, "build", "packages"), only: [] }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--git-sha") { + out.gitSha = argv[++i]; + } else if (arg === "--out-dir") { + out.outDir = argv[++i]; + } else if (arg === "--only") { + out.only.push(argv[++i]); + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + if (!out.gitSha) { + throw new Error("--git-sha is required"); + } + return out; +} + +function zipDir(srcDir, zipPath) { + return new Promise((resolve, reject) => { + const child = spawn("zip", ["-qr", zipPath, "."], { cwd: srcDir, stdio: "inherit" }); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`zip exited ${code}`)); + }); + }); +} + +async function build(name, gitSha, outDir) { + const dest = mkdtempSync(join(tmpdir(), `payments-${name}-`)); + try { + cpSync(join(ROOT, "src"), join(dest, "src"), { recursive: true }); + cpSync(join(ROOT, "package.json"), join(dest, "package.json")); + if (existsSync(join(ROOT, "package-lock.json"))) { + cpSync(join(ROOT, "package-lock.json"), join(dest, "package-lock.json")); + } + writeFileSync( + join(dest, "src", "buildInfo.js"), + `export const GIT_SHA = ${JSON.stringify(gitSha)};\n`, + "utf8", + ); + const npm = spawnSync("npm", ["ci", "--omit=dev"], { cwd: dest, stdio: "inherit" }); + if (npm.status !== 0) { + throw new Error("npm ci --omit=dev failed"); + } + mkdirSync(outDir, { recursive: true }); + const zipPath = join(outDir, `${name}.zip`); + rmSync(zipPath, { force: true }); + await zipDir(dest, zipPath); + return zipPath; + } finally { + rmSync(dest, { recursive: true, force: true }); + } +} + +const args = parseArgs(process.argv.slice(2)); +const selected = args.only.length ? args.only : FUNCTIONS; +const unknown = selected.filter((name) => !FUNCTIONS.includes(name)); +if (unknown.length) { + console.error(`unknown function keys: ${unknown.join(", ")}`); + process.exit(2); +} + +for (const name of selected) { + const path = await build(name, args.gitSha, args.outDir); + console.log(path); +} diff --git a/template.yaml b/template.yaml deleted file mode 100644 index 70b7cfa..0000000 --- a/template.yaml +++ /dev/null @@ -1,936 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: Payments Dashboard - S3 CSV ingestion to Slack App Home - -Parameters: - DynamoDbCmkArn: - Type: AWS::SSM::Parameter::Value - Default: /seahaven/dynamodb/cmk-arn - Description: >- - ARN of the shared customer-managed CMK (alias/seahaven-dynamodb) that - encrypts the PaymentsDashboard table. Functions that read/write the table - need kms:Decrypt/GenerateDataKey/DescribeKey on this key (the boundary - permits exactly these), or DynamoDB calls fail with AccessDeniedException. - -Globals: - Function: - Runtime: nodejs24.x - Architectures: - - arm64 - Timeout: 30 - MemorySize: 256 - PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary - Environment: - Variables: - TABLE_NAME: !Ref DashboardTable - # Access logging + default throttling on the implicit HTTP API (audit M-18). - HttpApi: - AccessLogSettings: - DestinationArn: !GetAtt ApiAccessLogGroup.Arn - Format: '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","requestTime":"$context.requestTime","method":"$context.httpMethod","routeKey":"$context.routeKey","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength","integrationError":"$context.integrationErrorMessage"}' - DefaultRouteSettings: - ThrottlingBurstLimit: 50 - ThrottlingRateLimit: 100 - -Resources: - ApiAccessLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/apigateway/payments-dashboard - RetentionInDays: 90 - - # VPC with private subnet + NAT Gateway for static outbound IP - Vpc: - Type: AWS::EC2::VPC - Properties: - CidrBlock: 10.20.0.0/16 - EnableDnsSupport: true - EnableDnsHostnames: true - Tags: - - Key: Name - Value: payments-dashboard-vpc - - PrivateSubnet: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref Vpc - CidrBlock: 10.20.1.0/24 - AvailabilityZone: !Select [0, !GetAZs ""] - Tags: - - Key: Name - Value: payments-dashboard-private - - PublicSubnet: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref Vpc - CidrBlock: 10.20.2.0/24 - AvailabilityZone: !Select [0, !GetAZs ""] - Tags: - - Key: Name - Value: payments-dashboard-public - - InternetGateway: - Type: AWS::EC2::InternetGateway - - VpcGatewayAttachment: - Type: AWS::EC2::VPCGatewayAttachment - Properties: - VpcId: !Ref Vpc - InternetGatewayId: !Ref InternetGateway - - NatEip: - Type: AWS::EC2::EIP - Properties: - Domain: vpc - - NatGateway: - Type: AWS::EC2::NatGateway - Properties: - AllocationId: !GetAtt NatEip.AllocationId - SubnetId: !Ref PublicSubnet - - PublicRouteTable: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref Vpc - - PublicRoute: - Type: AWS::EC2::Route - DependsOn: VpcGatewayAttachment - Properties: - RouteTableId: !Ref PublicRouteTable - DestinationCidrBlock: 0.0.0.0/0 - GatewayId: !Ref InternetGateway - - PublicSubnetRouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - SubnetId: !Ref PublicSubnet - RouteTableId: !Ref PublicRouteTable - - PrivateRouteTable: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref Vpc - - PrivateRoute: - Type: AWS::EC2::Route - Properties: - RouteTableId: !Ref PrivateRouteTable - DestinationCidrBlock: 0.0.0.0/0 - NatGatewayId: !Ref NatGateway - - # Gateway endpoints (audit M-22): keep S3/DynamoDB traffic off the NAT - # gateway — free, and removes per-GB NAT data-processing charges. - S3GatewayEndpoint: - Type: AWS::EC2::VPCEndpoint - Properties: - VpcId: !Ref Vpc - ServiceName: !Sub com.amazonaws.${AWS::Region}.s3 - VpcEndpointType: Gateway - RouteTableIds: - - !Ref PublicRouteTable - - !Ref PrivateRouteTable - - DynamoDbGatewayEndpoint: - Type: AWS::EC2::VPCEndpoint - Properties: - VpcId: !Ref Vpc - ServiceName: !Sub com.amazonaws.${AWS::Region}.dynamodb - VpcEndpointType: Gateway - RouteTableIds: - - !Ref PublicRouteTable - - !Ref PrivateRouteTable - - PrivateSubnetRouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - SubnetId: !Ref PrivateSubnet - RouteTableId: !Ref PrivateRouteTable - - LambdaSecurityGroup: - Type: AWS::EC2::SecurityGroup - Properties: - GroupDescription: Payments Dashboard Lambda outbound access - VpcId: !Ref Vpc - SecurityGroupEgress: - - IpProtocol: "-1" - CidrIp: 0.0.0.0/0 - - PaymentsCsvBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: !Sub seahaven-payments-csv-${AWS::AccountId} - PublicAccessBlockConfiguration: - BlockPublicAcls: true - IgnorePublicAcls: true - BlockPublicPolicy: true - RestrictPublicBuckets: true - - # Raw archive of every BoA reporting API response (exact bytes, keyed - # raw//_/.json). Replayable corpus for - # parser changes + audit trail. Retain: a template revert must never - # attempt to delete a bank-data bucket; decommission goes through the CFN - # decommission runbook (inventory, purge, deliberate deletion). - # Retain + fixed name = rollback-orphan hazard (same class as the RETAIN - # secret deadlock): if a failed deploy orphans the bucket, ADOPT it back - # with a CloudFormation resource import — never delete-and-recreate. - BoaRawBucket: - Type: AWS::S3::Bucket - DeletionPolicy: Retain - UpdateReplacePolicy: Retain - Properties: - BucketName: !Sub seahaven-payments-boa-raw-${AWS::AccountId} - PublicAccessBlockConfiguration: - BlockPublicAcls: true - IgnorePublicAcls: true - BlockPublicPolicy: true - RestrictPublicBuckets: true - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 - LifecycleConfiguration: - Rules: - - Id: expire-raw-responses - Status: Enabled - ExpirationInDays: 730 - - BoaRawBucketPolicy: - Type: AWS::S3::BucketPolicy - Properties: - Bucket: !Ref BoaRawBucket - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: DenyInsecureTransport - Effect: Deny - Principal: "*" - Action: s3:* - Resource: - - !GetAtt BoaRawBucket.Arn - - !Sub "${BoaRawBucket.Arn}/*" - Condition: - Bool: - aws:SecureTransport: "false" - - DashboardTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: PaymentsDashboard - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: pk - AttributeType: S - KeySchema: - - AttributeName: pk - KeyType: HASH - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - # SSE-KMS with the shared CMK (alias/seahaven-dynamodb, INFRA-95 / M-3). - # The table was migrated to this key out-of-band, so declaring it here - # reconciles the template drift (no-op against the live table). Consumer - # roles still need explicit kms perms below (SAM policies do not auto-add). - SSESpecification: - SSEEnabled: true - SSEType: KMS - KMSMasterKeyId: !Ref DynamoDbCmkArn - - ProcessPaymentCsvDLQ: - Type: AWS::SQS::Queue - Properties: - QueueName: payments-processPaymentCsv-async-dlq - MessageRetentionPeriod: 1209600 # 14d - - # ALARM-only Lambda Errors alarms (INFRA-41 / H-8). Threshold > 0 on the - # Errors Sum, no OK/recovery action by convention. - ProcessPaymentCsvErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-processPaymentCsv-errors - AlarmDescription: payments-processPaymentCsv invocation errors - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref ProcessPaymentCsvFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - # ── Lambda Errors alarms (Wave 1) ────────────────────────────────────────── - # Clone of ProcessPaymentCsvErrorsAlarm for the remaining functions. AWS/Lambda - # Errors, Sum over 5m, threshold > 0, ALARM-only by convention. - SlackAppHomeErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-slackAppHome-errors - AlarmDescription: payments-slackAppHome invocation errors - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref SlackAppHomeFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - FetchBoaTransactionsErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-fetchBoaTransactions-errors - AlarmDescription: payments-fetchBoaTransactions invocation errors - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref FetchBoaTransactionsFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ExpenseReceiverErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-expenseReceiver-errors - AlarmDescription: payments-expenseReceiver invocation errors - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref ExpenseReceiverFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ExpenseProcessorErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-expenseProcessor-errors - AlarmDescription: payments-expenseProcessor invocation errors - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref ExpenseProcessorFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - # ── Lambda Throttles alarms (Wave 1) ─────────────────────────────────────── - # AWS/Lambda Throttles, Sum over 5m, threshold > 0, ALARM-only. Throttling - # signals concurrency exhaustion / reserved-concurrency starvation. - ProcessPaymentCsvThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-processPaymentCsv-throttles - AlarmDescription: payments-processPaymentCsv invocations throttled - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref ProcessPaymentCsvFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - FetchBoaTransactionsThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-fetchBoaTransactions-throttles - AlarmDescription: payments-fetchBoaTransactions invocations throttled - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref FetchBoaTransactionsFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - SlackAppHomeThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-slackAppHome-throttles - AlarmDescription: payments-slackAppHome invocations throttled - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref SlackAppHomeFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ExpenseReceiverThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-expenseReceiver-throttles - AlarmDescription: payments-expenseReceiver invocations throttled - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref ExpenseReceiverFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ExpenseProcessorThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-expenseProcessor-throttles - AlarmDescription: payments-expenseProcessor invocations throttled - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref ExpenseProcessorFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - # ── Lambda Duration alarms (Wave 1) ──────────────────────────────────────── - # AWS/Lambda Duration (ms), Statistic Maximum over 5m. Thresholds are ~80% of - # each function's configured timeout — early warning before timeout-kills. - ProcessPaymentCsvDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-processPaymentCsv-duration - AlarmDescription: payments-processPaymentCsv approaching timeout (~80% of 120s) - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref ProcessPaymentCsvFunction - Statistic: Maximum - Period: 300 - EvaluationPeriods: 1 - Threshold: 96000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - FetchBoaTransactionsDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-fetchBoaTransactions-duration - AlarmDescription: payments-fetchBoaTransactions approaching timeout (~80% of 60s) - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref FetchBoaTransactionsFunction - Statistic: Maximum - Period: 300 - EvaluationPeriods: 1 - Threshold: 48000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ExpenseProcessorDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-expenseProcessor-duration - AlarmDescription: payments-expenseProcessor approaching timeout (~80% of 15s) - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref ExpenseProcessorFunction - Statistic: Maximum - Period: 300 - EvaluationPeriods: 1 - Threshold: 12000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ExpenseReceiverDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-expenseReceiver-duration - AlarmDescription: payments-expenseReceiver approaching timeout (~80% of 5s) - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref ExpenseReceiverFunction - Statistic: Maximum - Period: 300 - EvaluationPeriods: 1 - Threshold: 4000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - SlackAppHomeDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-slackAppHome-duration - AlarmDescription: payments-slackAppHome approaching timeout (~80% of 30s default) - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref SlackAppHomeFunction - Statistic: Maximum - Period: 300 - EvaluationPeriods: 1 - Threshold: 24000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - # ── DynamoDB alarms (Wave 1, SCOPE-CONFIRM) ──────────────────────────────── - # AWS/DynamoDB throttle metrics for the PaymentsDashboard table. These metrics - # emit at the TableName dimension and only on the occurrence of a throttle - # event — none are currently present in CloudWatch (the table is - # PAY_PER_REQUEST, so sustained throttling is unlikely but possible during - # burst-capacity ramp). SystemErrors is intentionally not alarmed: AWS/DynamoDB - # SystemErrors does not emit at the TableName-only dimension, so it can never - # fire. Threshold > 0, Sum over 5m, ALARM-only. - DashboardTableReadThrottleAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-dashboard-table-read-throttle - AlarmDescription: PaymentsDashboard table read requests throttled - Namespace: AWS/DynamoDB - MetricName: ReadThrottleEvents - Dimensions: - - Name: TableName - Value: !Ref DashboardTable - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - DashboardTableWriteThrottleAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-dashboard-table-write-throttle - AlarmDescription: PaymentsDashboard table write requests throttled - Namespace: AWS/DynamoDB - MetricName: WriteThrottleEvents - Dimensions: - - Name: TableName - Value: !Ref DashboardTable - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - # ── API Gateway (HTTP API v2) alarms (Wave 1, SCOPE-CONFIRM) ──────────────── - # AWS/ApiGateway v2 metrics on the implicit ServerlessHttpApi (ApiId dim). - # v2 metric names are 4xx/5xx/Latency (not 4XXError/5XXError). 5xx and Latency - # alarm on the API itself; 4xx is mostly client-driven so its threshold is - # set above zero to avoid noise (Slack URL-verification / bad requests). - ApiGateway5xxAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-dashboard-api-5xx - AlarmDescription: payments-dashboard HTTP API returned 5xx responses - Namespace: AWS/ApiGateway - MetricName: 5xx - Dimensions: - - Name: ApiId - Value: !Ref ServerlessHttpApi - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ApiGateway4xxAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-dashboard-api-4xx - AlarmDescription: payments-dashboard HTTP API elevated 4xx responses - Namespace: AWS/ApiGateway - MetricName: 4xx - Dimensions: - - Name: ApiId - Value: !Ref ServerlessHttpApi - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 10 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ApiGatewayLatencyAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-dashboard-api-latency-p99 - AlarmDescription: payments-dashboard HTTP API p99 latency elevated (>3s) - Namespace: AWS/ApiGateway - MetricName: Latency - Dimensions: - - Name: ApiId - Value: !Ref ServerlessHttpApi - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 1 - Threshold: 3000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - # Messages-present alarms on the async-invoke OnFailure DLQs, - # Threshold > 0 on the visible-message count, ALARM-only. - ProcessPaymentCsvDLQAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: payments-processPaymentCsv-async-dlq-messages - AlarmDescription: Failed processPaymentCsv async invocations landed in the DLQ - Namespace: AWS/SQS - MetricName: ApproximateNumberOfMessagesVisible - Dimensions: - - Name: QueueName - Value: !GetAtt ProcessPaymentCsvDLQ.QueueName - Statistic: Maximum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - # ALARM-only notification by convention — no OK/recovery action - AlarmActions: - - !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts - - ProcessPaymentCsvLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/lambda/payments-processPaymentCsv - RetentionInDays: 60 - - SlackAppHomeLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/lambda/payments-slackAppHome - RetentionInDays: 60 - - FetchBoaTransactionsLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/lambda/payments-fetchBoaTransactions - RetentionInDays: 60 - - ExpenseReceiverLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/lambda/payments-expenseReceiver - RetentionInDays: 60 - - ExpenseProcessorLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/lambda/payments-expenseProcessor - RetentionInDays: 60 - - ProcessPaymentCsvFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: payments-processPaymentCsv - Handler: src/processPaymentCsv.handler - Timeout: 120 - EventInvokeConfig: - MaximumRetryAttempts: 2 - MaximumEventAgeInSeconds: 21600 - DestinationConfig: - OnFailure: - Type: SQS - Destination: !GetAtt ProcessPaymentCsvDLQ.Arn - Environment: - Variables: - BOA_BASE_URL: https://api.bofa.com - BOA_CHECK_MGMT_SECRET_NAME: payments-dashboard/boa-check-mgmt - VpcConfig: - SubnetIds: - - !Ref PrivateSubnet - SecurityGroupIds: - - !Ref LambdaSecurityGroup - Events: - CsvUpload: - Type: S3 - Properties: - Bucket: !Ref PaymentsCsvBucket - Events: s3:ObjectCreated:* - Filter: - S3Key: - Rules: - - Name: suffix - Value: .csv - Policies: - - S3ReadPolicy: - BucketName: !Sub seahaven-payments-csv-${AWS::AccountId} - - DynamoDBCrudPolicy: - TableName: !Ref DashboardTable - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - kms:Decrypt - - kms:GenerateDataKey - - kms:DescribeKey - Resource: !Ref DynamoDbCmkArn - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/boa-check-mgmt-* - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - ec2:CreateNetworkInterface - - ec2:DescribeNetworkInterfaces - - ec2:DeleteNetworkInterface - Resource: "*" - - SlackAppHomeFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: payments-slackAppHome - Handler: src/slackAppHome.handler - VpcConfig: - SubnetIds: - - !Ref PrivateSubnet - SecurityGroupIds: - - !Ref LambdaSecurityGroup - Environment: - Variables: - SLACK_BOT_TOKEN_SECRET_NAME: payments-dashboard/slack-bot-token - SLACK_SIGNING_SECRET_NAME: payments-dashboard/slack-signing-secret - Events: - SlackEvent: - Type: HttpApi - Properties: - Path: /slack/events - Method: POST - Policies: - - DynamoDBReadPolicy: - TableName: !Ref DashboardTable - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - kms:Decrypt - - kms:DescribeKey - Resource: !Ref DynamoDbCmkArn - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: - - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/slack-bot-token-* - - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/slack-signing-secret-* - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - ec2:CreateNetworkInterface - - ec2:DescribeNetworkInterfaces - - ec2:DeleteNetworkInterface - Resource: "*" - - FetchBoaTransactionsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: payments-fetchBoaTransactions - Handler: src/fetchBoaTransactions.handler - Timeout: 60 - VpcConfig: - SubnetIds: - - !Ref PrivateSubnet - SecurityGroupIds: - - !Ref LambdaSecurityGroup - Environment: - Variables: - BOA_BASE_URL: https://api.bofa.com - BOA_REPORTING_SECRET_NAME: payments-dashboard/boa-reporting - BOA_RAW_BUCKET: !Ref BoaRawBucket - Events: - DailySchedule: - Type: Schedule - Properties: - Schedule: cron(0 13 ? * MON-FRI *) - Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC) - Enabled: true - # One rule for all intraday runs so they can be disabled as a unit - # (aws events disable-rule) without touching the authoritative 9am - # previous-day sweep. Fixed UTC: ~12/3/6pm ET in DST, 11/2/5pm in - # winter (accepted drift, documented in README). - IntradaySchedule: - Type: Schedule - Properties: - Schedule: cron(0 16,19,22 ? * MON-FRI *) - Description: Intraday BoA current-day sweep (~12pm/3pm/6pm ET) - Enabled: true - Input: '{"endpoint":"current-day"}' - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref DashboardTable - - Version: "2012-10-17" - Statement: - # Archive writes only: no read, no list, no other principal. - # Derived from the bucket resource so a rename can't silently - # detach the grant. - - Effect: Allow - Action: s3:PutObject - Resource: !Sub "${BoaRawBucket.Arn}/*" - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - kms:Decrypt - - kms:GenerateDataKey - - kms:DescribeKey - Resource: !Ref DynamoDbCmkArn - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/boa-reporting-* - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - ec2:CreateNetworkInterface - - ec2:DescribeNetworkInterfaces - - ec2:DeleteNetworkInterface - Resource: "*" - - ExpenseProcessorFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: payments-expenseProcessor - Handler: src/expenseProcessor.handler - Timeout: 15 - Environment: - Variables: - EXPENSE_BOT_TOKEN_SECRET_NAME: payments-dashboard/expense-slack-token - Policies: - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/expense-slack-token-* - - ExpenseReceiverFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: payments-expenseReceiver - Handler: src/expenseReceiver.handler - Timeout: 5 - Environment: - Variables: - EXPENSE_PROCESSOR_FN: !Ref ExpenseProcessorFunction - EXPENSE_SIGNING_SECRET_NAME: payments-dashboard/expense-slack-signing-secret - Events: - ExpenseSlackEvent: - Type: HttpApi - Properties: - Path: /slack/expense-events - Method: POST - Policies: - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: lambda:InvokeFunction - Resource: !GetAtt ExpenseProcessorFunction.Arn - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/expense-slack-signing-secret-* - -Outputs: - SlackEventUrl: - Description: URL to set as the Slack app Request URL - Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events - CsvBucket: - Description: S3 bucket for CSV uploads - Value: !Ref PaymentsCsvBucket - StaticOutboundIp: - Description: Static IP for BoA API whitelist - Value: !Ref NatEip - ExpenseSlackEventsUrl: - Description: URL for Expense Approval Bot Slack Event Subscriptions - Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/expense-events - ExpenseProcessorFunctionArn: - Description: Expense Processor Lambda ARN - Value: !GetAtt ExpenseProcessorFunction.Arn - ExpenseReceiverFunctionArn: - Description: Expense Receiver Lambda ARN - Value: !GetAtt ExpenseReceiverFunction.Arn diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..5422af0 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.1" + constraints = "~> 2.8" + hashes = [ + "h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=", + "zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec", + "zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058", + "zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59", + "zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4", + "zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35", + "zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6", + "zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad", + "zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9", + "zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831", + "zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249", + "zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.64.0" + constraints = "~> 6.64" + hashes = [ + "h1:wXARLY+IeQ7ufYxCLTPCwToWGMRvOpiOTfJS97iwUzI=", + "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", + "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", + "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", + "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", + "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", + "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", + "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", + "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", + "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", + "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", + "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", + "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", + "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", + "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", + ] +} diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..643265a --- /dev/null +++ b/terraform/alarms.tf @@ -0,0 +1,161 @@ +locals { + lambda_alarm_matrix = { + errors = { + metric_name = "Errors" + statistic = "Sum" + threshold = 0 + comparison = "GreaterThanThreshold" + period = 300 + } + throttles = { + metric_name = "Throttles" + statistic = "Sum" + threshold = 0 + comparison = "GreaterThanThreshold" + period = 300 + } + } + + lambda_alarms = { + for pair in flatten([ + for fn_key, fn in local.functions : [ + for metric_key, metric in local.lambda_alarm_matrix : { + key = "${fn_key}-${metric_key}" + function = fn.function_name + metric_key = metric_key + metric_name = metric.metric_name + statistic = metric.statistic + threshold = metric.threshold + comparison = metric.comparison + period = metric.period + alarm_name = "${fn.function_name}-${metric_key}" + description = "${fn.function_name} ${metric_key}" + } + ] + ]) : pair.key => pair + } +} + +resource "aws_cloudwatch_metric_alarm" "lambda_errors_throttles" { + for_each = local.lambda_alarms + + alarm_name = each.value.alarm_name + alarm_description = each.value.description + namespace = "AWS/Lambda" + metric_name = each.value.metric_name + dimensions = { FunctionName = each.value.function } + statistic = each.value.statistic + period = each.value.period + evaluation_periods = 1 + threshold = each.value.threshold + comparison_operator = each.value.comparison + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "lambda_duration" { + for_each = local.functions + + alarm_name = "${each.value.function_name}-duration" + alarm_description = "${each.value.function_name} approaching timeout (~80% of ${each.value.timeout}s)" + namespace = "AWS/Lambda" + metric_name = "Duration" + dimensions = { FunctionName = each.value.function_name } + statistic = "Maximum" + period = 300 + evaluation_periods = 1 + threshold = each.value.duration_ms + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "ddb_read_throttle" { + alarm_name = "payments-dashboard-table-read-throttle" + alarm_description = "PaymentsDashboard table read requests throttled" + namespace = "AWS/DynamoDB" + metric_name = "ReadThrottleEvents" + dimensions = { TableName = aws_dynamodb_table.dashboard.name } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "ddb_write_throttle" { + alarm_name = "payments-dashboard-table-write-throttle" + alarm_description = "PaymentsDashboard table write requests throttled" + namespace = "AWS/DynamoDB" + metric_name = "WriteThrottleEvents" + dimensions = { TableName = aws_dynamodb_table.dashboard.name } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "api_5xx" { + alarm_name = "payments-dashboard-api-5xx" + alarm_description = "payments-dashboard HTTP API returned 5xx responses" + namespace = "AWS/ApiGateway" + metric_name = "5xx" + dimensions = { ApiId = aws_apigatewayv2_api.http.id } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "api_4xx" { + alarm_name = "payments-dashboard-api-4xx" + alarm_description = "payments-dashboard HTTP API elevated 4xx responses" + namespace = "AWS/ApiGateway" + metric_name = "4xx" + dimensions = { ApiId = aws_apigatewayv2_api.http.id } + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 10 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "api_latency" { + alarm_name = "payments-dashboard-api-latency-p99" + alarm_description = "payments-dashboard HTTP API p99 latency elevated (>3s)" + namespace = "AWS/ApiGateway" + metric_name = "Latency" + dimensions = { ApiId = aws_apigatewayv2_api.http.id } + extended_statistic = "p99" + period = 300 + evaluation_periods = 1 + threshold = 3000 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} + +resource "aws_cloudwatch_metric_alarm" "process_csv_dlq" { + alarm_name = "payments-processPaymentCsv-async-dlq-messages" + alarm_description = "Failed processPaymentCsv async invocations landed in the DLQ" + namespace = "AWS/SQS" + metric_name = "ApproximateNumberOfMessagesVisible" + dimensions = { QueueName = aws_sqs_queue.process_csv_dlq.name } + statistic = "Maximum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [local.site_alerts_arn] +} diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf new file mode 100644 index 0000000..35b564d --- /dev/null +++ b/terraform/apigateway.tf @@ -0,0 +1,73 @@ +resource "aws_apigatewayv2_api" "http" { + name = local.project + protocol_type = "HTTP" + description = "payments-dashboard Slack App Home and expense bot API" +} + +resource "aws_apigatewayv2_integration" "slack_app_home" { + api_id = aws_apigatewayv2_api.http.id + integration_type = "AWS_PROXY" + integration_method = "POST" + integration_uri = aws_lambda_function.this["slack_app_home"].invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = 30000 +} + +resource "aws_apigatewayv2_integration" "expense_receiver" { + api_id = aws_apigatewayv2_api.http.id + integration_type = "AWS_PROXY" + integration_method = "POST" + integration_uri = aws_lambda_function.this["expense_receiver"].invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = 5000 +} + +resource "aws_apigatewayv2_route" "slack_events" { + api_id = aws_apigatewayv2_api.http.id + route_key = "POST /slack/events" + target = "integrations/${aws_apigatewayv2_integration.slack_app_home.id}" +} + +resource "aws_apigatewayv2_route" "expense_events" { + api_id = aws_apigatewayv2_api.http.id + route_key = "POST /slack/expense-events" + target = "integrations/${aws_apigatewayv2_integration.expense_receiver.id}" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.http.id + name = "$default" + auto_deploy = true + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_access.arn + format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}" + } + + default_route_settings { + throttling_burst_limit = 50 + throttling_rate_limit = 100 + } + + depends_on = [ + aws_apigatewayv2_route.slack_events, + aws_apigatewayv2_route.expense_events, + aws_iam_role_policy.hcptf_apply_services, + ] +} + +resource "aws_lambda_permission" "api_slack_app_home" { + statement_id = "AllowApiGatewayInvokeSlackAppHome" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this["slack_app_home"].function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" +} + +resource "aws_lambda_permission" "api_expense_receiver" { + statement_id = "AllowApiGatewayInvokeExpenseReceiver" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this["expense_receiver"].function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" +} diff --git a/terraform/bootstrap/stub/package.json b/terraform/bootstrap/stub/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/terraform/bootstrap/stub/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/terraform/bootstrap/stub/src/expenseProcessor.js b/terraform/bootstrap/stub/src/expenseProcessor.js new file mode 100644 index 0000000..7a48369 --- /dev/null +++ b/terraform/bootstrap/stub/src/expenseProcessor.js @@ -0,0 +1,7 @@ +export async function handler() { + return { + statusCode: 503, + headers: { "content-type": "application/json" }, + body: '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}', + }; +} diff --git a/terraform/bootstrap/stub/src/expenseReceiver.js b/terraform/bootstrap/stub/src/expenseReceiver.js new file mode 100644 index 0000000..7a48369 --- /dev/null +++ b/terraform/bootstrap/stub/src/expenseReceiver.js @@ -0,0 +1,7 @@ +export async function handler() { + return { + statusCode: 503, + headers: { "content-type": "application/json" }, + body: '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}', + }; +} diff --git a/terraform/bootstrap/stub/src/fetchBoaTransactions.js b/terraform/bootstrap/stub/src/fetchBoaTransactions.js new file mode 100644 index 0000000..7a48369 --- /dev/null +++ b/terraform/bootstrap/stub/src/fetchBoaTransactions.js @@ -0,0 +1,7 @@ +export async function handler() { + return { + statusCode: 503, + headers: { "content-type": "application/json" }, + body: '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}', + }; +} diff --git a/terraform/bootstrap/stub/src/processPaymentCsv.js b/terraform/bootstrap/stub/src/processPaymentCsv.js new file mode 100644 index 0000000..7a48369 --- /dev/null +++ b/terraform/bootstrap/stub/src/processPaymentCsv.js @@ -0,0 +1,7 @@ +export async function handler() { + return { + statusCode: 503, + headers: { "content-type": "application/json" }, + body: '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}', + }; +} diff --git a/terraform/bootstrap/stub/src/slackAppHome.js b/terraform/bootstrap/stub/src/slackAppHome.js new file mode 100644 index 0000000..7a48369 --- /dev/null +++ b/terraform/bootstrap/stub/src/slackAppHome.js @@ -0,0 +1,7 @@ +export async function handler() { + return { + statusCode: 503, + headers: { "content-type": "application/json" }, + body: '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}', + }; +} diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..0df5022 --- /dev/null +++ b/terraform/data.tf @@ -0,0 +1,56 @@ +data "aws_ssm_parameter" "dynamodb_cmk" { + name = local.dynamodb_cmk_ssm +} + +resource "aws_dynamodb_table" "dashboard" { + name = local.table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "pk" + + attribute { + name = "pk" + type = "S" + } + + ttl { + attribute_name = "ttl" + enabled = true + } + + server_side_encryption { + enabled = true + kms_key_arn = data.aws_ssm_parameter.dynamodb_cmk.value + } +} + +resource "aws_sqs_queue" "process_csv_dlq" { + name = "payments-processPaymentCsv-async-dlq" + message_retention_seconds = 1209600 + sqs_managed_sse_enabled = true +} + +data "aws_iam_policy_document" "process_csv_dlq" { + statement { + sid = "AllowLambdaOnFailure" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + + actions = ["sqs:SendMessage"] + resources = [aws_sqs_queue.process_csv_dlq.arn] + + condition { + test = "ArnEquals" + variable = "aws:SourceArn" + values = [aws_lambda_function.this["process_csv"].arn] + } + } +} + +resource "aws_sqs_queue_policy" "process_csv_dlq" { + queue_url = aws_sqs_queue.process_csv_dlq.id + policy = data.aws_iam_policy_document.process_csv_dlq.json +} diff --git a/terraform/events.tf b/terraform/events.tf new file mode 100644 index 0000000..a2404e2 --- /dev/null +++ b/terraform/events.tf @@ -0,0 +1,47 @@ +# EventBridge schedules. Keep schedules_enabled=false until Slack Request URLs +# and the Stampli uploader point at this stack. + +locals { + schedules = { + daily = { + description = "Fetch BoA previous day transactions at 9am ET (13:00 UTC)" + schedule = "cron(0 13 ? * MON-FRI *)" + function_key = "fetch_boa" + input = null + } + intraday = { + description = "Intraday BoA current-day sweep (~12pm/3pm/6pm ET)" + schedule = "cron(0 16,19,22 ? * MON-FRI *)" + function_key = "fetch_boa" + input = jsonencode({ endpoint = "current-day" }) + } + } +} + +resource "aws_cloudwatch_event_rule" "schedule" { + for_each = local.schedules + + name = "${local.project}-${each.key}" + description = each.value.description + schedule_expression = each.value.schedule + state = var.schedules_enabled ? "ENABLED" : "DISABLED" +} + +resource "aws_cloudwatch_event_target" "schedule" { + for_each = local.schedules + + rule = aws_cloudwatch_event_rule.schedule[each.key].name + target_id = "${local.project}-${each.key}" + arn = aws_lambda_function.this[each.value.function_key].arn + input = each.value.input +} + +resource "aws_lambda_permission" "schedule" { + for_each = local.schedules + + statement_id = "AllowEventBridgeInvoke-${each.key}" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this[each.value.function_key].function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn +} diff --git a/terraform/hcp_iam.tf b/terraform/hcp_iam.tf new file mode 100644 index 0000000..97f7088 --- /dev/null +++ b/terraform/hcp_iam.tf @@ -0,0 +1,906 @@ +# HCP plan/apply roles for payments-dashboard-prod (PLAT-79 / PLAT-144). +# Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example +# with the payments-dashboard service set. Create, do not import. +# +# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy +# and PutRolePolicy on hcptf-* (including this role). First-apply sequence: +# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh +# --account prod --allow-workspace payments-dashboard-prod +# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap / +# hcptf-bootstrap-plan (workspace vars, never a project set). +# 3. One Manual apply (create roles + scoped inline + boundary + stack, +# schedules_enabled=false). +# 4. Point TFC_AWS_* back at hcptf-payments-dashboard / +# hcptf-payments-dashboard-plan. +# 5. Re-run the script without --allow-workspace to pin trust back to +# iam-bootstrap-prod only. +# Later apply-role IAM edits use the same window. Do not add StringLike +# on bootstrap trust. CreatePolicy stays on hcptf-bootstrap only; boundary +# document changes after seal also need that window. + +data "aws_iam_policy_document" "hcptf_apply_trust" { + statement { + sid = "HcpApply" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = [ + "organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply", + ] + } + } +} + +data "aws_iam_policy_document" "hcptf_plan_trust" { + statement { + sid = "HcpPlan" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = [ + "organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan", + ] + } + } +} + +data "aws_iam_policy_document" "hcptf_scoped_iam" { + statement { + sid = "DenyCreatePolicy" + effect = "Deny" + actions = [ + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ] + resources = ["*"] + } + + statement { + sid = "CreateExecRoleWithBoundary" + effect = "Allow" + actions = ["iam:CreateRole"] + resources = [ + "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", + ] + + condition { + test = "StringLike" + variable = "iam:PermissionsBoundary" + values = [ + "arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*", + "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary", + "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}", + ] + } + } + + statement { + sid = "MutateExecRoleWithBoundary" + effect = "Allow" + actions = [ + "iam:AttachRolePolicy", + "iam:PutRolePolicy", + "iam:PutRolePermissionsBoundary", + ] + resources = [ + "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", + ] + + condition { + test = "StringLike" + variable = "iam:PermissionsBoundary" + values = [ + "arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*", + "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary", + "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}", + ] + } + } + + statement { + sid = "WriteExecRoles" + effect = "Allow" + actions = [ + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ] + resources = [ + "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", + ] + } + + statement { + sid = "PassExecRolesToLambda" + effect = "Allow" + actions = ["iam:PassRole"] + resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["lambda.amazonaws.com"] + } + } + + statement { + sid = "CreateDeployRole" + effect = "Allow" + actions = ["iam:CreateRole"] + resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"] + + condition { + test = "Null" + variable = "iam:PermissionsBoundary" + values = ["true"] + } + } + + statement { + sid = "WriteDeployRoles" + effect = "Allow" + actions = [ + "iam:AttachRolePolicy", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ] + resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"] + } + + statement { + sid = "IamReadOnly" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListPolicies", + "iam:ListPolicyVersions", + "iam:ListRolePolicies", + "iam:ListRoleTags", + "iam:ListRoles", + ] + resources = ["*"] + } + + statement { + sid = "DenySelfMutation" + effect = "Deny" + actions = [ + "iam:AttachRolePolicy", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DeleteRolePermissionsBoundary", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:PutRolePermissionsBoundary", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ] + resources = [ + "arn:aws:iam::${local.account_id}:role/hcptf-*", + "arn:aws:iam::${local.account_id}:role/github-cfn-execution-role", + "arn:aws:iam::${local.account_id}:role/githubdeploy-*", + "arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*", + "arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole", + "arn:aws:iam::${local.account_id}:role/seahaven-*", + ] + } + + statement { + sid = "DenyBoundaryTampering" + effect = "Deny" + actions = [ + "iam:DeleteRolePermissionsBoundary", + "iam:DeleteUserPermissionsBoundary", + ] + resources = [ + "arn:aws:iam::${local.account_id}:role/*", + "arn:aws:iam::${local.account_id}:user/*", + ] + } + + statement { + sid = "DenyBoundaryPolicyEdit" + effect = "Deny" + actions = [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ] + resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"] + } +} + +data "aws_iam_policy_document" "hcptf_apply_services" { + # checkov:skip=CKV_AWS_111: List/describe, HTTP API log delivery, and VPC/NAT lifecycle APIs require Resource=*. Function, bucket, table, queue, secret, alarm, and SSM writes are ARN-prefixed. + statement { + sid = "LambdaAll" + effect = "Allow" + actions = [ + "lambda:*", + ] + resources = [ + "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-*", + ] + } + + statement { + sid = "LambdaList" + effect = "Allow" + actions = [ + "lambda:ListFunctions", + "lambda:ListLayers", + "lambda:GetAccountSettings", + ] + resources = ["*"] + } + + statement { + sid = "EventBridgeRules" + effect = "Allow" + actions = [ + "events:*", + ] + resources = [ + "arn:aws:events:${var.aws_region}:${local.account_id}:rule/${local.project}-*", + ] + } + + statement { + sid = "EventBridgeList" + effect = "Allow" + actions = ["events:ListRules", "events:ListRuleNamesByTarget"] + resources = ["*"] + } + + statement { + sid = "CloudWatchLogs" + effect = "Allow" + actions = [ + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:PutRetentionPolicy", + "logs:DeleteRetentionPolicy", + "logs:TagResource", + "logs:UntagResource", + "logs:ListTagsForResource", + "logs:PutMetricFilter", + "logs:DeleteMetricFilter", + "logs:DescribeMetricFilters", + ] + resources = [ + "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/payments-*", + "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/${local.project}", + "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/${local.project}:*", + ] + } + + statement { + sid = "CloudWatchLogsDescribe" + effect = "Allow" + actions = ["logs:DescribeLogGroups"] + resources = ["*"] + } + + statement { + sid = "ApiGwAccessLogDelivery" + effect = "Allow" + actions = [ + "logs:CreateLogDelivery", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:ListLogDeliveries", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies", + ] + resources = ["*"] + } + + statement { + sid = "StackBuckets" + effect = "Allow" + actions = [ + "s3:*", + ] + resources = [ + "arn:aws:s3:::${local.artifacts_bucket_name}", + "arn:aws:s3:::${local.artifacts_bucket_name}/*", + "arn:aws:s3:::${local.csv_bucket_name}", + "arn:aws:s3:::${local.csv_bucket_name}/*", + "arn:aws:s3:::${local.boa_raw_bucket_name}", + "arn:aws:s3:::${local.boa_raw_bucket_name}/*", + ] + } + + statement { + sid = "DynamoDBTable" + effect = "Allow" + actions = [ + "dynamodb:*", + ] + resources = [ + "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}", + "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*", + ] + } + + statement { + sid = "DynamoDBList" + effect = "Allow" + actions = ["dynamodb:ListTables"] + resources = ["*"] + } + + statement { + sid = "SqsDlq" + effect = "Allow" + actions = [ + "sqs:*", + ] + resources = [ + "arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq", + ] + } + + statement { + sid = "SqsList" + effect = "Allow" + actions = ["sqs:ListQueues"] + resources = ["*"] + } + + statement { + sid = "HttpApiManage" + effect = "Allow" + actions = [ + "apigateway:*", + ] + resources = [ + "arn:aws:apigateway:${var.aws_region}::/apis", + "arn:aws:apigateway:${var.aws_region}::/apis/*", + "arn:aws:apigateway:${var.aws_region}::/tags/*", + "arn:aws:apigateway:${var.aws_region}::/vpclinks", + "arn:aws:apigateway:${var.aws_region}::/vpclinks/*", + ] + } + + statement { + sid = "PaymentsSsm" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:AddTagsToResource", + "ssm:RemoveTagsFromResource", + "ssm:ListTagsForResource", + ] + resources = [ + "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*", + "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.dynamodb_cmk_ssm}", + ] + } + + statement { + sid = "SsmDescribeParameters" + effect = "Allow" + actions = ["ssm:DescribeParameters"] + resources = ["*"] + } + + statement { + sid = "SecretsManagerRead" + effect = "Allow" + actions = [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + "secretsmanager:ListSecretVersionIds", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + ] + resources = [ + "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:payments-dashboard/*", + ] + } + + statement { + sid = "SecretsManagerList" + effect = "Allow" + actions = ["secretsmanager:ListSecrets"] + resources = ["*"] + } + + statement { + sid = "KmsTableCmk" + effect = "Allow" + actions = [ + "kms:DescribeKey", + "kms:GetKeyPolicy", + "kms:ListResourceTags", + "kms:CreateGrant", + "kms:ListGrants", + "kms:RetireGrant", + ] + resources = [data.aws_ssm_parameter.dynamodb_cmk.value] + } + + statement { + sid = "CloudWatchAlarms" + effect = "Allow" + actions = [ + "cloudwatch:PutMetricAlarm", + "cloudwatch:DeleteAlarms", + "cloudwatch:DescribeAlarms", + "cloudwatch:TagResource", + "cloudwatch:UntagResource", + "cloudwatch:ListTagsForResource", + ] + resources = [ + "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:payments-*", + ] + } + + statement { + sid = "CloudWatchDescribeAlarms" + effect = "Allow" + actions = ["cloudwatch:DescribeAlarms"] + resources = ["*"] + } + + statement { + sid = "SnsPublishSiteAlerts" + effect = "Allow" + actions = [ + "sns:Publish", + "sns:GetTopicAttributes", + "sns:ListTagsForResource", + ] + resources = [local.site_alerts_arn] + } + + statement { + sid = "ManageTfManagedBoundary" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyVersions", + "iam:ListPolicyTags", + "iam:TagPolicy", + "iam:UntagPolicy", + ] + resources = [ + "arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*", + ] + } + + statement { + sid = "Ec2VpcManagement" + effect = "Allow" + actions = [ + "ec2:AllocateAddress", + "ec2:AssociateRouteTable", + "ec2:AttachInternetGateway", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:CreateInternetGateway", + "ec2:CreateNatGateway", + "ec2:CreateRoute", + "ec2:CreateRouteTable", + "ec2:CreateSecurityGroup", + "ec2:CreateSubnet", + "ec2:CreateVpc", + "ec2:CreateVpcEndpoint", + "ec2:CreateTags", + "ec2:DeleteInternetGateway", + "ec2:DeleteNatGateway", + "ec2:DeleteRoute", + "ec2:DeleteRouteTable", + "ec2:DeleteSecurityGroup", + "ec2:DeleteSubnet", + "ec2:DeleteVpc", + "ec2:DeleteVpcEndpoints", + "ec2:DescribeAccountAttributes", + "ec2:DescribeAddresses", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeNatGateways", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeTags", + "ec2:DescribeVpcAttribute", + "ec2:DescribeVpcEndpoints", + "ec2:DescribeVpcs", + "ec2:DescribePrefixLists", + "ec2:DetachInternetGateway", + "ec2:DisassociateAddress", + "ec2:DisassociateRouteTable", + "ec2:ModifySubnetAttribute", + "ec2:ModifyVpcAttribute", + "ec2:ModifyVpcEndpoint", + "ec2:ReleaseAddress", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:UpdateSecurityGroupRuleDescriptionsEgress", + "ec2:UpdateSecurityGroupRuleDescriptionsIngress", + ] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "hcptf_plan_refresh" { + statement { + sid = "RefreshIamRoles" + effect = "Allow" + actions = [ + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListRolePolicies", + "iam:ListAttachedRolePolicies", + "iam:ListRoleTags", + ] + resources = [ + "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", + "arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}", + "arn:aws:iam::${local.account_id}:role/${local.apply_role}", + "arn:aws:iam::${local.account_id}:role/${local.plan_role}", + ] + } + + statement { + sid = "RefreshManagedPolicies" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + ] + resources = ["*"] + } + + statement { + sid = "RefreshLambda" + effect = "Allow" + actions = [ + "lambda:GetFunction", + "lambda:GetFunctionConfiguration", + "lambda:GetPolicy", + "lambda:GetFunctionCodeSigningConfig", + "lambda:GetFunctionConcurrency", + "lambda:GetFunctionEventInvokeConfig", + "lambda:GetFunctionUrlConfig", + "lambda:GetRuntimeManagementConfig", + "lambda:GetFunctionRecursionConfig", + "lambda:ListTags", + "lambda:ListVersionsByFunction", + "lambda:ListAliases", + ] + resources = [ + "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-*", + ] + } + + statement { + sid = "RefreshLambdaList" + effect = "Allow" + actions = [ + "lambda:ListFunctions", + "lambda:ListLayers", + "lambda:GetAccountSettings", + ] + resources = ["*"] + } + + statement { + sid = "RefreshBuckets" + effect = "Allow" + actions = [ + "s3:GetAccelerateConfiguration", + "s3:GetAnalyticsConfiguration", + "s3:GetBucketAcl", + "s3:GetBucketCORS", + "s3:GetBucketLifecycleConfiguration", + "s3:GetBucketLocation", + "s3:GetBucketLogging", + "s3:GetBucketNotification", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketOwnershipControls", + "s3:GetBucketPolicy", + "s3:GetBucketPolicyStatus", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketReplication", + "s3:GetBucketRequestPayment", + "s3:GetBucketTagging", + "s3:GetBucketVersioning", + "s3:GetBucketWebsite", + "s3:GetEncryptionConfiguration", + "s3:GetIntelligentTieringConfiguration", + "s3:GetInventoryConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetMetricsConfiguration", + "s3:GetObject", + "s3:GetObjectTagging", + "s3:GetObjectVersion", + "s3:GetReplicationConfiguration", + "s3:ListBucket", + ] + resources = [ + "arn:aws:s3:::${local.artifacts_bucket_name}", + "arn:aws:s3:::${local.artifacts_bucket_name}/*", + "arn:aws:s3:::${local.csv_bucket_name}", + "arn:aws:s3:::${local.csv_bucket_name}/*", + "arn:aws:s3:::${local.boa_raw_bucket_name}", + "arn:aws:s3:::${local.boa_raw_bucket_name}/*", + ] + } + + statement { + sid = "RefreshDynamoDB" + effect = "Allow" + actions = [ + "dynamodb:DescribeTable", + "dynamodb:DescribeTimeToLive", + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeKinesisStreamingDestination", + "dynamodb:ListTagsOfResource", + ] + resources = [ + "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}", + ] + } + + statement { + sid = "RefreshEventBridge" + effect = "Allow" + actions = [ + "events:DescribeRule", + "events:ListTargetsByRule", + "events:ListTagsForResource", + ] + resources = [ + "arn:aws:events:${var.aws_region}:${local.account_id}:rule/${local.project}-*", + ] + } + + statement { + sid = "RefreshLogs" + effect = "Allow" + actions = [ + "logs:DescribeLogGroups", + "logs:ListTagsForResource", + ] + resources = ["*"] + } + + statement { + sid = "RefreshHttpApi" + effect = "Allow" + actions = [ + "apigateway:GET", + ] + resources = [ + "arn:aws:apigateway:${var.aws_region}::/apis", + "arn:aws:apigateway:${var.aws_region}::/apis/*", + "arn:aws:apigateway:${var.aws_region}::/tags/*", + ] + } + + statement { + sid = "RefreshSsm" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:ListTagsForResource", + ] + resources = [ + "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*", + "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.dynamodb_cmk_ssm}", + ] + } + + statement { + sid = "RefreshSsmDescribeParameters" + effect = "Allow" + actions = ["ssm:DescribeParameters"] + resources = ["*"] + } + + statement { + sid = "RefreshSecrets" + effect = "Allow" + actions = [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + "secretsmanager:ListSecretVersionIds", + ] + resources = [ + "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:payments-dashboard/*", + ] + } + + statement { + sid = "RefreshSecretsList" + effect = "Allow" + actions = ["secretsmanager:ListSecrets"] + resources = ["*"] + } + + statement { + sid = "RefreshAlarms" + effect = "Allow" + actions = [ + "cloudwatch:DescribeAlarms", + "cloudwatch:ListTagsForResource", + ] + resources = ["*"] + } + + statement { + sid = "RefreshSns" + effect = "Allow" + actions = [ + "sns:GetTopicAttributes", + "sns:ListTagsForResource", + ] + resources = [local.site_alerts_arn] + } + + statement { + sid = "RefreshSqs" + effect = "Allow" + actions = [ + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + "sqs:ListQueueTags", + ] + resources = [ + "arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq", + ] + } + + statement { + sid = "RefreshKms" + effect = "Allow" + actions = [ + "kms:DescribeKey", + "kms:GetKeyPolicy", + "kms:ListResourceTags", + ] + resources = [data.aws_ssm_parameter.dynamodb_cmk.value] + } + + statement { + sid = "RefreshEc2" + effect = "Allow" + actions = [ + "ec2:DescribeAddresses", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeNatGateways", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeTags", + "ec2:DescribeVpcAttribute", + "ec2:DescribeVpcEndpoints", + "ec2:DescribeVpcs", + "ec2:DescribePrefixLists", + ] + resources = ["*"] + } +} + +resource "aws_iam_role" "hcptf_apply" { + name = local.apply_role + assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json + max_session_duration = 3600 + + tags = { + Owner = "adam@seahavenind.com" + ManagedBy = "terraform" + } +} + +resource "aws_iam_role" "hcptf_plan" { + name = local.plan_role + assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json + max_session_duration = 3600 + + tags = { + Owner = "adam@seahavenind.com" + ManagedBy = "terraform" + } +} + +resource "aws_iam_role_policy" "hcptf_scoped_iam" { + name = "scoped-iam-management" + role = aws_iam_role.hcptf_apply.id + policy = data.aws_iam_policy_document.hcptf_scoped_iam.json +} + +resource "aws_iam_role_policy" "hcptf_apply_services" { + # checkov:skip=CKV_AWS_111: List/describe, HTTP API log delivery, and VPC/NAT lifecycle APIs require Resource=*. Function, bucket, table, queue, secret, alarm, and SSM writes are ARN-prefixed. + name = "payments-dashboard-services" + role = aws_iam_role.hcptf_apply.id + policy = data.aws_iam_policy_document.hcptf_apply_services.json +} + +resource "aws_iam_role_policy" "hcptf_plan_refresh" { + # checkov:skip=CKV_AWS_107: ViewOnlyAccess plus this sidecar is the org HCP plan-role pattern (PLAT-144 / afterhours). Sidecar Get* is scoped to this stack's roles, buckets, table, queues, functions, and parameters. It does not add iam:CreateAccessKey, secretsmanager:GetSecretValue, or ssm:GetParameter on *. + name = "payments-dashboard-plan-refresh" + role = aws_iam_role.hcptf_plan.id + policy = data.aws_iam_policy_document.hcptf_plan_refresh.json +} + +resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" { + role = aws_iam_role.hcptf_plan.name + policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" +} + +resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" { + role_name = aws_iam_role.hcptf_apply.name + policy_arns = [] +} + +resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" { + role_name = aws_iam_role.hcptf_plan.name + policy_arns = [ + "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", + ] +} diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf new file mode 100644 index 0000000..1aa8eff --- /dev/null +++ b/terraform/iam_github_deploy.tf @@ -0,0 +1,103 @@ +# GitHub Actions OIDC role for .github/workflows/deploy.yaml. +# +# Trust is pinned three ways: aud, sub to Environment prod (immutable and +# classic subject forms), and job_workflow_ref to deploy.yaml at +# refs/heads/main only. No v* tags until a later release ticket. +# +# Not a Lambda execution role: no permissions_boundary. Path /tf-managed/ so +# seahaven-hcptf-iam-management DenySelfMutation (role/githubdeploy-*) does not +# match. + +data "aws_iam_policy_document" "github_deploy_assume" { + statement { + sid = "GithubDeployOidc" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = [ + local.github_oidc_sub, + "repo:${var.github_repo}:environment:prod", + ] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:job_workflow_ref" + values = [ + "${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/${var.github_deploy_branch}", + ] + } + } +} + +resource "aws_iam_role" "github_deploy" { + name = local.deploy_role + path = "/tf-managed/" + description = "GitHub Actions Lambda deploy role for ${var.github_repo} Environment prod" + assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json + max_session_duration = 3600 +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + sid = "ListArtifactsBucket" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [aws_s3_bucket.artifacts.arn] + } + + statement { + sid = "UploadFunctionArtifacts" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + resources = ["${aws_s3_bucket.artifacts.arn}/functions/*"] + } + + statement { + sid = "UpdateFunctionCode" + effect = "Allow" + actions = [ + "lambda:GetFunction", + "lambda:GetFunctionConfiguration", + "lambda:UpdateFunctionCode", + ] + resources = [for fn in local.functions : "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:${fn.function_name}"] + } + + statement { + sid = "DeployParams" + effect = "Allow" + actions = [ + "ssm:GetParameter", + ] + resources = [ + "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/*", + ] + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "payments-dashboard-deploy" + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf new file mode 100644 index 0000000..b6db98a --- /dev/null +++ b/terraform/lambda.tf @@ -0,0 +1,250 @@ +# Terraform owns the function skeletons (role, runtime, memory, environment). +# Code is owned by .github/workflows/deploy.yaml, which uploads +# functions//.zip and calls update-function-code. The lifecycle +# block is the seam: an app deploy is not drift, and a Terraform apply never +# rolls the code back to the bootstrap stub. + +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +locals { + table_arn = aws_dynamodb_table.dashboard.arn + cmk_arn = data.aws_ssm_parameter.dynamodb_cmk.value + + lambda_identity = { + process_csv = [ + { + sid = "CsvRead" + actions = ["s3:GetObject", "s3:GetObjectVersion"] + resources = ["${aws_s3_bucket.csv.arn}/*"] + }, + { + sid = "DdbCrud" + actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] + resources = [local.table_arn, "${local.table_arn}/*"] + }, + { + sid = "Cmk" + actions = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"] + resources = [local.cmk_arn] + }, + { + sid = "BoaCheckMgmtSecret" + actions = ["secretsmanager:GetSecretValue"] + resources = [local.secret_arns["payments-dashboard/boa-check-mgmt"]] + }, + { + sid = "DlqSend" + actions = ["sqs:SendMessage"] + resources = [aws_sqs_queue.process_csv_dlq.arn] + }, + ] + slack_app_home = [ + { + sid = "DdbRead" + actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"] + resources = [local.table_arn, "${local.table_arn}/*"] + }, + { + sid = "CmkDecrypt" + actions = ["kms:Decrypt", "kms:DescribeKey"] + resources = [local.cmk_arn] + }, + { + sid = "SlackSecrets" + actions = ["secretsmanager:GetSecretValue"] + resources = [ + local.secret_arns["payments-dashboard/slack-bot-token"], + local.secret_arns["payments-dashboard/slack-signing-secret"], + ] + }, + ] + fetch_boa = [ + { + sid = "DdbCrud" + actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] + resources = [local.table_arn, "${local.table_arn}/*"] + }, + { + sid = "BoaRawPut" + actions = ["s3:PutObject"] + resources = ["${aws_s3_bucket.boa_raw.arn}/*"] + }, + { + sid = "Cmk" + actions = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"] + resources = [local.cmk_arn] + }, + { + sid = "BoaReportingSecret" + actions = ["secretsmanager:GetSecretValue"] + resources = [local.secret_arns["payments-dashboard/boa-reporting"]] + }, + ] + expense_receiver = [ + { + sid = "InvokeProcessor" + actions = ["lambda:InvokeFunction"] + resources = ["arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-expenseProcessor"] + }, + { + sid = "ExpenseSigningSecret" + actions = ["secretsmanager:GetSecretValue"] + resources = [local.secret_arns["payments-dashboard/expense-slack-signing-secret"]] + }, + ] + expense_processor = [ + { + sid = "ExpenseBotSecret" + actions = ["secretsmanager:GetSecretValue"] + resources = [local.secret_arns["payments-dashboard/expense-slack-token"]] + }, + ] + } + + lambda_env = { + process_csv = { + TABLE_NAME = aws_dynamodb_table.dashboard.name + BOA_BASE_URL = var.boa_base_url + BOA_CHECK_MGMT_SECRET_NAME = "payments-dashboard/boa-check-mgmt" + } + slack_app_home = { + TABLE_NAME = aws_dynamodb_table.dashboard.name + SLACK_BOT_TOKEN_SECRET_NAME = "payments-dashboard/slack-bot-token" + SLACK_SIGNING_SECRET_NAME = "payments-dashboard/slack-signing-secret" + } + fetch_boa = { + TABLE_NAME = aws_dynamodb_table.dashboard.name + BOA_BASE_URL = var.boa_base_url + BOA_REPORTING_SECRET_NAME = "payments-dashboard/boa-reporting" + BOA_RAW_BUCKET = aws_s3_bucket.boa_raw.id + } + expense_receiver = { + TABLE_NAME = aws_dynamodb_table.dashboard.name + EXPENSE_PROCESSOR_FN = "payments-expenseProcessor" + EXPENSE_SIGNING_SECRET_NAME = "payments-dashboard/expense-slack-signing-secret" + } + expense_processor = { + TABLE_NAME = aws_dynamodb_table.dashboard.name + EXPENSE_BOT_TOKEN_SECRET_NAME = "payments-dashboard/expense-slack-token" + } + } +} + +resource "aws_iam_role" "lambda" { + for_each = local.functions + + name = each.value.role_name + path = "/tf-managed/" + description = "Lambda execution role for ${each.value.function_name}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = aws_iam_policy.lambda_boundary.arn +} + +data "aws_iam_policy_document" "lambda" { + for_each = local.functions + + dynamic "statement" { + for_each = local.lambda_identity[each.key] + + content { + sid = statement.value.sid + effect = "Allow" + actions = statement.value.actions + resources = statement.value.resources + } + } +} + +resource "aws_iam_role_policy" "lambda" { + for_each = local.functions + + name = each.key + role = aws_iam_role.lambda[each.key].id + policy = data.aws_iam_policy_document.lambda[each.key].json +} + +resource "aws_iam_role_policy_attachment" "lambda_basic" { + for_each = local.functions + + role = aws_iam_role.lambda[each.key].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "lambda_vpc" { + for_each = { for k, v in local.functions : k => v if v.vpc } + + role = aws_iam_role.lambda[each.key].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_lambda_function" "this" { + for_each = local.functions + + function_name = each.value.function_name + role = aws_iam_role.lambda[each.key].arn + handler = each.value.handler + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = 256 + timeout = each.value.timeout + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.bootstrap_stub.key + source_code_hash = data.archive_file.bootstrap_stub.output_base64sha256 + + environment { + variables = local.lambda_env[each.key] + } + + dynamic "vpc_config" { + for_each = each.value.vpc ? [1] : [] + + content { + subnet_ids = [aws_subnet.private.id] + security_group_ids = [aws_security_group.lambda.id] + } + } + + lifecycle { + ignore_changes = [filename, s3_bucket, s3_key, s3_object_version, source_code_hash] + } + + depends_on = [ + aws_cloudwatch_log_group.lambda, + aws_iam_role_policy.lambda, + aws_iam_role_policy_attachment.lambda_basic, + aws_iam_role_policy_attachment.lambda_vpc, + aws_nat_gateway.this, + ] +} + +resource "aws_lambda_function_event_invoke_config" "process_csv" { + function_name = aws_lambda_function.this["process_csv"].function_name + maximum_event_age_in_seconds = 21600 + maximum_retry_attempts = 2 + + destination_config { + on_failure { + destination = aws_sqs_queue.process_csv_dlq.arn + } + } +} + +resource "aws_lambda_permission" "s3_csv" { + statement_id = "AllowS3InvokeProcessCsv" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this["process_csv"].function_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.csv.arn + source_account = local.account_id +} diff --git a/terraform/lambda_boundary.tf b/terraform/lambda_boundary.tf new file mode 100644 index 0000000..90d1589 --- /dev/null +++ b/terraform/lambda_boundary.tf @@ -0,0 +1,147 @@ +# Per-workload Lambda permissions boundary. Created on the first (bootstrap) +# apply. The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion, +# so later edits to this document need the hcptf-bootstrap window. + +data "aws_iam_policy_document" "lambda_boundary" { + # checkov:skip=CKV_AWS_111: AWS requires Resource=* for logs:DescribeLogGroups, xray Put*, and EC2 ENI lifecycle used by VPC Lambdas. Secrets, table, CMK, buckets, DLQ, and invoke are ARN-pinned. + statement { + sid = "CloudWatchLogsWrite" + effect = "Allow" + actions = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + ] + resources = [ + "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda*", + ] + } + + statement { + sid = "CloudWatchLogsDescribe" + effect = "Allow" + actions = ["logs:DescribeLogGroups"] + resources = ["*"] + } + + statement { + sid = "XRay" + effect = "Allow" + actions = [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + ] + resources = ["*"] + } + + statement { + sid = "Ec2Eni" + effect = "Allow" + actions = [ + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DeleteNetworkInterface", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeVpcs", + ] + resources = ["*"] + } + + statement { + sid = "PaymentsSecrets" + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue", + ] + resources = [for arn in local.secret_arns : arn] + } + + statement { + sid = "PaymentsDynamoDB" + effect = "Allow" + actions = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:DescribeTable", + "dynamodb:ConditionCheckItem", + ] + resources = [ + "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}", + "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*", + ] + } + + statement { + sid = "PaymentsCmk" + effect = "Allow" + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:DescribeKey", + ] + resources = [data.aws_ssm_parameter.dynamodb_cmk.value] + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["dynamodb.${var.aws_region}.amazonaws.com"] + } + } + + statement { + sid = "PaymentsCsvRead" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:GetObjectVersion", + ] + resources = ["arn:aws:s3:::${local.csv_bucket_name}/*"] + } + + statement { + sid = "PaymentsBoaRawPut" + effect = "Allow" + actions = [ + "s3:PutObject", + ] + resources = ["arn:aws:s3:::${local.boa_raw_bucket_name}/*"] + } + + statement { + sid = "PaymentsDlqSend" + effect = "Allow" + actions = [ + "sqs:SendMessage", + ] + resources = [ + "arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq", + ] + } + + statement { + sid = "PaymentsInvokeExpenseProcessor" + effect = "Allow" + actions = [ + "lambda:InvokeFunction", + ] + resources = [ + "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-expenseProcessor", + ] + } +} + +resource "aws_iam_policy" "lambda_boundary" { + # checkov:skip=CKV_AWS_111: AWS requires Resource=* for logs:DescribeLogGroups, xray Put*, and EC2 ENI lifecycle used by VPC Lambdas. Secrets, table, CMK, buckets, DLQ, and invoke are ARN-pinned. + name = "payments-dashboard-lambda-boundary" + path = "/tf-managed/" + description = "Per-workload Lambda permissions boundary for payments-dashboard (PLAT-79)." + policy = data.aws_iam_policy_document.lambda_boundary.json +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..ab14795 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,77 @@ +locals { + project = "payments-dashboard" + account_id = "011934824531" + environment = "prod" + + hcp_project = "seahaven-prod" + hcp_workspace = "payments-dashboard-prod" + apply_role = "hcptf-payments-dashboard" + plan_role = "hcptf-payments-dashboard-plan" + deploy_role = "githubdeploy-payments-dashboard" + stack_name = local.project + stack_prefix = "payments-dashboard-" + + artifacts_bucket_name = "payments-dashboard-artifacts-${local.account_id}" + csv_bucket_name = "seahaven-payments-csv-${local.account_id}" + boa_raw_bucket_name = "seahaven-payments-boa-raw-${local.account_id}" + ssm_prefix = "/payments-dashboard" + table_name = "PaymentsDashboard" + site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts" + dynamodb_cmk_ssm = "/seahaven/dynamodb/cmk-arn" + + github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" + # Org has Actions OIDC use_immutable_subject=true. + github_oidc_sub = "repo:Sea-Haven-Industries@183236204/payments-dashboard@1206210946:environment:prod" + + secret_names = [ + "payments-dashboard/slack-bot-token", + "payments-dashboard/slack-signing-secret", + "payments-dashboard/boa-check-mgmt", + "payments-dashboard/boa-reporting", + "payments-dashboard/expense-slack-token", + "payments-dashboard/expense-slack-signing-secret", + ] + + functions = { + process_csv = { + function_name = "payments-processPaymentCsv" + role_name = "payments-dashboard-process-csv" + handler = "src/processPaymentCsv.handler" + timeout = 120 + duration_ms = 96000 + vpc = true + } + slack_app_home = { + function_name = "payments-slackAppHome" + role_name = "payments-dashboard-slack-app-home" + handler = "src/slackAppHome.handler" + timeout = 30 + duration_ms = 24000 + vpc = true + } + fetch_boa = { + function_name = "payments-fetchBoaTransactions" + role_name = "payments-dashboard-fetch-boa" + handler = "src/fetchBoaTransactions.handler" + timeout = 60 + duration_ms = 48000 + vpc = true + } + expense_receiver = { + function_name = "payments-expenseReceiver" + role_name = "payments-dashboard-expense-receiver" + handler = "src/expenseReceiver.handler" + timeout = 5 + duration_ms = 4000 + vpc = false + } + expense_processor = { + function_name = "payments-expenseProcessor" + role_name = "payments-dashboard-expense-processor" + handler = "src/expenseProcessor.handler" + timeout = 15 + duration_ms = 12000 + vpc = false + } + } +} diff --git a/terraform/logs.tf b/terraform/logs.tf new file mode 100644 index 0000000..e2e52d0 --- /dev/null +++ b/terraform/logs.tf @@ -0,0 +1,11 @@ +resource "aws_cloudwatch_log_group" "lambda" { + for_each = local.functions + + name = "/aws/lambda/${each.value.function_name}" + retention_in_days = 60 +} + +resource "aws_cloudwatch_log_group" "api_access" { + name = "/aws/apigateway/${local.project}" + retention_in_days = 90 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..58f51f4 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,54 @@ +output "slack_request_url" { + description = "Slack App Home Request URL." + value = "${aws_apigatewayv2_api.http.api_endpoint}/slack/events" +} + +output "expense_slack_events_url" { + description = "Expense Approval Bot Slack Request URL." + value = "${aws_apigatewayv2_api.http.api_endpoint}/slack/expense-events" +} + +output "api_origin" { + description = "HTTP API origin." + value = aws_apigatewayv2_api.http.api_endpoint +} + +output "csv_bucket_name" { + description = "S3 bucket for Stampli CSV uploads." + value = aws_s3_bucket.csv.id +} + +output "boa_raw_bucket_name" { + description = "Retain-protected BoA raw archive bucket." + value = aws_s3_bucket.boa_raw.id +} + +output "static_outbound_ip" { + description = "NAT EIP for Bank of America CashPro IP whitelist." + value = aws_eip.nat.public_ip +} + +output "table_name" { + description = "DynamoDB table name." + value = aws_dynamodb_table.dashboard.name +} + +output "github_deploy_role_arn" { + description = "OIDC role ARN for .github/workflows/deploy.yaml (GitHub Environment prod variable DEPLOY_ROLE_ARN)." + value = aws_iam_role.github_deploy.arn +} + +output "artifacts_bucket_name" { + description = "Lambda artifacts bucket. deploy.yaml uploads functions//.zip." + value = aws_s3_bucket.artifacts.id +} + +output "hcptf_apply_role_arn" { + description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window." + value = aws_iam_role.hcptf_apply.arn +} + +output "hcptf_plan_role_arn" { + description = "HCP plan role ARN. Set TFC_AWS_PLAN_ROLE_ARN after the bootstrap window." + value = aws_iam_role.hcptf_plan.arn +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..c3854cb --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,12 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = local.project + Environment = "prod" + ManagedBy = "terraform" + Workspace = local.hcp_workspace + } + } +} diff --git a/terraform/s3.tf b/terraform/s3.tf new file mode 100644 index 0000000..a91d6ac --- /dev/null +++ b/terraform/s3.tf @@ -0,0 +1,275 @@ +# Lambda artifacts bucket. Terraform ships only the bootstrap stub. +# .github/workflows/deploy.yaml uploads functions//.zip and calls +# update-function-code. Functions ignore code attributes afterwards. + +resource "aws_s3_bucket" "artifacts" { + bucket = local.artifacts_bucket_name + + tags = { + Purpose = "Lambda deployment packages for payments-dashboard" + } +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + id = "expire-noncurrent-packages" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = 180 + } + } + + rule { + id = "abort-incomplete-multipart" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + depends_on = [aws_s3_bucket_versioning.artifacts] +} + +data "aws_iam_policy_document" "artifacts" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.artifacts.arn, + "${aws_s3_bucket.artifacts.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + policy = data.aws_iam_policy_document.artifacts.json + + depends_on = [aws_s3_bucket_public_access_block.artifacts] +} + +data "archive_file" "bootstrap_stub" { + type = "zip" + source_dir = "${path.module}/bootstrap/stub" + output_path = "${path.module}/build/packages/bootstrap-stub.zip" +} + +resource "aws_s3_object" "bootstrap_stub" { + bucket = aws_s3_bucket.artifacts.id + key = "functions/bootstrap-stub.zip" + content_base64 = filebase64(data.archive_file.bootstrap_stub.output_path) + source_hash = data.archive_file.bootstrap_stub.output_base64sha256 +} + +resource "aws_s3_bucket" "csv" { + bucket = local.csv_bucket_name + + tags = { + Purpose = "Stampli payment CSV drop folder" + } +} + +resource "aws_s3_bucket_public_access_block" "csv" { + bucket = aws_s3_bucket.csv.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "csv" { + bucket = aws_s3_bucket.csv.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "csv" { + bucket = aws_s3_bucket.csv.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +data "aws_iam_policy_document" "csv" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [aws_s3_bucket.csv.arn, "${aws_s3_bucket.csv.arn}/*"] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "csv" { + bucket = aws_s3_bucket.csv.id + policy = data.aws_iam_policy_document.csv.json + + depends_on = [aws_s3_bucket_public_access_block.csv] +} + +resource "aws_s3_bucket" "boa_raw" { + bucket = local.boa_raw_bucket_name + + tags = { + Purpose = "BoA reporting API raw archive" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "boa_raw" { + bucket = aws_s3_bucket.boa_raw.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "boa_raw" { + bucket = aws_s3_bucket.boa_raw.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "boa_raw" { + bucket = aws_s3_bucket.boa_raw.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "boa_raw" { + bucket = aws_s3_bucket.boa_raw.id + + rule { + id = "expire-raw-responses" + status = "Enabled" + + filter {} + + expiration { + days = 730 + } + } +} + +data "aws_iam_policy_document" "boa_raw" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [aws_s3_bucket.boa_raw.arn, "${aws_s3_bucket.boa_raw.arn}/*"] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "boa_raw" { + bucket = aws_s3_bucket.boa_raw.id + policy = data.aws_iam_policy_document.boa_raw.json + + depends_on = [aws_s3_bucket_public_access_block.boa_raw] +} + +resource "aws_s3_bucket_notification" "csv" { + bucket = aws_s3_bucket.csv.id + + lambda_function { + lambda_function_arn = aws_lambda_function.this["process_csv"].arn + events = ["s3:ObjectCreated:*"] + filter_suffix = ".csv" + } + + depends_on = [aws_lambda_permission.s3_csv] +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..eaeddbf --- /dev/null +++ b/terraform/secrets.tf @@ -0,0 +1,8 @@ +data "aws_secretsmanager_secret" "this" { + for_each = toset(local.secret_names) + name = each.value +} + +locals { + secret_arns = { for name, secret in data.aws_secretsmanager_secret.this : name => secret.arn } +} diff --git a/terraform/ssm.tf b/terraform/ssm.tf new file mode 100644 index 0000000..bc5886f --- /dev/null +++ b/terraform/ssm.tf @@ -0,0 +1,15 @@ +resource "aws_ssm_parameter" "deploy_artifacts_bucket" { + name = "${local.ssm_prefix}/deploy/artifacts-bucket" + type = "String" + value = aws_s3_bucket.artifacts.id + description = "Lambda artifacts bucket; deploy.yaml uploads functions//.zip" +} + +resource "aws_ssm_parameter" "deploy_function_name" { + for_each = local.functions + + name = "${local.ssm_prefix}/deploy/${each.key}-function-name" + type = "String" + value = each.value.function_name + description = "Lambda function name for ${each.key}; deploy.yaml calls update-function-code" +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..013f31c --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,29 @@ +variable "aws_region" { + description = "Region every resource in this configuration is created in." + type = string + default = "us-east-1" +} + +variable "schedules_enabled" { + description = "When false, EventBridge rules exist but do not fire. Keep false until Slack and the Stampli uploader point at this stack." + type = bool + default = false +} + +variable "github_repo" { + description = "GitHub owner/name for the deploy OIDC trust." + type = string + default = "Sea-Haven-Industries/payments-dashboard" +} + +variable "github_deploy_branch" { + description = "Git branch pinned in job_workflow_ref for the deploy role." + type = string + default = "main" +} + +variable "boa_base_url" { + description = "Bank of America CashPro API base URL." + type = string + default = "https://api.bofa.com" +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..d90b039 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.64" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.8" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "payments-dashboard-prod" + } + } +} diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000..04d6d3e --- /dev/null +++ b/terraform/vpc.tf @@ -0,0 +1,145 @@ +data "aws_availability_zones" "available" { + state = "available" +} + +resource "aws_vpc" "this" { + cidr_block = "10.20.0.0/16" + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "payments-dashboard-vpc" + } +} + +resource "aws_subnet" "private" { + vpc_id = aws_vpc.this.id + cidr_block = "10.20.1.0/24" + availability_zone = data.aws_availability_zones.available.names[0] + + tags = { + Name = "payments-dashboard-private" + } +} + +resource "aws_subnet" "public" { + vpc_id = aws_vpc.this.id + cidr_block = "10.20.2.0/24" + availability_zone = data.aws_availability_zones.available.names[0] + + tags = { + Name = "payments-dashboard-public" + } +} + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = { + Name = "payments-dashboard-igw" + } +} + +resource "aws_eip" "nat" { + domain = "vpc" + + tags = { + Name = "payments-dashboard-nat" + } + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_nat_gateway" "this" { + allocation_id = aws_eip.nat.id + subnet_id = aws_subnet.public.id + + tags = { + Name = "payments-dashboard-nat" + } + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + tags = { + Name = "payments-dashboard-public" + } +} + +resource "aws_route" "public_default" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.this.id + + tags = { + Name = "payments-dashboard-private" + } +} + +resource "aws_route" "private_default" { + route_table_id = aws_route_table.private.id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this.id +} + +resource "aws_route_table_association" "private" { + subnet_id = aws_subnet.private.id + route_table_id = aws_route_table.private.id +} + +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [ + aws_route_table.public.id, + aws_route_table.private.id, + ] + + tags = { + Name = "payments-dashboard-s3" + } +} + +resource "aws_vpc_endpoint" "dynamodb" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.aws_region}.dynamodb" + vpc_endpoint_type = "Gateway" + route_table_ids = [ + aws_route_table.public.id, + aws_route_table.private.id, + ] + + tags = { + Name = "payments-dashboard-dynamodb" + } +} + +resource "aws_security_group" "lambda" { + name = "payments-dashboard-lambda" + description = "Payments Dashboard Lambda outbound access" + vpc_id = aws_vpc.this.id + + tags = { + Name = "payments-dashboard-lambda" + } +} + +resource "aws_vpc_security_group_egress_rule" "lambda_all" { + security_group_id = aws_security_group.lambda.id + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + description = "All outbound for BoA and AWS APIs" +} diff --git a/tests/infra/hcpContract.test.js b/tests/infra/hcpContract.test.js new file mode 100644 index 0000000..c05d01d --- /dev/null +++ b/tests/infra/hcpContract.test.js @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const TERRAFORM = join(ROOT, "terraform"); +const lambdaTf = readFileSync(join(TERRAFORM, "lambda.tf"), "utf8"); +const hcpIam = readFileSync(join(TERRAFORM, "hcp_iam.tf"), "utf8"); +const deploy = readFileSync(join(ROOT, ".github", "workflows", "deploy.yaml"), "utf8"); +const ci = readFileSync(join(ROOT, ".github", "workflows", "ci.yaml"), "utf8"); +const locals = readFileSync(join(TERRAFORM, "locals.tf"), "utf8"); +const variables = readFileSync(join(TERRAFORM, "variables.tf"), "utf8"); +const versions = readFileSync(join(TERRAFORM, "versions.tf"), "utf8"); +const githubDeploy = readFileSync(join(TERRAFORM, "iam_github_deploy.tf"), "utf8"); + +describe("HCP Terraform seam (PLAT-79)", () => { + it("removes the SAM template", () => { + assert.equal(existsSync(join(ROOT, "template.yaml")), false); + assert.equal(existsSync(join(ROOT, "samconfig.toml.example")), false); + }); + + it("ignores Lambda code attributes so zip CD is not drift", () => { + for (const attr of ["filename", "s3_bucket", "s3_key", "s3_object_version", "source_code_hash"]) { + assert.match(lambdaTf, new RegExp(attr)); + } + assert.match(lambdaTf, /lifecycle/); + assert.match(lambdaTf, /ignore_changes/); + }); + + it("keeps schedules disabled by default", () => { + const chunk = variables.split('variable "schedules_enabled"')[1].split("variable ")[0]; + assert.match(chunk, /default\s+= false/); + }); + + it("is prod-only", () => { + assert.match(versions, /payments-dashboard-prod/); + assert.doesNotMatch(versions, /payments-dashboard-dev/); + assert.match(locals, /environment = "prod"/); + assert.doesNotMatch(locals, /seahaven-dev/); + }); + + it("declares in-repo hcptf roles", () => { + assert.match(locals, /apply_role\s+= "hcptf-payments-dashboard"/); + assert.match(locals, /plan_role\s+= "hcptf-payments-dashboard-plan"/); + assert.match(hcpIam, /hcptf_apply/); + assert.match(hcpIam, /DenyCreatePolicy/); + }); + + it("uses prod zip CD without SAM or GitHub Releases", () => { + assert.doesNotMatch(deploy, /release: published/); + assert.doesNotMatch(deploy, /cd-sam/); + assert.match(deploy, /environment: prod/); + assert.match(deploy, /deploy-payments-dashboard-prod/); + assert.doesNotMatch(deploy, /gh release create/); + assert.match(deploy, /package_lambdas\.mjs/); + assert.match(deploy, /update-function-code/); + }); + + it("runs npm test and terraform validate behind ci / ci", () => { + assert.doesNotMatch(ci, /ci-typescript-cdk/); + assert.doesNotMatch(ci, /run-sam-validate/); + assert.match(ci, /npm test/); + assert.match(ci, /terraform fmt -check/); + assert.match(ci, /terraform init -backend=false/); + assert.match(ci, /terraform validate/); + assert.match(ci, /name: ci \/ ci/); + }); + + it("names the five live functions", () => { + for (const name of [ + "payments-processPaymentCsv", + "payments-slackAppHome", + "payments-fetchBoaTransactions", + "payments-expenseReceiver", + "payments-expenseProcessor", + ]) { + assert.match(locals, new RegExp(name)); + } + assert.doesNotMatch(locals, /payments-processPayrollEmail/); + }); + + it("pins GitHub deploy trust to Environment prod", () => { + assert.match(githubDeploy, /environment:prod/); + assert.match(githubDeploy, /deploy.yaml@refs\/heads\/\$\{var.github_deploy_branch\}/); + assert.doesNotMatch(githubDeploy, /deploy.yaml@\*/); + assert.doesNotMatch(githubDeploy, /refs\/tags\/v\*/); + }); + + it("includes provider-6 S3 Get* needed for refresh", () => { + assert.match(hcpIam, /s3:GetLifecycleConfiguration/); + assert.match(hcpIam, /s3:GetReplicationConfiguration/); + assert.match(hcpIam, /s3:GetBucketReplication/); + }); +});