mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-01 18:43:12 +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
|
||||
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
|
||||
|
|
|
|||
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