From 38f4f228a20b213eeb2f5669f9e83d19604f442c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 26 Apr 2026 10:36:48 -0400 Subject: [PATCH] Make Modal gateway auth optional until configured --- .github/scripts/modal-sync-secrets.sh | 9 ++++++ .github/workflows/modal-deploy.reusable.yml | 3 ++ .../src/modal/gateway/app.py | 3 +- .../src/modal/gateway/auth.py | 26 +++++++++++++--- .../tests/gateway/test_auth.py | 30 ++++++++++++++++++- 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/scripts/modal-sync-secrets.sh b/.github/scripts/modal-sync-secrets.sh index b56384d88..faeca9637 100755 --- a/.github/scripts/modal-sync-secrets.sh +++ b/.github/scripts/modal-sync-secrets.sh @@ -25,4 +25,13 @@ if [ -n "${GCP_CREDENTIALS_JSON:-}" ]; then --force || true fi +# Sync gateway auth config. Empty values preserve the existing public-gateway +# behavior unless GATEWAY_AUTH_REQUIRED is explicitly set. +uv run modal secret create policyengine-gateway-auth \ + "GATEWAY_AUTH_ISSUER=${GATEWAY_AUTH_ISSUER:-}" \ + "GATEWAY_AUTH_AUDIENCE=${GATEWAY_AUTH_AUDIENCE:-}" \ + "GATEWAY_AUTH_REQUIRED=${GATEWAY_AUTH_REQUIRED:-}" \ + --env="$MODAL_ENV" \ + --force || true + echo "Modal secrets synced" diff --git a/.github/workflows/modal-deploy.reusable.yml b/.github/workflows/modal-deploy.reusable.yml index 07ce2d777..5fff1b510 100644 --- a/.github/workflows/modal-deploy.reusable.yml +++ b/.github/workflows/modal-deploy.reusable.yml @@ -55,6 +55,9 @@ jobs: MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }} GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} + GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} + GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} + GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.environment }}" - name: Deploy simulation API to Modal diff --git a/projects/policyengine-api-simulation/src/modal/gateway/app.py b/projects/policyengine-api-simulation/src/modal/gateway/app.py index 55d506007..968462ab6 100644 --- a/projects/policyengine-api-simulation/src/modal/gateway/app.py +++ b/projects/policyengine-api-simulation/src/modal/gateway/app.py @@ -12,6 +12,7 @@ # Stable app name - this should rarely change app = modal.App("policyengine-simulation-gateway") +gateway_auth_secret = modal.Secret.from_name("policyengine-gateway-auth") # Lightweight image for gateway - no heavy dependencies gateway_image = ( @@ -30,7 +31,7 @@ ) -@app.function(image=gateway_image) +@app.function(image=gateway_image, secrets=[gateway_auth_secret]) @modal.asgi_app() def web_app(): """ diff --git a/projects/policyengine-api-simulation/src/modal/gateway/auth.py b/projects/policyengine-api-simulation/src/modal/gateway/auth.py index 385113b7f..118d137e3 100644 --- a/projects/policyengine-api-simulation/src/modal/gateway/auth.py +++ b/projects/policyengine-api-simulation/src/modal/gateway/auth.py @@ -12,6 +12,7 @@ - ``GATEWAY_AUTH_ISSUER`` - Auth0 issuer URL (must end with ``/``) - ``GATEWAY_AUTH_AUDIENCE`` - Auth0 API identifier the gateway accepts +- ``GATEWAY_AUTH_REQUIRED`` - if truthy, missing issuer/audience is a 503 For local development and unit tests the dependency can be bypassed by setting ``GATEWAY_AUTH_DISABLED=1``. This bypass is hard-gated by @@ -20,8 +21,8 @@ missing or looks like production, and otherwise requires an explicit ``GATEWAY_AUTH_DISABLED_ACK=I_UNDERSTAND_THIS_IS_DEV`` acknowledgement so the bypass cannot be activated by a single stray env var. The gateway -also returns ``503`` to callers if auth is enabled but the issuer/audience -configuration is missing. +also returns ``503`` to callers if auth is required but the issuer/audience +configuration is missing, or if only one of issuer/audience is present. """ from __future__ import annotations @@ -40,6 +41,7 @@ GATEWAY_AUTH_ISSUER_ENV = "GATEWAY_AUTH_ISSUER" GATEWAY_AUTH_AUDIENCE_ENV = "GATEWAY_AUTH_AUDIENCE" +GATEWAY_AUTH_REQUIRED_ENV = "GATEWAY_AUTH_REQUIRED" GATEWAY_AUTH_DISABLED_ENV = "GATEWAY_AUTH_DISABLED" GATEWAY_AUTH_DISABLED_ACK_ENV = "GATEWAY_AUTH_DISABLED_ACK" GATEWAY_AUTH_DISABLED_ACK_VALUE = "I_UNDERSTAND_THIS_IS_DEV" @@ -64,6 +66,15 @@ def _auth_disabled() -> bool: } +def _auth_required() -> bool: + return os.environ.get(GATEWAY_AUTH_REQUIRED_ENV, "").lower() in { + "1", + "true", + "yes", + "on", + } + + @functools.lru_cache(maxsize=8) def _build_decoder(issuer: str, audience: str) -> JWTDecoder: """Construct and cache a ``JWTDecoder`` keyed by issuer/audience. @@ -182,13 +193,20 @@ def require_auth( missing or invalid token produces a 403 (matching the underlying decoder's contract). - If issuer/audience env configuration is missing the dependency returns - 503 so operators see a clear misconfiguration instead of silent bypass. + If issuer/audience env configuration is absent, the dependency preserves + the legacy public gateway behavior unless ``GATEWAY_AUTH_REQUIRED`` is + truthy. Partial auth configuration always returns 503 because it indicates + an operator intended to enable auth but shipped an incomplete secret. """ if _auth_disabled(): return None + issuer = os.environ.get(GATEWAY_AUTH_ISSUER_ENV) + audience = os.environ.get(GATEWAY_AUTH_AUDIENCE_ENV) + if not issuer and not audience and not _auth_required(): + return None + try: decoder = _get_decoder() except RuntimeError as exc: diff --git a/projects/policyengine-api-simulation/tests/gateway/test_auth.py b/projects/policyengine-api-simulation/tests/gateway/test_auth.py index a9a98e416..da7f32a0a 100644 --- a/projects/policyengine-api-simulation/tests/gateway/test_auth.py +++ b/projects/policyengine-api-simulation/tests/gateway/test_auth.py @@ -73,10 +73,24 @@ def test__given_auth_disabled_env__then_dependency_returns_none(monkeypatch): assert auth_module.require_auth(token=None) is None -def test__given_auth_misconfigured__then_dependency_raises_503(monkeypatch): +def test__given_auth_not_configured_and_not_required__then_dependency_allows( + monkeypatch, +): + monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False) + monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False) + monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False) + monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False) + + assert auth_module.require_auth(token=None) is None + + +def test__given_auth_required_and_misconfigured__then_dependency_raises_503( + monkeypatch, +): from fastapi import HTTPException monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False) + monkeypatch.setenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, "1") monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False) monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False) @@ -86,6 +100,20 @@ def test__given_auth_misconfigured__then_dependency_raises_503(monkeypatch): assert exc_info.value.status_code == 503 +def test__given_partial_auth_config__then_dependency_raises_503(monkeypatch): + from fastapi import HTTPException + + monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False) + monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False) + monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/") + monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False) + + with pytest.raises(HTTPException) as exc_info: + auth_module.require_auth(token=None) + + assert exc_info.value.status_code == 503 + + def test__given_health_endpoint__then_auth_not_required(monkeypatch): """Health/ping/versions endpoints remain public by design."""