# HTTP API fronting the order form. # # Three authorization modes coexist, matching template.yaml: # NONE — the public form routes (submit-order, menu, form-status, roster) # 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 # the route key. resource "aws_apigatewayv2_api" "order_api" { name = local.project protocol_type = "HTTP" description = "meal-order-manager order form and admin API" cors_configuration { allow_origins = [ "https://orders.seahaven.com", "https://internal.seahaven.com", "https://internal.dev.seahaven.com", "http://localhost:5173", "http://localhost:4173", ] allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] allow_headers = ["Authorization", "Content-Type"] allow_credentials = false max_age = 3600 } } # Result caching is off. With caching, an expired Google token or an admin # removed from the allow-list would stay authorized for the cache TTL, and the # tokeninfo call dominates latency anyway. # # Invoke permission is a Lambda resource policy (below), not AuthorizerCredentialsArn. # The credentials-role path returned 500 without invoking the authorizer in prod # (PLAT-102); resource policy matches the submit-order route grants and the live hotfix. resource "aws_apigatewayv2_authorizer" "admin_google" { api_id = aws_apigatewayv2_api.order_api.id name = "AdminGoogleAuthorizer" authorizer_type = "REQUEST" authorizer_uri = aws_lambda_function.admin_authorizer.invoke_arn authorizer_payload_format_version = "2.0" authorizer_result_ttl_in_seconds = 0 enable_simple_responses = true identity_sources = ["$request.header.Authorization"] } resource "aws_apigatewayv2_integration" "submit_order" { api_id = aws_apigatewayv2_api.order_api.id integration_type = "AWS_PROXY" integration_method = "POST" integration_uri = aws_lambda_function.submit_order.invoke_arn payload_format_version = "2.0" timeout_milliseconds = 30000 } resource "aws_apigatewayv2_route" "this" { for_each = local.api_routes api_id = aws_apigatewayv2_api.order_api.id route_key = each.value.route_key target = "integrations/${aws_apigatewayv2_integration.submit_order.id}" authorization_type = each.value.authorizer authorizer_id = each.value.authorizer == "CUSTOM" ? aws_apigatewayv2_authorizer.admin_google.id : null } resource "aws_apigatewayv2_stage" "default" { api_id = aws_apigatewayv2_api.order_api.id name = "$default" auto_deploy = true access_log_settings { destination_arn = aws_cloudwatch_log_group.api_access.arn format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}" } default_route_settings { throttling_burst_limit = 50 throttling_rate_limit = 100 } # Order submission is human-paced; menu publication runs once a week. Both are # throttled well below the account default so a loop in either client cannot # exhaust the API's burst budget for the public form. route_settings { route_key = "POST /api/submit-order" throttling_burst_limit = 10 throttling_rate_limit = 5 } route_settings { route_key = "POST /api/publish/menu" throttling_burst_limit = 2 throttling_rate_limit = 1 } depends_on = [aws_apigatewayv2_route.this] } # One grant per route rather than a single wildcard, so adding a route to the # API does not silently make the function invocable through it. resource "aws_lambda_permission" "api_route" { for_each = local.api_routes statement_id = "AllowApiGatewayInvoke-${each.key}" action = "lambda:InvokeFunction" function_name = aws_lambda_function.submit_order.function_name principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.order_api.execution_arn}/*/${each.value.permission_source}" } # Grant API Gateway permission to invoke the admin authorizer Lambda. Source ARN # is the authorizer itself (not a route), matching the AWS HTTP API docs. # Import adopts the PLAT-102 live hotfix statement so the first apply does not # attempt a duplicate AddPermission. import { to = aws_lambda_permission.admin_authorizer id = "meal-order-manager-admin-authorizer/AllowApiGatewayInvokeAuthorizer" } resource "aws_lambda_permission" "admin_authorizer" { statement_id = "AllowApiGatewayInvokeAuthorizer" action = "lambda:InvokeFunction" function_name = aws_lambda_function.admin_authorizer.function_name principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.order_api.execution_arn}/authorizers/${aws_apigatewayv2_authorizer.admin_google.id}" } # PLAT-102 follow-up: role already deleted in AWS after apply failed on # iam:ListInstanceProfilesForRole. Drop from state without a destroy call. removed { from = aws_iam_role.admin_authorizer_invoke lifecycle { destroy = false } }