mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Add gateway-level authorizer to admin API (INFRA-100) (#24)
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
parent
5ec63687a1
commit
75fb3280f5
4 changed files with 348 additions and 0 deletions
123
functions/admin_authorizer/handler.py
Normal file
123
functions/admin_authorizer/handler.py
Normal 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
|
||||||
1
functions/admin_authorizer/requirements.txt
Normal file
1
functions/admin_authorizer/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
|
|
@ -215,6 +215,27 @@ Resources:
|
||||||
Type: AWS::Serverless::HttpApi
|
Type: AWS::Serverless::HttpApi
|
||||||
Properties:
|
Properties:
|
||||||
StageName: $default
|
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).
|
# Access logging + default throttling (audit M-18).
|
||||||
AccessLogSettings:
|
AccessLogSettings:
|
||||||
DestinationArn: !GetAtt ApiAccessLogGroup.Arn
|
DestinationArn: !GetAtt ApiAccessLogGroup.Arn
|
||||||
|
|
@ -302,24 +323,82 @@ Resources:
|
||||||
ApiId: !Ref OrderApi
|
ApiId: !Ref OrderApi
|
||||||
Path: /api/admin/orders
|
Path: /api/admin/orders
|
||||||
Method: GET
|
Method: GET
|
||||||
|
Auth:
|
||||||
|
Authorizer: AdminGoogleAuthorizer
|
||||||
AdminOrdersUpdate:
|
AdminOrdersUpdate:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
Properties:
|
Properties:
|
||||||
ApiId: !Ref OrderApi
|
ApiId: !Ref OrderApi
|
||||||
Path: /api/admin/orders
|
Path: /api/admin/orders
|
||||||
Method: PUT
|
Method: PUT
|
||||||
|
Auth:
|
||||||
|
Authorizer: AdminGoogleAuthorizer
|
||||||
AdminOrdersDelete:
|
AdminOrdersDelete:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
Properties:
|
Properties:
|
||||||
ApiId: !Ref OrderApi
|
ApiId: !Ref OrderApi
|
||||||
Path: /api/admin/orders
|
Path: /api/admin/orders
|
||||||
Method: DELETE
|
Method: DELETE
|
||||||
|
Auth:
|
||||||
|
Authorizer: AdminGoogleAuthorizer
|
||||||
AdminSummaryPdf:
|
AdminSummaryPdf:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
Properties:
|
Properties:
|
||||||
ApiId: !Ref OrderApi
|
ApiId: !Ref OrderApi
|
||||||
Path: /api/admin/summary-pdf
|
Path: /api/admin/summary-pdf
|
||||||
Method: GET
|
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:
|
CloseFormFunction:
|
||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
|
|
|
||||||
145
tests/test_admin_authorizer.py
Normal file
145
tests/test_admin_authorizer.py
Normal 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"]))
|
||||||
Loading…
Add table
Reference in a new issue