Add gateway-level authorizer to admin API (INFRA-100) (#24)
Some checks failed
Deploy / deploy (push) Has been cancelled

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).
This commit is contained in:
Adam Moussa 2026-06-08 18:05:09 -04:00 • committed by GitHub
parent 5ec63687a1
commit 75fb3280f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 348 additions and 0 deletions

View file

@ -0,0 +1,123 @@
"""API Gateway (HTTP API) Lambda authorizer for the meal-order-manager admin API.
Gates every ``/api/admin/*`` route at the gateway so they are no longer
AuthorizationType NONE. The authorizer validates the same Google ID token the
admin panel already sends in the ``Authorization: Bearer <token>`` header and
confirms the caller is in the ``admin_emails`` allow-list (DynamoDB
CONFIG/SETTINGS) — exactly the checks ``submit_order``'s ``_verify_admin`` does
in-handler today. No client change is required: the existing admin UI already
sends this header.
Returns the HTTP API v2 *simple response* shape (``{"isAuthorized": bool}``);
a False result causes API Gateway to return 403 before the integration runs.
The in-handler ``_verify_admin`` check stays in place as defense-in-depth.
"""
import json
import logging
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from shared.db import get_settings
from shared.secrets import get_parameter
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
if not logger.handlers:
logger.addHandler(logging.StreamHandler(sys.stderr))
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
_DENY = {"isAuthorized": False}
_ALLOW = {"isAuthorized": True}
def _get_google_client_id() -> str:
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "")
if not param:
return ""
try:
return get_parameter(param, decrypt=False) or ""
except Exception as exc: # ParameterNotFound or transient — fail closed
logger.error("Failed to read Google client ID param: %s", exc)
return ""
def _verify_google_token(token: str, client_id: str) -> dict | None:
"""Verify a Google ID token via the tokeninfo endpoint.
Returns the decoded {name, email} on success, or None on any failure
(bad token, wrong audience/domain, or service unavailable). The authorizer
fails closed: any verification problem denies access.
"""
try:
qs = urllib.parse.urlencode({"id_token": token})
req = urllib.request.Request(f"https://oauth2.googleapis.com/tokeninfo?{qs}")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as exc:
logger.warning("Google token rejected (HTTP %s)", exc.code)
return None
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logger.error("Google token verification service unavailable: %s", exc)
return None
except Exception as exc:
logger.error("Google token verification failed: %s", exc)
return None
if data.get("aud") != client_id:
logger.warning("Google token audience mismatch")
return None
if data.get("hd") not in ALLOWED_DOMAINS:
logger.warning("Google token domain mismatch: %s", data.get("hd"))
return None
return {"name": data.get("name", ""), "email": data.get("email", "")}
def _extract_token(event) -> str:
"""Pull the bearer token from the Authorization header.
HTTP API lowercases header names; check both for safety.
"""
headers = event.get("headers") or {}
raw = headers.get("authorization") or headers.get("Authorization") or ""
if raw.lower().startswith("bearer "):
return raw[7:].strip()
return ""
def lambda_handler(event, context):
if not os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""):
logger.error("Authorizer misconfigured: GOOGLE_CLIENT_ID_PARAM unset")
return _DENY
token = _extract_token(event)
if not token:
return _DENY
client_id = _get_google_client_id()
if not client_id:
logger.error("Google client ID unavailable; denying")
return _DENY
user_info = _verify_google_token(token, client_id)
if user_info is None:
return _DENY
try:
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
except Exception as exc:
# DynamoDB unavailable / missing item: fail closed with DENY rather than
# letting the unhandled exception surface as a 500 from the gateway.
logger.error("Failed to load admin_emails from DynamoDB: %s", exc)
return _DENY
if user_info["email"].lower() not in admin_emails:
logger.warning("Non-admin %s denied at gateway", user_info["email"])
return _DENY
logger.info("Admin %s authorized at gateway", user_info["email"])
return _ALLOW

View file

@ -0,0 +1 @@
boto3

View file

@ -215,6 +215,27 @@ Resources:
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
@ -302,24 +323,82 @@ Resources:
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

View file

