Compare commits

...

8 commits

Author SHA1 Message Date
renovate[bot]
1d869a5b6f
chore(deps): update pip minor and patch 2026-09-21 06:09:54 +00:00
Adam Moussa
bbbe359858
fix(iam): ignore default tags on hcptf roles (PLAT-210) (#196)
* fix(iam): ignore default tags on hcptf roles (PLAT-210)

The apply role cannot iam:TagRole on itself. Provider default_tags from
the env split 403'd the prod apply on hcptf-meal-order-manager and -plan.

* fix(iam): ignore tags_all on hcptf roles (PLAT-210)

ignore_changes on tags does not cover provider default_tags. The prod
speculative plan still wanted Environment on tags_all and would TagRole.
2026-09-18 20:53:13 +00:00
Adam Moussa
44c79fdefd
feat(infra): add lightweight meal-order-manager-dev (PLAT-210) (#195)
* feat(infra): add lightweight meal-order-manager-dev (PLAT-210)

Parameterize the HCP root for seahaven-dev with schedules, PITR, alarms, and Paychex gated off so a second env does not clone production cost or side effects.

* fix(infra): drop prod-only authorizer import so dev can create it (PLAT-210)

The PLAT-102 import is already in meal-order-manager-prod state. A shared import block fails in seahaven-dev because the permission does not exist there.

* fix(iam): allow creating the weekly-menu githubdeploy role in seahaven-dev (PLAT-210)

Prod imported that role. A new account needs CreateRole on tf-managed/githubdeploy-meal-order-manager-weekly-menu.
2026-09-18 18:38:45 +00:00
Adam Moussa
12f1eb881f
fix(auth): accept federated portal Cognito tokens for meals admin (DEV-283) (#194)
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
* fix(auth): accept federated portal Cognito tokens for meals admin

Google Workspace federation stores email_verified=false, which 403'd the
portal Admin probe while the public menu still loaded.

* fix(iam): grant plan role CloudFront DescribeFunction
2026-09-18 15:31:17 +00:00
Adam Moussa
85f36fcfff
feat(edge): proxy portal meals API paths on orders.seahaven.com (DEV-282) (#193)
Some checks are pending
Build Lambda Layer / build (push) Waiting to run
* feat(edge): proxy portal meals API paths on orders.seahaven.com (DEV-282)

Keep the static form on / while CloudFront forwards submit, form-status, admin, and caller-only order lookup so the portal can use the public meals host without a form redirect.

* fix(style): apply ruff format
2026-09-17 23:52:51 +00:00
renovate[bot]
a81241dc03
chore(deps): update sea-haven-industries/.github action to v1.0.11 (#190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 16:45:46 +00:00
Adam Moussa
26a92a2f62
feat(auth): accept portal Cognito ID tokens (DEV-238) (#192)
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
* feat(auth): accept portal Cognito ID tokens

* fix(auth): distinguish portal verification outages

* docs(auth): document Cognito workspace variables
2026-09-15 22:12:01 +00:00
Adam Moussa
86bb476e27
feat(menu): expose production menu API (#191) 2026-09-15 21:41:10 +00:00
36 changed files with 1507 additions and 126 deletions

View file

@ -9,7 +9,7 @@ permissions:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with:
run-tests: false

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

View file

@ -111,7 +111,9 @@ sit in ALARM between runs). Alarm names follow `meal-order-manager-<fn>-<signal>
## Authentication
Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains. Cloud form generation and Lambda order submission fail closed unless `/meal-order-manager/google-client-id` is configured. The local Flask workflow can still use manual name and email entry when Google auth is not configured.
Google Identity Services and portal Cognito ID tokens coexist until portal cutover. Google tokens use the tokeninfo endpoint. Portal tokens are verified locally against the configured Cognito issuer, audience, signature, expiry, token use, and email domain. Both paths accept only `seahavenind.com` and `seahaven.com` identities and fail closed when their SSM configuration is unavailable. The local Flask workflow can still use manual name and email entry when Google auth is not configured.
Set the `portal_cognito_issuer` and `portal_cognito_audience` HCP Terraform workspace variables from one internal-portal stage. Add the other stage to `portal_cognito_extra_trust` so portal-dev and portal-prod tokens both work against this single meals API. Google federated portal users are accepted without an `email_verified=true` claim; identity still has to be a `seahavenind.com` or `seahaven.com` email from a trusted pool.
## Admin Panel

View file

@ -1,12 +1,9 @@
"""API Gateway (HTTP API) Lambda authorizer for the meal-order-manager admin API.
Gates every ``/api/admin/*`` route at the gateway so they are no longer
AuthorizationType NONE. The authorizer validates the same Google ID token the
admin panel already sends in the ``Authorization: Bearer <token>`` header and
confirms the caller is in the ``admin_emails`` allow-list (DynamoDB
CONFIG/SETTINGS) — exactly the checks ``submit_order``'s ``_verify_admin`` does
in-handler today. No client change is required: the existing admin UI already
sends this header.
Gates every ``/api/admin/*`` route at the gateway. The authorizer accepts the
existing Google ID token or a portal Cognito ID token in the Authorization
header, then confirms the caller is in the ``admin_emails`` allow-list
(DynamoDB CONFIG/SETTINGS).
Returns the HTTP API v2 *simple response* shape (``{"isAuthorized": bool}``);
a False result causes API Gateway to return 403 before the integration runs.
@ -21,6 +18,11 @@ import urllib.error
import urllib.parse
import urllib.request
from shared.cognito import (
CognitoVerificationUnavailable,
looks_like_cognito_token,
verify_cognito_id_token,
)
from shared.db import get_settings
from shared.secrets import get_parameter
@ -89,21 +91,35 @@ def _extract_token(event) -> str:
return ""
def lambda_handler(event, context):
if not os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""):
logger.error("Authorizer misconfigured: GOOGLE_CLIENT_ID_PARAM unset")
return _DENY
def _verify_portal_token(token: str) -> dict | None:
return verify_cognito_id_token(
token,
os.environ.get("PORTAL_COGNITO_ISSUER_PARAM", ""),
os.environ.get("PORTAL_COGNITO_AUDIENCE_PARAM", ""),
os.environ.get("PORTAL_COGNITO_TRUST_PARAM", ""),
)
def lambda_handler(event, context):
token = _extract_token(event)
if not token:
return _DENY
client_id = _get_google_client_id()
if not client_id:
logger.error("Google client ID unavailable; denying")
return _DENY
user_info = _verify_google_token(token, client_id)
if looks_like_cognito_token(token):
try:
user_info = _verify_portal_token(token)
except CognitoVerificationUnavailable:
logger.error("Cognito verification service unavailable; denying")
return _DENY
else:
if not os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""):
logger.error("Authorizer misconfigured: GOOGLE_CLIENT_ID_PARAM unset")
return _DENY
client_id = _get_google_client_id()
if not client_id:
logger.error("Google client ID unavailable; denying")
return _DENY
user_info = _verify_google_token(token, client_id)
if user_info is None:
return _DENY

View file

@ -1 +1 @@
boto3==1.43.78
boto3==1.43.97

View file

@ -1 +1 @@
boto3==1.43.78
boto3==1.43.97

View file

@ -1 +1 @@
boto3==1.43.78
boto3==1.43.97

View file

@ -1 +1 @@
boto3==1.43.78
boto3==1.43.97

View file

@ -13,6 +13,11 @@ from zoneinfo import ZoneInfo
import boto3
from shared.cognito import (
CognitoVerificationUnavailable,
looks_like_cognito_token,
verify_cognito_id_token,
)
from shared.db import (
current_week,
delete_order,
@ -156,36 +161,80 @@ def _authentication_service_unavailable() -> dict:
return response(503, {"error": "Authentication service temporarily unavailable"})
def _verify_admin(event) -> tuple[dict | None, dict | None]:
"""Verify Google auth and admin access. Returns (user_info, error_response)."""
if not _google_auth_configured():
return None, response(403, {"error": "Authentication not configured"})
def _extract_bearer_token(event) -> str:
headers = event.get("headers") or {}
raw = headers.get("authorization") or headers.get("Authorization") or ""
if raw.lower().startswith("bearer "):
return raw[7:].strip()
return ""
token = (
event.get("headers", {})
.get("authorization", "")
.removeprefix("Bearer ")
.strip()
def _verify_portal_token(token: str) -> dict | None:
return verify_cognito_id_token(
token,
os.environ.get("PORTAL_COGNITO_ISSUER_PARAM", ""),
os.environ.get("PORTAL_COGNITO_AUDIENCE_PARAM", ""),
os.environ.get("PORTAL_COGNITO_TRUST_PARAM", ""),
)
def _verify_identity(event) -> tuple[dict | None, dict | None]:
"""Verify a portal Cognito or Google bearer token."""
token = _extract_bearer_token(event)
if not token:
return None, response(403, {"error": "Authentication required"})
if looks_like_cognito_token(token):
try:
user_info = _verify_portal_token(token)
except CognitoVerificationUnavailable:
return None, _authentication_service_unavailable()
if user_info is None:
return None, response(
403, {"error": "Invalid or unauthorized portal account"}
)
return user_info, None
if not _google_auth_configured():
return None, response(403, {"error": "Authentication not configured"})
user_info, status = _verify_google_token(token)
if status == "unavailable":
return None, _authentication_service_unavailable()
if user_info is None:
return None, response(403, {"error": "Invalid or unauthorized Google account"})
return user_info, None
def _verify_admin(event) -> tuple[dict | None, dict | None]:
"""Verify portal or Google auth and admin access."""
user_info, err = _verify_identity(event)
if err:
return None, err
if user_info["email"].lower() not in _get_admin_emails():
return None, response(403, {"error": "Admin access required"})
return user_info, None
def _week_id(requested: str) -> str | None:
if re.fullmatch(r"\d{4}-W\d{2}", requested):
return requested
match = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", requested)
if not match:
return None
try:
day = _dt.date(int(match[1]), int(match[2]), int(match[3]))
except ValueError:
return None
return day.strftime("%Y-W%U")
def lambda_handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "")
if "/api/menu/" in path and method == "GET":
return handle_menu(event)
if path == "/api/publish/settings" and method == "GET":
return handle_publish_settings()
@ -205,6 +254,9 @@ def lambda_handler(event, context):
if "/form-status/" in path:
return handle_form_status(event)
if "/api/orders/" in path and method == "GET":
return handle_my_orders(event)
if "/roster" in path:
return handle_roster()
@ -214,6 +266,40 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"})
def handle_menu(event):
"""Return the published menu in the portal's public MenuPayload shape."""
requested_week = (event.get("pathParameters") or {}).get("week", "")
if requested_week == "current":
week = current_week()
elif re.fullmatch(r"\d{4}-W\d{2}", requested_week):
week = requested_week
else:
return response(400, {"error": "week path param must be current or YYYY-WNN"})
menu = get_menu(week)
if menu is None:
return response(404, {"error": "No menu available for this week"})
meals = menu.get("meals") or []
settings = get_settings()
return response(
200,
{
"week": week,
"form_status": menu.get("form_status", "closed"),
"scraped_at": menu.get("scraped_at"),
"menu_url": menu.get("menu_url"),
"meal_count": int(menu.get("meal_count", len(meals))),
"meals": meals,
"bulk_discount_percent": float(settings.get("bulk_discount_percent", 0)),
"company_subsidy_percent": float(
settings.get("company_subsidy_percent", 0)
),
"order_deadline": settings.get("order_deadline") or "Thursday at 11:59 PM",
},
)
def handle_publish_settings():
"""Return only the pricing fields needed by the weekly-menu workflow."""
settings = get_settings()
@ -450,6 +536,29 @@ def handle_admin_update(event):
)
def handle_my_orders(event):
"""Return only the caller's order for the week. Coworker orders stay off the wire."""
user, err = _verify_identity(event)
if err:
return err
requested_week = (event.get("pathParameters") or {}).get("week", "")
week = _week_id(requested_week)
if week is None:
return response(400, {"error": "week path param must be YYYY-WNN"})
order = get_order(week, user["email"].lower())
if order is None:
return response(200, {"week": week, "orders": []})
return response(
200,
{
"week": week,
"orders": [{"employee_email": order.get("employee_email", "")}],
},
)
def handle_form_status(event):
week = event.get("pathParameters", {}).get("week", current_week())
status = get_form_status(week)
@ -482,25 +591,33 @@ def handle_submit(event):
except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON"})
# The Lambda is the cloud submission path, so Google authentication is always
# required. Local/manual submissions are handled only by src/server/app.py.
google_token = body.get("google_id_token")
try:
client_id = _get_google_client_id()
except Exception as exc:
logger.error("SSM fetch failed for Google client ID: %s", exc)
return _authentication_service_unavailable()
if not client_id:
logger.error("Google client ID is unavailable")
return _authentication_service_unavailable()
if not google_token:
return response(403, {"error": "Google authentication is required"})
user_info, verify_status = _verify_google_token(google_token)
if verify_status == "unavailable":
return _authentication_service_unavailable()
if user_info is None:
return response(403, {"error": "Invalid or unauthorized Google account"})
portal_token = _extract_bearer_token(event)
if portal_token:
try:
user_info = _verify_portal_token(portal_token)
except CognitoVerificationUnavailable:
return _authentication_service_unavailable()
if user_info is None:
return response(403, {"error": "Invalid or unauthorized portal account"})
else:
# The Lambda is the cloud submission path, so one of the two verified
# identity providers is required. Local/manual submissions remain in Flask.
google_token = body.get("google_id_token")
try:
client_id = _get_google_client_id()
except Exception as exc:
logger.error("SSM fetch failed for Google client ID: %s", exc)
return _authentication_service_unavailable()
if not client_id:
logger.error("Google client ID is unavailable")
return _authentication_service_unavailable()
if not google_token:
return response(403, {"error": "Google authentication is required"})
user_info, verify_status = _verify_google_token(google_token)
if verify_status == "unavailable":
return _authentication_service_unavailable()
if user_info is None:
return response(403, {"error": "Invalid or unauthorized Google account"})
name = user_info["name"]
email = user_info["email"]
@ -617,5 +734,5 @@ def response(status_code: int, body: dict) -> dict:
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
"body": json.dumps(body, default=float),
}

View file

@ -1 +1 @@
boto3==1.43.78
boto3==1.43.97

View file

@ -1,4 +1,4 @@
playwright==1.62.0
playwright==1.63.0
flask==3.1.3
boto3==1.43.78
boto3==1.43.97
jinja2==3.1.6

View file

@ -1,2 +1,3 @@
boto3==1.43.78
boto3==1.43.97
fpdf2==2.8.8
PyJWT[crypto]==2.14.0

View file

@ -0,0 +1,182 @@
"""Verification for portal Cognito ID tokens."""
import json
import logging
import re
from functools import lru_cache
import jwt
from jwt import PyJWKClient
from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError, PyJWTError
from shared.secrets import get_parameter
logger = logging.getLogger(__name__)
ALLOWED_EMAIL_DOMAINS = {"seahavenind.com", "seahaven.com"}
_COGNITO_ISSUER_RE = re.compile(
r"^https://cognito-idp\.[a-z0-9-]+\.amazonaws\.com/[A-Za-z0-9_-]+$"
)
_UNVERIFIED_DECODE = {
"verify_signature": False,
"verify_exp": False,
"verify_aud": False,
}
class CognitoVerificationUnavailable(RuntimeError):
"""Trusted Cognito configuration or JWKS could not be loaded."""
def looks_like_cognito_token(token: str) -> bool:
"""Return whether untrusted claims identify a Cognito ID token."""
try:
claims = jwt.decode(
token,
options=_UNVERIFIED_DECODE,
algorithms=["RS256"],
)
except PyJWTError:
return False
issuer = claims.get("iss")
return (
isinstance(issuer, str)
and bool(_COGNITO_ISSUER_RE.fullmatch(issuer.rstrip("/")))
and claims.get("token_use") == "id"
)
@lru_cache(maxsize=4)
def _jwk_client(issuer: str) -> PyJWKClient:
return PyJWKClient(
f"{issuer}/.well-known/jwks.json",
cache_keys=True,
lifespan=300,
timeout=5,
)
def verify_cognito_id_token(
token: str,
issuer_param: str,
audience_param: str,
trust_param: str = "",
) -> dict | None:
"""Verify a portal token and return its trusted identity claims."""
if not token:
return None
try:
trusted = _trusted_clients(issuer_param, audience_param, trust_param)
except CognitoVerificationUnavailable:
raise
except Exception as exc:
logger.error("Failed to load Cognito verification parameters: %s", exc)
raise CognitoVerificationUnavailable from exc
if not trusted:
logger.error("Cognito verification is not configured")
return None
try:
unverified = jwt.decode(
token,
options=_UNVERIFIED_DECODE,
algorithms=["RS256"],
)
except PyJWTError as exc:
logger.warning("Cognito token rejected: %s", type(exc).__name__)
return None
issuer = unverified.get("iss")
if not isinstance(issuer, str):
logger.warning("Cognito token rejected: missing issuer")
return None
issuer = issuer.rstrip("/")
audience = trusted.get(issuer)
if not audience:
logger.warning("Cognito token rejected: untrusted issuer")
return None
try:
signing_key = _jwk_client(issuer).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
options={
"require": [
"aud",
"email",
"exp",
"iat",
"iss",
"token_use",
]
},
)
except PyJWKClientConnectionError as exc:
logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__)
raise CognitoVerificationUnavailable from exc
except OSError as exc:
logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__)
raise CognitoVerificationUnavailable from exc
except (PyJWKClientError, PyJWTError, TypeError, ValueError) as exc:
logger.warning("Cognito token rejected: %s", type(exc).__name__)
return None
return _identity_from_claims(claims)
def _trusted_clients(
issuer_param: str, audience_param: str, trust_param: str
) -> dict[str, str]:
trusted: dict[str, str] = {}
if trust_param:
raw = get_parameter(trust_param, decrypt=False)
items = json.loads(raw)
if not isinstance(items, list):
raise CognitoVerificationUnavailable
for item in items:
if not isinstance(item, dict):
continue
issuer = str(item.get("issuer", "")).rstrip("/")
audience = str(item.get("audience", "")).strip()
if _COGNITO_ISSUER_RE.fullmatch(issuer) and audience:
trusted[issuer] = audience
if issuer_param and audience_param:
issuer = get_parameter(issuer_param, decrypt=False).rstrip("/")
audience = get_parameter(audience_param, decrypt=False).strip()
if not _COGNITO_ISSUER_RE.fullmatch(issuer) or not audience:
if not trusted:
logger.error("Cognito verification parameters are invalid")
raise CognitoVerificationUnavailable
else:
trusted.setdefault(issuer, audience)
return trusted
def _identity_from_claims(claims: dict) -> dict | None:
if claims.get("token_use") != "id":
return None
email = claims.get("email")
if not isinstance(email, str) or not email.strip() or "@" not in email:
return None
email = email.strip()
if email.rsplit("@", 1)[1].lower() not in ALLOWED_EMAIL_DOMAINS:
return None
name = _display_name(claims, email)
if not name:
return None
return {"name": name, "email": email}
def _display_name(claims: dict, email: str) -> str | None:
for key in ("name", "given_name"):
value = claims.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
local = email.split("@", 1)[0].strip()
return local or None

View file

@ -14,7 +14,13 @@
# 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" {
count = var.attach_custom_domain ? 1 : 0
domain = var.domain_name
statuses = ["ISSUED"]
most_recent = true
}
moved {
from = data.aws_acm_certificate.orders
to = data.aws_acm_certificate.orders[0]
}

View file

@ -12,7 +12,7 @@
# here for throttle coverage.
locals {
alarm_functions = {
alarm_functions = local.is_prod ? {
"submit-order" = {
function_name = aws_lambda_function.submit_order.function_name
duration_threshold = 8000
@ -49,7 +49,7 @@ locals {
duration_timeout = "60s"
duration_datapoints = 1
}
}
} : {}
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
@ -65,7 +65,7 @@ resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
FunctionName = each.value.function_name
@ -85,7 +85,7 @@ resource "aws_cloudwatch_metric_alarm" "lambda_throttles" {
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
FunctionName = each.value.function_name
@ -106,7 +106,7 @@ resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
threshold = each.value.duration_threshold
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
FunctionName = each.value.function_name
@ -118,6 +118,8 @@ resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-orders-read-throttle"
alarm_description = "orders table read requests were throttled in the last 5 minutes."
namespace = "AWS/DynamoDB"
@ -128,7 +130,7 @@ resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
TableName = aws_dynamodb_table.orders.name
@ -136,6 +138,8 @@ resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
}
resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-orders-write-throttle"
alarm_description = "orders table write requests were throttled in the last 5 minutes."
namespace = "AWS/DynamoDB"
@ -146,7 +150,7 @@ resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
TableName = aws_dynamodb_table.orders.name
@ -158,6 +162,8 @@ resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-order-api-5xx"
alarm_description = "OrderApi returned one or more 5xx responses in 5 minutes."
namespace = "AWS/ApiGateway"
@ -168,7 +174,7 @@ resource "aws_cloudwatch_metric_alarm" "api_5xx" {
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
@ -178,6 +184,8 @@ resource "aws_cloudwatch_metric_alarm" "api_5xx" {
# 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" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-order-api-4xx"
alarm_description = "OrderApi 4xx responses exceeded 20 in 5 minutes (beyond routine auth noise)."
namespace = "AWS/ApiGateway"
@ -189,7 +197,7 @@ resource "aws_cloudwatch_metric_alarm" "api_4xx" {
threshold = 20
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
@ -197,6 +205,8 @@ resource "aws_cloudwatch_metric_alarm" "api_4xx" {
}
resource "aws_cloudwatch_metric_alarm" "api_latency" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-order-api-latency"
alarm_description = "OrderApi p99 latency exceeded 3000ms."
namespace = "AWS/ApiGateway"
@ -208,9 +218,34 @@ resource "aws_cloudwatch_metric_alarm" "api_latency" {
threshold = 3000
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
}
}
moved {
from = aws_cloudwatch_metric_alarm.orders_read_throttle
to = aws_cloudwatch_metric_alarm.orders_read_throttle[0]
}
moved {
from = aws_cloudwatch_metric_alarm.orders_write_throttle
to = aws_cloudwatch_metric_alarm.orders_write_throttle[0]
}
moved {
from = aws_cloudwatch_metric_alarm.api_5xx
to = aws_cloudwatch_metric_alarm.api_5xx[0]
}
moved {
from = aws_cloudwatch_metric_alarm.api_4xx
to = aws_cloudwatch_metric_alarm.api_4xx[0]
}
moved {
from = aws_cloudwatch_metric_alarm.api_latency
to = aws_cloudwatch_metric_alarm.api_latency[0]
}

View file

@ -1,12 +1,12 @@
# HTTP API fronting the order form.
#
# Three authorization modes coexist, matching template.yaml:
# NONE — the public form routes (submit-order, form-status, roster)
# NONE — public form routes plus GET /api/orders/{week} (bearer checked in Lambda)
# 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
# All eleven routes integrate with submit-order, which dispatches internally on
# the route key.
resource "aws_apigatewayv2_api" "order_api" {
@ -14,13 +14,18 @@ resource "aws_apigatewayv2_api" "order_api" {
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
allow_origins = [
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Authorization", "Content-Type"]
allow_credentials = false
max_age = 3600
}
}
@ -109,13 +114,9 @@ resource "aws_lambda_permission" "api_route" {
# Grant API Gateway permission to invoke the admin authorizer Lambda. Source ARN
# is the authorizer itself (not a route), matching the AWS HTTP API docs.
# Import adopts the PLAT-102 live hotfix statement so the first apply does not
# attempt a duplicate AddPermission.
import {
to = aws_lambda_permission.admin_authorizer
id = "meal-order-manager-admin-authorizer/AllowApiGatewayInvokeAuthorizer"
}
# The PLAT-102 live hotfix was imported into meal-order-manager-prod state; do
# not keep a workspace-global import block or seahaven-dev cannot create this
# permission.
resource "aws_lambda_permission" "admin_authorizer" {
statement_id = "AllowApiGatewayInvokeAuthorizer"
action = "lambda:InvokeFunction"

View file

@ -6,6 +6,77 @@ resource "aws_cloudfront_origin_access_control" "form" {
signing_protocol = "sigv4"
}
resource "aws_cloudfront_cache_policy" "menu_api" {
name = "${local.project}-menu-api"
comment = "Cache public menu responses for 60 seconds per request origin"
default_ttl = 60
max_ttl = 60
min_ttl = 60
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "whitelist"
headers {
items = ["Origin"]
}
}
query_strings_config {
query_string_behavior = "none"
}
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
}
}
resource "aws_cloudfront_origin_request_policy" "menu_api" {
name = "${local.project}-menu-api"
comment = "Forward CORS preflight headers to the HTTP API"
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "whitelist"
headers {
items = [
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
]
}
}
query_strings_config {
query_string_behavior = "none"
}
}
# Rewrite extensionless form paths to index.html on the S3 origin only.
# A distribution-wide 403 custom error page would also rewrite API 403s
# (non-admin, bad bearer) into form HTML.
resource "aws_cloudfront_function" "form_spa_rewrite" {
name = "${local.project}-form-spa-rewrite"
runtime = "cloudfront-js-2.0"
comment = "Rewrite extensionless form paths to /index.html"
publish = true
code = <<-EOF
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {
request.uri = '/index.html';
}
return request;
}
EOF
}
resource "aws_cloudfront_distribution" "form" {
enabled = true
is_ipv6_enabled = true
@ -26,6 +97,74 @@ resource "aws_cloudfront_distribution" "form" {
origin_access_control_id = aws_cloudfront_origin_access_control.form.id
}
origin {
origin_id = "OrderApiOrigin"
domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
ordered_cache_behavior {
path_pattern = "/api/menu/*"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.menu_api.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.menu_api.id
}
# Do not use path `/api/*`. That would front IAM-only publish routes without SigV4.
ordered_cache_behavior {
path_pattern = "/api/form-status/*"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = local.api_cache_policy_id
origin_request_policy_id = local.api_origin_request_policy_id
}
ordered_cache_behavior {
path_pattern = "/api/orders/*"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = local.api_cache_policy_id
origin_request_policy_id = local.api_origin_request_policy_id
}
ordered_cache_behavior {
path_pattern = "/api/submit-order"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = local.cloudfront_all_methods
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = local.api_cache_policy_id
origin_request_policy_id = local.api_origin_request_policy_id
}
ordered_cache_behavior {
path_pattern = "/api/admin/*"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = local.cloudfront_all_methods
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = local.api_cache_policy_id
origin_request_policy_id = local.api_origin_request_policy_id
}
default_cache_behavior {
target_origin_id = "S3FormOrigin"
viewer_protocol_policy = "redirect-to-https"
@ -36,16 +175,12 @@ resource "aws_cloudfront_distribution" "form" {
# 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"
}
cache_policy_id = local.api_cache_policy_id
# 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"
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.form_spa_rewrite.arn
}
}
restrictions {
@ -56,12 +191,15 @@ resource "aws_cloudfront_distribution" "form" {
viewer_certificate {
cloudfront_default_certificate = !var.attach_custom_domain
acm_certificate_arn = var.attach_custom_domain ? data.aws_acm_certificate.orders.arn : null
acm_certificate_arn = var.attach_custom_domain ? data.aws_acm_certificate.orders[0].arn : null
ssl_support_method = var.attach_custom_domain ? "sni-only" : null
minimum_protocol_version = var.attach_custom_domain ? "TLSv1.2_2021" : null
}
lifecycle {
# prevent_destroy cannot interpolate. Keep it on so a tagged apply cannot
# destroy the live distribution; tear down a leftover dev distribution
# from the AWS console.
prevent_destroy = true
}
}

View file

@ -6,13 +6,20 @@ data "aws_caller_identity" "current" {}
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}."
error_message = "This configuration targets account ${local.account_id} (${var.environment}), but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
}
}
# Alarm sink owned by seahaven-org-baseline, not by this configuration.
# Looked up only in prod because CloudWatch alarms are skipped in dev.
data "aws_sns_topic" "site_alerts" {
name = "site-alerts"
count = local.is_prod ? 1 : 0
name = "site-alerts"
}
moved {
from = data.aws_sns_topic.site_alerts
to = data.aws_sns_topic.site_alerts[0]
}
# Shared CloudFront WAF WebACL (audit M-17), published to Parameter Store by the
@ -40,3 +47,24 @@ check "google_client_id_present" {
error_message = "SSM parameter ${local.google_client_id_param} is missing or empty; create it out of band before apply."
}
}
check "dev_has_no_paychex" {
assert {
condition = local.is_prod || var.checkcomponents_queue_url == ""
error_message = "checkcomponents_queue_url must be empty in non-prod so aggregate-orders cannot send to the prod Paychex queue."
}
}
check "checkcomponents_pair" {
assert {
condition = (var.checkcomponents_queue_url == "") == (var.checkcomponents_queue_arn == "")
error_message = "checkcomponents_queue_url and checkcomponents_queue_arn must both be set or both be empty."
}
}
check "dev_has_no_custom_domain" {
assert {
condition = local.is_prod || !var.attach_custom_domain
error_message = "attach_custom_domain must be false in non-prod; use the CloudFront distribution domain."
}
}

View file

@ -1,14 +1,18 @@
# 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.
# Deletion protection and point-in-time recovery are on in prod: this table
# holds the only copy of submitted orders, and a rebuild would lose payroll
# history. Dev is disposable (PITR and deletion protection off).
# lifecycle.prevent_destroy cannot interpolate, so it stays true in both
# environments. Destroy a leftover dev table from the AWS console after
# setting deletion_protection_enabled = false in a follow-up apply.
resource "aws_dynamodb_table" "orders" {
name = local.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK"
range_key = "SK"
deletion_protection_enabled = true
deletion_protection_enabled = local.is_prod
attribute {
name = "PK"
@ -26,7 +30,7 @@ resource "aws_dynamodb_table" "orders" {
}
point_in_time_recovery {
enabled = true
enabled = local.is_prod
}
lifecycle {

View file

@ -62,7 +62,7 @@ resource "aws_cloudwatch_event_rule" "schedule" {
name = "${local.project}-${each.key}"
description = each.value.description
schedule_expression = each.value.schedule
state = "ENABLED"
state = local.is_prod ? "ENABLED" : "DISABLED"
}
resource "aws_cloudwatch_event_target" "schedule" {

View file

@ -4,14 +4,14 @@
# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). Import apply sequence:
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
# --account prod --allow-workspace meal-order-manager-prod
# --account prod|dev --allow-workspace meal-order-manager-<env>
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply (import + detach seahaven-hcptf-iam-management +
# put scoped inline).
# 4. Point TFC_AWS_* back at hcptf-meal-order-manager / hcptf-meal-order-manager-plan.
# 5. Re-run the script without --allow-workspace to pin trust back to
# iam-bootstrap-prod only.
# iam-bootstrap-<env> only.
# seahaven-lambda-execution-boundary remains seahaven-lambda-execution-boundary-meal-order-manager.
import {
@ -70,7 +70,7 @@ data "aws_iam_policy_document" "hcptf_apply_trust" {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:seahaven-prod:workspace:meal-order-manager-prod:run_phase:apply",
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply",
]
}
}
@ -97,7 +97,7 @@ data "aws_iam_policy_document" "hcptf_plan_trust" {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:seahaven-prod:workspace:meal-order-manager-prod:run_phase:plan",
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan",
]
}
}
@ -181,6 +181,8 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:CreateRole",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
@ -634,6 +636,7 @@ resource "aws_iam_role_policy" "hcptf_plan_refresh" {
},
{
Action = [
"cloudfront:DescribeFunction",
"cloudfront:Get*",
"cloudfront:List*",
]
@ -716,6 +719,12 @@ resource "aws_iam_role" "hcptf_apply" {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
# Apply role cannot iam:TagRole on itself. Provider default_tags
# merge into tags_all, so ignoring tags alone still 403s mid-apply.
lifecycle {
ignore_changes = [tags, tags_all]
}
}
# Empty exclusive set keeps seahaven-hcptf-iam-management detached.
@ -734,6 +743,11 @@ resource "aws_iam_role" "hcptf_plan" {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
# Same self-tag restriction as hcptf_apply.
lifecycle {
ignore_changes = [tags, tags_all]
}
}
resource "aws_iam_role_policy_attachment" "hcptf_plan_viewonly" {

View file

@ -252,11 +252,14 @@ data "aws_iam_policy_document" "aggregate_orders" {
resources = [aws_lambda_function.slack_notifier.arn]
}
statement {
sid = "CheckcomponentsSend"
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = [var.checkcomponents_queue_arn]
dynamic "statement" {
for_each = var.checkcomponents_queue_arn != "" ? [var.checkcomponents_queue_arn] : []
content {
sid = "CheckcomponentsSend"
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = [statement.value]
}
}
}

View file

@ -37,8 +37,11 @@ resource "aws_lambda_function" "submit_order" {
environment {
variables = merge(local.common_env, {
SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
PORTAL_COGNITO_ISSUER_PARAM = aws_ssm_parameter.portal_cognito_issuer.name
PORTAL_COGNITO_AUDIENCE_PARAM = aws_ssm_parameter.portal_cognito_audience.name
PORTAL_COGNITO_TRUST_PARAM = aws_ssm_parameter.portal_cognito_trust.name
})
}
@ -70,7 +73,10 @@ resource "aws_lambda_function" "admin_authorizer" {
environment {
variables = merge(local.common_env, {
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
PORTAL_COGNITO_ISSUER_PARAM = aws_ssm_parameter.portal_cognito_issuer.name
PORTAL_COGNITO_AUDIENCE_PARAM = aws_ssm_parameter.portal_cognito_audience.name
PORTAL_COGNITO_TRUST_PARAM = aws_ssm_parameter.portal_cognito_trust.name
})
}

View file

@ -1,6 +1,9 @@
locals {
project = "meal-order-manager"
account_id = "011934824531"
project = "meal-order-manager"
is_prod = var.environment == "prod"
account_id = local.is_prod ? "011934824531" : "710827005802"
hcp_project = "seahaven-${var.environment}"
hcp_workspace = "${local.project}-${var.environment}"
# Lambda execution roles under /tf-managed/ carry the per-workload ceiling
# (PLAT-52). githubdeploy-meal-order-manager-weekly-menu must not.
@ -22,6 +25,10 @@ locals {
# (data.tf) but never owns it.
google_client_id_param = "${local.ssm_prefix}/google-client-id"
portal_cognito_issuer_param = "${local.ssm_prefix}/portal-cognito-issuer"
portal_cognito_audience_param = "${local.ssm_prefix}/portal-cognito-audience"
portal_cognito_trust_param = "${local.ssm_prefix}/portal-cognito-trust"
# 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"
@ -48,6 +55,13 @@ locals {
SLACK_BOT_SM_NAME = local.slack_bot_secret_name
}
# AWS managed CachingDisabled / AllViewerExceptHostHeader. Authenticated
# portal calls must not be cached and must not send the viewer Host to the
# HTTP API (Forbidden).
api_cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
api_origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
cloudfront_all_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
# 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 `*`).
@ -57,6 +71,16 @@ locals {
authorizer = "NONE"
permission_source = "POST/api/submit-order"
}
menu = {
route_key = "GET /api/menu/{week}"
authorizer = "NONE"
permission_source = "GET/api/menu/*"
}
my_orders = {
route_key = "GET /api/orders/{week}"
authorizer = "NONE"
permission_source = "GET/api/orders/*"
}
form_status = {
route_key = "GET /api/form-status/{week}"
authorizer = "NONE"

View file

@ -7,11 +7,11 @@ resource "aws_cloudwatch_log_group" "function" {
for_each = local.function_packages
name = "/aws/lambda/${local.project}-${replace(each.key, "_", "-")}"
retention_in_days = 60
retention_in_days = local.is_prod ? 60 : 14
}
# 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
retention_in_days = local.is_prod ? 90 : 14
}

View file

@ -3,9 +3,10 @@ provider "aws" {
default_tags {
tags = {
Project = "meal-order-manager"
ManagedBy = "terraform"
Workspace = "meal-order-manager-prod"
Project = "meal-order-manager"
Environment = var.environment
ManagedBy = "terraform"
Workspace = "meal-order-manager-${var.environment}"
}
}
}

View file

@ -11,6 +11,30 @@ resource "aws_ssm_parameter" "slack_channel_id" {
description = "Slack channel ID for meal order notifications"
}
resource "aws_ssm_parameter" "portal_cognito_issuer" {
name = local.portal_cognito_issuer_param
type = "String"
value = var.portal_cognito_issuer
description = "Trusted portal Cognito user-pool issuer for ID-token verification"
}
resource "aws_ssm_parameter" "portal_cognito_audience" {
name = local.portal_cognito_audience_param
type = "String"
value = var.portal_cognito_audience
description = "Trusted portal Cognito app client ID for ID-token verification"
}
resource "aws_ssm_parameter" "portal_cognito_trust" {
name = local.portal_cognito_trust_param
type = "String"
value = jsonencode(concat(
[{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }],
var.portal_cognito_extra_trust,
))
description = "Trusted portal Cognito issuer/audience pairs for ID-token verification"
}
# ---------------------------------------------------------------------------
# Deploy-time lookups
# ---------------------------------------------------------------------------

View file

@ -1,18 +1,41 @@
# Values for the meal-order-manager-prod HCP Terraform workspace.
# Workspace variable reference for meal-order-manager HCP workspaces.
# Set these as workspace variables; this file is a reference, not an input.
# HCP workspace stage. Selects account 011934824531 (prod) or 710827005802 (dev).
# Required. meal-order-manager-prod must set "prod" in the same cut as this
# variable is added so a prod plan does not target seahaven-dev.
environment = "prod"
# 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). Attach only at DNS cutover.
domain_name = "orders.seahaven.com"
attach_custom_domain = false
# must already exist in us-east-1 (see acm.tf). Keep false in dev.
domain_name = "orders.seahaven.com"
attach_custom_domain = false
# 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.
# /meal-order-manager/slack-channel-id. Use a sandbox channel in dev.
slack_channel_id = "C00000000000"
# Portal Cognito pools and public app clients accepted by the meals API.
# The primary pair is required. extra_trust lists additional pools so
# portal-dev and portal-prod tokens can both call this meals stack.
portal_cognito_issuer = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE"
portal_cognito_audience = "examplepublicappclientid"
portal_cognito_extra_trust = [
{
issuer = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLEPROD"
audience = "exampleprodappclientid"
},
]
# paychex-checkcomponents SQS. Defaults in variables.tf are the prod queue.
# meal-order-manager-dev must set both to empty so aggregate-orders cannot
# SendMessage to prod Paychex.
# checkcomponents_queue_url = ""
# checkcomponents_queue_arn = ""

View file

@ -4,6 +4,16 @@ variable "aws_region" {
default = "us-east-1"
}
variable "environment" {
description = "HCP workspace stage. Selects account and workspace name."
type = string
validation {
condition = contains(["dev", "prod"], var.environment)
error_message = "environment must be \"dev\" or \"prod\"."
}
}
variable "domain_name" {
description = "Custom domain served by the CloudFront distribution when attach_custom_domain is true. An ISSUED ACM certificate for this domain must already exist in us-east-1 (see acm.tf)."
type = string
@ -31,6 +41,45 @@ variable "slack_channel_id" {
type = string
}
variable "portal_cognito_issuer" {
description = "Exact issuer URL for the portal Cognito user pool whose ID tokens meal-order-manager accepts."
type = string
validation {
condition = can(regex("^https://cognito-idp\\.[a-z0-9-]+\\.amazonaws\\.com/[A-Za-z0-9_-]+$", var.portal_cognito_issuer))
error_message = "portal_cognito_issuer must be an exact Cognito user-pool issuer URL without a trailing slash."
}
}
variable "portal_cognito_audience" {
description = "Portal Cognito app client ID required in accepted ID-token aud claims."
type = string
validation {
condition = length(trimspace(var.portal_cognito_audience)) > 0
error_message = "portal_cognito_audience must not be empty."
}
}
variable "portal_cognito_extra_trust" {
description = "Additional portal Cognito issuer/audience pairs trusted by the meals API. Use this so portal-dev and portal-prod tokens both work against the single prod meals stack."
type = list(object({
issuer = string
audience = string
}))
default = []
validation {
condition = alltrue([
for pair in var.portal_cognito_extra_trust : (
can(regex("^https://cognito-idp\\.[a-z0-9-]+\\.amazonaws\\.com/[A-Za-z0-9_-]+$", pair.issuer))
&& length(trimspace(pair.audience)) > 0
)
])
error_message = "Each extra trust entry must be a Cognito issuer URL without a trailing slash and a non-empty audience."
}
}
variable "checkcomponents_queue_url" {
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
type = string

View file

@ -20,7 +20,7 @@ terraform {
organization = "seahaven"
workspaces {
name = "meal-order-manager-prod"
tags = ["app:meal-order-manager"]
}
}
}

View file

@ -62,6 +62,57 @@ def test_valid_user_but_not_admin_denied(mock_cid, mock_verify, mock_settings):
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch("admin_authorizer_handler.get_settings")
@patch(
"admin_authorizer_handler._verify_portal_token",
return_value={"name": "Portal Admin", "email": ADMIN_EMAIL},
)
@patch("admin_authorizer_handler.looks_like_cognito_token", return_value=True)
def test_valid_portal_admin_allowed(mock_looks_like, mock_verify, mock_settings):
mock_settings.return_value = {"admin_emails": [ADMIN_EMAIL]}
assert authorizer.lambda_handler(_event("portal-id-token"), None) == {
"isAuthorized": True
}
@patch("admin_authorizer_handler.get_settings")
@patch(
"admin_authorizer_handler._verify_portal_token",
return_value={"name": "Portal User", "email": "user@seahavenind.com"},
)
@patch("admin_authorizer_handler.looks_like_cognito_token", return_value=True)
def test_portal_non_admin_denied(mock_looks_like, mock_verify, mock_settings):
mock_settings.return_value = {"admin_emails": [ADMIN_EMAIL]}
assert authorizer.lambda_handler(_event("portal-id-token"), None) == {
"isAuthorized": False
}
@patch("admin_authorizer_handler._verify_portal_token", return_value=None)
@patch("admin_authorizer_handler.looks_like_cognito_token", return_value=True)
def test_invalid_portal_token_denied_without_google_fallback(
mock_looks_like, mock_verify
):
with patch("admin_authorizer_handler._get_google_client_id") as mock_google:
assert authorizer.lambda_handler(_event("bad-portal-token"), None) == {
"isAuthorized": False
}
mock_google.assert_not_called()
@patch(
"admin_authorizer_handler._verify_portal_token",
side_effect=authorizer.CognitoVerificationUnavailable,
)
@patch("admin_authorizer_handler.looks_like_cognito_token", return_value=True)
def test_portal_verification_outage_denied(mock_looks_like, mock_verify):
assert authorizer.lambda_handler(_event("portal-id-token"), None) == {
"isAuthorized": False
}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)

213
tests/test_cognito.py Normal file
View file

@ -0,0 +1,213 @@
"""Unit tests for portal Cognito ID-token verification."""
import json
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from shared import cognito
ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_TEST"
AUDIENCE = "portal-client-id"
ISSUER_2 = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_PROD"
AUDIENCE_2 = "portal-prod-client"
ISSUER_PARAM = "/meal-order-manager/portal-cognito-issuer"
AUDIENCE_PARAM = "/meal-order-manager/portal-cognito-audience"
TRUST_PARAM = "/meal-order-manager/portal-cognito-trust"
@pytest.fixture(scope="module")
def signing_key():
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key, private_key.public_key()
def _claims(**overrides):
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": AUDIENCE,
"token_use": "id",
"exp": now + 300,
"iat": now,
"email": "employee@seahavenind.com",
"email_verified": False,
"name": "Test Employee",
}
claims.update(overrides)
return claims
def _token(private_key, **overrides):
return jwt.encode(
_claims(**overrides),
private_key,
algorithm="RS256",
headers={"kid": "test-key"},
)
def _parameter(name, decrypt=False):
assert decrypt is False
return {
ISSUER_PARAM: ISSUER,
AUDIENCE_PARAM: AUDIENCE,
TRUST_PARAM: json.dumps(
[
{"issuer": ISSUER, "audience": AUDIENCE},
{"issuer": ISSUER_2, "audience": AUDIENCE_2},
]
),
}[name]
def _verify(token, public_key, trust_param=""):
jwks = MagicMock()
jwks.get_signing_key_from_jwt.return_value = SimpleNamespace(key=public_key)
with (
patch("shared.cognito.get_parameter", side_effect=_parameter),
patch("shared.cognito._jwk_client", return_value=jwks),
):
return cognito.verify_cognito_id_token(
token, ISSUER_PARAM, AUDIENCE_PARAM, trust_param
)
def test_valid_id_token_returns_trusted_identity(signing_key):
private_key, public_key = signing_key
result = _verify(_token(private_key), public_key)
assert result == {
"name": "Test Employee",
"email": "employee@seahavenind.com",
}
def test_unverified_federated_email_is_accepted(signing_key):
private_key, public_key = signing_key
result = _verify(_token(private_key, email_verified=False), public_key)
assert result["email"] == "employee@seahavenind.com"
def test_missing_name_falls_back_to_given_name(signing_key):
private_key, public_key = signing_key
claims = _claims()
del claims["name"]
claims["given_name"] = "Ada"
token = jwt.encode(
claims, private_key, algorithm="RS256", headers={"kid": "test-key"}
)
assert _verify(token, public_key) == {
"name": "Ada",
"email": "employee@seahavenind.com",
}
def test_missing_name_falls_back_to_email_local_part(signing_key):
private_key, public_key = signing_key
claims = _claims()
del claims["name"]
token = jwt.encode(
claims, private_key, algorithm="RS256", headers={"kid": "test-key"}
)
assert _verify(token, public_key) == {
"name": "employee",
"email": "employee@seahavenind.com",
}
def test_trust_list_accepts_second_issuer(signing_key):
private_key, public_key = signing_key
token = _token(private_key, iss=ISSUER_2, aud=AUDIENCE_2)
result = _verify(token, public_key, trust_param=TRUST_PARAM)
assert result == {
"name": "Test Employee",
"email": "employee@seahavenind.com",
}
def test_untrusted_issuer_is_rejected(signing_key):
private_key, public_key = signing_key
token = _token(
private_key,
iss="https://cognito-idp.us-east-1.amazonaws.com/us-east-1_OTHER",
aud=AUDIENCE,
)
assert _verify(token, public_key, trust_param=TRUST_PARAM) is None
@pytest.mark.parametrize(
"overrides",
[
{"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_OTHER"},
{"aud": "other-client"},
{"token_use": "access"},
{"exp": int(time.time()) - 60},
{"email": "employee@example.com"},
],
)
def test_invalid_claims_are_rejected(signing_key, overrides):
private_key, public_key = signing_key
assert _verify(_token(private_key, **overrides), public_key) is None
def test_missing_required_claim_is_rejected(signing_key):
private_key, public_key = signing_key
claims = _claims()
del claims["email"]
token = jwt.encode(
claims, private_key, algorithm="RS256", headers={"kid": "test-key"}
)
assert _verify(token, public_key) is None
def test_ssm_failure_is_rejected(signing_key):
private_key, _ = signing_key
with patch(
"shared.cognito.get_parameter", side_effect=RuntimeError("SSM unavailable")
):
with pytest.raises(cognito.CognitoVerificationUnavailable):
cognito.verify_cognito_id_token(
_token(private_key), ISSUER_PARAM, AUDIENCE_PARAM
)
def test_jwks_failure_is_rejected(signing_key):
private_key, _ = signing_key
jwks = MagicMock()
jwks.get_signing_key_from_jwt.side_effect = OSError("JWKS unavailable")
with (
patch("shared.cognito.get_parameter", side_effect=_parameter),
patch("shared.cognito._jwk_client", return_value=jwks),
):
with pytest.raises(cognito.CognitoVerificationUnavailable):
cognito.verify_cognito_id_token(
_token(private_key), ISSUER_PARAM, AUDIENCE_PARAM
)
def test_cognito_token_detection_is_untrusted_routing_hint(signing_key):
private_key, _ = signing_key
assert cognito.looks_like_cognito_token(_token(private_key)) is True
assert cognito.looks_like_cognito_token("not-a-jwt") is False
assert (
cognito.looks_like_cognito_token(
_token(private_key, iss="https://accounts.google.com")
)
is False
)

View file

@ -9,6 +9,7 @@ import os
import sys
import urllib.error
from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, patch
from zoneinfo import ZoneInfo
@ -204,6 +205,106 @@ def test_publish_menu_rejects_invalid_json(mock_put):
mock_put.assert_not_called()
# ===========================================================================
# PUBLIC MENU
# ===========================================================================
@patch(
"submit_order_handler.get_settings",
return_value={
"bulk_discount_percent": Decimal("10"),
"company_subsidy_percent": Decimal("50"),
"order_deadline": "Thursday at 11:59 PM",
},
)
@patch("submit_order_handler.get_menu")
@patch("submit_order_handler.current_week", return_value="2026-W37")
def test_menu_current_returns_portal_payload(mock_week, mock_get_menu, mock_settings):
mock_get_menu.return_value = {
"form_status": "open",
"scraped_at": "2026-09-14T07:30:00-04:00",
"menu_url": "https://example.com/menu",
"meal_count": Decimal("1"),
"meals": [{"name": "Chicken Bowl", "price": Decimal("12.50")}],
}
event = _make_event(
method="GET",
path="/api/menu/current",
path_parameters={"week": "current"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 200
assert body == {
"week": "2026-W37",
"form_status": "open",
"scraped_at": "2026-09-14T07:30:00-04:00",
"menu_url": "https://example.com/menu",
"meal_count": 1,
"meals": [{"name": "Chicken Bowl", "price": 12.5}],
"bulk_discount_percent": 10.0,
"company_subsidy_percent": 50.0,
"order_deadline": "Thursday at 11:59 PM",
}
mock_get_menu.assert_called_once_with("2026-W37")
@patch(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch("submit_order_handler.get_menu")
def test_menu_explicit_week_uses_safe_defaults(mock_get_menu, mock_settings):
mock_get_menu.return_value = {"meals": [{"name": "Soup", "price": 8}]}
event = _make_event(
method="GET",
path="/api/menu/2026-W36",
path_parameters={"week": "2026-W36"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 200
assert body["week"] == "2026-W36"
assert body["form_status"] == "closed"
assert body["meal_count"] == 1
assert body["order_deadline"] == "Thursday at 11:59 PM"
mock_get_menu.assert_called_once_with("2026-W36")
@patch("submit_order_handler.get_settings")
@patch("submit_order_handler.get_menu", return_value=None)
def test_menu_missing_returns_404(mock_get_menu, mock_settings):
event = _make_event(
method="GET",
path="/api/menu/2026-W35",
path_parameters={"week": "2026-W35"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 404
assert body == {"error": "No menu available for this week"}
mock_settings.assert_not_called()
@patch("submit_order_handler.get_menu")
def test_menu_rejects_malformed_week_without_lookup(mock_get_menu):
event = _make_event(
method="GET",
path="/api/menu/not-a-week",
path_parameters={"week": "not-a-week"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 400
assert "current or YYYY-WNN" in body["error"]
mock_get_menu.assert_not_called()
# ---------------------------------------------------------------------------
# Module-level patches that must be active before the handler is imported
# ---------------------------------------------------------------------------
@ -509,6 +610,116 @@ def test_total_summation_multiple_items(
# ===========================================================================
@patch("submit_order_handler._lambda")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@patch(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch("submit_order_handler._get_google_client_id")
@patch(
"submit_order_handler._verify_portal_token",
return_value={
"name": "Portal Employee",
"email": "portal.employee@seahavenind.com",
},
)
@patch("submit_order_handler.get_menu")
def test_portal_token_identity_takes_precedence(
mock_get_menu,
mock_portal,
mock_google_client_id,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lambda,
):
items = _make_items([(10.00, 1)])
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(
items,
employee_name="Body Name",
employee_email="body@example.com",
extra_body={"google_id_token": "body-google-token"},
)
event["headers"] = {"authorization": "Bearer portal-id-token"}
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 200, body
saved_order = mock_put.call_args.args[2]
assert saved_order["employee_name"] == "Portal Employee"
assert saved_order["employee_email"] == "portal.employee@seahavenind.com"
mock_portal.assert_called_once_with("portal-id-token")
mock_google_client_id.assert_not_called()
@patch("submit_order_handler.put_order")
@patch("submit_order_handler._get_google_client_id")
@patch("submit_order_handler._verify_portal_token", return_value=None)
def test_invalid_portal_token_never_falls_back_to_google_or_body(
mock_portal, mock_google_client_id, mock_put
):
event = _submit_event(
_make_items([(10.00, 1)]),
employee_name="Body Name",
employee_email="body@seahavenind.com",
extra_body={"google_id_token": "valid-google-token"},
)
event["headers"] = {"Authorization": "Bearer invalid-portal-token"}
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 403
assert body == {"error": "Invalid or unauthorized portal account"}
mock_portal.assert_called_once_with("invalid-portal-token")
mock_google_client_id.assert_not_called()
mock_put.assert_not_called()
@patch("submit_order_handler.put_order")
@patch(
"submit_order_handler._verify_portal_token",
side_effect=submit_order_handler.CognitoVerificationUnavailable,
)
def test_portal_verification_outage_returns_503(mock_portal, mock_put):
event = _submit_event(_make_items([(10.00, 1)]))
event["headers"] = {"authorization": "Bearer portal-id-token"}
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 503
assert body == {"error": "Authentication service temporarily unavailable"}
mock_put.assert_not_called()
@patch(
"submit_order_handler.get_settings",
return_value={"admin_emails": ["portal.admin@seahaven.com"]},
)
@patch(
"submit_order_handler._verify_portal_token",
return_value={"name": "Portal Admin", "email": "portal.admin@seahaven.com"},
)
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
def test_in_handler_admin_check_accepts_portal_token(
mock_looks_like, mock_portal, mock_settings
):
event = _make_event(
method="GET",
path="/api/admin/orders",
headers={"authorization": "Bearer portal-id-token"},
)
user, error = submit_order_handler._verify_admin(event)
assert error is None
assert user == {"name": "Portal Admin", "email": "portal.admin@seahaven.com"}
@patch("submit_order_handler._lambda")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@ -1810,3 +2021,97 @@ def test_admin_summary_pdf_requires_admin(mock_verify):
status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None))
assert status == 401, f"Expected 401, got {status}: {body}"
# ===========================================================================
# GET /api/orders/{week} (caller-only)
# ===========================================================================
def _orders_event(week="2026-W36", token="portal-id-token"):
headers = {"authorization": f"Bearer {token}"} if token else {}
return _make_event(
method="GET",
path=f"/api/orders/{week}",
headers=headers,
path_parameters={"week": week},
)
@patch(
"submit_order_handler.get_order",
return_value={
"employee_email": "portal.employee@seahavenind.com",
"employee_name": "Portal Employee",
"items": [{"name": "Soup", "quantity": 1}],
"total": 5.5,
},
)
@patch(
"submit_order_handler._verify_portal_token",
return_value={
"name": "Portal Employee",
"email": "portal.employee@seahavenind.com",
},
)
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
def test_my_orders_returns_only_caller_email(mock_looks_like, mock_portal, mock_get):
status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(), None)
)
assert status == 200
assert body == {
"week": "2026-W36",
"orders": [{"employee_email": "portal.employee@seahavenind.com"}],
}
mock_get.assert_called_once_with("2026-W36", "portal.employee@seahavenind.com")
@patch("submit_order_handler.get_order", return_value=None)
@patch(
"submit_order_handler._verify_portal_token",
return_value={
"name": "Portal Employee",
"email": "portal.employee@seahavenind.com",
},
)
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
def test_my_orders_empty_when_none(mock_looks_like, mock_portal, mock_get):
status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(), None)
)
assert status == 200
assert body == {"week": "2026-W36", "orders": []}
def test_my_orders_requires_bearer():
status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(token=""), None)
)
assert status == 403
assert body == {"error": "Authentication required"}
@patch("submit_order_handler.get_order")
@patch(
"submit_order_handler._verify_portal_token",
return_value={
"name": "Portal Employee",
"email": "portal.employee@seahavenind.com",
},
)
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
def test_my_orders_rejects_malformed_week(mock_looks_like, mock_portal, mock_get):
status, body = _parse_response(
submit_order_handler.lambda_handler(
_orders_event(week="not-a-week"),
None,
)
)
assert status == 400
mock_get.assert_not_called()
assert "YYYY-WNN" in body["error"]

View file

@ -0,0 +1,55 @@
"""Structural checks for portal Cognito ID-token configuration."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_portal_cognito_parameters_are_managed():
locals_tf = _read("locals.tf")
ssm_tf = _read("ssm.tf")
variables_tf = _read("variables.tf")
assert (
'portal_cognito_issuer_param = "${local.ssm_prefix}/portal-cognito-issuer"'
in locals_tf
)
assert (
'portal_cognito_audience_param = "${local.ssm_prefix}/portal-cognito-audience"'
in locals_tf
)
assert (
'portal_cognito_trust_param = "${local.ssm_prefix}/portal-cognito-trust"'
in locals_tf
)
assert 'resource "aws_ssm_parameter" "portal_cognito_issuer"' in ssm_tf
assert 'resource "aws_ssm_parameter" "portal_cognito_audience"' in ssm_tf
assert 'resource "aws_ssm_parameter" "portal_cognito_trust"' in ssm_tf
assert 'variable "portal_cognito_issuer"' in variables_tf
assert 'variable "portal_cognito_audience"' in variables_tf
assert 'variable "portal_cognito_extra_trust"' in variables_tf
def test_both_api_lambdas_receive_parameter_names():
lambda_tf = _read("lambda.tf")
assert lambda_tf.count("PORTAL_COGNITO_ISSUER_PARAM") == 2
assert lambda_tf.count("PORTAL_COGNITO_AUDIENCE_PARAM") == 2
assert lambda_tf.count("PORTAL_COGNITO_TRUST_PARAM") == 2
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_issuer.name") == 2
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_audience.name") == 2
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_trust.name") == 2
def test_submit_route_remains_public_for_google_compatibility():
locals_tf = _read("locals.tf")
submit_route = locals_tf.split("submit_order = {", 1)[1].split("}", 1)[0]
assert 'route_key = "POST /api/submit-order"' in submit_route
assert 'authorizer = "NONE"' in submit_route
assert 'authorizer_type = "JWT"' not in _read("apigateway.tf")

View file

@ -0,0 +1,83 @@
"""Structural checks for the public menu route, portal CORS, and edge cache."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_public_menu_route_and_scoped_lambda_permission():
locals_tf = _read("locals.tf")
assert 'route_key = "GET /api/menu/{week}"' in locals_tf
assert 'authorizer = "NONE"' in locals_tf
assert 'permission_source = "GET/api/menu/*"' in locals_tf
def test_cors_allows_form_portal_and_local_origins():
api = _read("apigateway.tf")
for origin in (
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
):
assert f'"{origin}"' in api
assert 'allow_headers = ["Authorization", "Content-Type"]' in api
assert 'allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]' in api
assert "allow_credentials = false" in api
def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
cloudfront = _read("cloudfront.tf")
assert 'origin_id = "OrderApiOrigin"' in cloudfront
assert (
'domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")'
in cloudfront
)
assert 'path_pattern = "/api/menu/*"' in cloudfront
assert 'target_origin_id = "OrderApiOrigin"' in cloudfront
assert (
"cache_policy_id = aws_cloudfront_cache_policy.menu_api.id"
in cloudfront
)
assert (
"origin_request_policy_id = aws_cloudfront_origin_request_policy.menu_api.id"
in cloudfront
)
assert "default_ttl = 60" in cloudfront
assert "max_ttl = 60" in cloudfront
assert "min_ttl = 60" in cloudfront
assert 'items = ["Origin"]' in cloudfront
def test_cloudfront_forwards_portal_api_paths_without_publish_or_roster():
cloudfront = _read("cloudfront.tf")
locals_tf = _read("locals.tf")
assert 'route_key = "GET /api/orders/{week}"' in locals_tf
assert 'permission_source = "GET/api/orders/*"' in locals_tf
for pattern in (
'"/api/form-status/*"',
'"/api/orders/*"',
'"/api/submit-order"',
'"/api/admin/*"',
):
assert pattern in cloudfront
assert 'path_pattern = "/api/*"' not in cloudfront
assert "/api/publish" not in cloudfront
assert "/api/roster" not in cloudfront
assert "custom_error_response" not in cloudfront
assert 'resource "aws_cloudfront_function" "form_spa_rewrite"' in cloudfront
assert "function_arn = aws_cloudfront_function.form_spa_rewrite.arn" in cloudfront
assert "cloudfront:DescribeFunction" in _read("hcp_iam.tf")
assert "AllViewerExceptHostHeader" in locals_tf or (
"b689b0a8-53d0-40ab-baf2-68738e2966ac" in locals_tf
)