feat(infra): migrate meal-order-manager to HCP Terraform

Freeze SAM CD and add greenfield Terraform for seahaven-prod so HCP is the sole stack deploy path.
This commit is contained in:
Adam Moussa 2026-08-07 19:19:51 -04:00
parent b595744882
commit 40ea4ed898
No known key found for this signature in database
31 changed files with 2313 additions and 43 deletions

68
.github/workflows/build-layer.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: Build Lambda Layer
# Build verification only. Terraform owns Lambda packaging: terraform/artifacts.tf
# runs terraform/build_packages.sh during plan and carries the resulting zips into
# the plan as content_base64, so there is no artifact for this workflow to upload
# and no job here holds AWS credentials.
#
# What it does check is that the layer still builds for the Lambda target
# (python3.12 / arm64) and stays small enough to travel inside a plan. boto3 and
# friends are stripped by build_packages.sh because the runtime provides them; if
# that strip ever stops working, the size guard below fails the PR rather than
# letting a multi-hundred-megabyte plan payload reach HCP Terraform.
on:
pull_request:
branches: [main]
paths:
- "src/shared/**"
- "functions/**"
- "terraform/build_packages.sh"
- "terraform/build_packages_external.sh"
- ".github/workflows/build-layer.yml"
push:
branches: [main]
paths:
- "src/shared/**"
- "functions/**"
- "terraform/build_packages.sh"
- "terraform/build_packages_external.sh"
- ".github/workflows/build-layer.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-layer-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
- name: Build packages
run: bash terraform/build_packages.sh
- name: Check layer size
run: |
set -euo pipefail
cd terraform/build/layer
zip -qrX ../packages/layer-check.zip python
BYTES=$(wc -c < ../packages/layer-check.zip)
LIMIT=$((40 * 1024 * 1024))
echo "Layer zip: $BYTES bytes (limit $LIMIT)"
if [ "$BYTES" -gt "$LIMIT" ]; then
echo "Layer exceeds the plan-payload budget. Check that build_packages.sh still strips the runtime-provided packages." >&2
exit 1
fi
if [ -d python/boto3 ]; then
echo "boto3 is present in the layer; the runtime already provides it." >&2
exit 1
fi

32
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Terraform CI
on:
pull_request:
branches: [main]
paths:
- "terraform/**"
- ".github/workflows/ci-terraform.yaml"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

View file

@ -1,22 +0,0 @@
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
with:
stack-name: meal-order-manager
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}

View file

@ -63,30 +63,31 @@ jobs:
if: env.SKIP_RUN != 'true' if: env.SKIP_RUN != 'true'
run: python3 src/scraper/scrape_menu.py run: python3 src/scraper/scrape_menu.py
- name: Get stack outputs # Deploy targets come from Parameter Store, written by Terraform
# (terraform/ssm.tf). They replace the CloudFormation stack outputs this
# job used to read; there is no CloudFormation stack any more.
- name: Get deploy parameters
if: env.SKIP_RUN != 'true' if: env.SKIP_RUN != 'true'
id: stack id: stack
run: | run: |
API_URL=$(aws cloudformation describe-stacks \ set -euo pipefail
--stack-name meal-order-manager \ get_param() {
--query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \ aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text
--output text) }
FORM_BUCKET=$(aws cloudformation describe-stacks \ API_URL=$(get_param /meal-order-manager/deploy/api-url)
--stack-name meal-order-manager \ FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket)
--query 'Stacks[0].Outputs[?OutputKey==`FormBucketName`].OutputValue' \ DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id)
--output text) FORM_URL=$(get_param /meal-order-manager/deploy/form-url)
DIST_ID=$(aws cloudformation describe-stacks \ for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do
--stack-name meal-order-manager \ if [ -z "$v" ] || [ "$v" = "None" ]; then
--query 'Stacks[0].Outputs[?OutputKey==`DistributionId`].OutputValue' \ echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2
--output text) exit 1
FORM_URL=$(aws cloudformation describe-stacks \ fi
--stack-name meal-order-manager \ done
--query 'Stacks[0].Outputs[?OutputKey==`FormUrl`].OutputValue' \ echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
--output text) echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT"
echo "api_url=$API_URL" >> $GITHUB_OUTPUT echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT"
echo "form_bucket=$FORM_BUCKET" >> $GITHUB_OUTPUT echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT"
echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
- name: Get discount settings - name: Get discount settings
if: env.SKIP_RUN != 'true' if: env.SKIP_RUN != 'true'

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ output/
.aws-sam/ .aws-sam/
samconfig.toml samconfig.toml
node_modules/ node_modules/
terraform/build/
.terraform/
*.tfvars

View file

@ -0,0 +1,39 @@
# github-meal-order-manager-layer-artifacts
**Not provisioned, and not currently needed.** Kept as the reference definition in case
S3-mediated layer artifacts are reintroduced.
Terraform now owns Lambda packaging. `terraform/artifacts.tf` runs
`terraform/build_packages.sh` during plan and carries the layer and function zips into the
plan as `content_base64`, uploading them to `meal-order-manager-artifacts-011934824531` at
apply time under the HCP Terraform workspace's own credentials. No GitHub Actions job
writes to that bucket, so `.github/workflows/build-layer.yml` holds no AWS credentials and
runs as build verification only.
Account and bucket references in `trust-policy.json` and `permissions-policy.json` are
updated to prod (`011934824531`) so the definition stays usable as-is.
## Scope, if it is ever created
An OIDC role for the `upload` job of `.github/workflows/build-layer.yml`, writing and
reading objects under the `layers/` prefix of one bucket and nothing else. The
`DenyEverythingElse` statement uses `NotAction` so any future Allow — added here or
inherited — cannot widen the role beyond those three S3 actions. `s3:ListBucket` is
required because `head-object` on a missing key returns 403 instead of 404 without it,
which would make the "already present" check indistinguishable from a permissions failure;
it is prefix-conditioned to `layers/*`.
## Trust
Pinned three ways: `sub` to `refs/heads/main`, `job_workflow_ref` to the build-layer
workflow file at main, and `aud` to `sts.amazonaws.com`. The `job_workflow_ref` pin is what
stops any other workflow in the repo — including a future one added by a PR — from
assuming it.
Deliberately **not** trusted for `pull_request`. A PR-triggered run executes the PR's own
copy of the workflow, so a credentialed PR job could overwrite an artifact that a later
apply publishes, without the PR ever merging.
Both mandatory gates (cross-family review and `/sh-security-review`) must pass on these
exact JSONs before the role lands in the `github-oidc-deploy-roles` stack. Repo secret
would be `AWS_LAYER_ARTIFACTS_ROLE_ARN`.

View file

