diff --git a/.github/workflows/build-layer.yml b/.github/workflows/build-layer.yml new file mode 100644 index 0000000..d22a5b8 --- /dev/null +++ b/.github/workflows/build-layer.yml @@ -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 diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml new file mode 100644 index 0000000..41091ae --- /dev/null +++ b/.github/workflows/ci-terraform.yaml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index f15c559..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml index f6d0ed2..cd9a6ad 100644 --- a/.github/workflows/weekly-menu.yml +++ b/.github/workflows/weekly-menu.yml @@ -63,30 +63,31 @@ jobs: if: env.SKIP_RUN != 'true' 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' id: stack run: | - API_URL=$(aws cloudformation describe-stacks \ - --stack-name meal-order-manager \ - --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \ - --output text) - FORM_BUCKET=$(aws cloudformation describe-stacks \ - --stack-name meal-order-manager \ - --query 'Stacks[0].Outputs[?OutputKey==`FormBucketName`].OutputValue' \ - --output text) - DIST_ID=$(aws cloudformation describe-stacks \ - --stack-name meal-order-manager \ - --query 'Stacks[0].Outputs[?OutputKey==`DistributionId`].OutputValue' \ - --output text) - FORM_URL=$(aws cloudformation describe-stacks \ - --stack-name meal-order-manager \ - --query 'Stacks[0].Outputs[?OutputKey==`FormUrl`].OutputValue' \ - --output text) - echo "api_url=$API_URL" >> $GITHUB_OUTPUT - echo "form_bucket=$FORM_BUCKET" >> $GITHUB_OUTPUT - echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT - echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT + set -euo pipefail + get_param() { + aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text + } + API_URL=$(get_param /meal-order-manager/deploy/api-url) + FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket) + DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id) + FORM_URL=$(get_param /meal-order-manager/deploy/form-url) + for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do + if [ -z "$v" ] || [ "$v" = "None" ]; then + echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2 + exit 1 + fi + done + echo "api_url=$API_URL" >> "$GITHUB_OUTPUT" + echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT" + echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT" + echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT" - name: Get discount settings if: env.SKIP_RUN != 'true' diff --git a/.gitignore b/.gitignore index 59e120c..db71d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ output/ .aws-sam/ samconfig.toml node_modules/ +terraform/build/ +.terraform/ +*.tfvars diff --git a/infra/layer-artifacts-role/README.md b/infra/layer-artifacts-role/README.md new file mode 100644 index 0000000..a2b6950 --- /dev/null +++ b/infra/layer-artifacts-role/README.md @@ -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`. diff --git a/infra/layer-artifacts-role/permissions-policy.json b/infra/layer-artifacts-role/permissions-policy.json new file mode 100644 index 0000000..d4b85ce --- /dev/null +++ b/infra/layer-artifacts-role/permissions-policy.json @@ -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": "*" + } + ] +} diff --git a/infra/layer-artifacts-role/trust-policy.json b/infra/layer-artifacts-role/trust-policy.json new file mode 100644 index 0000000..65df815 --- /dev/null +++ b/infra/layer-artifacts-role/trust-policy.json @@ -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" + } + } + } + ] +} diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..7e949f1 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/acm.tf b/terraform/acm.tf new file mode 100644 index 0000000..90781fb --- /dev/null +++ b/terraform/acm.tf @@ -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 +} diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..ca0015c --- /dev/null +++ b/terraform/alarms.tf @@ -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 + } +} diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf new file mode 100644 index 0000000..e947e27 --- /dev/null +++ b/terraform/apigateway.tf @@ -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}" +} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf new file mode 100644 index 0000000..b2b8c93 --- /dev/null +++ b/terraform/artifacts.tf @@ -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 +} diff --git a/terraform/build_packages.sh b/terraform/build_packages.sh new file mode 100755 index 0000000..ed7d2f8 --- /dev/null +++ b/terraform/build_packages.sh @@ -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 diff --git a/terraform/build_packages_external.sh b/terraform/build_packages_external.sh new file mode 100755 index 0000000..799d9cb --- /dev/null +++ b/terraform/build_packages_external.sh @@ -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}" diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf new file mode 100644 index 0000000..2347c2e --- /dev/null +++ b/terraform/cloudfront.tf @@ -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 + } +} diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..1b16222 --- /dev/null +++ b/terraform/data.tf @@ -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 +} diff --git a/terraform/dynamodb.tf b/terraform/dynamodb.tf new file mode 100644 index 0000000..32634b8 --- /dev/null +++ b/terraform/dynamodb.tf @@ -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 + } +} diff --git a/terraform/events.tf b/terraform/events.tf new file mode 100644 index 0000000..12c3d0e --- /dev/null +++ b/terraform/events.tf @@ -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 +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..95dc6f1 --- /dev/null +++ b/terraform/iam.tf @@ -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 +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf new file mode 100644 index 0000000..07d8151 --- /dev/null +++ b/terraform/lambda.tf @@ -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, + ] +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..e225699 --- /dev/null +++ b/terraform/locals.tf @@ -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" + } + } +} diff --git a/terraform/logs.tf b/terraform/logs.tf new file mode 100644 index 0000000..d53f453 --- /dev/null +++ b/terraform/logs.tf @@ -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/), 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 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..78ea0d1 --- /dev/null +++ b/terraform/outputs.tf @@ -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 + } +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..2e67825 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "meal-order-manager" + ManagedBy = "terraform" + Workspace = "meal-order-manager-prod" + } + } +} diff --git a/terraform/s3.tf b/terraform/s3.tf new file mode 100644 index 0000000..b84dca8 --- /dev/null +++ b/terraform/s3.tf @@ -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] +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..d5e4965 --- /dev/null +++ b/terraform/secrets.tf @@ -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 +# +# 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. diff --git a/terraform/ssm.tf b/terraform/ssm.tf new file mode 100644 index 0000000..33d5b5e --- /dev/null +++ b/terraform/ssm.tf @@ -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" +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..dc82164 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -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" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..0cd8e78 --- /dev/null +++ b/terraform/variables.tf @@ -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 +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..8c6f6af --- /dev/null +++ b/terraform/versions.tf @@ -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" + } + } +}