From 88399fe153ebdc915a87a8c34975bfb8ad72de08 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:27:59 -0400 Subject: [PATCH] refactor(weekly-menu): isolate menu writes behind API (#94) * feat(api): add IAM-authenticated menu publication Keep weekly menu writes behind Lambda so the GitHub runtime role cannot access the shared DynamoDB table directly. * refactor(workflow): publish weekly menus through API Use SigV4 requests for settings and menu publication so the scheduled workflow no longer needs direct DynamoDB access. * fix: address review comments * style(python): apply Ruff formatting --- .github/workflows/weekly-menu.yml | 28 +++---- README.md | 21 ++++- functions/submit_order/handler.py | 63 +++++++++++++++ scripts/upload_menu.py | 122 ++++++++++++++++++++---------- src/shared/shared/db.py | 22 +++--- template.yaml | 22 +++++- tests/test_generate_form.py | 22 ++++++ tests/test_submit_order.py | 84 ++++++++++++++++++++ 8 files changed, 314 insertions(+), 70 deletions(-) diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml index d465940..f6d0ed2 100644 --- a/.github/workflows/weekly-menu.yml +++ b/.github/workflows/weekly-menu.yml @@ -92,22 +92,12 @@ jobs: if: env.SKIP_RUN != 'true' id: discount run: | - RESULT=$(aws dynamodb get-item \ - --table-name meal-order-manager-orders \ - --key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \ - --output json 2>/dev/null || echo '{}') - BULK=$(echo "$RESULT" | python3 -c " - import sys, json - d = json.load(sys.stdin) - print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0')) - " 2>/dev/null || echo "0") - SUBSIDY=$(echo "$RESULT" | python3 -c " - import sys, json - d = json.load(sys.stdin) - print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0')) - " 2>/dev/null || echo "0") - echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT - echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT + SETTINGS=$(python3 scripts/upload_menu.py settings \ + --api-url "${{ steps.stack.outputs.api_url }}") + BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS") + SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS") + echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT" + echo "company_subsidy=$SUBSIDY" >> "$GITHUB_OUTPUT" - name: Get Google Client ID if: env.SKIP_RUN != 'true' @@ -132,9 +122,11 @@ jobs: --company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \ --google-client-id "${{ steps.google.outputs.client_id }}" - - name: Upload menu to DynamoDB + - name: Publish menu through API if: env.SKIP_RUN != 'true' - run: python3 scripts/upload_menu.py + run: | + python3 scripts/upload_menu.py publish \ + --api-url "${{ steps.stack.outputs.api_url }}" - name: Upload form to S3 if: env.SKIP_RUN != 'true' diff --git a/README.md b/README.md index 070167d..c6d0860 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Monday 7:30am ET Employees (Mon–Thu) Thursda ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ GitHub Actions │ │ orders.seahaven │ │ EventBridge │ │ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │ -│ - Generate form │ + DynamoDB │ (CloudFront+S3) │──POST───┐ │ - Aggregate │ +│ - Generate form │ + signed API │ (CloudFront+S3) │──POST───┐ │ - Aggregate │ │ - Slack notify │ └──────────────────┘ │ │ - Slack summary │ └─────────────────┘ ▼ └──────────────────┘ ┌──────────┐ @@ -50,6 +50,25 @@ the generated HTML, and the generated deployment artifact remains self-contained | Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM | | Thursday 6pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge → Lambda chain | +### Weekly menu publication boundary + +The scheduled GitHub workflow has no DynamoDB permissions. It signs two requests +with its short-lived OIDC role credentials: + +- `GET /api/publish/settings` returns only the bulk discount and company subsidy. +- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu. + +Both routes use API Gateway `AWS_IAM` authorization and invoke the existing +submit-order Lambda. The GitHub role can invoke only these method and path +combinations. Employee orders, the roster, admin configuration, and all direct +DynamoDB actions remain inaccessible to the role. + +Deploy the API routes before switching the workflow and IAM policy. No data +migration is required because the Lambda writes the existing `WEEK#...` / `MENU` +record shape. To roll back, restore the previous workflow and its DynamoDB policy +together; restoring only the policy does not make the API-based workflow depend on +DynamoDB access. + ### Reports (written to `meal-order-manager-reports-*` at Thursday close) | Key | Contents | diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index d6c6276..5f3efb2 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -24,6 +24,7 @@ from shared.db import ( get_settings, get_summary, list_weeks, + put_menu, put_order, ) from shared.secrets import get_parameter @@ -185,6 +186,12 @@ def lambda_handler(event, context): method = event.get("requestContext", {}).get("http", {}).get("method", "GET") path = event.get("rawPath", "") + if path == "/api/publish/settings" and method == "GET": + return handle_publish_settings() + + if path == "/api/publish/menu" and method == "POST": + return handle_publish_menu(event) + if "/admin/orders" in path: if method == "DELETE": return handle_admin_delete(event) @@ -207,6 +214,62 @@ def lambda_handler(event, context): return response(405, {"error": "Method not allowed"}) +def handle_publish_settings(): + """Return only the pricing fields needed by the weekly-menu workflow.""" + settings = get_settings() + return response( + 200, + { + "bulk_discount_percent": float(settings.get("bulk_discount_percent", 0)), + "company_subsidy_percent": float( + settings.get("company_subsidy_percent", 0) + ), + }, + ) + + +def handle_publish_menu(event): + """Persist a scraped menu for the current Eastern-time week.""" + try: + body = json.loads(event.get("body", "{}")) + except json.JSONDecodeError: + return response(400, {"error": "Invalid JSON"}) + + if not isinstance(body, dict): + return response(400, {"error": "Request body must be a JSON object"}) + + meals = body.get("meals") + if not isinstance(meals, list) or not meals: + return response(400, {"error": "At least one meal is required"}) + + for meal in meals: + price = meal.get("price") if isinstance(meal, dict) else None + if ( + not isinstance(meal, dict) + or not str(meal.get("name", "")).strip() + or isinstance(price, bool) + or not isinstance(price, (int, float)) + or price < 0 + ): + return response( + 400, {"error": "Each meal requires a name and non-negative price"} + ) + + week = current_week() + menu = { + "scraped_at": body.get("scraped_at"), + "menu_url": body.get("menu_url"), + "meal_count": len(meals), + "meals": meals, + } + put_menu(week, menu) + logger.info("Published %s meals for %s", len(meals), week) + return response( + 200, + {"status": "published", "week": week, "meal_count": len(meals)}, + ) + + def handle_admin_orders(event): user, err = _verify_admin(event) if err: diff --git a/scripts/upload_menu.py b/scripts/upload_menu.py index 7efd138..b9b5f12 100644 --- a/scripts/upload_menu.py +++ b/scripts/upload_menu.py @@ -1,60 +1,102 @@ -""" -Uploads the latest scraped menu JSON to DynamoDB. -Used by the GitHub Actions weekly workflow after scraping. -""" +"""Call the IAM-protected weekly-menu publication API with SigV4.""" +import argparse import json import os import sys -from datetime import datetime -from decimal import Decimal +import urllib.error +import urllib.request from pathlib import Path import boto3 +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest PROJECT_ROOT = Path(__file__).resolve().parents[1] OUTPUT_DIR = PROJECT_ROOT / "output" -TABLE_NAME = os.environ.get("TABLE_NAME", "meal-order-manager-orders") -def convert_floats(obj): - if isinstance(obj, float): - return Decimal(str(obj)) - if isinstance(obj, dict): - return {k: convert_floats(v) for k, v in obj.items()} - if isinstance(obj, list): - return [convert_floats(i) for i in obj] - return obj +def signed_request( + api_url: str, + path: str, + method: str = "GET", + body: dict | None = None, + region: str = "us-east-1", +) -> dict: + if not api_url.startswith(("http://", "https://")): + raise ValueError(f"api_url must use http(s) scheme: {api_url!r}") + data = json.dumps(body).encode() if body is not None else None + headers = {"Content-Type": "application/json"} if data is not None else {} + request = AWSRequest( + method=method, + url=f"{api_url.rstrip('/')}{path}", + data=data, + headers=headers, + ) + credentials = boto3.Session().get_credentials() + if credentials is None: + raise RuntimeError("AWS credentials are required") + SigV4Auth(credentials.get_frozen_credentials(), "execute-api", region).add_auth( + request + ) + + prepared = request.prepare() + http_request = urllib.request.Request( + prepared.url, + data=prepared.body, + headers=dict(prepared.headers), + method=method, + ) + try: + with urllib.request.urlopen(http_request, timeout=30) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace") + raise RuntimeError( + f"Publication API returned HTTP {exc.code}: {detail}" + ) from exc + + +def get_settings(api_url: str, region: str) -> dict: + return signed_request(api_url, "/api/publish/settings", method="GET", region=region) + + +def publish_menu(api_url: str, region: str) -> dict: + files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True) + if not files: + raise RuntimeError("No menu JSON found. Run scrape_menu.py first.") + + with files[0].open() as menu_file: + menu = json.load(menu_file) + return signed_request( + api_url, + "/api/publish/menu", + method="POST", + body=menu, + region=region, + ) def main(): - files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True) - if not files: - print("Error: No menu JSON found. Run scrape_menu.py first.", file=sys.stderr) - sys.exit(1) + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("settings", "publish")) + parser.add_argument("--api-url", required=True) + parser.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1")) + args = parser.parse_args() - with open(files[0]) as f: - menu = json.load(f) + try: + result = ( + get_settings(args.api_url, args.region) + if args.action == "settings" + else publish_menu(args.api_url, args.region) + ) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 - week = datetime.now().strftime("%Y-W%U") - table = boto3.resource("dynamodb").Table(TABLE_NAME) - - import time - - item = { - "PK": f"WEEK#{week}", - "SK": "MENU", - "form_status": "open", - "scraped_at": menu.get("scraped_at"), - "menu_url": menu.get("menu_url"), - "meal_count": menu.get("meal_count"), - "meals": convert_floats(menu.get("meals", [])), - "ttl": int(time.time()) + (90 * 86400), - } - - table.put_item(Item=item) - print(f"Uploaded menu for {week} to DynamoDB ({len(menu.get('meals', []))} meals)") + print(json.dumps(result)) + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/src/shared/shared/db.py b/src/shared/shared/db.py index 27502a9..39cffde 100644 --- a/src/shared/shared/db.py +++ b/src/shared/shared/db.py @@ -34,16 +34,18 @@ def ttl_days(days: int) -> int: def put_menu(week: str, menu_data: dict): _get_table().put_item( - Item={ - "PK": f"WEEK#{week}", - "SK": "MENU", - "form_status": "open", - "scraped_at": menu_data.get("scraped_at"), - "menu_url": menu_data.get("menu_url"), - "meal_count": menu_data.get("meal_count"), - "meals": menu_data.get("meals"), - "ttl": ttl_days(90), - } + Item=_to_decimal( + { + "PK": f"WEEK#{week}", + "SK": "MENU", + "form_status": "open", + "scraped_at": menu_data.get("scraped_at"), + "menu_url": menu_data.get("menu_url"), + "meal_count": menu_data.get("meal_count"), + "meals": menu_data.get("meals"), + "ttl": ttl_days(90), + } + ) ) diff --git a/template.yaml b/template.yaml index d51f97b..b2f5737 100644 --- a/template.yaml +++ b/template.yaml @@ -220,8 +220,9 @@ Resources: # authorizer validates the same Google ID token (Authorization: Bearer) # the admin panel already sends, so admin routes are no longer # AuthorizationType NONE. Public routes (submit-order, form-status, - # roster) stay open — they're explicitly set to NONE on their events. + # roster) stay open, while weekly-menu publication routes opt into IAM. Auth: + EnableIamAuthorizer: true Authorizers: AdminGoogleAuthorizer: FunctionArn: !GetAtt AdminAuthorizerFunction.Arn @@ -248,6 +249,9 @@ Resources: 'POST /api/submit-order': ThrottlingBurstLimit: 10 ThrottlingRateLimit: 5 + 'POST /api/publish/menu': + ThrottlingBurstLimit: 2 + ThrottlingRateLimit: 1 # CORS only allows the production domain. For local development, use the # Flask dev server (app.py) which proxies API requests and doesn't enforce CORS. CorsConfiguration: @@ -317,6 +321,22 @@ Resources: ApiId: !Ref OrderApi Path: /api/roster Method: GET + PublishSettings: + Type: HttpApi + Properties: + ApiId: !Ref OrderApi + Path: /api/publish/settings + Method: GET + Auth: + Authorizer: AWS_IAM + PublishMenu: + Type: HttpApi + Properties: + ApiId: !Ref OrderApi + Path: /api/publish/menu + Method: POST + Auth: + Authorizer: AWS_IAM AdminOrders: Type: HttpApi Properties: diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 1cd103b..dd07dce 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -178,6 +178,28 @@ class TestGenerateFormStructural: assert "ThrottlingBurstLimit: 10" in submit_settings assert "ThrottlingRateLimit: 5" in submit_settings + def test_weekly_menu_uses_iam_protected_api_without_dynamodb(self): + template = (REPO_ROOT / "template.yaml").read_text() + workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text() + script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text() + + for route in ("/api/publish/settings", "/api/publish/menu"): + next_event = ( + " PublishMenu:" + if route == "/api/publish/settings" + else " AdminOrders:" + ) + route_config = template.split(f"Path: {route}", 1)[1].split(next_event, 1)[ + 0 + ] + assert "Authorizer: AWS_IAM" in route_config + assert route in script + + assert "scripts/upload_menu.py settings" in workflow + assert "scripts/upload_menu.py publish" in workflow + assert "aws dynamodb" not in workflow + assert 'boto3.resource("dynamodb")' not in script + def test_local_and_google_render(self): local = _render(google=False) google = _render(google=True) diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index a4ee2b7..cc578e3 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -120,6 +120,90 @@ def _menu_doc_from_retail_pairs(pairs): } +# =========================================================================== +# WEEKLY MENU PUBLICATION +# =========================================================================== + + +@patch( + "submit_order_handler.get_settings", + return_value={ + "bulk_discount_percent": 10, + "company_subsidy_percent": 50, + "admin_emails": ["admin@seahavenind.com"], + }, +) +def test_publish_settings_returns_only_pricing(mock_settings): + result = submit_order_handler.lambda_handler( + _make_event(method="GET", path="/api/publish/settings"), None + ) + status, body = _parse_response(result) + + assert status == 200 + assert body == { + "bulk_discount_percent": 10.0, + "company_subsidy_percent": 50.0, + } + + +@patch("submit_order_handler.put_menu") +@patch("submit_order_handler.current_week", return_value="2026-W30") +def test_publish_menu_uses_current_week_and_server_meal_count(mock_week, mock_put): + menu = { + "scraped_at": "2026-07-27T07:31:00-04:00", + "menu_url": "https://example.com/menu", + "meal_count": 999, + "meals": [{"name": "Chicken Bowl", "price": 12.5}], + } + result = submit_order_handler.lambda_handler( + _make_event(method="POST", path="/api/publish/menu", body=menu), None + ) + status, body = _parse_response(result) + + assert status == 200 + assert body == {"status": "published", "week": "2026-W30", "meal_count": 1} + mock_put.assert_called_once_with( + "2026-W30", + { + "scraped_at": menu["scraped_at"], + "menu_url": menu["menu_url"], + "meal_count": 1, + "meals": menu["meals"], + }, + ) + + +@pytest.mark.parametrize( + "body", + [ + {}, + {"meals": []}, + {"meals": [{"name": "", "price": 12.5}]}, + {"meals": [{"name": "Chicken Bowl", "price": -1}]}, + {"meals": [{"name": "Chicken Bowl", "price": "12.50"}]}, + ], +) +@patch("submit_order_handler.put_menu") +def test_publish_menu_rejects_invalid_payload(mock_put, body): + result = submit_order_handler.lambda_handler( + _make_event(method="POST", path="/api/publish/menu", body=body), None + ) + status, _ = _parse_response(result) + + assert status == 400 + mock_put.assert_not_called() + + +@patch("submit_order_handler.put_menu") +def test_publish_menu_rejects_invalid_json(mock_put): + event = _make_event(method="POST", path="/api/publish/menu") + event["body"] = "{" + status, _ = _parse_response(submit_order_handler.lambda_handler(event, None)) + + assert status == 400 + mock_put.assert_not_called() + + # --------------------------------------------------------------------------- # Module-level patches that must be active before the handler is imported # ---------------------------------------------------------------------------