@ -0,0 +1,26 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "LayerArtifactWrite",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::meal-order-manager-artifacts-011934824531/layers/*"
},
{
"Sid": "HeadObjectRequiresListBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::meal-order-manager-artifacts-011934824531",
"Condition": {
"StringLike": {"s3:prefix": "layers/*"}
}
},
{
"Sid": "DenyEverythingElse",
"Effect": "Deny",
"NotAction": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
"Resource": "*"
}
]
}

View file

@ -0,0 +1,19 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::011934824531:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:Sea-Haven-Industries/meal-order-manager:ref:refs/heads/main",
"token.actions.githubusercontent.com:job_workflow_ref": "Sea-Haven-Industries/meal-order-manager/.github/workflows/build-layer.yml@refs/heads/main"
}
}
}
]
}

74
terraform/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,74 @@
# 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.0"
constraints = "~> 2.0"
hashes = [
"h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=",
"h1:cMBtvdHEgvTmglbioVehZCaOIPocumu9+vlGw5Dsaro=",
"h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=",
"h1:oRWx6ZDIdlNBF90L8TzV9X7pQ+P403hoCDiBfmUfhC0=",
"zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2",
"zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f",
"zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a",
"zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea",
"zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997",
"zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0",
"zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940",
"zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa",
"zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368",
"zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4",
"zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d",
]
}
provider "registry.terraform.io/hashicorp/aws" {
version = "6.58.0"
constraints = "~> 6.57"
hashes = [
"h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=",
"h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=",
"h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=",
"h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=",
"zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250",
"zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f",
"zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48",
"zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba",
"zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7",
"zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56",
"zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6",
"zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80",
"zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75",
"zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a",
"zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c",
"zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae",
"zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca",
"zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056",
]
}
provider "registry.terraform.io/hashicorp/external" {
version = "2.4.0"
constraints = "~> 2.0"
hashes = [
"h1:AmY6ZeIvqoTT5ZjzD+P49PeQH6Va1QLMkX+7MUQfYoA=",
"zh:0772afb42b658468ac5e15df33bf2080456f8f0b8ab163bfe9c50d2b2ea02135",
"zh:0ac31a9aaa43dfcff5944b791596cdc94e153348e4bb4642282d034dff548134",
"zh:32d8492b1bdcc956ca3c6d00c6392d0a83942ff11d4820c7ee63ca6796e06950",
"zh:3c0482e894429f528ce6655a76ab0d8a9f7c0dacc6c828865e1515d4a7dbb852",
"zh:61e68100b4db2f930b31491f23c602126382fd5e51252be1b551f0e17f8ddbee",
"zh:6d60f615a0ad85eb962c9eb94f25e3eba7a72684ce276ba5dfb23f36b295a8f8",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:9ced2745eb5f1346203027d2dd7bf856ad1d279a25730ff7dbc6eec187aaca0c",
"zh:a8378558a177d43f55aa0d79d4fae91a704695122a1b109668c1daa8fb76f09d",
"zh:aadd98086133d3ebea67437d56512fdcc6dfb3bd34dfc23f276c0db9272e27b4",
"zh:beff701b653841e70441978137768f54e7dc6c27e7bf12a4589087f01f5bbcee",
"zh:c91c2223b29fdbc0044d20e1936ccc051d010727a13f2ff1e75e51f09bff33a3",
"zh:d491f9c2d32a39dc4031628469ae7c8aec0074312a7c1f0286b173cdcf854a54",
]
}

20
terraform/acm.tf Normal file
View file

@ -0,0 +1,20 @@
# ACM certificate for the order form's custom domain.
#
# The certificate is an out-of-band bootstrap dependency and is deliberately NOT
# created here. It was requested in the prod account ahead of this configuration
# (arn:aws:acm:us-east-1:011934824531:certificate/4edac16c-0e19-4307-a34a-f6da257ccda3)
# and validated by DNS. Declaring an aws_acm_certificate resource as well would
# request a second certificate for the same domain on the first apply, so this
# configuration only reads the issued one.
#
# Bootstrap order, if the domain is ever rebuilt from nothing:
# 1. aws acm request-certificate --domain-name orders.seahaven.com \
# --validation-method DNS --region us-east-1
# 2. Publish the CNAME validation record and wait for status ISSUED.
# 3. Run terraform apply. Until step 2 completes, this data source finds no
# ISSUED certificate and the plan fails closed.
data "aws_acm_certificate" "orders" {
domain = var.domain_name
statuses = ["ISSUED"]
most_recent = true
}

222
terraform/alarms.tf Normal file
View file

@ -0,0 +1,222 @@
# CloudWatch alarms. All notify the shared site-alerts topic. No OK actions (no
# recovery spam), and treat_missing_data = notBreaching so cron functions do not
# sit in ALARM between invocations.
#
# Duration alarms use the p99 extended statistic at ~80% of each function's
# timeout. API-fronted functions evaluate 3 datapoints; cron and async-invoked
# functions evaluate one, because they fire too rarely to fill a longer window.
#
# DynamoDB note: the table does not publish ThrottledRequests or SystemErrors at
# the TableName-only dimension, so no alarm on those would ever evaluate.
# ReadThrottleEvents and WriteThrottleEvents do carry TableName and are used
# here for throttle coverage.
locals {
alarm_functions = {
"submit-order" = {
function_name = aws_lambda_function.submit_order.function_name
duration_threshold = 8000
duration_timeout = "10s"
duration_datapoints = 3
}
"admin-authorizer" = {
function_name = aws_lambda_function.admin_authorizer.function_name
duration_threshold = 8000
duration_timeout = "10s"
duration_datapoints = 3
}
"close-form" = {
function_name = aws_lambda_function.close_form.function_name
duration_threshold = 24000
duration_timeout = "30s"
duration_datapoints = 1
}
"aggregate-orders" = {
function_name = aws_lambda_function.aggregate_orders.function_name
duration_threshold = 48000
duration_timeout = "60s"
duration_datapoints = 1
}
"slack-notifier" = {
function_name = aws_lambda_function.slack_notifier.function_name
duration_threshold = 24000
duration_timeout = "30s"
duration_datapoints = 1
}
"sync-roster" = {
function_name = aws_lambda_function.sync_roster.function_name
duration_threshold = 48000
duration_timeout = "60s"
duration_datapoints = 1
}
"email-report" = {
function_name = aws_lambda_function.email_report.function_name
duration_threshold = 24000
duration_timeout = "30s"
duration_datapoints = 1
}
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
for_each = local.alarm_functions
alarm_name = "${local.project}-${each.key}-errors"
alarm_description = "${each.key} Lambda reported one or more errors in 5 minutes."
namespace = "AWS/Lambda"
metric_name = "Errors"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
FunctionName = each.value.function_name
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_throttles" {
for_each = local.alarm_functions
alarm_name = "${local.project}-${each.key}-throttles"
alarm_description = "${each.key} Lambda was throttled in the last 5 minutes."
namespace = "AWS/Lambda"
metric_name = "Throttles"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
FunctionName = each.value.function_name
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
for_each = local.alarm_functions
alarm_name = "${local.project}-${each.key}-duration"
alarm_description = "${each.key} p99 duration exceeded ${each.value.duration_threshold}ms (80% of its ${each.value.duration_timeout} timeout)."
namespace = "AWS/Lambda"
metric_name = "Duration"
extended_statistic = "p99"
period = 300
evaluation_periods = each.value.duration_datapoints
datapoints_to_alarm = each.value.duration_datapoints
threshold = each.value.duration_threshold
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
FunctionName = each.value.function_name
}
}
# ---------------------------------------------------------------------------
# DynamoDB
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
alarm_name = "${local.project}-orders-read-throttle"
alarm_description = "orders table read requests were throttled in the last 5 minutes."
namespace = "AWS/DynamoDB"
metric_name = "ReadThrottleEvents"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
TableName = aws_dynamodb_table.orders.name
}
}
resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
alarm_name = "${local.project}-orders-write-throttle"
alarm_description = "orders table write requests were throttled in the last 5 minutes."
namespace = "AWS/DynamoDB"
metric_name = "WriteThrottleEvents"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
TableName = aws_dynamodb_table.orders.name
}
}
# ---------------------------------------------------------------------------
# HTTP API
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
alarm_name = "${local.project}-order-api-5xx"
alarm_description = "OrderApi returned one or more 5xx responses in 5 minutes."
namespace = "AWS/ApiGateway"
metric_name = "5xx"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
}
}
# Threshold raised and 2-of-3 datapoints, to absorb the routine 401s the
# token-based admin authorizer produces without paging.
resource "aws_cloudwatch_metric_alarm" "api_4xx" {
alarm_name = "${local.project}-order-api-4xx"
alarm_description = "OrderApi 4xx responses exceeded 20 in 5 minutes (beyond routine auth noise)."
namespace = "AWS/ApiGateway"
metric_name = "4xx"
statistic = "Sum"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = 20
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
}
}
resource "aws_cloudwatch_metric_alarm" "api_latency" {
alarm_name = "${local.project}-order-api-latency"
alarm_description = "OrderApi p99 latency exceeded 3000ms."
namespace = "AWS/ApiGateway"
metric_name = "Latency"
extended_statistic = "p99"
period = 300
evaluation_periods = 3
datapoints_to_alarm = 3
threshold = 3000
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
}
}

105
terraform/apigateway.tf Normal file
View file

@ -0,0 +1,105 @@
# HTTP API fronting the order form.
#
# Three authorization modes coexist, matching template.yaml:
# NONE — the public form routes (submit-order, form-status, roster)
# AWS_IAM — the weekly-menu publication routes, called with SigV4 by
# scripts/upload_menu.py from GitHub Actions
# CUSTOM — every /api/admin route, behind the Google ID token authorizer
#
# All nine routes integrate with submit-order, which dispatches internally on
# the route key.
resource "aws_apigatewayv2_api" "order_api" {
name = local.project
protocol_type = "HTTP"
description = "meal-order-manager order form and admin API"
# Only the production origin. Local development uses the Flask dev server in
# app.py, which proxies API calls and does not enforce CORS.
cors_configuration {
allow_origins = [local.form_url]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization"]
max_age = 3600
}
}
# Result caching is off. With caching, an expired Google token or an admin
# removed from the allow-list would stay authorized for the cache TTL, and the
# tokeninfo call dominates latency anyway.
resource "aws_apigatewayv2_authorizer" "admin_google" {
api_id = aws_apigatewayv2_api.order_api.id
name = "AdminGoogleAuthorizer"
authorizer_type = "REQUEST"
authorizer_uri = aws_lambda_function.admin_authorizer.invoke_arn
authorizer_credentials_arn = aws_iam_role.admin_authorizer_invoke.arn
authorizer_payload_format_version = "2.0"
authorizer_result_ttl_in_seconds = 0
enable_simple_responses = true
identity_sources = ["$request.header.Authorization"]
}
resource "aws_apigatewayv2_integration" "submit_order" {
api_id = aws_apigatewayv2_api.order_api.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.submit_order.invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 30000
}
resource "aws_apigatewayv2_route" "this" {
for_each = local.api_routes
api_id = aws_apigatewayv2_api.order_api.id
route_key = each.value.route_key
target = "integrations/${aws_apigatewayv2_integration.submit_order.id}"
authorization_type = each.value.authorizer
authorizer_id = each.value.authorizer == "CUSTOM" ? aws_apigatewayv2_authorizer.admin_google.id : null
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.order_api.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
}
# Order submission is human-paced; menu publication runs once a week. Both are
# throttled well below the account default so a loop in either client cannot
# exhaust the API's burst budget for the public form.
route_settings {
route_key = "POST /api/submit-order"
throttling_burst_limit = 10
throttling_rate_limit = 5
}
route_settings {
route_key = "POST /api/publish/menu"
throttling_burst_limit = 2
throttling_rate_limit = 1
}
depends_on = [aws_apigatewayv2_route.this]
}
# One grant per route rather than a single wildcard, so adding a route to the
# API does not silently make the function invocable through it.
resource "aws_lambda_permission" "api_route" {
for_each = local.api_routes
statement_id = "AllowApiGatewayInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.submit_order.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.order_api.execution_arn}/*/${each.value.permission_source}"
}

130
terraform/artifacts.tf Normal file
View file

@ -0,0 +1,130 @@
# Lambda packaging.
#
# HCP plan and apply run on separate workers, so a zip written during plan is
# not on disk at apply time. The bytes are therefore carried inside the plan as
# content_base64 on aws_s3_object and uploaded at apply, and the functions and
# layer read from S3 rather than from a local file.
#
# The build itself runs during plan through an external data source:
# local-exec provisioners only run on apply, and archive_file needs build/ to
# already exist when the plan is computed.
#
# build_packages.sh deletes boto3, botocore, s3transfer, jmespath and urllib3
# from the layer after pip install. The Python 3.12 runtime ships boto3, and
# leaving it in the layer would push the base64-encoded plan payload into the
# tens of megabytes.
data "external" "package_build" {
program = ["bash", "${path.module}/build_packages_external.sh"]
}
resource "aws_s3_bucket" "artifacts" {
bucket = local.artifacts_bucket_name
tags = {
Purpose = "Lambda deployment packages for meal-order-manager"
}
}
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"
}
}
# Superseded package versions are only useful for a manual rollback, and the
# function/layer resources always point at the current object.
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]
}
# ---------------------------------------------------------------------------
# Packages
# ---------------------------------------------------------------------------
data "archive_file" "shared_layer" {
type = "zip"
source_dir = "${path.module}/build/layer"
output_path = "${path.module}/build/packages/shared-layer.zip"
depends_on = [data.external.package_build]
}
data "archive_file" "function" {
for_each = local.function_packages
type = "zip"
source_dir = "${path.module}/build/functions/${each.key}"
output_path = "${path.module}/build/packages/${each.key}.zip"
depends_on = [data.external.package_build]
}
resource "aws_s3_object" "shared_layer" {
bucket = aws_s3_bucket.artifacts.id
key = "layers/meal-order-manager-shared.zip"
content_base64 = filebase64(data.archive_file.shared_layer.output_path)
source_hash = data.archive_file.shared_layer.output_base64sha256
}
resource "aws_s3_object" "function" {
for_each = local.function_packages
bucket = aws_s3_bucket.artifacts.id
key = "functions/${each.key}.zip"
content_base64 = filebase64(data.archive_file.function[each.key].output_path)
source_hash = data.archive_file.function[each.key].output_base64sha256
}

