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.
This commit is contained in:
Adam Moussa 2026-09-16 13:41:47 -04:00
parent 219ea1001a
commit 6f32f3bfcd
No known key found for this signature in database
34 changed files with 2961 additions and 975 deletions

View file

@ -1,4 +1,5 @@
name: CI
on:
pull_request:
branches: [main]
@ -8,10 +9,79 @@ permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Install
run: npm ci
- name: Test
run: npm test
terraform:
name: Terraform
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with:
run-typecheck: false
run-tests: true
run-cdk-synth: false
run-sam-validate: true
name: ci / ci
needs: [test, terraform]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check jobs
env:
TEST_RESULT: ${{ needs.test.result }}
TERRAFORM_RESULT: ${{ needs.terraform.result }}
run: |
set -euo pipefail
fail=0
check() {
local name="$1"
local result="$2"
case "${result}" in
success)
echo "${name}: ${result}"
;;
*)
echo "${name}: ${result}" >&2
fail=1
;;
esac
}
check test "${TEST_RESULT}"
check terraform "${TERRAFORM_RESULT}"
exit "${fail}"

View file

@ -1,21 +1,143 @@
name: Deploy
# Terraform owns Lambda skeletons. This workflow ships zips to prod and calls
# update-function-code. It never creates an HCP run. No GitHub Releases and no
# tagging in this workflow.
on:
push:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "README.md"
- "SETUP.md"
- "AGENTS.md"
workflow_dispatch:
inputs:
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
id-token: write
contents: read
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with:
stack-name: payments-dashboard
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
name: Deploy to prod
runs-on: ubuntu-latest
timeout-minutes: 30
environment: prod
concurrency:
group: deploy-payments-dashboard-prod
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
persist-credentials: false
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
echo "Building ${sha}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build function zips
env:
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
node scripts/package_lambdas.mjs --git-sha "${GIT_SHA}" --out-dir build/packages
python3 - <<'PY'
import os, zipfile
from pathlib import Path
sha = os.environ["GIT_SHA"]
names = [
"process_csv",
"slack_app_home",
"fetch_boa",
"expense_receiver",
"expense_processor",
]
for name in names:
path = Path("build/packages") / f"{name}.zip"
if not path.is_file():
raise SystemExit(f"missing {path}")
with zipfile.ZipFile(path) as zf:
info = zf.read("src/buildInfo.js").decode()
if sha not in info:
raise SystemExit(f"{path} missing GIT_SHA {sha}")
if "src/processPaymentCsv.js" not in zf.namelist():
raise SystemExit(f"{path} missing src/")
print("zips ok")
PY
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
run: |
set -euo pipefail
prefix=/payments-dashboard/deploy
ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)
{
echo "artifacts_bucket=${ARTIFACTS_BUCKET}"
echo "process_csv=$(aws ssm get-parameter --name "${prefix}/process_csv-function-name" --query Parameter.Value --output text)"
echo "slack_app_home=$(aws ssm get-parameter --name "${prefix}/slack_app_home-function-name" --query Parameter.Value --output text)"
echo "fetch_boa=$(aws ssm get-parameter --name "${prefix}/fetch_boa-function-name" --query Parameter.Value --output text)"
echo "expense_receiver=$(aws ssm get-parameter --name "${prefix}/expense_receiver-function-name" --query Parameter.Value --output text)"
echo "expense_processor=$(aws ssm get-parameter --name "${prefix}/expense_processor-function-name" --query Parameter.Value --output text)"
} >> "${GITHUB_OUTPUT}"
- name: Upload zips and update function code
env:
ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
PROCESS_CSV: ${{ steps.deploy.outputs.process_csv }}
SLACK_APP_HOME: ${{ steps.deploy.outputs.slack_app_home }}
FETCH_BOA: ${{ steps.deploy.outputs.fetch_boa }}
EXPENSE_RECEIVER: ${{ steps.deploy.outputs.expense_receiver }}
EXPENSE_PROCESSOR: ${{ steps.deploy.outputs.expense_processor }}
run: |
set -euo pipefail
keys=(
process_csv:"${PROCESS_CSV}"
slack_app_home:"${SLACK_APP_HOME}"
fetch_boa:"${FETCH_BOA}"
expense_receiver:"${EXPENSE_RECEIVER}"
expense_processor:"${EXPENSE_PROCESSOR}"
)
for pair in "${keys[@]}"; do
name="${pair%%:*}"
fn="${pair#*:}"
key="functions/${name}/${GIT_SHA}.zip"
aws s3 cp "build/packages/${name}.zip" "s3://${ARTIFACTS_BUCKET}/${key}"
aws lambda update-function-code \
--function-name "${fn}" \
--s3-bucket "${ARTIFACTS_BUCKET}" \
--s3-key "${key}" \
--query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \
--output table
aws lambda wait function-updated-v2 --function-name "${fn}"
done

