Skip to content

Commit 2ab14ae

Browse files
authored
Make Modal gateway auth optional until configured (#467)
1 parent 4fb9977 commit 2ab14ae

5 files changed

Lines changed: 65 additions & 6 deletions

File tree

.github/scripts/modal-sync-secrets.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,13 @@ if [ -n "${GCP_CREDENTIALS_JSON:-}" ]; then
2525
--force || true
2626
fi
2727

28+
# Sync gateway auth config. Empty values preserve the existing public-gateway
29+
# behavior unless GATEWAY_AUTH_REQUIRED is explicitly set.
30+
uv run modal secret create policyengine-gateway-auth \
31+
"GATEWAY_AUTH_ISSUER=${GATEWAY_AUTH_ISSUER:-}" \
32+
"GATEWAY_AUTH_AUDIENCE=${GATEWAY_AUTH_AUDIENCE:-}" \
33+
"GATEWAY_AUTH_REQUIRED=${GATEWAY_AUTH_REQUIRED:-}" \
34+
--env="$MODAL_ENV" \
35+
--force || true
36+
2837
echo "Modal secrets synced"

.github/workflows/modal-deploy.reusable.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ jobs:
5555
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
5656
LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }}
5757
GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }}
58+
GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }}
59+
GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }}
60+
GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }}
5861
run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.environment }}"
5962

6063
- name: Deploy simulation API to Modal

projects/policyengine-api-simulation/src/modal/gateway/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
# Stable app name - this should rarely change
1414
app = modal.App("policyengine-simulation-gateway")
15+
gateway_auth_secret = modal.Secret.from_name("policyengine-gateway-auth")
1516

1617
# Lightweight image for gateway - no heavy dependencies
1718
gateway_image = (
@@ -30,7 +31,7 @@
3031
)
3132

3233

33-
@app.function(image=gateway_image)
34+
@app.function(image=gateway_image, secrets=[gateway_auth_secret])
3435
@modal.asgi_app()
3536
def web_app():
3637
"""

projects/policyengine-api-simulation/src/modal/gateway/auth.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
1313
- ``GATEWAY_AUTH_ISSUER`` - Auth0 issuer URL (must end with ``/``)
1414
- ``GATEWAY_AUTH_AUDIENCE`` - Auth0 API identifier the gateway accepts
15+
- ``GATEWAY_AUTH_REQUIRED`` - if truthy, missing issuer/audience is a 503
1516
1617
For local development and unit tests the dependency can be bypassed by
1718
setting ``GATEWAY_AUTH_DISABLED=1``. This bypass is hard-gated by
@@ -20,8 +21,8 @@
2021
missing or looks like production, and otherwise requires an explicit
2122
``GATEWAY_AUTH_DISABLED_ACK=I_UNDERSTAND_THIS_IS_DEV`` acknowledgement so
2223
the bypass cannot be activated by a single stray env var. The gateway
23-
also returns ``503`` to callers if auth is enabled but the issuer/audience
24-
configuration is missing.
24+
also returns ``503`` to callers if auth is required but the issuer/audience
25+
configuration is missing, or if only one of issuer/audience is present.
2526
"""
2627

2728
from __future__ import annotations
@@ -40,6 +41,7 @@
4041

4142
GATEWAY_AUTH_ISSUER_ENV = "GATEWAY_AUTH_ISSUER"
4243
GATEWAY_AUTH_AUDIENCE_ENV = "GATEWAY_AUTH_AUDIENCE"
44+
GATEWAY_AUTH_REQUIRED_ENV = "GATEWAY_AUTH_REQUIRED"
4345
GATEWAY_AUTH_DISABLED_ENV = "GATEWAY_AUTH_DISABLED"
4446
GATEWAY_AUTH_DISABLED_ACK_ENV = "GATEWAY_AUTH_DISABLED_ACK"
4547
GATEWAY_AUTH_DISABLED_ACK_VALUE = "I_UNDERSTAND_THIS_IS_DEV"
@@ -64,6 +66,15 @@ def _auth_disabled() -> bool:
6466
}
6567

6668

69+
def _auth_required() -> bool:
70+
return os.environ.get(GATEWAY_AUTH_REQUIRED_ENV, "").lower() in {
71+
"1",
72+
"true",
73+
"yes",
74+
"on",
75+
}
76+
77+
6778
@functools.lru_cache(maxsize=8)
6879
def _build_decoder(issuer: str, audience: str) -> JWTDecoder:
6980
"""Construct and cache a ``JWTDecoder`` keyed by issuer/audience.
@@ -182,13 +193,20 @@ def require_auth(
182193
missing or invalid token produces a 403 (matching the underlying
183194
decoder's contract).
184195
185-
If issuer/audience env configuration is missing the dependency returns
186-
503 so operators see a clear misconfiguration instead of silent bypass.
196+
If issuer/audience env configuration is absent, the dependency preserves
197+
the legacy public gateway behavior unless ``GATEWAY_AUTH_REQUIRED`` is
198+
truthy. Partial auth configuration always returns 503 because it indicates
199+
an operator intended to enable auth but shipped an incomplete secret.
187200
"""
188201

189202
if _auth_disabled():
190203
return None
191204

205+
issuer = os.environ.get(GATEWAY_AUTH_ISSUER_ENV)
206+
audience = os.environ.get(GATEWAY_AUTH_AUDIENCE_ENV)
207+
if not issuer and not audience and not _auth_required():
208+
return None
209+
192210
try:
193211
decoder = _get_decoder()
194212
except RuntimeError as exc:

projects/policyengine-api-simulation/tests/gateway/test_auth.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,24 @@ def test__given_auth_disabled_env__then_dependency_returns_none(monkeypatch):
7373
assert auth_module.require_auth(token=None) is None
7474

7575

76-
def test__given_auth_misconfigured__then_dependency_raises_503(monkeypatch):
76+
def test__given_auth_not_configured_and_not_required__then_dependency_allows(
77+
monkeypatch,
78+
):
79+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
80+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False)
81+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False)
82+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
83+
84+
assert auth_module.require_auth(token=None) is None
85+
86+
87+
def test__given_auth_required_and_misconfigured__then_dependency_raises_503(
88+
monkeypatch,
89+
):
7790
from fastapi import HTTPException
7891

7992
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
93+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, "1")
8094
monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False)
8195
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
8296

@@ -86,6 +100,20 @@ def test__given_auth_misconfigured__then_dependency_raises_503(monkeypatch):
86100
assert exc_info.value.status_code == 503
87101

88102

103+
def test__given_partial_auth_config__then_dependency_raises_503(monkeypatch):
104+
from fastapi import HTTPException
105+
106+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
107+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False)
108+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/")
109+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
110+
111+
with pytest.raises(HTTPException) as exc_info:
112+
auth_module.require_auth(token=None)
113+
114+
assert exc_info.value.status_code == 503
115+
116+
89117
def test__given_health_endpoint__then_auth_not_required(monkeypatch):
90118
"""Health/ping/versions endpoints remain public by design."""
91119

0 commit comments

Comments
 (0)