108
terraform/build_packages.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Package the shared layer and every function zip for HCP plan/apply.
# Runs on the Terraform worker during plan (see artifacts.tf).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
BUILD="${ROOT}/build"
REPO="$(cd "${ROOT}/.." && pwd)"
FUNCS="${REPO}/functions"
SHARED="${REPO}/src/shared"
FUNCTIONS=(
admin_authorizer
aggregate_orders
close_form
email_report
slack_notifier
submit_order
sync_roster
)
# Copy a regular file, refusing symlinks and any path that resolves outside the
# expected tree.
copy_file() {
local src_path="$1"
local dest="$2"
local base="$3"
if [[ -L "${src_path}" ]]; then
echo "error: refusing symlink source: ${src_path}" >&2
exit 1
fi
if [[ ! -f "${src_path}" ]]; then
echo "error: missing regular file: ${src_path}" >&2
exit 1
fi
local resolved
resolved="$(cd "$(dirname "${src_path}")" && pwd)/$(basename "${src_path}")"
case "${resolved}" in
"${base}"/*) ;;
*)
echo "error: path escapes ${base}: ${resolved}" >&2
exit 1
;;
esac
mkdir -p "$(dirname "${dest}")"
# -P: never follow symlinks if the destination path is replaced mid-run.
cp -P "${src_path}" "${dest}"
}
# Shipped by the python3.12 Lambda runtime. Keeping them in the layer adds tens
# of megabytes to the base64-encoded plan payload for no runtime benefit.
RUNTIME_PROVIDED=(
boto3
botocore
jmespath
s3transfer
urllib3
)
rm -rf "${BUILD}"
mkdir -p "${BUILD}/layer/python" "${BUILD}/packages"
# ---------------------------------------------------------------------------
# Shared layer: pip dependencies + the `shared` package.
#
# Wheels must match the Lambda target (python3.12 / arm64), not the worker.
# --only-binary=:all: makes a source-only package fail loudly here rather than
# silently shipping a wheel built for the wrong platform.
# ---------------------------------------------------------------------------
python3 -m pip install \
--quiet \
--disable-pip-version-check \
-r "${SHARED}/requirements.txt" \
-t "${BUILD}/layer/python" \
--platform manylinux2014_aarch64 \
--implementation cp \
--python-version 3.12 \
--only-binary=:all: \
--upgrade
# boto3 is pinned in src/shared/requirements.txt so local development and the
# test suite resolve a known version, but it must not ship in the layer: the
# runtime already provides it. Drop each package and its metadata after the
# install rather than removing the pin.
for pkg in "${RUNTIME_PROVIDED[@]}"; do
rm -rf "${BUILD}/layer/python/${pkg}"
find "${BUILD}/layer/python" -maxdepth 1 \
\( -name "${pkg}-*.dist-info" -o -name "${pkg}-*.egg-info" \) \
-prune -exec rm -rf {} +
done
cp -RP "${SHARED}/shared" "${BUILD}/layer/python/shared"
# ---------------------------------------------------------------------------
# Function packages: handler only. boto3 and fpdf2 come from the shared layer,
# so functions/*/requirements.txt is not part of the deployment artifact.
# ---------------------------------------------------------------------------
for fn in "${FUNCTIONS[@]}"; do
mkdir -p "${BUILD}/functions/${fn}"
copy_file "${FUNCS}/${fn}/handler.py" "${BUILD}/functions/${fn}/handler.py" "${FUNCS}"
done
# Byte-compiled caches would make the zip hash unstable across workers.
find "${BUILD}" -name '__pycache__' -type d -prune -exec rm -rf {} +
find "${BUILD}" -name '*.pyc' -type f -delete

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Terraform external data source entrypoint. Stdout must be JSON only.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
"${ROOT}/build_packages.sh" >&2
# sha256sum on Linux workers, shasum on macOS.
if command -v sha256sum >/dev/null 2>&1; then
SHA=(sha256sum)
else
SHA=(shasum -a 256)
fi
hash="$(
{
# -P: do not follow symlinks; only hash regular files under build/.
find -P "${ROOT}/build" -type f -print0 2>/dev/null \
| sort -z \
| xargs -0 "${SHA[@]}"
} | "${SHA[@]}" | awk '{print $1}'
)"
printf '{"status":"ok","hash":"%s"}\n' "${hash}"

64
terraform/cloudfront.tf Normal file
View file

@ -0,0 +1,64 @@
resource "aws_cloudfront_origin_access_control" "form" {
name = "${local.project}-oac"
description = "OAC for the meal-order-manager form origin bucket"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "form" {
enabled = true
is_ipv6_enabled = true
http_version = "http2and3"
comment = "meal-order-manager form hosting"
default_root_object = "index.html"
price_class = "PriceClass_100"
aliases = [var.domain_name]
# Shared org CloudFront WAF (audit M-17), resolved from Parameter Store.
web_acl_id = data.aws_ssm_parameter.app_web_acl_arn.value
origin {
origin_id = "S3FormOrigin"
domain_name = aws_s3_bucket.form.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.form.id
}
default_cache_behavior {
target_origin_id = "S3FormOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
# AWS managed policy: CachingDisabled. The form HTML is republished weekly
# and read through a signed API, so a stale edge copy is worse than an
# origin fetch.
cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
}
# A request for an object the private origin does not hold returns 403, not
# 404. Rewriting it to the form keeps deep links working, matching the SAM
# template.
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = data.aws_acm_certificate.orders.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
lifecycle {
prevent_destroy = true
}
}

32
terraform/data.tf Normal file
View file

@ -0,0 +1,32 @@
data "aws_caller_identity" "current" {}
# Resource names in locals.tf embed the account ID. If the workspace is ever
# pointed at another account, fail the plan here rather than creating a parallel
# set of oddly-named resources somewhere else.
check "correct_account" {
assert {
condition = data.aws_caller_identity.current.account_id == local.account_id
error_message = "This configuration targets account ${local.account_id}, but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
}
}
# Alarm sink owned by seahaven-org-baseline, not by this configuration.
data "aws_sns_topic" "site_alerts" {
name = "site-alerts"
}
# Shared CloudFront WAF WebACL (audit M-17), published to Parameter Store by the
# org baseline. aws_cloudfront_distribution.web_acl_id takes the WAFv2 ARN
# despite the attribute name.
data "aws_ssm_parameter" "app_web_acl_arn" {
name = "/seahaven/waf/app-web-acl-arn"
}
# Google OAuth client ID used by submit-order and the admin authorizer. The
# parameter is created and rotated out-of-band because it varies per
# environment; this lookup only asserts that it exists before an apply wires
# functions that read it at runtime. Its NAME, not its value, is what reaches
# the functions.
data "aws_ssm_parameter" "google_client_id" {
name = local.google_client_id_param
}

35
terraform/dynamodb.tf Normal file
View file

@ -0,0 +1,35 @@
# Single-table store for orders, roster entries and weekly settings.
#
# Deletion protection and point-in-time recovery are both on: this table holds
# the only copy of submitted orders, and a rebuild would lose payroll history.
resource "aws_dynamodb_table" "orders" {
name = local.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK"
range_key = "SK"
deletion_protection_enabled = true
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
ttl {
attribute_name = "ttl"
enabled = true
}
point_in_time_recovery {
enabled = true
}
lifecycle {
prevent_destroy = true
}
}

99
terraform/events.tf Normal file
View file

@ -0,0 +1,99 @@
# EventBridge schedules.
#
# Every schedule is an EST/EDT pair firing the same function one hour apart in
# UTC: EventBridge cron has no timezone. Both fire year-round and the handlers
# are idempotent, so the run that lands in the wrong offset is a harmless no-op.
# Do not "deduplicate" a pair.
#
# Rule names are stable and hand-chosen (the retired SAM stack used generated
# physical IDs). They are load-bearing: each aws_lambda_permission grants
# events.amazonaws.com on the matching rule ARN.
locals {
schedules = {
"close-form-est" = {
description = "Close form Thursday 11:59pm EST (04:59 UTC Friday)"
schedule = "cron(59 4 ? * FRI *)"
function_arn = aws_lambda_function.close_form.arn
function_name = aws_lambda_function.close_form.function_name
input = null
}
"close-form-edt" = {
description = "Close form Thursday 11:59pm EDT (03:59 UTC Friday)"
schedule = "cron(59 3 ? * FRI *)"
function_arn = aws_lambda_function.close_form.arn
function_name = aws_lambda_function.close_form.function_name
input = null
}
"reminder-est" = {
description = "DM reminders Thursday 10am EST (15:00 UTC)"
schedule = "cron(0 15 ? * THU *)"
function_arn = aws_lambda_function.slack_notifier.arn
function_name = aws_lambda_function.slack_notifier.function_name
input = "{\"event\": \"reminder\"}"
}
"reminder-edt" = {
description = "DM reminders Thursday 10am EDT (14:00 UTC)"
schedule = "cron(0 14 ? * THU *)"
function_arn = aws_lambda_function.slack_notifier.arn
function_name = aws_lambda_function.slack_notifier.function_name
input = "{\"event\": \"reminder\"}"
}
"sync-roster-est" = {
description = "Sync roster Monday 6:55am EST (11:55 UTC), before menu publish"
schedule = "cron(55 11 ? * MON *)"
function_arn = aws_lambda_function.sync_roster.arn
function_name = aws_lambda_function.sync_roster.function_name
input = null
}
"sync-roster-edt" = {
description = "Sync roster Monday 6:55am EDT (10:55 UTC), before menu publish"
schedule = "cron(55 10 ? * MON *)"
function_arn = aws_lambda_function.sync_roster.arn
function_name = aws_lambda_function.sync_roster.function_name
input = null
}
"payroll-email-est" = {
description = "Email payroll deductions Monday 7am EST (12:00 UTC)"
schedule = "cron(0 12 ? * MON *)"
function_arn = aws_lambda_function.email_report.arn
function_name = aws_lambda_function.email_report.function_name
input = null
}
"payroll-email-edt" = {
description = "Email payroll deductions Monday 7am EDT (11:00 UTC)"
schedule = "cron(0 11 ? * MON *)"
function_arn = aws_lambda_function.email_report.arn
function_name = aws_lambda_function.email_report.function_name
input = null
}
}
}
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 = "ENABLED"
}
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 = each.value.function_arn
input = each.value.input
}
resource "aws_lambda_permission" "schedule" {
for_each = local.schedules
statement_id = "AllowEventBridgeInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = each.value.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn
}

