mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 05:23:12 +00:00
feat(infra): migrate payments-dashboard to HCP Terraform (PLAT-79) (#109)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* 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. * fix(infra): pin secret and CMK ARNs for bootstrap-plan hcptf-bootstrap-plan cannot ssm:GetParameter or DescribeSecret, so the first plan must not data-source those values. * fix(infra): add EIP describe and DynamoDB CMK grants for first apply Scoped apply missed ec2:DescribeAddressesAttribute and kms Encrypt/Decrypt/GenerateDataKey on the table CMK.
This commit is contained in:
parent
219ea1001a
commit
0e3e95c240
34 changed files with 2964 additions and 975 deletions
82
.github/workflows/ci.yaml
vendored
82
.github/workflows/ci.yaml
vendored
|
|
@ -1,4 +1,5 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
|
@ -8,10 +9,79 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
run-typecheck: false
|
||||
run-tests: true
|
||||
run-cdk-synth: false
|
||||
run-sam-validate: true
|
||||
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:
|
||||
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}"
|
||||
|
|
|
|||
142
.github/workflows/deploy.yaml
vendored
142
.github/workflows/deploy.yaml
vendored
|
|
@ -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
|
||||
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:
|
||||
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 }}
|
||||
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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,6 +2,9 @@ node_modules/
|
|||
.aws-sam/
|
||||
samconfig.toml
|
||||
data/
|
||||
build/
|
||||
terraform/.terraform/
|
||||
terraform/build/
|
||||
.DS_Store
|
||||
BofA API Resources/
|
||||
*.csv
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -1,22 +1,22 @@
|
|||
# Payments Dashboard
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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/<endpoint>/<fromDate>_<toDate>/<runAt>.json`, SSE-S3, 730-day lifecycle, PutObject-only grant; Retain-protected — decommission goes through the CFN decommission runbook) and upserts per-date `boa_balance#<asOfDate>#<endpoint>` 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/<endpoint>/<fromDate>_<toDate>/<runAt>.json`, SSE-S3, 730-day lifecycle, PutObject-only grant; Retain-protected in Terraform) and upserts per-date `boa_balance#<asOfDate>#<endpoint>` 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#<check_number>`. 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#<check_number>` (payment records), `metadata` (ingest metadata), `boa_txn#<ts>#<action>` (BoA submission journal, 90d TTL), `boa_recon#<from>_<to>#<runAt>` (reconciliation run summaries, 90d TTL), `boa_balance#<asOfDate>#<endpoint>` (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.
|
||||
|
|
|
|||
74
SETUP.md
Normal file
74
SETUP.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
92
scripts/package_lambdas.mjs
Normal file
92
scripts/package_lambdas.mjs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build one Node zip per Lambda key. Used by deploy.yaml.
|
||||
* Each zip is functions/<name>/<sha>.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);
|
||||
}
|
||||
936
template.yaml
936
template.yaml
|
|
@ -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<String>
|
||||
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/<endpoint>/<fromDate>_<toDate>/<runAt>.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
|
||||
47
terraform/.terraform.lock.hcl
generated
Normal file
47
terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
161
terraform/alarms.tf
Normal file
161
terraform/alarms.tf
Normal file
|
|
@ -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]
|
||||
}
|
||||
73
terraform/apigateway.tf
Normal file
73
terraform/apigateway.tf
Normal file
|
|
@ -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}/*/*"
|
||||
}
|
||||
3
terraform/bootstrap/stub/package.json
Normal file
3
terraform/bootstrap/stub/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
7
terraform/bootstrap/stub/src/expenseProcessor.js
Normal file
7
terraform/bootstrap/stub/src/expenseProcessor.js
Normal file
|
|
@ -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."}}',
|
||||
};
|
||||
}
|
||||
7
terraform/bootstrap/stub/src/expenseReceiver.js
Normal file
7
terraform/bootstrap/stub/src/expenseReceiver.js
Normal file
|
|
@ -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."}}',
|
||||
};
|
||||
}
|
||||
7
terraform/bootstrap/stub/src/fetchBoaTransactions.js
Normal file
7
terraform/bootstrap/stub/src/fetchBoaTransactions.js
Normal file
|
|
@ -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."}}',
|
||||
};
|
||||
}
|
||||
7
terraform/bootstrap/stub/src/processPaymentCsv.js
Normal file
7
terraform/bootstrap/stub/src/processPaymentCsv.js
Normal file
|
|
@ -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."}}',
|
||||
};
|
||||
}
|
||||
7
terraform/bootstrap/stub/src/slackAppHome.js
Normal file
7
terraform/bootstrap/stub/src/slackAppHome.js
Normal file
|
|
@ -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."}}',
|
||||
};
|
||||
}
|
||||
52
terraform/data.tf
Normal file
52
terraform/data.tf
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 = local.dynamodb_cmk_arn
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
47
terraform/events.tf
Normal file
47
terraform/events.tf
Normal file
|
|
@ -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
|
||||
}
|
||||
915
terraform/hcp_iam.tf
Normal file
915
terraform/hcp_iam.tf
Normal file
|
|
@ -0,0 +1,915 @@
|
|||
# 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",
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:GenerateDataKeyWithoutPlaintext",
|
||||
]
|
||||
resources = [local.dynamodb_cmk_arn]
|
||||
}
|
||||
|
||||
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:DescribeAddressesAttribute",
|
||||
"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",
|
||||
"kms:CreateGrant",
|
||||
"kms:ListGrants",
|
||||
]
|
||||
resources = [local.dynamodb_cmk_arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "RefreshEc2"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"ec2:DescribeAccountAttributes",
|
||||
"ec2:DescribeAddresses",
|
||||
"ec2:DescribeAddressesAttribute",
|
||||
"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",
|
||||
]
|
||||
}
|
||||
103
terraform/iam_github_deploy.tf
Normal file
103
terraform/iam_github_deploy.tf
Normal file
|
|
@ -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
|
||||
}
|
||||
250
terraform/lambda.tf
Normal file
250
terraform/lambda.tf
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# Terraform owns the function skeletons (role, runtime, memory, environment).
|
||||
# Code is owned by .github/workflows/deploy.yaml, which uploads
|
||||
# functions/<name>/<sha>.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 = local.dynamodb_cmk_arn
|
||||
|
||||
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
|
||||
}
|
||||
147
terraform/lambda_boundary.tf
Normal file
147
terraform/lambda_boundary.tf
Normal file
|
|
@ -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 = [local.dynamodb_cmk_arn]
|
||||
|
||||
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
|
||||
}
|
||||
81
terraform/locals.tf
Normal file
81
terraform/locals.tf
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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"
|
||||
|
||||
dynamodb_cmk_arn = "arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12"
|
||||
|
||||
# Exact ARNs (ticket rule). Hardcoded so the first plan can run as
|
||||
# hcptf-bootstrap-plan, which cannot ssm:GetParameter / DescribeSecret.
|
||||
secret_arns = {
|
||||
"payments-dashboard/slack-bot-token" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/slack-bot-token-0pAM3S"
|
||||
"payments-dashboard/slack-signing-secret" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/slack-signing-secret-u0T6h8"
|
||||
"payments-dashboard/boa-check-mgmt" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/boa-check-mgmt-LEbC65"
|
||||
"payments-dashboard/boa-reporting" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/boa-reporting-JoR9lq"
|
||||
"payments-dashboard/expense-slack-token" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/expense-slack-token-SeMg3s"
|
||||
"payments-dashboard/expense-slack-signing-secret" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/expense-slack-signing-secret-lbb78J"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
terraform/logs.tf
Normal file
11
terraform/logs.tf
Normal file
|
|
@ -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
|
||||
}
|
||||
54
terraform/outputs.tf
Normal file
54
terraform/outputs.tf
Normal file
|
|
@ -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/<name>/<sha>.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
|
||||
}
|
||||
12
terraform/providers.tf
Normal file
12
terraform/providers.tf
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
provider "aws" {
|
||||
region = var.aws_region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "terraform"
|
||||
Workspace = local.hcp_workspace
|
||||
}
|
||||
}
|
||||
}
|
||||
275
terraform/s3.tf
Normal file
275
terraform/s3.tf
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
# Lambda artifacts bucket. Terraform ships only the bootstrap stub.
|
||||
# .github/workflows/deploy.yaml uploads functions/<name>/<sha>.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]
|
||||
}
|
||||
2
terraform/secrets.tf
Normal file
2
terraform/secrets.tf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Secret values stay in Secrets Manager. ARNs are pinned in locals.tf so the
|
||||
# first bootstrap-plan does not need secretsmanager:DescribeSecret.
|
||||
15
terraform/ssm.tf
Normal file
15
terraform/ssm.tf
Normal file
|
|
@ -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/<name>/<sha>.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"
|
||||
}
|
||||
29
terraform/variables.tf
Normal file
29
terraform/variables.tf
Normal file
|
|
@ -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"
|
||||
}
|
||||
22
terraform/versions.tf
Normal file
22
terraform/versions.tf
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
145
terraform/vpc.tf
Normal file
145
terraform/vpc.tf
Normal file
|
|
@ -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"
|
||||
}
|
||||
96
tests/infra/hcpContract.test.js
Normal file
96
tests/infra/hcpContract.test.js
Normal file
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue