meal-order-manager/template.yaml
Adam Moussa 75fb3280f5
Some checks failed
Deploy / deploy (push) Has been cancelled
Add gateway-level authorizer to admin API (INFRA-100) (#24)
The /api/admin/* routes (GET/PUT/DELETE /api/admin/orders and
GET /api/admin/summary-pdf) were AuthorizationType NONE, relying entirely on
the in-handler _verify_admin Google-token + admin-email check. This adds an
HTTP API Lambda authorizer that enforces the same check at the gateway, so
unauthenticated requests are rejected before reaching the integration.

- New admin_authorizer Lambda: validates the Authorization: Bearer Google ID
  token (aud + allowed Workspace domain) and the admin_emails allow-list from
  DynamoDB, returning the HTTP API simple response {isAuthorized}. Fails closed
  on missing config, unavailable client ID, bad token, or DynamoDB error.
- OrderApi gains an AdminGoogleAuthorizer with result caching disabled
  (AuthorizerResultTtlInSeconds: 0) so expired tokens / removed admins can't be
  served from cache. Wired onto all four admin events; no DefaultAuthorizer, so
  public routes (submit-order, form-status, roster) stay NONE.
- IAM role for API Gateway to invoke the authorizer; 60-day log group.
- 10 unit tests for the authorizer.

No client change: the admin panel already sends Authorization: Bearer
<google_id_token>. The in-handler _verify_admin check stays as defense-in-depth.

Cross-reviewed by GPT-4.1 (APPROVE-WITH-FIXES); both BLOCK items applied
(disable authorizer caching, fail-closed on DynamoDB error).
2026-06-08 18:05:09 -04:00

652 lines
22 KiB
YAML

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
meal-order-manager — automated weekly meal ordering from Redefine Meals
with employee order collection, Slack notifications, and payroll deduction reports.
Parameters:
CustomDomain:
Type: String
Default: orders.seahaven.com
Description: Custom domain for the order form (requires ACM cert)
CertificateArn:
Type: String
Default: ''
Description: ACM certificate ARN for the custom domain (us-east-1)
PayrollEmail:
Type: String
Default: payroll@seahavenind.com
Description: Email address for payroll deduction reports
SenderEmail:
Type: String
Default: adam@seahavenind.com
Description: SES verified sender email for payroll reports
# Shared CloudFront WAF WebACL ARN (audit M-17), published to SSM by
# seahaven-account-baseline. Resolved at deploy time.
WebAclArn:
Type: AWS::SSM::Parameter::Value<String>
Default: /seahaven/waf/app-web-acl-arn
Description: ARN of the shared seahaven-app-waf CloudFront WebACL
Conditions:
HasCustomDomain: !Not [!Equals [!Ref CertificateArn, '']]
Globals:
Function:
Runtime: python3.12
Architectures:
- arm64
Timeout: 30
MemorySize: 256
Environment:
Variables:
TABLE_NAME: !Ref OrdersTable
REPORTS_BUCKET: !Ref ReportsBucket
SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id
FORM_URL: !If
- HasCustomDomain
- !Sub 'https://${CustomDomain}'
- !Sub 'https://${FormDistribution.DomainName}'
SLACK_BOT_SM_NAME: meal-order-manager/slack-bot-token
Layers:
- !Ref SharedLayer
Resources:
# ─── Shared Layer ───────────────────────────────────────────────
SharedLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: meal-order-manager-shared
ContentUri: src/shared/
CompatibleRuntimes:
- python3.12
CompatibleArchitectures:
- arm64
Metadata:
BuildMethod: python3.12
BuildArchitecture: arm64
# ─── DynamoDB ───────────────────────────────────────────────────
OrdersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: meal-order-manager-orders
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
- AttributeName: SK
AttributeType: S
KeySchema:
- AttributeName: PK
KeyType: HASH
- AttributeName: SK
KeyType: RANGE
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
# ─── S3 Buckets ────────────────────────────────────────────────
FormBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub 'meal-order-manager-form-${AWS::AccountId}'
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: delete-old-archives
Prefix: archive/
Status: Enabled
ExpirationInDays: 90
Tags:
- Key: Purpose
Value: meal-order-form-hosting
- Key: ManagedBy
Value: meal-order-manager
FormBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref FormBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontOAC
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${FormBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FormDistribution}'
ReportsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub 'meal-order-manager-reports-${AWS::AccountId}'
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: archive-old-reports
Status: Enabled
Transitions:
- StorageClass: GLACIER_IR
TransitionInDays: 90
Tags:
- Key: Purpose
Value: meal-order-reports
- Key: ManagedBy
Value: meal-order-manager
# ─── CloudFront ────────────────────────────────────────────────
FormOAC:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: meal-order-manager-oac
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
FormDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
Comment: meal-order-manager form hosting
PriceClass: PriceClass_100
HttpVersion: http2and3
WebACLId: !Ref WebAclArn # shared CloudFront WAF (audit M-17)
Aliases: !If
- HasCustomDomain
- [!Ref CustomDomain]
- !Ref AWS::NoValue
ViewerCertificate: !If
- HasCustomDomain
- AcmCertificateArn: !Ref CertificateArn
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.2_2021
- CloudFrontDefaultCertificate: true
Origins:
- Id: S3FormOrigin
DomainName: !GetAtt FormBucket.RegionalDomainName
OriginAccessControlId: !Ref FormOAC
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
TargetOriginId: S3FormOrigin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
Compress: true
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CustomErrorResponses:
- ErrorCode: 403
ResponseCode: 200
ResponsePagePath: /index.html
# ─── API Gateway ───────────────────────────────────────────────
ApiAccessLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/apigateway/meal-order-manager
RetentionInDays: 90
OrderApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: $default
# Gateway-level auth for /api/admin/* routes (INFRA-100). A Lambda
# 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.
Auth:
Authorizers:
AdminGoogleAuthorizer:
FunctionArn: !GetAtt AdminAuthorizerFunction.Arn
FunctionInvokeRole: !GetAtt AdminAuthorizerInvokeRole.Arn
Identity:
Headers:
- Authorization
AuthorizerPayloadFormatVersion: '2.0'
EnableSimpleResponses: true
# Disable result caching: with caching, an expired Google token or
# an admin removed from admin_emails would stay authorized for the
# cache TTL. The tokeninfo call is the dominant latency anyway.
AuthorizerResultTtlInSeconds: 0
# No DefaultAuthorizer — routes opt in individually so the public
# routes remain unauthenticated.
# Access logging + default throttling (audit M-18).
AccessLogSettings:
DestinationArn: !GetAtt ApiAccessLogGroup.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"}'
DefaultRouteSettings:
ThrottlingBurstLimit: 50
ThrottlingRateLimit: 100
# 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:
AllowOrigins:
- !If
- HasCustomDomain
- !Sub 'https://${CustomDomain}'
- !Sub 'https://${FormDistribution.DomainName}'
AllowMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
AllowHeaders:
- Content-Type
- x-api-key
- Authorization
MaxAge: 3600
# ─── Lambda Functions ──────────────────────────────────────────
SubmitOrderFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-submit-order
Handler: handler.lambda_handler
CodeUri: functions/submit_order/
MemorySize: 128
Timeout: 10
Environment:
Variables:
FORM_APIKEY_SM_NAME: meal-order-manager/form-api-key
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
REPORTS_BUCKET: !Ref ReportsBucket
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt SlackNotifierFunction.Arn
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
# Read-only access to weekly summary PDFs (only — not the
# payroll/order CSVs) for the admin summary-pdf presigned-URL
# endpoint.
- Effect: Allow
Action: s3:GetObject
Resource: !Sub '${ReportsBucket.Arn}/reports/*/weekly-summary-*.pdf'
Events:
SubmitOrder:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/submit-order
Method: POST
FormStatus:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/form-status/{week}
Method: GET
Roster:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/roster
Method: GET
AdminOrders:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/orders
Method: GET
Auth:
Authorizer: AdminGoogleAuthorizer
AdminOrdersUpdate:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/orders
Method: PUT
Auth:
Authorizer: AdminGoogleAuthorizer
AdminOrdersDelete:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/orders
Method: DELETE
Auth:
Authorizer: AdminGoogleAuthorizer
AdminSummaryPdf:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/summary-pdf
Method: GET
Auth:
Authorizer: AdminGoogleAuthorizer
# ─── Admin API Authorizer (INFRA-100) ─────────────────────────
# Lambda authorizer validating the Google ID token + admin-email allow-list
# for every /api/admin/* route. Mirrors submit_order's _verify_admin so the
# existing admin panel works unchanged.
AdminAuthorizerFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-admin-authorizer
Handler: handler.lambda_handler
CodeUri: functions/admin_authorizer/
MemorySize: 128
Timeout: 10
Environment:
Variables:
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
Policies:
- DynamoDBReadPolicy:
TableName: !Ref OrdersTable
- Statement:
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
AdminAuthorizerLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${AdminAuthorizerFunction}'
RetentionInDays: 60
# IAM role API Gateway assumes to invoke the authorizer Lambda.
AdminAuthorizerInvokeRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: apigateway.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: invoke-admin-authorizer
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt AdminAuthorizerFunction.Arn
CloseFormFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-close-form
Handler: handler.lambda_handler
CodeUri: functions/close_form/
MemorySize: 128
Timeout: 30
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt AggregateOrdersFunction.Arn
Environment:
Variables:
AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn
# Both EST and EDT schedules fire every week year-round. The handler is
# idempotent, so the "wrong timezone" firing is a harmless no-op.
Events:
CloseEST:
Type: Schedule
Properties:
Schedule: cron(59 4 ? * FRI *)
Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)'
Enabled: true
CloseEDT:
Type: Schedule
Properties:
Schedule: cron(59 3 ? * FRI *)
Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)'
Enabled: true
AggregateOrdersFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-aggregate-orders
Handler: handler.lambda_handler
CodeUri: functions/aggregate_orders/
MemorySize: 256
Timeout: 60
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- S3CrudPolicy:
BucketName: !Ref ReportsBucket
- Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt SlackNotifierFunction.Arn
Environment:
Variables:
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
SlackNotifierFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-slack-notifier
Handler: handler.lambda_handler
CodeUri: functions/slack_notifier/
MemorySize: 128
Timeout: 30
Policies:
- DynamoDBReadPolicy:
TableName: !Ref OrdersTable
- Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
Events:
ReminderEST:
Type: Schedule
Properties:
Schedule: cron(0 15 ? * THU *)
Description: 'DM reminders Thursday 10am EST (15:00 UTC)'
Enabled: true
Input: '{"event": "reminder"}'
ReminderEDT:
Type: Schedule
Properties:
Schedule: cron(0 14 ? * THU *)
Description: 'DM reminders Thursday 10am EDT (14:00 UTC)'
Enabled: true
Input: '{"event": "reminder"}'
SyncRosterFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-sync-roster
Handler: handler.lambda_handler
CodeUri: functions/sync_roster/
MemorySize: 128
Timeout: 60
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
Events:
SyncEST:
Type: Schedule
Properties:
Schedule: cron(55 11 ? * MON *)
Description: 'Sync roster Monday 6:55am EST (11:55 UTC) — before menu publish'
Enabled: true
SyncEDT:
Type: Schedule
Properties:
Schedule: cron(55 10 ? * MON *)
Description: 'Sync roster Monday 6:55am EDT (10:55 UTC) — before menu publish'
Enabled: true
EmailReportFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: meal-order-manager-email-report
Handler: handler.lambda_handler
CodeUri: functions/email_report/
MemorySize: 128
Timeout: 30
Environment:
Variables:
PAYROLL_EMAIL: !Ref PayrollEmail
SENDER_EMAIL: !Ref SenderEmail
Policies:
- DynamoDBReadPolicy:
TableName: !Ref OrdersTable
- S3ReadPolicy:
BucketName: !Ref ReportsBucket
- Statement:
- Effect: Allow
Action:
- ses:SendRawEmail
Resource: '*'
Events:
PayrollEmailEST:
Type: Schedule
Properties:
Schedule: cron(0 12 ? * MON *)
Description: 'Email payroll deductions Monday 7am EST (12:00 UTC)'
Enabled: true
PayrollEmailEDT:
Type: Schedule
Properties:
Schedule: cron(0 11 ? * MON *)
Description: 'Email payroll deductions Monday 7am EDT (11:00 UTC)'
Enabled: true
# ─── CloudWatch Log Groups (60-day retention) ──────────────────
SubmitOrderLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${SubmitOrderFunction}'
RetentionInDays: 60
CloseFormLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${CloseFormFunction}'
RetentionInDays: 60
AggregateOrdersLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${AggregateOrdersFunction}'
RetentionInDays: 60
SlackNotifierLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${SlackNotifierFunction}'
RetentionInDays: 60
EmailReportLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${EmailReportFunction}'
RetentionInDays: 60
SyncRosterLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/lambda/${SyncRosterFunction}'
RetentionInDays: 60
# ─── SSM Parameters ────────────────────────────────────────────
SlackChannelParam:
Type: AWS::SSM::Parameter
Properties:
Name: /meal-order-manager/slack-channel-id
Type: String
Value: CHANGE_ME
Description: Slack channel ID for meal order notifications
# GoogleClientIdParam (/meal-order-manager/google-client-id) is managed
# manually via AWS CLI since it varies per environment. Create it with:
# aws ssm put-parameter --name /meal-order-manager/google-client-id \
# --type String --value "<YOUR_GOOGLE_CLIENT_ID>"
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub 'https://${OrderApi}.execute-api.${AWS::Region}.amazonaws.com'
FormUrl:
Description: Order form URL
Value: !If
- HasCustomDomain
- !Sub 'https://${CustomDomain}'
- !Sub 'https://${FormDistribution.DomainName}'
DistributionId:
Description: CloudFront distribution ID (for cache invalidation)
Value: !Ref FormDistribution
FormBucketName:
Description: S3 bucket for form HTML
Value: !Ref FormBucket
ReportsBucketName:
Description: S3 bucket for CSV reports
Value: !Ref ReportsBucket
OrdersTableName:
Description: DynamoDB table name
Value: !Ref OrdersTable
SubmitOrderFunctionArn:
Description: Submit Order Lambda ARN
Value: !GetAtt SubmitOrderFunction.Arn
CloseFormFunctionArn:
Description: Close Form Lambda ARN
Value: !GetAtt CloseFormFunction.Arn
AggregateOrdersFunctionArn:
Description: Aggregate Orders Lambda ARN
Value: !GetAtt AggregateOrdersFunction.Arn
SlackNotifierFunctionArn:
Description: Slack Notifier Lambda ARN
Value: !GetAtt SlackNotifierFunction.Arn
SyncRosterFunctionArn:
Description: Sync Roster Lambda ARN
Value: !GetAtt SyncRosterFunction.Arn
EmailReportFunctionArn:
Description: Email Report Lambda ARN
Value: !GetAtt EmailReportFunction.Arn