mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-02 00:13:11 +00:00
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:
parent
7e73bd2bfe
commit
88399fe153
8 changed files with 314 additions and 70 deletions
28
.github/workflows/weekly-menu.yml
vendored
28
.github/workflows/weekly-menu.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
21
README.md
21
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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue