mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 03:03:12 +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]:
|
||||
"""Verify portal or Google auth and admin access."""
|
||||
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"})
|
||||
|
|
@ -192,23 +192,41 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]:
|
|||
return None, response(
|
||||
403, {"error": "Invalid or unauthorized portal account"}
|
||||
)
|
||||
else:
|
||||
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
|
||||
|
||||
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", "")
|
||||
|
|
@ -235,6 +253,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()
|
||||
|
||||
|
|
@ -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):
|
||||
week = event.get("pathParameters", {}).get("week", current_week())
|
||||
status = get_form_status(week)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# HTTP API fronting the order form.
|
||||
#
|
||||
# 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
|
||||
# scripts/upload_menu.py from GitHub Actions
|
||||
# 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.
|
||||
|
||||
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" {
|
||||
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
|
||||
}
|
||||
|
||||
# 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"
|
||||
|
|
@ -110,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 {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,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 `*`).
|
||||
|
|
@ -65,6 +72,11 @@ locals {
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -2021,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"]
|
||||
|
|
|
|||
|
|
@ -56,3 +56,27 @@ def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
|
|||
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 "AllViewerExceptHostHeader" in locals_tf or (
|
||||
"b689b0a8-53d0-40ab-baf2-68738e2966ac" in locals_tf
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue