mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Compare commits
8 commits
51208390db
...
1d869a5b6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d869a5b6f | ||
|
|
bbbe359858 | ||
|
|
44c79fdefd | ||
|
|
12f1eb881f | ||
|
|
85f36fcfff | ||
|
|
a81241dc03 | ||
|
|
26a92a2f62 | ||
|
|
86bb476e27 |
36 changed files with 1507 additions and 126 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.78
|
||||
boto3==1.43.97
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.78
|
||||
boto3==1.43.97
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.78
|
||||
boto3==1.43.97
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.78
|
||||
boto3==1.43.97
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.78
|
||||
boto3==1.43.97
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
boto3==1.43.78
|
||||
boto3==1.43.97
|
||||
fpdf2==2.8.8
|
||||
PyJWT[crypto]==2.14.0
|
||||
|
|
|
|||
182
src/shared/shared/cognito.py
Normal file
182
src/shared/shared/cognito.py
Normal 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
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ terraform {
|
|||
organization = "seahaven"
|
||||
|
||||
workspaces {
|
||||
name = "meal-order-manager-prod"
|
||||
tags = ["app:meal-order-manager"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
213
tests/test_cognito.py
Normal 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
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
55
tests/test_terraform_cognito_auth.py
Normal file
55
tests/test_terraform_cognito_auth.py
Normal 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")
|
||||
83
tests/test_terraform_menu_api.py
Normal file
83
tests/test_terraform_menu_api.py
Normal 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
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue