mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 00:01:58 +00:00
feat(edge): proxy portal meals API paths on orders.seahaven.com (DEV-282) (#193)
Some checks are pending
Build Lambda Layer / build (push) Waiting to run
Some checks are pending
Build Lambda Layer / build (push) Waiting to run
* feat(edge): proxy portal meals API paths on orders.seahaven.com (DEV-282) Keep the static form on / while CloudFront forwards submit, form-status, admin, and caller-only order lookup so the portal can use the public meals host without a form redirect. * fix(style): apply ruff format
This commit is contained in:
parent
a81241dc03
commit
85f36fcfff
6 changed files with 259 additions and 24 deletions
|
|
@ -177,8 +177,8 @@ def _verify_portal_token(token: str) -> dict | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _verify_admin(event) -> tuple[dict | None, dict | None]:
|
def _verify_identity(event) -> tuple[dict | None, dict | None]:
|
||||||
"""Verify portal or Google auth and admin access."""
|
"""Verify a portal Cognito or Google bearer token."""
|
||||||
token = _extract_bearer_token(event)
|
token = _extract_bearer_token(event)
|
||||||
if not token:
|
if not token:
|
||||||
return None, response(403, {"error": "Authentication required"})
|
return None, response(403, {"error": "Authentication required"})
|
||||||
|
|
@ -192,23 +192,41 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]:
|
||||||
return None, response(
|
return None, response(
|
||||||
403, {"error": "Invalid or unauthorized portal account"}
|
403, {"error": "Invalid or unauthorized portal account"}
|
||||||
)
|
)
|
||||||
else:
|
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"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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():
|
if user_info["email"].lower() not in _get_admin_emails():
|
||||||
return None, response(403, {"error": "Admin access required"})
|
return None, response(403, {"error": "Admin access required"})
|
||||||
|
|
||||||
return user_info, None
|
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):
|
def lambda_handler(event, context):
|
||||||
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
|
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
|
||||||
path = event.get("rawPath", "")
|
path = event.get("rawPath", "")
|
||||||
|
|
@ -235,6 +253,9 @@ def lambda_handler(event, context):
|
||||||
if "/form-status/" in path:
|
if "/form-status/" in path:
|
||||||
return handle_form_status(event)
|
return handle_form_status(event)
|
||||||
|
|
||||||
|
if "/api/orders/" in path and method == "GET":
|
||||||
|
return handle_my_orders(event)
|
||||||
|
|
||||||
if "/roster" in path:
|
if "/roster" in path:
|
||||||
return handle_roster()
|
return handle_roster()
|
||||||
|
|
||||||
|
|
@ -514,6 +535,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):
|
def handle_form_status(event):
|
||||||
week = event.get("pathParameters", {}).get("week", current_week())
|
week = event.get("pathParameters", {}).get("week", current_week())
|
||||||
status = get_form_status(week)
|
status = get_form_status(week)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# HTTP API fronting the order form.
|
# HTTP API fronting the order form.
|
||||||
#
|
#
|
||||||
# Three authorization modes coexist, matching template.yaml:
|
# Three authorization modes coexist, matching template.yaml:
|
||||||
# NONE — the public form routes (submit-order, menu, 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
|
# AWS_IAM — the weekly-menu publication routes, called with SigV4 by
|
||||||
# scripts/upload_menu.py from GitHub Actions
|
# scripts/upload_menu.py from GitHub Actions
|
||||||
# CUSTOM — every /api/admin route, behind the Google ID token authorizer
|
# CUSTOM — every /api/admin route, behind the Google ID token authorizer
|
||||||
#
|
#
|
||||||
# All ten routes integrate with submit-order, which dispatches internally on
|
# All eleven routes integrate with submit-order, which dispatches internally on
|
||||||
# the route key.
|
# the route key.
|
||||||
|
|
||||||
resource "aws_apigatewayv2_api" "order_api" {
|
resource "aws_apigatewayv2_api" "order_api" {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,26 @@ resource "aws_cloudfront_origin_request_policy" "menu_api" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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" {
|
resource "aws_cloudfront_distribution" "form" {
|
||||||
enabled = true
|
enabled = true
|
||||||
is_ipv6_enabled = true
|
is_ipv6_enabled = true
|
||||||
|
|
@ -100,6 +120,51 @@ resource "aws_cloudfront_distribution" "form" {
|
||||||
origin_request_policy_id = aws_cloudfront_origin_request_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 {
|
default_cache_behavior {
|
||||||
target_origin_id = "S3FormOrigin"
|
target_origin_id = "S3FormOrigin"
|
||||||
viewer_protocol_policy = "redirect-to-https"
|
viewer_protocol_policy = "redirect-to-https"
|
||||||
|
|
@ -110,16 +175,12 @@ resource "aws_cloudfront_distribution" "form" {
|
||||||
# AWS managed policy: CachingDisabled. The form HTML is republished weekly
|
# 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
|
# and read through a signed API, so a stale edge copy is worse than an
|
||||||
# origin fetch.
|
# 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
|
function_association {
|
||||||
# 404. Rewriting it to the form keeps deep links working, matching the SAM
|
event_type = "viewer-request"
|
||||||
# template.
|
function_arn = aws_cloudfront_function.form_spa_rewrite.arn
|
||||||
custom_error_response {
|
}
|
||||||
error_code = 403
|
|
||||||
response_code = 200
|
|
||||||
response_page_path = "/index.html"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restrictions {
|
restrictions {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ locals {
|
||||||
SLACK_BOT_SM_NAME = local.slack_bot_secret_name
|
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
|
# HTTP API routes, all integrated with submit-order. `authorizer` selects the
|
||||||
# authorization mode; `permission_source` is the method/path suffix of the
|
# authorization mode; `permission_source` is the method/path suffix of the
|
||||||
# per-route lambda:InvokeFunction grant (path parameters become `*`).
|
# per-route lambda:InvokeFunction grant (path parameters become `*`).
|
||||||
|
|
@ -65,6 +72,11 @@ locals {
|
||||||
authorizer = "NONE"
|
authorizer = "NONE"
|
||||||
permission_source = "GET/api/menu/*"
|
permission_source = "GET/api/menu/*"
|
||||||
}
|
}
|
||||||
|
my_orders = {
|
||||||
|
route_key = "GET /api/orders/{week}"
|
||||||
|
authorizer = "NONE"
|
||||||
|
permission_source = "GET/api/orders/*"
|
||||||
|
}
|
||||||
form_status = {
|
form_status = {
|
||||||
route_key = "GET /api/form-status/{week}"
|
route_key = "GET /api/form-status/{week}"
|
||||||
authorizer = "NONE"
|
authorizer = "NONE"
|
||||||
|
|
|
||||||
|
|
@ -2021,3 +2021,97 @@ def test_admin_summary_pdf_requires_admin(mock_verify):
|
||||||
status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None))
|
status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None))
|
||||||
|
|
||||||
assert status == 401, f"Expected 401, got {status}: {body}"
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,27 @@ def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
|
||||||
assert "max_ttl = 60" in cloudfront
|
assert "max_ttl = 60" in cloudfront
|
||||||
assert "min_ttl = 60" in cloudfront
|
assert "min_ttl = 60" in cloudfront
|
||||||
assert 'items = ["Origin"]' 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 "AllViewerExceptHostHeader" in locals_tf or (
|
||||||
|
"b689b0a8-53d0-40ab-baf2-68738e2966ac" in locals_tf
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue