mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 13:33:13 +00:00
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).
145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
"""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"]))
|