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
This commit is contained in:
Adam Moussa 2026-08-03 14:27:59 -04:00 • committed by GitHub
parent 7e73bd2bfe
commit 88399fe153
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 314 additions and 70 deletions

View file

@ -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'

View file

@ -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 |

View file

@ -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:

View file

@ -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())

View file

@ -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),
}
)
)

View file

@ -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:

View file

@ -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)

View file

@ -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
# ---------------------------------------------------------------------------