@ -0,0 +1,145 @@
"""Unit tests for the admin API Lambda authorizer (INFRA-100)."""
import importlib.util
import os
import sys
from unittest.mock import patch
import pytest
# Make the shared layer importable (conftest also does this, but keep explicit).
_shared = os.path.join(os.path.dirname(__file__), os.pardir, "src", "shared")
sys.path.insert(0, os.path.abspath(_shared))
# Do NOT set GOOGLE_CLIENT_ID_PARAM at module scope — test_submit_order relies
# on it defaulting to "" globally. Each test below patches it explicitly.
os.environ.setdefault("TABLE_NAME", "meal-order-manager-orders-test")
_handler_path = os.path.join(
os.path.dirname(__file__), os.pardir, "functions", "admin_authorizer", "handler.py"
)
_spec = importlib.util.spec_from_file_location(
"admin_authorizer_handler", os.path.abspath(_handler_path)
)
authorizer = importlib.util.module_from_spec(_spec)
sys.modules["admin_authorizer_handler"] = authorizer
_spec.loader.exec_module(authorizer)
CLIENT_ID = "123456789.apps.googleusercontent.com"
ADMIN_EMAIL = "adam@seahavenind.com"
def _event(token="good-token"):
headers = {}
if token is not None:
headers["authorization"] = f"Bearer {token}"
return {"headers": headers}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler.get_settings")
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_valid_admin_allowed(mock_cid, mock_verify, mock_settings):
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = {"name": "Adam", "email": ADMIN_EMAIL}
mock_settings.return_value = {"admin_emails": [ADMIN_EMAIL]}
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": True}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler.get_settings")
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_valid_user_but_not_admin_denied(mock_cid, mock_verify, mock_settings):
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = {"name": "Bob", "email": "bob@seahavenind.com"}
mock_settings.return_value = {"admin_emails": [ADMIN_EMAIL]}
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_invalid_token_denied(mock_cid, mock_verify):
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = None # bad/expired token or wrong domain
assert authorizer.lambda_handler(_event("bad"), None) == {"isAuthorized": False}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
def test_missing_token_denied():
assert authorizer.lambda_handler(_event(token=None), None) == {
"isAuthorized": False
}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler._get_google_client_id")
def test_client_id_unavailable_denied(mock_cid):
mock_cid.return_value = "" # SSM failure or empty -> fail closed
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch.dict(os.environ, {"GOOGLE_CLIENT_ID_PARAM": ""}, clear=False)
def test_authorizer_unconfigured_denied():
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
@patch.dict(
os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"}
)
@patch("admin_authorizer_handler.get_settings")
@patch("admin_authorizer_handler._verify_google_token")
@patch("admin_authorizer_handler._get_google_client_id")
def test_dynamodb_failure_denied(mock_cid, mock_verify, mock_settings):
"""A DynamoDB error loading admin_emails fails closed (DENY, not a 500)."""
mock_cid.return_value = CLIENT_ID
mock_verify.return_value = {"name": "Adam", "email": ADMIN_EMAIL}
mock_settings.side_effect = RuntimeError("dynamo down")
assert authorizer.lambda_handler(_event(), None) == {"isAuthorized": False}
def test_audience_mismatch_denied():
"""_verify_google_token rejects a token whose aud != configured client ID."""
with patch.object(authorizer.urllib.request, "urlopen") as mock_open:
resp = mock_open.return_value.__enter__.return_value
resp.read.return_value = (
b'{"aud":"other-client","hd":"seahavenind.com","email":"x@seahavenind.com"}'
)
assert authorizer._verify_google_token("t", CLIENT_ID) is None
def test_domain_mismatch_denied():
"""_verify_google_token rejects a token from a non-allowed Workspace domain."""
with patch.object(authorizer.urllib.request, "urlopen") as mock_open:
resp = mock_open.return_value.__enter__.return_value
resp.read.return_value = (
f'{{"aud":"{CLIENT_ID}","hd":"evil.com","email":"x@evil.com"}}'.encode()
)
assert authorizer._verify_google_token("t", CLIENT_ID) is None
def test_valid_token_decoded():
with patch.object(authorizer.urllib.request, "urlopen") as mock_open:
resp = mock_open.return_value.__enter__.return_value
resp.read.return_value = (
f'{{"aud":"{CLIENT_ID}","hd":"seahavenind.com",'
f'"email":"{ADMIN_EMAIL}","name":"Adam"}}'.encode()
)
info = authorizer._verify_google_token("t", CLIENT_ID)
assert info == {"name": "Adam", "email": ADMIN_EMAIL}
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))