Skip to content

Commit 2f7b3a0

Browse files
authored
Merge pull request #471 from PolicyEngine/fix/enable-gateway-auth
Prepare simulation gateway auth enforcement
2 parents 9c74424 + f2e001d commit 2f7b3a0

9 files changed

Lines changed: 467 additions & 5 deletions

File tree

.github/scripts/modal-run-integ-tests.sh

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,104 @@ ENVIRONMENT="${1:?Environment required (beta or prod)}"
99
BASE_URL="${2:?Base URL required}"
1010
US_VERSION="${3:-}"
1111

12+
truthy() {
13+
case "${1:-}" in
14+
1|true|TRUE|yes|YES|on|ON) return 0 ;;
15+
*) return 1 ;;
16+
esac
17+
}
18+
19+
GATEWAY_AUTH_VARS=(
20+
GATEWAY_AUTH_ISSUER
21+
GATEWAY_AUTH_AUDIENCE
22+
GATEWAY_AUTH_CLIENT_ID
23+
GATEWAY_AUTH_CLIENT_SECRET
24+
)
25+
present=()
26+
missing=()
27+
for var in "${GATEWAY_AUTH_VARS[@]}"; do
28+
if [ -n "${!var:-}" ]; then
29+
present+=("$var")
30+
else
31+
missing+=("$var")
32+
fi
33+
done
34+
35+
if [ ${#present[@]} -gt 0 ] && [ ${#missing[@]} -gt 0 ]; then
36+
echo "Gateway auth integration-test config is partial." >&2
37+
echo " Present: ${present[*]-}" >&2
38+
echo " Missing: ${missing[*]-}" >&2
39+
exit 1
40+
fi
41+
42+
SHOULD_MINT_TOKEN=0
43+
if truthy "${GATEWAY_AUTH_REQUIRED:-}"; then
44+
if [ ${#missing[@]} -gt 0 ]; then
45+
echo "GATEWAY_AUTH_REQUIRED is enabled but integration-test auth secrets are missing." >&2
46+
echo " Missing: ${missing[*]-}" >&2
47+
exit 1
48+
fi
49+
SHOULD_MINT_TOKEN=1
50+
elif [ ${#present[@]} -eq ${#GATEWAY_AUTH_VARS[@]} ]; then
51+
SHOULD_MINT_TOKEN=1
52+
fi
53+
54+
ACCESS_TOKEN=""
55+
if [ "$SHOULD_MINT_TOKEN" -eq 1 ]; then
56+
ISSUER="${GATEWAY_AUTH_ISSUER%/}"
57+
TOKEN_URL="$ISSUER/oauth/token"
58+
59+
# Build the token-request JSON with Python so that any ", \, or newline in
60+
# the client secret is encoded correctly (Auth0-generated secrets are random
61+
# strings that routinely contain characters that break a shell heredoc).
62+
TOKEN_REQUEST_JSON=$(
63+
CLIENT_ID="$GATEWAY_AUTH_CLIENT_ID" \
64+
CLIENT_SECRET="$GATEWAY_AUTH_CLIENT_SECRET" \
65+
AUDIENCE="$GATEWAY_AUTH_AUDIENCE" \
66+
python3 -c '
67+
import json, os
68+
print(json.dumps({
69+
"client_id": os.environ["CLIENT_ID"],
70+
"client_secret": os.environ["CLIENT_SECRET"],
71+
"audience": os.environ["AUDIENCE"],
72+
"grant_type": "client_credentials",
73+
}))
74+
'
75+
)
76+
77+
echo "Requesting client_credentials access token from $TOKEN_URL"
78+
TOKEN_RESPONSE=$(
79+
curl --fail-with-body --silent --show-error \
80+
--request POST "$TOKEN_URL" \
81+
--header "content-type: application/json" \
82+
--data-binary "$TOKEN_REQUEST_JSON"
83+
)
84+
85+
ACCESS_TOKEN=$(
86+
printf '%s' "$TOKEN_RESPONSE" | python3 -c '
87+
import json, sys
88+
data = json.load(sys.stdin)
89+
token = data.get("access_token")
90+
if not token:
91+
sys.exit(f"Auth0 response missing access_token: {data}")
92+
print(token)
93+
'
94+
)
95+
if [ -z "$ACCESS_TOKEN" ]; then
96+
echo "Failed to extract access_token from Auth0 response" >&2
97+
exit 1
98+
fi
99+
fi
100+
12101
cd projects/policyengine-apis-integ
13102
uv sync --extra test
14103

15104
export simulation_integ_test_base_url="$BASE_URL"
105+
export simulation_integ_test_gateway_auth_required="${GATEWAY_AUTH_REQUIRED:-}"
106+
107+
if [ -n "$ACCESS_TOKEN" ]; then
108+
export simulation_integ_test_access_token="$ACCESS_TOKEN"
109+
fi
16110

17111
if [ -n "$US_VERSION" ]; then
18112
export simulation_integ_test_us_model_version="$US_VERSION"

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

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,45 @@ set -euo pipefail
88
MODAL_ENV="${1:?Modal environment required}"
99
GH_ENV="${2:?GitHub environment required}"
1010

11+
truthy() {
12+
case "${1:-}" in
13+
1|true|TRUE|yes|YES|on|ON) return 0 ;;
14+
*) return 1 ;;
15+
esac
16+
}
17+
1118
echo "Syncing secrets to Modal environment: $MODAL_ENV"
1219

20+
GATEWAY_AUTH_VARS=(
21+
GATEWAY_AUTH_ISSUER
22+
GATEWAY_AUTH_AUDIENCE
23+
GATEWAY_AUTH_CLIENT_ID
24+
GATEWAY_AUTH_CLIENT_SECRET
25+
)
26+
present=()
27+
missing=()
28+
for var in "${GATEWAY_AUTH_VARS[@]}"; do
29+
if [ -n "${!var:-}" ]; then
30+
present+=("$var")
31+
else
32+
missing+=("$var")
33+
fi
34+
done
35+
36+
if [ ${#present[@]} -gt 0 ] && [ ${#missing[@]} -gt 0 ]; then
37+
echo "Gateway auth config is partial." >&2
38+
echo " Present: ${present[*]-}" >&2
39+
echo " Missing: ${missing[*]-}" >&2
40+
echo "Refusing to sync a broken auth secret state." >&2
41+
exit 1
42+
fi
43+
44+
if truthy "${GATEWAY_AUTH_REQUIRED:-}" && [ ${#missing[@]} -gt 0 ]; then
45+
echo "GATEWAY_AUTH_REQUIRED is enabled but gateway auth secrets are missing." >&2
46+
echo " Missing: ${missing[*]-}" >&2
47+
exit 1
48+
fi
49+
1350
# Sync Logfire secret
1451
uv run modal secret create policyengine-logfire \
1552
"LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}" \
@@ -25,13 +62,22 @@ if [ -n "${GCP_CREDENTIALS_JSON:-}" ]; then
2562
--force || true
2663
fi
2764

28-
# Sync gateway auth config. Empty values preserve the existing public-gateway
29-
# behavior unless GATEWAY_AUTH_REQUIRED is explicitly set.
65+
# Sync gateway auth config. The gateway runtime only needs issuer/audience and
66+
# the explicit requirement flag; client credentials stay on the GitHub side and
67+
# are only used to mint integration-test tokens.
68+
NORMALIZED_ISSUER="${GATEWAY_AUTH_ISSUER:-}"
69+
if [ -n "$NORMALIZED_ISSUER" ]; then
70+
case "$NORMALIZED_ISSUER" in
71+
*/) ;;
72+
*) NORMALIZED_ISSUER="$NORMALIZED_ISSUER/" ;;
73+
esac
74+
fi
75+
3076
uv run modal secret create policyengine-gateway-auth \
31-
"GATEWAY_AUTH_ISSUER=${GATEWAY_AUTH_ISSUER:-}" \
77+
"GATEWAY_AUTH_ISSUER=$NORMALIZED_ISSUER" \
3278
"GATEWAY_AUTH_AUDIENCE=${GATEWAY_AUTH_AUDIENCE:-}" \
3379
"GATEWAY_AUTH_REQUIRED=${GATEWAY_AUTH_REQUIRED:-}" \
3480
--env="$MODAL_ENV" \
35-
--force || true
81+
--force
3682

3783
echo "Modal secrets synced"

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ jobs:
5757
GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }}
5858
GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }}
5959
GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }}
60+
GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }}
61+
GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }}
6062
GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }}
6163
run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.environment }}"
6264