406
terraform/iam.tf Normal file
View file

@ -0,0 +1,406 @@
# Execution roles for the seven Lambda functions plus the API Gateway role that
# invokes the admin authorizer.
#
# Every role is created under the /tf-managed/ path and carries the account's
# seahaven-lambda-execution-boundary permissions boundary. The path is what
# distinguishes Terraform-owned roles from the /cfn-managed/ roles the retired
# SAM stack created.
#
# The inline policies below are hand-expanded from the SAM policy templates in
# template.yaml (DynamoDBCrudPolicy, DynamoDBReadPolicy, S3CrudPolicy,
# S3ReadPolicy). Two deliberate narrowings from the SAM expansions:
# - s3:PutObjectAcl is omitted. The reports bucket enforces
# BucketOwnerEnforced ownership, so ACL writes fail regardless.
# - s3:GetLifecycleConfiguration / s3:PutLifecycleConfiguration are omitted.
# Bucket lifecycle is owned by this configuration, not by function code.
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
# ---------------------------------------------------------------------------
# Reusable policy documents
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "dynamodb_crud" {
statement {
sid = "OrdersTableCrud"
effect = "Allow"
actions = [
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:UpdateItem",
]
resources = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*",
]
}
}
data "aws_iam_policy_document" "dynamodb_read" {
statement {
sid = "OrdersTableRead"
effect = "Allow"
actions = [
"dynamodb:BatchGetItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:Scan",
]
resources = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*",
]
}
}
data "aws_iam_policy_document" "ssm_read" {
statement {
sid = "ReadProjectParameters"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [local.ssm_parameter_arn_wildcard]
}
}
# The SAM template granted secretsmanager:GetSecretValue on
# `secret:meal-order-manager/*`. Scoped here to the one secret the code actually
# reads, supplied as an ARN so the grant cannot drift onto a future secret that
# happens to share the prefix.
data "aws_iam_policy_document" "slack_bot_secret_read" {
statement {
sid = "ReadSlackBotToken"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [var.slack_bot_secret_arn]
}
}
# ---------------------------------------------------------------------------
# submit-order
# ---------------------------------------------------------------------------
resource "aws_iam_role" "submit_order" {
name = "${local.project}-submit-order"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "submit_order_basic" {
role = aws_iam_role.submit_order.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "submit_order" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_crud.json,
data.aws_iam_policy_document.ssm_read.json,
]
statement {
sid = "InvokeSlackNotifier"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.slack_notifier.arn]
}
# Read-only access to the weekly summary PDFs only — not the payroll or order
# CSVs — for the admin summary-pdf presigned-URL endpoint.
statement {
sid = "ReadWeeklySummaryPdfs"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.reports.arn}/reports/*/weekly-summary-*.pdf"]
}
}
resource "aws_iam_role_policy" "submit_order" {
name = "submit-order"
role = aws_iam_role.submit_order.id
policy = data.aws_iam_policy_document.submit_order.json
}
# ---------------------------------------------------------------------------
# admin-authorizer
# ---------------------------------------------------------------------------
resource "aws_iam_role" "admin_authorizer" {
name = "${local.project}-admin-authorizer"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "admin_authorizer_basic" {
role = aws_iam_role.admin_authorizer.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "admin_authorizer" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_read.json,
data.aws_iam_policy_document.ssm_read.json,
]
}
resource "aws_iam_role_policy" "admin_authorizer" {
name = "admin-authorizer"
role = aws_iam_role.admin_authorizer.id
policy = data.aws_iam_policy_document.admin_authorizer.json
}
# Role API Gateway assumes to invoke the authorizer Lambda. The authorizer has
# no resource policy of its own; this identity-based grant is the only path.
data "aws_iam_policy_document" "apigateway_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["apigateway.amazonaws.com"]
}
}
}
resource "aws_iam_role" "admin_authorizer_invoke" {
name = "${local.project}-admin-authorizer-invoke"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.apigateway_assume.json
permissions_boundary = local.boundary_arn
}
data "aws_iam_policy_document" "admin_authorizer_invoke" {
statement {
sid = "InvokeAdminAuthorizer"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.admin_authorizer.arn]
}
}
resource "aws_iam_role_policy" "admin_authorizer_invoke" {
name = "invoke-admin-authorizer"
role = aws_iam_role.admin_authorizer_invoke.id
policy = data.aws_iam_policy_document.admin_authorizer_invoke.json
}
# ---------------------------------------------------------------------------
# close-form
# ---------------------------------------------------------------------------
resource "aws_iam_role" "close_form" {
name = "${local.project}-close-form"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "close_form_basic" {
role = aws_iam_role.close_form.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "close_form" {
source_policy_documents = [data.aws_iam_policy_document.dynamodb_crud.json]
statement {
sid = "InvokeAggregateOrders"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.aggregate_orders.arn]
}
}
resource "aws_iam_role_policy" "close_form" {
name = "close-form"
role = aws_iam_role.close_form.id
policy = data.aws_iam_policy_document.close_form.json
}
# ---------------------------------------------------------------------------
# aggregate-orders
# ---------------------------------------------------------------------------
resource "aws_iam_role" "aggregate_orders" {
name = "${local.project}-aggregate-orders"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "aggregate_orders_basic" {
role = aws_iam_role.aggregate_orders.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "aggregate_orders" {
source_policy_documents = [data.aws_iam_policy_document.dynamodb_crud.json]
statement {
sid = "ReportsBucketCrud"
effect = "Allow"
actions = [
"s3:DeleteObject",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
]
resources = ["${aws_s3_bucket.reports.arn}/*"]
}
statement {
sid = "ReportsBucketList"
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = [aws_s3_bucket.reports.arn]
}
statement {
sid = "InvokeSlackNotifier"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.slack_notifier.arn]
}
}
resource "aws_iam_role_policy" "aggregate_orders" {
name = "aggregate-orders"
role = aws_iam_role.aggregate_orders.id
policy = data.aws_iam_policy_document.aggregate_orders.json
}
# ---------------------------------------------------------------------------
# slack-notifier
# ---------------------------------------------------------------------------
resource "aws_iam_role" "slack_notifier" {
name = "${local.project}-slack-notifier"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "slack_notifier_basic" {
role = aws_iam_role.slack_notifier.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "slack_notifier" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_read.json,
data.aws_iam_policy_document.ssm_read.json,
data.aws_iam_policy_document.slack_bot_secret_read.json,
]
}
resource "aws_iam_role_policy" "slack_notifier" {
name = "slack-notifier"
role = aws_iam_role.slack_notifier.id
policy = data.aws_iam_policy_document.slack_notifier.json
}
# ---------------------------------------------------------------------------
# sync-roster
# ---------------------------------------------------------------------------
resource "aws_iam_role" "sync_roster" {
name = "${local.project}-sync-roster"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "sync_roster_basic" {
role = aws_iam_role.sync_roster.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "sync_roster" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_crud.json,
data.aws_iam_policy_document.ssm_read.json,
data.aws_iam_policy_document.slack_bot_secret_read.json,
]
}
resource "aws_iam_role_policy" "sync_roster" {
name = "sync-roster"
role = aws_iam_role.sync_roster.id
policy = data.aws_iam_policy_document.sync_roster.json
}
# ---------------------------------------------------------------------------
# email-report
# ---------------------------------------------------------------------------
resource "aws_iam_role" "email_report" {
name = "${local.project}-email-report"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "email_report_basic" {
role = aws_iam_role.email_report.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "email_report" {
source_policy_documents = [data.aws_iam_policy_document.dynamodb_read.json]
statement {
sid = "ReportsBucketRead"
effect = "Allow"
actions = ["s3:GetObject", "s3:GetObjectVersion"]
resources = ["${aws_s3_bucket.reports.arn}/*"]
}
statement {
sid = "ReportsBucketList"
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = [aws_s3_bucket.reports.arn]
}
# SES does not support resource-level permissions for SendRawEmail; the
# sender identity is enforced by SES verification, not IAM. Matches
# template.yaml.
statement {
sid = "SendPayrollReport"
effect = "Allow"
actions = ["ses:SendRawEmail"]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "email_report" {
name = "email-report"
role = aws_iam_role.email_report.id
policy = data.aws_iam_policy_document.email_report.json
}

239
terraform/lambda.tf Normal file
View file

@ -0,0 +1,239 @@
# The shared layer and the seven functions.
#
# Packages come from the artifacts bucket (see artifacts.tf). Function packages
# contain the handler only: boto3 comes from the runtime, and fpdf2 plus the
# `shared` package come from the layer.
resource "aws_lambda_layer_version" "shared" {
layer_name = "${local.project}-shared"
description = "fpdf2 and the shared package for meal-order-manager"
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.shared_layer.key
source_code_hash = data.archive_file.shared_layer.output_base64sha256
compatible_runtimes = ["python3.12"]
compatible_architectures = ["arm64"]
}
# ---------------------------------------------------------------------------
# submit-order — HTTP API handler for the order form and the admin surface
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "submit_order" {
function_name = "${local.project}-submit-order"
role = aws_iam_role.submit_order.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 128
timeout = 10
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["submit_order"].key
source_code_hash = data.archive_file.function["submit_order"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = merge(local.common_env, {
SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
})
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.submit_order,
aws_iam_role_policy_attachment.submit_order_basic,
]
}
# ---------------------------------------------------------------------------
# admin-authorizer — validates the Google ID token for every /api/admin route
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "admin_authorizer" {
function_name = "${local.project}-admin-authorizer"
role = aws_iam_role.admin_authorizer.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 128
timeout = 10
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["admin_authorizer"].key
source_code_hash = data.archive_file.function["admin_authorizer"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = merge(local.common_env, {
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
})
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.admin_authorizer,
aws_iam_role_policy_attachment.admin_authorizer_basic,
]
}
# ---------------------------------------------------------------------------
# close-form — closes the weekly order window, then fans out to aggregation
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "close_form" {
function_name = "${local.project}-close-form"
role = aws_iam_role.close_form.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 128
timeout = 30
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["close_form"].key
source_code_hash = data.archive_file.function["close_form"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = merge(local.common_env, {
AGGREGATE_FUNCTION_ARN = aws_lambda_function.aggregate_orders.arn
})
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.close_form,
aws_iam_role_policy_attachment.close_form_basic,
]
}
# ---------------------------------------------------------------------------
# aggregate-orders — rolls the week's orders into CSV and PDF artifacts
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "aggregate_orders" {
function_name = "${local.project}-aggregate-orders"
role = aws_iam_role.aggregate_orders.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 256
timeout = 60
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["aggregate_orders"].key
source_code_hash = data.archive_file.function["aggregate_orders"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = merge(local.common_env, {
SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn
})
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.aggregate_orders,
aws_iam_role_policy_attachment.aggregate_orders_basic,
]
}
# ---------------------------------------------------------------------------
# slack-notifier — reminders and summaries into Slack
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "slack_notifier" {
function_name = "${local.project}-slack-notifier"
role = aws_iam_role.slack_notifier.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 128
timeout = 30
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["slack_notifier"].key
source_code_hash = data.archive_file.function["slack_notifier"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = local.common_env
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.slack_notifier,
aws_iam_role_policy_attachment.slack_notifier_basic,
]
}
# ---------------------------------------------------------------------------
# sync-roster — pulls the employee roster from Slack ahead of the menu publish
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "sync_roster" {
function_name = "${local.project}-sync-roster"
role = aws_iam_role.sync_roster.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 128
timeout = 60
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["sync_roster"].key
source_code_hash = data.archive_file.function["sync_roster"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = local.common_env
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.sync_roster,
aws_iam_role_policy_attachment.sync_roster_basic,
]
}
# ---------------------------------------------------------------------------
# email-report — emails the weekly payroll deduction report
# ---------------------------------------------------------------------------
resource "aws_lambda_function" "email_report" {
function_name = "${local.project}-email-report"
role = aws_iam_role.email_report.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
architectures = ["arm64"]
memory_size = 128
timeout = 30
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["email_report"].key
source_code_hash = data.archive_file.function["email_report"].output_base64sha256
layers = [aws_lambda_layer_version.shared.arn]
environment {
variables = merge(local.common_env, {
PAYROLL_EMAIL = var.payroll_email
SENDER_EMAIL = var.sender_email
})
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.email_report,
aws_iam_role_policy_attachment.email_report_basic,
]
}

100
terraform/locals.tf Normal file
View file

@ -0,0 +1,100 @@
locals {
project = "meal-order-manager"
account_id = "011934824531"
# Every execution role in this configuration is created under /tf-managed/ and
# carries the account's Lambda execution boundary.
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary"
form_bucket_name = "${local.project}-form-${local.account_id}"
reports_bucket_name = "${local.project}-reports-${local.account_id}"
artifacts_bucket_name = "${local.project}-artifacts-${local.account_id}"
table_name = "${local.project}-orders"
form_url = "https://${var.domain_name}"
ssm_prefix = "/${local.project}"
slack_channel_param = "${local.ssm_prefix}/slack-channel-id"
# google-client-id is created and rotated out-of-band. Terraform reads it
# (data.tf) but never owns it.
google_client_id_param = "${local.ssm_prefix}/google-client-id"
# shared/slack.py resolves the token by NAME, while the IAM grant is scoped to
# the ARN in var.slack_bot_secret_arn. Both must refer to the same secret.
slack_bot_secret_name = "${local.project}/slack-bot-token"
ssm_parameter_arn_wildcard = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*"
# Directory names under functions/, which build_packages.sh mirrors into
# terraform/build/functions/.
function_packages = toset([
"admin_authorizer",
"aggregate_orders",
"close_form",
"email_report",
"slack_notifier",
"submit_order",
"sync_roster",
])
# SAM Globals.Function.Environment.Variables — every function receives these.
common_env = {
TABLE_NAME = local.table_name
REPORTS_BUCKET = local.reports_bucket_name
SLACK_CHANNEL_PARAM = local.slack_channel_param
FORM_URL = local.form_url
SLACK_BOT_SM_NAME = local.slack_bot_secret_name
}
# HTTP API routes, all integrated with submit-order. `authorizer` selects the
# authorization mode; `permission_source` is the method/path suffix of the
# per-route lambda:InvokeFunction grant (path parameters become `*`).
api_routes = {
submit_order = {
route_key = "POST /api/submit-order"
authorizer = "NONE"
permission_source = "POST/api/submit-order"
}
form_status = {
route_key = "GET /api/form-status/{week}"
authorizer = "NONE"
permission_source = "GET/api/form-status/*"
}
roster = {
route_key = "GET /api/roster"
authorizer = "NONE"
permission_source = "GET/api/roster"
}
publish_settings = {
route_key = "GET /api/publish/settings"
authorizer = "AWS_IAM"
permission_source = "GET/api/publish/settings"
}
publish_menu = {
route_key = "POST /api/publish/menu"
authorizer = "AWS_IAM"
permission_source = "POST/api/publish/menu"
}
admin_orders_get = {
route_key = "GET /api/admin/orders"
authorizer = "CUSTOM"
permission_source = "GET/api/admin/orders"
}
admin_orders_put = {
route_key = "PUT /api/admin/orders"
authorizer = "CUSTOM"
permission_source = "PUT/api/admin/orders"
}
admin_orders_delete = {
route_key = "DELETE /api/admin/orders"
authorizer = "CUSTOM"
permission_source = "DELETE/api/admin/orders"
}
admin_summary_pdf = {
route_key = "GET /api/admin/summary-pdf"
authorizer = "CUSTOM"
permission_source = "GET/api/admin/summary-pdf"
}
}
}

17
terraform/logs.tf Normal file
View file

@ -0,0 +1,17 @@
# Log groups are created explicitly rather than left to Lambda's implicit
# on-first-invoke creation, so retention is enforced from the start. Each name
# matches the runtime default (/aws/lambda/<function-name>), and every function
# depends on its group.
resource "aws_cloudwatch_log_group" "function" {
for_each = local.function_packages
name = "/aws/lambda/${local.project}-${replace(each.key, "_", "-")}"
retention_in_days = 60
}
# Longer than the Lambda groups on purpose, for request-level forensics.
resource "aws_cloudwatch_log_group" "api_access" {
name = "/aws/apigateway/${local.project}"
retention_in_days = 90
}

57
terraform/outputs.tf Normal file
View file

@ -0,0 +1,57 @@
output "api_url" {
description = "HTTP API endpoint URL."
value = aws_apigatewayv2_api.order_api.api_endpoint
}
output "form_url" {
description = "Public order form URL."
value = local.form_url
}
output "distribution_id" {
description = "CloudFront distribution ID, for cache invalidation."
value = aws_cloudfront_distribution.form.id
}
output "distribution_domain_name" {
description = "CloudFront distribution domain name, the DNS target for the custom domain."
value = aws_cloudfront_distribution.form.domain_name
}
output "form_bucket_name" {
description = "S3 bucket holding the order form HTML."
value = aws_s3_bucket.form.id
}
output "reports_bucket_name" {
description = "S3 bucket holding payroll CSVs and weekly summary PDFs."
value = aws_s3_bucket.reports.id
}
output "artifacts_bucket_name" {
description = "S3 bucket holding Lambda deployment packages."
value = aws_s3_bucket.artifacts.id
}
output "orders_table_name" {
description = "DynamoDB orders table."
value = aws_dynamodb_table.orders.name
}
output "shared_layer_arn" {
description = "Version ARN of the shared Lambda layer."
value = aws_lambda_layer_version.shared.arn
}
output "function_arns" {
description = "ARNs of every Lambda function in this configuration."
value = {
admin_authorizer = aws_lambda_function.admin_authorizer.arn
aggregate_orders = aws_lambda_function.aggregate_orders.arn
close_form = aws_lambda_function.close_form.arn
email_report = aws_lambda_function.email_report.arn
slack_notifier = aws_lambda_function.slack_notifier.arn
submit_order = aws_lambda_function.submit_order.arn
sync_roster = aws_lambda_function.sync_roster.arn
}
}

11
terraform/providers.tf Normal file
View file

@ -0,0 +1,11 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "meal-order-manager"
ManagedBy = "terraform"
Workspace = "meal-order-manager-prod"
}
}
}

210
terraform/s3.tf Normal file
View file

@ -0,0 +1,210 @@
# Form-hosting and reports buckets. The Lambda artifact bucket lives in
# artifacts.tf.
# ---------------------------------------------------------------------------
# Form bucket — private origin for the CloudFront distribution
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "form" {
bucket = local.form_bucket_name
tags = {
Purpose = "meal-order-form-hosting"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_public_access_block" "form" {
bucket = aws_s3_bucket.form.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "form" {
bucket = aws_s3_bucket.form.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "form" {
bucket = aws_s3_bucket.form.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "form" {
bucket = aws_s3_bucket.form.id
rule {
id = "delete-old-archives"
status = "Enabled"
filter {
prefix = "archive/"
}
expiration {
days = 90
}
}
}
data "aws_iam_policy_document" "form" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:*"]
resources = [
aws_s3_bucket.form.arn,
"${aws_s3_bucket.form.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
statement {
sid = "AllowCloudFrontOAC"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.form.arn}/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.form.arn]
}
}
}
resource "aws_s3_bucket_policy" "form" {
bucket = aws_s3_bucket.form.id
policy = data.aws_iam_policy_document.form.json
depends_on = [aws_s3_bucket_public_access_block.form]
}
# ---------------------------------------------------------------------------
# Reports bucket — payroll CSVs and weekly summary PDFs
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "reports" {
bucket = local.reports_bucket_name
tags = {
Purpose = "meal-order-reports"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "reports" {
bucket = aws_s3_bucket.reports.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "reports" {
bucket = aws_s3_bucket.reports.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "reports" {
bucket = aws_s3_bucket.reports.id
# Whole-bucket rule, matching the SAM template's unprefixed rule.
rule {
id = "archive-old-reports"
status = "Enabled"
filter {
prefix = ""
}
transition {
days = 90
storage_class = "GLACIER_IR"
}
}
}
# The SAM template attached no policy to this bucket. The TLS-only deny is a
# deliberate addition; it grants nothing and blocks plaintext access to payroll
# data.
data "aws_iam_policy_document" "reports" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:*"]
resources = [
aws_s3_bucket.reports.arn,
"${aws_s3_bucket.reports.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "reports" {
bucket = aws_s3_bucket.reports.id
policy = data.aws_iam_policy_document.reports.json
depends_on = [aws_s3_bucket_public_access_block.reports]
}

18
terraform/secrets.tf Normal file
View file

@ -0,0 +1,18 @@
# Secrets Manager — intentionally empty of resources.
#
# meal-order-manager/slack-bot-token is created and rotated out-of-band. Only
# its ARN enters this configuration, through var.slack_bot_secret_arn, and it is
# used for one thing: scoping secretsmanager:GetSecretValue on the slack-notifier
# and sync-roster execution roles (see iam.tf).
#
# Secret VALUES never enter Terraform state. An aws_secretsmanager_secret_version
# resource would write the plaintext into state and is never used in this repo.
# Rotate with:
# aws secretsmanager put-secret-value \
# --secret-id meal-order-manager/slack-bot-token --secret-string <value>
#
# There is no `data "aws_secretsmanager_secret_version"` lookup either: reading a
# version through a data source also lands the plaintext in state.
#
# If a future resource needs another secret, add a variable carrying its ARN —
# never a managed resource, and never a version.

48
terraform/ssm.tf Normal file
View file

@ -0,0 +1,48 @@
# Parameter Store entries.
#
# /meal-order-manager/google-client-id is deliberately NOT declared here. It is
# created and rotated out-of-band because it varies per environment; data.tf
# reads it. Do not turn that lookup into a resource.
resource "aws_ssm_parameter" "slack_channel_id" {
name = local.slack_channel_param
type = "String"
value = var.slack_channel_id
description = "Slack channel ID for meal order notifications"
}
# ---------------------------------------------------------------------------
# Deploy-time lookups
# ---------------------------------------------------------------------------
#
# These replace the CloudFormation stack outputs that
# .github/workflows/weekly-menu.yml used to read, so the job can resolve its
# deploy targets without a CloudFormation stack.
resource "aws_ssm_parameter" "deploy_api_url" {
name = "${local.ssm_prefix}/deploy/api-url"
type = "String"
value = aws_apigatewayv2_api.order_api.api_endpoint
description = "API Gateway endpoint URL; read by the weekly-menu deploy job"
}
resource "aws_ssm_parameter" "deploy_form_bucket" {
name = "${local.ssm_prefix}/deploy/form-bucket"
type = "String"
value = aws_s3_bucket.form.id
description = "S3 bucket holding the order form; sync target for the weekly-menu deploy job"
}
resource "aws_ssm_parameter" "deploy_distribution_id" {
name = "${local.ssm_prefix}/deploy/distribution-id"
type = "String"
value = aws_cloudfront_distribution.form.id
description = "CloudFront distribution ID; cache-invalidation target for the weekly-menu deploy job"
}
resource "aws_ssm_parameter" "deploy_form_url" {
name = "${local.ssm_prefix}/deploy/form-url"
type = "String"
value = local.form_url
description = "Public order form URL; reported by the weekly-menu deploy job"
}

View file

@ -0,0 +1,21 @@
# Values for the meal-order-manager-prod HCP Terraform workspace.
# Set these as workspace variables; this file is a reference, not an input.
# Region every resource is created in.
aws_region = "us-east-1"
# Custom domain for the order form. An ISSUED ACM certificate for this domain
# must already exist in us-east-1 (see acm.tf).
domain_name = "orders.seahaven.com"
# Payroll deduction report recipient and SES-verified sender.
payroll_email = "payroll@seahavenind.com"
sender_email = "adam@seahavenind.com"
# ARN of the out-of-band Secrets Manager secret holding the Slack bot token.
# Only the ARN is used; the value never enters Terraform state.
slack_bot_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:meal-order-manager/slack-bot-token-XXXXXX"
# Slack channel that receives meal order notifications. Written to
# /meal-order-manager/slack-channel-id.
slack_channel_id = "C00000000000"

38
terraform/variables.tf Normal file
View file

@ -0,0 +1,38 @@
variable "aws_region" {
description = "Region every resource in this configuration is created in."
type = string
default = "us-east-1"
}
variable "domain_name" {
description = "Custom domain served by the CloudFront distribution. An ISSUED ACM certificate for this domain must already exist in us-east-1 (see acm.tf)."
type = string
default = "orders.seahaven.com"
}
variable "payroll_email" {
description = "Recipient of the weekly payroll deduction report."
type = string
default = "payroll@seahavenind.com"
}
variable "sender_email" {
description = "SES-verified From address for the payroll deduction report."
type = string
default = "adam@seahavenind.com"
}
variable "slack_bot_secret_arn" {
description = "ARN of the Secrets Manager secret holding the Slack bot token. The secret and its value are managed out-of-band; only the ARN enters this configuration."
type = string
validation {
condition = can(regex("^arn:aws:secretsmanager:", var.slack_bot_secret_arn))
error_message = "slack_bot_secret_arn must be a Secrets Manager ARN."
}
}
variable "slack_channel_id" {
description = "Slack channel ID for meal order notifications. Written to /meal-order-manager/slack-channel-id."
type = string
}

26
terraform/versions.tf Normal file
View file

@ -0,0 +1,26 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
external = {
source = "hashicorp/external"
version = "~> 2.0"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "meal-order-manager-prod"
}
}
}