Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/scripts/modal-sync-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions .github/workflows/modal-deploy.reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -30,7 +31,7 @@
)


@app.function(image=gateway_image)
@app.function(image=gateway_image, secrets=[gateway_auth_secret])
@modal.asgi_app()
def web_app():
"""
Expand Down
26 changes: 22 additions & 4 deletions projects/policyengine-api-simulation/src/modal/gateway/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion projects/policyengine-api-simulation/tests/gateway/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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."""

Expand Down
Loading