mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 04:13:12 +00:00
feat(infra): migrate payments-dashboard to HCP Terraform (PLAT-79) (#109)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* feat(infra): migrate payments-dashboard to HCP Terraform (PLAT-79) Replace the mgmt SAM stack with a prod-only HCP workspace using the afterhours stub-plus-zip-CD seam so GitHub Actions owns function code and Terraform owns infrastructure. * fix(infra): pin secret and CMK ARNs for bootstrap-plan hcptf-bootstrap-plan cannot ssm:GetParameter or DescribeSecret, so the first plan must not data-source those values. * fix(infra): add EIP describe and DynamoDB CMK grants for first apply Scoped apply missed ec2:DescribeAddressesAttribute and kms Encrypt/Decrypt/GenerateDataKey on the table CMK.
This commit is contained in:
parent
219ea1001a
commit
0e3e95c240
34 changed files with 2964 additions and 975 deletions
82
.github/workflows/ci.yaml
vendored
82
.github/workflows/ci.yaml
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
@ -8,10 +9,79 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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:
|
ci:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
name: ci / ci
|
||||||
with:
|
needs: [test, terraform]
|
||||||
run-typecheck: false
|
if: ${{ always() && !cancelled() }}
|
||||||
run-tests: true
|
runs-on: ubuntu-latest
|
||||||
run-cdk-synth: false
|
timeout-minutes: 5
|
||||||
run-sam-validate: true
|
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}"
|
||||||
|
|
|
||||||
144
.github/workflows/deploy.yaml
vendored
144
.github/workflows/deploy.yaml
vendored
|
|
@ -1,21 +1,143 @@
|
||||||
name: Deploy
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
permissions:
|
||||||
id-token: write
|
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
name: Deploy to prod
|
||||||
with:
|
runs-on: ubuntu-latest
|
||||||
stack-name: payments-dashboard
|
timeout-minutes: 30
|
||||||
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
|
environment: prod
|
||||||
secrets:
|
concurrency:
|
||||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
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
3
.gitignore
vendored
|
|
@ -2,6 +2,9 @@ node_modules/
|
||||||
.aws-sam/
|
.aws-sam/
|
||||||
samconfig.toml
|
samconfig.toml
|
||||||
data/
|
data/
|
||||||
|
build/
|
||||||
|
terraform/.terraform/
|
||||||
|
terraform/build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
BofA API Resources/
|
BofA API Resources/
|
||||||
*.csv
|
*.csv
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -1,22 +1,22 @@
|
||||||
# Payments Dashboard
|
# Payments Dashboard
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

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