3
.gitignore vendored
View file

@ -2,6 +2,9 @@ node_modules/
.aws-sam/
samconfig.toml
data/
build/
terraform/.terraform/
terraform/build/
.DS_Store
BofA API Resources/
*.csv

View file

@ -1,22 +1,22 @@
# Payments Dashboard
![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?logo=javascript&logoColor=black)
![AWS SAM](https://img.shields.io/badge/AWS-SAM-FF9900?logo=amazonaws&logoColor=white)
![AWS](https://img.shields.io/badge/AWS-HCP%20Terraform-FF9900?logo=amazonaws&logoColor=white)
![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/payments-dashboard/actions/workflows/ci.yaml/badge.svg)
AWS SAM application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, surfaces an outstanding-payments dashboard in Slack, and routes expense approvals through a Slack reaction-driven workflow.
HCP Terraform application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, surfaces an outstanding-payments dashboard in Slack, and routes expense approvals through a Slack reaction-driven workflow. Prod workspace: `payments-dashboard-prod` (trigger prefix `terraform/**`). Zip CD is GitHub Actions Environment `prod`.
## Architecture
- **ProcessPaymentCsv** — Lambda triggered by S3 CSV upload. Parses Stampli payment exports, upserts to DynamoDB, and submits new/cancelled checks to the CashPro Check Management API.
- **FetchBoaTransactions** — Scheduled Lambda. Weekdays 9am ET it calls the CashPro **previous-day** Transaction Inquiry (authoritative sweep, trailing 7 days); weekdays at 16:00/19:00/22:00 UTC (~12/3/6pm ET, fixed-UTC so it drifts an hour in winter) it calls the **current-day** inquiry for same-day visibility (EventBridge `Input: {"endpoint":"current-day"}`, today-only, staleness sweep skipped). Every run archives the exact raw response to the `seahaven-payments-boa-raw-*` bucket (`raw/<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
View 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`.

View file

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

View 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);
}

View file

@ -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
View 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
View 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
View 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}/*/*"
}

View file

@ -0,0 +1,3 @@
{
"type": "module"
}

View 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."}}',
};
}

View 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."}}',
};
}

View 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."}}',
};
}

View 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."}}',
};
}

View 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."}}',
};
}

56
terraform/data.tf Normal file
View file

@ -0,0 +1,56 @@
data "aws_ssm_parameter" "dynamodb_cmk" {
name = local.dynamodb_cmk_ssm
}
resource "aws_dynamodb_table" "dashboard" {
name = local.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "pk"
attribute {
name = "pk"
type = "S"
}
ttl {
attribute_name = "ttl"
enabled = true
}
server_side_encryption {
enabled = true
kms_key_arn = data.aws_ssm_parameter.dynamodb_cmk.value
}
}
resource "aws_sqs_queue" "process_csv_dlq" {
name = "payments-processPaymentCsv-async-dlq"
message_retention_seconds = 1209600
sqs_managed_sse_enabled = true
}
data "aws_iam_policy_document" "process_csv_dlq" {
statement {
sid = "AllowLambdaOnFailure"
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.process_csv_dlq.arn]
condition {
test = "ArnEquals"
variable = "aws:SourceArn"
values = [aws_lambda_function.this["process_csv"].arn]
}
}
}
resource "aws_sqs_queue_policy" "process_csv_dlq" {
queue_url = aws_sqs_queue.process_csv_dlq.id
policy = data.aws_iam_policy_document.process_csv_dlq.json
}

47
terraform/events.tf Normal file
View 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
}

906
terraform/hcp_iam.tf Normal file
View file

@ -0,0 +1,906 @@
# HCP plan/apply roles for payments-dashboard-prod (PLAT-79 / PLAT-144).
# Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example
# with the payments-dashboard service set. Create, do not import.
#
# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). First-apply sequence:
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
# --account prod --allow-workspace payments-dashboard-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply (create roles + scoped inline + boundary + stack,
# schedules_enabled=false).
# 4. Point TFC_AWS_* back at hcptf-payments-dashboard /
# hcptf-payments-dashboard-plan.
# 5. Re-run the script without --allow-workspace to pin trust back to
# iam-bootstrap-prod only.
# Later apply-role IAM edits use the same window. Do not add StringLike
# on bootstrap trust. CreatePolicy stays on hcptf-bootstrap only; boundary
# document changes after seal also need that window.
data "aws_iam_policy_document" "hcptf_apply_trust" {
statement {
sid = "HcpApply"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply",
]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_trust" {
statement {
sid = "HcpPlan"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan",
]
}
}
}
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
effect = "Deny"
actions = [
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "CreateExecRoleWithBoundary"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
sid = "MutateExecRoleWithBoundary"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
sid = "WriteExecRoles"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "PassExecRolesToLambda"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"]
}
}
statement {
sid = "CreateDeployRole"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"]
condition {
test = "Null"
variable = "iam:PermissionsBoundary"
values = ["true"]
}
}
statement {
sid = "WriteDeployRoles"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"]
}
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListRoles",
]
resources = ["*"]
}
statement {
sid = "DenySelfMutation"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/hcptf-*",
"arn:aws:iam::${local.account_id}:role/github-cfn-execution-role",
"arn:aws:iam::${local.account_id}:role/githubdeploy-*",
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${local.account_id}:role/seahaven-*",
]
}
statement {
sid = "DenyBoundaryTampering"
effect = "Deny"
actions = [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/*",
"arn:aws:iam::${local.account_id}:user/*",
]
}
statement {
sid = "DenyBoundaryPolicyEdit"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"]
}
}
data "aws_iam_policy_document" "hcptf_apply_services" {
# checkov:skip=CKV_AWS_111: List/describe, HTTP API log delivery, and VPC/NAT lifecycle APIs require Resource=*. Function, bucket, table, queue, secret, alarm, and SSM writes are ARN-prefixed.
statement {
sid = "LambdaAll"
effect = "Allow"
actions = [
"lambda:*",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-*",
]
}
statement {
sid = "LambdaList"
effect = "Allow"
actions = [
"lambda:ListFunctions",
"lambda:ListLayers",
"lambda:GetAccountSettings",
]
resources = ["*"]
}
statement {
sid = "EventBridgeRules"
effect = "Allow"
actions = [
"events:*",
]
resources = [
"arn:aws:events:${var.aws_region}:${local.account_id}:rule/${local.project}-*",
]
}
statement {
sid = "EventBridgeList"
effect = "Allow"
actions = ["events:ListRules", "events:ListRuleNamesByTarget"]
resources = ["*"]
}
statement {
sid = "CloudWatchLogs"
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DeleteRetentionPolicy",
"logs:TagResource",
"logs:UntagResource",
"logs:ListTagsForResource",
"logs:PutMetricFilter",
"logs:DeleteMetricFilter",
"logs:DescribeMetricFilters",
]
resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/payments-*",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/${local.project}",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/${local.project}:*",
]
}
statement {
sid = "CloudWatchLogsDescribe"
effect = "Allow"
actions = ["logs:DescribeLogGroups"]
resources = ["*"]
}
statement {
sid = "ApiGwAccessLogDelivery"
effect = "Allow"
actions = [
"logs:CreateLogDelivery",
"logs:GetLogDelivery",
"logs:UpdateLogDelivery",
"logs:DeleteLogDelivery",
"logs:ListLogDeliveries",
"logs:PutResourcePolicy",
"logs:DescribeResourcePolicies",
]
resources = ["*"]
}
statement {
sid = "StackBuckets"
effect = "Allow"
actions = [
"s3:*",
]
resources = [
"arn:aws:s3:::${local.artifacts_bucket_name}",
"arn:aws:s3:::${local.artifacts_bucket_name}/*",
"arn:aws:s3:::${local.csv_bucket_name}",
"arn:aws:s3:::${local.csv_bucket_name}/*",
"arn:aws:s3:::${local.boa_raw_bucket_name}",
"arn:aws:s3:::${local.boa_raw_bucket_name}/*",
]
}
statement {
sid = "DynamoDBTable"
effect = "Allow"
actions = [
"dynamodb:*",
]
resources = [
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*",
]
}
statement {
sid = "DynamoDBList"
effect = "Allow"
actions = ["dynamodb:ListTables"]
resources = ["*"]
}
statement {
sid = "SqsDlq"
effect = "Allow"
actions = [
"sqs:*",
]
resources = [
"arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq",
]
}
statement {
sid = "SqsList"
effect = "Allow"
actions = ["sqs:ListQueues"]
resources = ["*"]
}
statement {
sid = "HttpApiManage"
effect = "Allow"
actions = [
"apigateway:*",
]
resources = [
"arn:aws:apigateway:${var.aws_region}::/apis",
"arn:aws:apigateway:${var.aws_region}::/apis/*",
"arn:aws:apigateway:${var.aws_region}::/tags/*",
"arn:aws:apigateway:${var.aws_region}::/vpclinks",
"arn:aws:apigateway:${var.aws_region}::/vpclinks/*",
]
}
statement {
sid = "PaymentsSsm"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:AddTagsToResource",
"ssm:RemoveTagsFromResource",
"ssm:ListTagsForResource",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.dynamodb_cmk_ssm}",
]
}
statement {
sid = "SsmDescribeParameters"
effect = "Allow"
actions = ["ssm:DescribeParameters"]
resources = ["*"]
}
statement {
sid = "SecretsManagerRead"
effect = "Allow"
actions = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:ListSecretVersionIds",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
]
resources = [
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:payments-dashboard/*",
]
}
statement {
sid = "SecretsManagerList"
effect = "Allow"
actions = ["secretsmanager:ListSecrets"]
resources = ["*"]
}
statement {
sid = "KmsTableCmk"
effect = "Allow"
actions = [
"kms:DescribeKey",
"kms:GetKeyPolicy",
"kms:ListResourceTags",
"kms:CreateGrant",
"kms:ListGrants",
"kms:RetireGrant",
]
resources = [data.aws_ssm_parameter.dynamodb_cmk.value]
}
statement {
sid = "CloudWatchAlarms"
effect = "Allow"
actions = [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms",
"cloudwatch:TagResource",
"cloudwatch:UntagResource",
"cloudwatch:ListTagsForResource",
]
resources = [
"arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:payments-*",
]
}
statement {
sid = "CloudWatchDescribeAlarms"
effect = "Allow"
actions = ["cloudwatch:DescribeAlarms"]
resources = ["*"]
}
statement {
sid = "SnsPublishSiteAlerts"
effect = "Allow"
actions = [
"sns:Publish",
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
resources = [local.site_alerts_arn]
}
statement {
sid = "ManageTfManagedBoundary"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListPolicyVersions",
"iam:ListPolicyTags",
"iam:TagPolicy",
"iam:UntagPolicy",
]
resources = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "Ec2VpcManagement"
effect = "Allow"
actions = [
"ec2:AllocateAddress",
"ec2:AssociateRouteTable",
"ec2:AttachInternetGateway",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:CreateInternetGateway",
"ec2:CreateNatGateway",
"ec2:CreateRoute",
"ec2:CreateRouteTable",
"ec2:CreateSecurityGroup",
"ec2:CreateSubnet",
"ec2:CreateVpc",
"ec2:CreateVpcEndpoint",
"ec2:CreateTags",
"ec2:DeleteInternetGateway",
"ec2:DeleteNatGateway",
"ec2:DeleteRoute",
"ec2:DeleteRouteTable",
"ec2:DeleteSecurityGroup",
"ec2:DeleteSubnet",
"ec2:DeleteVpc",
"ec2:DeleteVpcEndpoints",
"ec2:DescribeAccountAttributes",
"ec2:DescribeAddresses",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeNatGateways",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeRouteTables",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcEndpoints",
"ec2:DescribeVpcs",
"ec2:DescribePrefixLists",
"ec2:DetachInternetGateway",
"ec2:DisassociateAddress",
"ec2:DisassociateRouteTable",
"ec2:ModifySubnetAttribute",
"ec2:ModifyVpcAttribute",
"ec2:ModifyVpcEndpoint",
"ec2:ReleaseAddress",
"ec2:RevokeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
"ec2:UpdateSecurityGroupRuleDescriptionsEgress",
"ec2:UpdateSecurityGroupRuleDescriptionsIngress",
]
resources = ["*"]
}
}
data "aws_iam_policy_document" "hcptf_plan_refresh" {
statement {
sid = "RefreshIamRoles"
effect = "Allow"
actions = [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:ListRoleTags",
]
resources = [
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}",
"arn:aws:iam::${local.account_id}:role/${local.apply_role}",
"arn:aws:iam::${local.account_id}:role/${local.plan_role}",
]
}
statement {
sid = "RefreshManagedPolicies"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "RefreshLambda"
effect = "Allow"
actions = [
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:GetPolicy",
"lambda:GetFunctionCodeSigningConfig",
"lambda:GetFunctionConcurrency",
"lambda:GetFunctionEventInvokeConfig",
"lambda:GetFunctionUrlConfig",
"lambda:GetRuntimeManagementConfig",
"lambda:GetFunctionRecursionConfig",
"lambda:ListTags",
"lambda:ListVersionsByFunction",
"lambda:ListAliases",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-*",
]
}
statement {
sid = "RefreshLambdaList"
effect = "Allow"
actions = [
"lambda:ListFunctions",
"lambda:ListLayers",
"lambda:GetAccountSettings",
]
resources = ["*"]
}
statement {
sid = "RefreshBuckets"
effect = "Allow"
actions = [
"s3:GetAccelerateConfiguration",
"s3:GetAnalyticsConfiguration",
"s3:GetBucketAcl",
"s3:GetBucketCORS",
"s3:GetBucketLifecycleConfiguration",
"s3:GetBucketLocation",
"s3:GetBucketLogging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetBucketOwnershipControls",
"s3:GetBucketPolicy",
"s3:GetBucketPolicyStatus",
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketReplication",
"s3:GetBucketRequestPayment",
"s3:GetBucketTagging",
"s3:GetBucketVersioning",
"s3:GetBucketWebsite",
"s3:GetEncryptionConfiguration",
"s3:GetIntelligentTieringConfiguration",
"s3:GetInventoryConfiguration",
"s3:GetLifecycleConfiguration",
"s3:GetMetricsConfiguration",
"s3:GetObject",
"s3:GetObjectTagging",
"s3:GetObjectVersion",
"s3:GetReplicationConfiguration",
"s3:ListBucket",
]
resources = [
"arn:aws:s3:::${local.artifacts_bucket_name}",
"arn:aws:s3:::${local.artifacts_bucket_name}/*",
"arn:aws:s3:::${local.csv_bucket_name}",
"arn:aws:s3:::${local.csv_bucket_name}/*",
"arn:aws:s3:::${local.boa_raw_bucket_name}",
"arn:aws:s3:::${local.boa_raw_bucket_name}/*",
]
}
statement {
sid = "RefreshDynamoDB"
effect = "Allow"
actions = [
"dynamodb:DescribeTable",
"dynamodb:DescribeTimeToLive",
"dynamodb:DescribeContinuousBackups",
"dynamodb:DescribeKinesisStreamingDestination",
"dynamodb:ListTagsOfResource",
]
resources = [
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
]
}
statement {
sid = "RefreshEventBridge"
effect = "Allow"
actions = [
"events:DescribeRule",
"events:ListTargetsByRule",
"events:ListTagsForResource",
]
resources = [
"arn:aws:events:${var.aws_region}:${local.account_id}:rule/${local.project}-*",
]
}
statement {
sid = "RefreshLogs"
effect = "Allow"
actions = [
"logs:DescribeLogGroups",
"logs:ListTagsForResource",
]
resources = ["*"]
}
statement {
sid = "RefreshHttpApi"
effect = "Allow"
actions = [
"apigateway:GET",
]
resources = [
"arn:aws:apigateway:${var.aws_region}::/apis",
"arn:aws:apigateway:${var.aws_region}::/apis/*",
"arn:aws:apigateway:${var.aws_region}::/tags/*",
]
}
statement {
sid = "RefreshSsm"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:ListTagsForResource",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.dynamodb_cmk_ssm}",
]
}
statement {
sid = "RefreshSsmDescribeParameters"
effect = "Allow"
actions = ["ssm:DescribeParameters"]
resources = ["*"]
}
statement {
sid = "RefreshSecrets"
effect = "Allow"
actions = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:ListSecretVersionIds",
]
resources = [
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:payments-dashboard/*",
]
}
statement {
sid = "RefreshSecretsList"
effect = "Allow"
actions = ["secretsmanager:ListSecrets"]
resources = ["*"]
}
statement {
sid = "RefreshAlarms"
effect = "Allow"
actions = [
"cloudwatch:DescribeAlarms",
"cloudwatch:ListTagsForResource",
]
resources = ["*"]
}
statement {
sid = "RefreshSns"
effect = "Allow"
actions = [
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
resources = [local.site_alerts_arn]
}
statement {
sid = "RefreshSqs"
effect = "Allow"
actions = [
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl",
"sqs:ListQueueTags",
]
resources = [
"arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq",
]
}
statement {
sid = "RefreshKms"
effect = "Allow"
actions = [
"kms:DescribeKey",
"kms:GetKeyPolicy",
"kms:ListResourceTags",
]
resources = [data.aws_ssm_parameter.dynamodb_cmk.value]
}
statement {
sid = "RefreshEc2"
effect = "Allow"
actions = [
"ec2:DescribeAddresses",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeNatGateways",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeRouteTables",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcEndpoints",
"ec2:DescribeVpcs",
"ec2:DescribePrefixLists",
]
resources = ["*"]
}
}
resource "aws_iam_role" "hcptf_apply" {
name = local.apply_role
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role" "hcptf_plan" {
name = local.plan_role
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
name = "scoped-iam-management"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
}
resource "aws_iam_role_policy" "hcptf_apply_services" {
# checkov:skip=CKV_AWS_111: List/describe, HTTP API log delivery, and VPC/NAT lifecycle APIs require Resource=*. Function, bucket, table, queue, secret, alarm, and SSM writes are ARN-prefixed.
name = "payments-dashboard-services"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_apply_services.json
}
resource "aws_iam_role_policy" "hcptf_plan_refresh" {
# checkov:skip=CKV_AWS_107: ViewOnlyAccess plus this sidecar is the org HCP plan-role pattern (PLAT-144 / afterhours). Sidecar Get* is scoped to this stack's roles, buckets, table, queues, functions, and parameters. It does not add iam:CreateAccessKey, secretsmanager:GetSecretValue, or ssm:GetParameter on *.
name = "payments-dashboard-plan-refresh"
role = aws_iam_role.hcptf_plan.id
policy = data.aws_iam_policy_document.hcptf_plan_refresh.json
}
resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" {
role = aws_iam_role.hcptf_plan.name
policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" {
role_name = aws_iam_role.hcptf_apply.name
policy_arns = []
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" {
role_name = aws_iam_role.hcptf_plan.name
policy_arns = [
"arn:aws:iam::aws:policy/job-function/ViewOnlyAccess",
]
}

View 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
View 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 = data.aws_ssm_parameter.dynamodb_cmk.value
lambda_identity = {
process_csv = [
{
sid = "CsvRead"
actions = ["s3:GetObject", "s3:GetObjectVersion"]
resources = ["${aws_s3_bucket.csv.arn}/*"]
},
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
},
{
sid = "Cmk"
actions = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"]
resources = [local.cmk_arn]
},
{
sid = "BoaCheckMgmtSecret"
actions = ["secretsmanager:GetSecretValue"]
resources = [local.secret_arns["payments-dashboard/boa-check-mgmt"]]
},
{
sid = "DlqSend"
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.process_csv_dlq.arn]
},
]
slack_app_home = [
{
sid = "DdbRead"
actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"]
resources = [local.table_arn, "${local.table_arn}/*"]
},
{
sid = "CmkDecrypt"
actions = ["kms:Decrypt", "kms:DescribeKey"]
resources = [local.cmk_arn]
},
{
sid = "SlackSecrets"
actions = ["secretsmanager:GetSecretValue"]
resources = [
local.secret_arns["payments-dashboard/slack-bot-token"],
local.secret_arns["payments-dashboard/slack-signing-secret"],
]
},
]
fetch_boa = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
},
{
sid = "BoaRawPut"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.boa_raw.arn}/*"]
},
{
sid = "Cmk"
actions = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"]
resources = [local.cmk_arn]
},
{
sid = "BoaReportingSecret"
actions = ["secretsmanager:GetSecretValue"]
resources = [local.secret_arns["payments-dashboard/boa-reporting"]]
},
]
expense_receiver = [
{
sid = "InvokeProcessor"
actions = ["lambda:InvokeFunction"]
resources = ["arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-expenseProcessor"]
},
{
sid = "ExpenseSigningSecret"
actions = ["secretsmanager:GetSecretValue"]
resources = [local.secret_arns["payments-dashboard/expense-slack-signing-secret"]]
},
]
expense_processor = [
{
sid = "ExpenseBotSecret"
actions = ["secretsmanager:GetSecretValue"]
resources = [local.secret_arns["payments-dashboard/expense-slack-token"]]
},
]
}
lambda_env = {
process_csv = {
TABLE_NAME = aws_dynamodb_table.dashboard.name
BOA_BASE_URL = var.boa_base_url
BOA_CHECK_MGMT_SECRET_NAME = "payments-dashboard/boa-check-mgmt"
}
slack_app_home = {
TABLE_NAME = aws_dynamodb_table.dashboard.name
SLACK_BOT_TOKEN_SECRET_NAME = "payments-dashboard/slack-bot-token"
SLACK_SIGNING_SECRET_NAME = "payments-dashboard/slack-signing-secret"
}
fetch_boa = {
TABLE_NAME = aws_dynamodb_table.dashboard.name
BOA_BASE_URL = var.boa_base_url
BOA_REPORTING_SECRET_NAME = "payments-dashboard/boa-reporting"
BOA_RAW_BUCKET = aws_s3_bucket.boa_raw.id
}
expense_receiver = {
TABLE_NAME = aws_dynamodb_table.dashboard.name
EXPENSE_PROCESSOR_FN = "payments-expenseProcessor"
EXPENSE_SIGNING_SECRET_NAME = "payments-dashboard/expense-slack-signing-secret"
}
expense_processor = {
TABLE_NAME = aws_dynamodb_table.dashboard.name
EXPENSE_BOT_TOKEN_SECRET_NAME = "payments-dashboard/expense-slack-token"
}
}
}
resource "aws_iam_role" "lambda" {
for_each = local.functions
name = each.value.role_name
path = "/tf-managed/"
description = "Lambda execution role for ${each.value.function_name}"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = aws_iam_policy.lambda_boundary.arn
}
data "aws_iam_policy_document" "lambda" {
for_each = local.functions
dynamic "statement" {
for_each = local.lambda_identity[each.key]
content {
sid = statement.value.sid
effect = "Allow"
actions = statement.value.actions
resources = statement.value.resources
}
}
}
resource "aws_iam_role_policy" "lambda" {
for_each = local.functions
name = each.key
role = aws_iam_role.lambda[each.key].id
policy = data.aws_iam_policy_document.lambda[each.key].json
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
for_each = local.functions
role = aws_iam_role.lambda[each.key].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy_attachment" "lambda_vpc" {
for_each = { for k, v in local.functions : k => v if v.vpc }
role = aws_iam_role.lambda[each.key].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
resource "aws_lambda_function" "this" {
for_each = local.functions
function_name = each.value.function_name
role = aws_iam_role.lambda[each.key].arn
handler = each.value.handler
runtime = "nodejs24.x"
architectures = ["arm64"]
memory_size = 256
timeout = each.value.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.bootstrap_stub.key
source_code_hash = data.archive_file.bootstrap_stub.output_base64sha256
environment {
variables = local.lambda_env[each.key]
}
dynamic "vpc_config" {
for_each = each.value.vpc ? [1] : []
content {
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.lambda.id]
}
}
lifecycle {
ignore_changes = [filename, s3_bucket, s3_key, s3_object_version, source_code_hash]
}
depends_on = [
aws_cloudwatch_log_group.lambda,
aws_iam_role_policy.lambda,
aws_iam_role_policy_attachment.lambda_basic,
aws_iam_role_policy_attachment.lambda_vpc,
aws_nat_gateway.this,
]
}
resource "aws_lambda_function_event_invoke_config" "process_csv" {
function_name = aws_lambda_function.this["process_csv"].function_name
maximum_event_age_in_seconds = 21600
maximum_retry_attempts = 2
destination_config {
on_failure {
destination = aws_sqs_queue.process_csv_dlq.arn
}
}
}
resource "aws_lambda_permission" "s3_csv" {
statement_id = "AllowS3InvokeProcessCsv"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this["process_csv"].function_name
principal = "s3.amazonaws.com"
source_arn = aws_s3_bucket.csv.arn
source_account = local.account_id
}

View 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 = [data.aws_ssm_parameter.dynamodb_cmk.value]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["dynamodb.${var.aws_region}.amazonaws.com"]
}
}
statement {
sid = "PaymentsCsvRead"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:GetObjectVersion",
]
resources = ["arn:aws:s3:::${local.csv_bucket_name}/*"]
}
statement {
sid = "PaymentsBoaRawPut"
effect = "Allow"
actions = [
"s3:PutObject",
]
resources = ["arn:aws:s3:::${local.boa_raw_bucket_name}/*"]
}
statement {
sid = "PaymentsDlqSend"
effect = "Allow"
actions = [
"sqs:SendMessage",
]
resources = [
"arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq",
]
}
statement {
sid = "PaymentsInvokeExpenseProcessor"
effect = "Allow"
actions = [
"lambda:InvokeFunction",
]
resources = [
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-expenseProcessor",
]
}
}
resource "aws_iam_policy" "lambda_boundary" {
# checkov:skip=CKV_AWS_111: AWS requires Resource=* for logs:DescribeLogGroups, xray Put*, and EC2 ENI lifecycle used by VPC Lambdas. Secrets, table, CMK, buckets, DLQ, and invoke are ARN-pinned.
name = "payments-dashboard-lambda-boundary"
path = "/tf-managed/"
description = "Per-workload Lambda permissions boundary for payments-dashboard (PLAT-79)."
policy = data.aws_iam_policy_document.lambda_boundary.json
}

77
terraform/locals.tf Normal file
View file

@ -0,0 +1,77 @@
locals {
project = "payments-dashboard"
account_id = "011934824531"
environment = "prod"
hcp_project = "seahaven-prod"
hcp_workspace = "payments-dashboard-prod"
apply_role = "hcptf-payments-dashboard"
plan_role = "hcptf-payments-dashboard-plan"
deploy_role = "githubdeploy-payments-dashboard"
stack_name = local.project
stack_prefix = "payments-dashboard-"
artifacts_bucket_name = "payments-dashboard-artifacts-${local.account_id}"
csv_bucket_name = "seahaven-payments-csv-${local.account_id}"
boa_raw_bucket_name = "seahaven-payments-boa-raw-${local.account_id}"
ssm_prefix = "/payments-dashboard"
table_name = "PaymentsDashboard"
site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"
dynamodb_cmk_ssm = "/seahaven/dynamodb/cmk-arn"
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
# Org has Actions OIDC use_immutable_subject=true.
github_oidc_sub = "repo:Sea-Haven-Industries@183236204/payments-dashboard@1206210946:environment:prod"
secret_names = [
"payments-dashboard/slack-bot-token",
"payments-dashboard/slack-signing-secret",
"payments-dashboard/boa-check-mgmt",
"payments-dashboard/boa-reporting",
"payments-dashboard/expense-slack-token",
"payments-dashboard/expense-slack-signing-secret",
]
functions = {
process_csv = {
function_name = "payments-processPaymentCsv"
role_name = "payments-dashboard-process-csv"
handler = "src/processPaymentCsv.handler"
timeout = 120
duration_ms = 96000
vpc = true
}
slack_app_home = {
function_name = "payments-slackAppHome"
role_name = "payments-dashboard-slack-app-home"
handler = "src/slackAppHome.handler"
timeout = 30
duration_ms = 24000
vpc = true
}
fetch_boa = {
function_name = "payments-fetchBoaTransactions"
role_name = "payments-dashboard-fetch-boa"
handler = "src/fetchBoaTransactions.handler"
timeout = 60
duration_ms = 48000
vpc = true
}
expense_receiver = {
function_name = "payments-expenseReceiver"
role_name = "payments-dashboard-expense-receiver"
handler = "src/expenseReceiver.handler"
timeout = 5
duration_ms = 4000
vpc = false
}
expense_processor = {
function_name = "payments-expenseProcessor"
role_name = "payments-dashboard-expense-processor"
handler = "src/expenseProcessor.handler"
timeout = 15
duration_ms = 12000
vpc = false
}
}
}

11
terraform/logs.tf Normal file
View 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
View 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
View 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
View 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]
}

8
terraform/secrets.tf Normal file
View file

@ -0,0 +1,8 @@
data "aws_secretsmanager_secret" "this" {
for_each = toset(local.secret_names)
name = each.value
}
locals {
secret_arns = { for name, secret in data.aws_secretsmanager_secret.this : name => secret.arn }
}

15
terraform/ssm.tf Normal file
View 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
View 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
View 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
View 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"
}

View 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/);
});
});