diff --git a/functions/admin_authorizer/handler.py b/functions/admin_authorizer/handler.py new file mode 100644 index 0000000..d443cbd --- /dev/null +++ b/functions/admin_authorizer/handler.py @@ -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 `` 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 diff --git a/functions/admin_authorizer/requirements.txt b/functions/admin_authorizer/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/functions/admin_authorizer/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/template.yaml b/template.yaml index aa07aec..c62a1f8 100644 --- a/template.yaml +++ b/template.yaml @@ -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 diff --git a/tests/test_admin_authorizer.py b/tests/test_admin_authorizer.py new file mode 100644 index 0000000..74faced --- /dev/null +++ b/tests/test_admin_authorizer.py @@ -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"]))