@@ -105,4 +107,10 @@ jobs:
105107
run: ./scripts/generate-clients.sh
106108

107109
- name: Run simulation integration tests
110+
env:
111+
GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }}
112+
GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }}
113+
GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }}
114+
GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }}
115+
GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }}
108116
run: .github/scripts/modal-run-integ-tests.sh "${{ inputs.environment }}" "${{ needs.deploy.outputs.simulation_api_url }}" "${{ needs.deploy.outputs.us_version }}"

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ def web_app():
4545
"""
4646
from fastapi import FastAPI
4747

48-
from src.modal.gateway.auth import enforce_production_auth_guard
48+
from src.modal.gateway.auth import (
49+
enforce_auth_configured_guard,
50+
enforce_production_auth_guard,
51+
)
4952
from src.modal.gateway.endpoints import router
5053

5154
# Startup guard: crash the container if GATEWAY_AUTH_DISABLED is set in
@@ -54,6 +57,7 @@ def web_app():
5457
# accidentally shipping to prod if a dev deploy grabs the wrong secret
5558
# bundle. See gateway.auth.enforce_production_auth_guard for the rules.
5659
enforce_production_auth_guard()
60+
enforce_auth_configured_guard()
5761

5862
api = FastAPI(
5963
title="PolicyEngine Simulation Gateway",

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ def _get_decoder() -> JWTDecoder:
103103
f"{GATEWAY_AUTH_ISSUER_ENV} and {GATEWAY_AUTH_AUDIENCE_ENV} or "
104104
f"{GATEWAY_AUTH_DISABLED_ENV}=1 for local/test use."
105105
)
106+
if not issuer.endswith("/"):
107+
issuer = issuer + "/"
106108
return _build_decoder(issuer, audience)
107109

108110

@@ -119,6 +121,10 @@ class AuthDisabledWithoutAckError(RuntimeError):
119121
"""Refuse to start when auth is disabled without the explicit ACK."""
120122

121123

124+
class AuthMisconfiguredError(RuntimeError):
125+
"""Refuse to start when required auth config is absent or partial."""
126+
127+
122128
def enforce_production_auth_guard() -> None:
123129
"""Validate at startup that the auth-disabled bypass is only used in dev.
124130
@@ -180,6 +186,38 @@ def enforce_production_auth_guard() -> None:
180186
pass
181187

182188

189+
def enforce_auth_configured_guard() -> None:
190+
"""Crash startup when gateway auth would serve broken gated endpoints.
191+
192+
Rules:
193+
- auth disabled: allow startup; the separate production guard handles safety.
194+
- partial issuer/audience config: always refuse startup because every gated
195+
endpoint would return 503 in that state.
196+
- auth required and both values missing: refuse startup.
197+
- auth optional and both values missing: allow startup (public gateway mode).
198+
"""
199+
if _auth_disabled():
200+
return
201+
202+
issuer = os.environ.get(GATEWAY_AUTH_ISSUER_ENV)
203+
audience = os.environ.get(GATEWAY_AUTH_AUDIENCE_ENV)
204+
205+
if bool(issuer) != bool(audience):
206+
raise AuthMisconfiguredError(
207+
"Gateway auth is partially configured: set both "
208+
f"{GATEWAY_AUTH_ISSUER_ENV} and {GATEWAY_AUTH_AUDIENCE_ENV}, "
209+
"or clear both."
210+
)
211+
212+
if _auth_required() and (not issuer or not audience):
213+
raise AuthMisconfiguredError(
214+
"Gateway auth is required but "
215+
f"{GATEWAY_AUTH_ISSUER_ENV}/{GATEWAY_AUTH_AUDIENCE_ENV} are not set "
216+
"in the container environment. Verify the "
217+
"'policyengine-gateway-auth' Modal secret is synced correctly."
218+
)
219+
220+
183221
def require_auth(
184222
token: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
185223
) -> dict | None:

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,98 @@ def test__given_disabled_in_dev_with_correct_ack__then_allows_and_logs(
309309
assert any(
310310
"GATEWAY AUTH IS DISABLED" in record.message for record in caplog.records
311311
), f"Expected critical auth-disabled banner, got {caplog.records!r}"
312+
313+
314+
class TestAuthConfiguredGuard:
315+
"""Startup guard for required-or-partial auth misconfiguration."""
316+
317+
def test__given_auth_disabled__then_guard_noops(self, monkeypatch):
318+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, "1")
319+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False)
320+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
321+
322+
auth_module.enforce_auth_configured_guard()
323+
324+
def test__given_auth_optional_and_unset__then_guard_noops(self, monkeypatch):
325+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
326+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False)
327+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False)
328+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
329+
330+
auth_module.enforce_auth_configured_guard()
331+
332+
def test__given_partial_auth_config__then_guard_raises(self, monkeypatch):
333+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
334+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False)
335+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/")
336+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
337+
338+
with pytest.raises(auth_module.AuthMisconfiguredError):
339+
auth_module.enforce_auth_configured_guard()
340+
341+
def test__given_required_and_missing__then_guard_raises(self, monkeypatch):
342+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
343+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, "1")
344+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, raising=False)
345+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, raising=False)
346+
347+
with pytest.raises(auth_module.AuthMisconfiguredError):
348+
auth_module.enforce_auth_configured_guard()
349+
350+
def test__given_required_and_configured__then_guard_noops(self, monkeypatch):
351+
monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False)
352+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, "1")
353+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/")
354+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, "aud")
355+
356+
auth_module.enforce_auth_configured_guard()
357+
358+
359+
class TestIssuerNormalization:
360+
"""Issuer should be normalized to the trailing-slash Auth0 form."""
361+
362+
def test__given_issuer_without_slash__then_decoder_receives_normalized_value(
363+
self, monkeypatch
364+
):
365+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example")
366+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, "aud")
367+
auth_module.reset_decoder_cache()
368+
369+
captured = {}
370+
371+
def fake_builder(issuer, audience):
372+
captured["issuer"] = issuer
373+
captured["audience"] = audience
374+
return object()
375+
376+
monkeypatch.setattr(auth_module, "_build_decoder", fake_builder)
377+
378+
auth_module._get_decoder()
379+
380+
assert captured == {
381+
"issuer": "https://issuer.example/",
382+
"audience": "aud",
383+
}
384+
385+
def test__given_issuer_with_slash__then_decoder_receives_unchanged_value(
386+
self, monkeypatch
387+
):
388+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/")
389+
monkeypatch.setenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, "aud")
390+
auth_module.reset_decoder_cache()
391+
392+
captured = {}
393+
394+
def fake_builder(issuer, audience):
395+
captured["issuer"] = issuer
396+
captured["audience"] = audience
397+
return object()
398+
399+
monkeypatch.setattr(auth_module, "_build_decoder", fake_builder)
400+
401+
auth_module._get_decoder()
402+
403+
assert captured == {
404+
"issuer": "https://issuer.example/",
405+
"audience": "aud",
406+
}

0 commit comments

Comments
 (0)