Skip to content

Commit 099224c

Browse files
committed
Load gateway auth secret from Secret Manager
1 parent 3510287 commit 099224c

11 files changed

Lines changed: 230 additions & 36 deletions

File tree

.github/scripts/validate_gateway_auth_env.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ required=(
66
GATEWAY_AUTH_ISSUER
77
GATEWAY_AUTH_AUDIENCE
88
GATEWAY_AUTH_CLIENT_ID
9-
GATEWAY_AUTH_CLIENT_SECRET
9+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE
1010
)
1111

1212
missing=()
@@ -18,6 +18,6 @@ for name in "${required[@]}"; do
1818
done
1919

2020
if [[ "${#missing[@]}" -gt 0 ]]; then
21-
echo "Missing required gateway auth secrets: ${missing[*]}" >&2
21+
echo "Missing required gateway auth configuration: ${missing[*]}" >&2
2222
exit 1
2323
fi

.github/workflows/push.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ jobs:
110110
GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }}
111111
GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }}
112112
GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }}
113-
GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }}
113+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET_RESOURCE }}
114114
- name: Deploy
115115
run: make deploy
116116
env:
@@ -123,7 +123,7 @@ jobs:
123123
GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }}
124124
GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }}
125125
GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }}
126-
GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }}
126+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET_RESOURCE }}
127127
docker:
128128
name: Docker
129129
runs-on: ubuntu-latest

changelog.d/3496.added.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Add Auth0 client-credentials bearer auth for outbound simulation-gateway calls in API v1, including runtime env plumbing for the four `GATEWAY_AUTH_*` values and startup validation for partial auth configuration.
1+
Add Auth0 client-credentials bearer auth for outbound simulation-gateway calls in API v1, including `GATEWAY_AUTH_REQUIRED`, startup validation for partial auth configuration, and Google Secret Manager support for the gateway client secret via `GATEWAY_AUTH_CLIENT_SECRET_RESOURCE`.

gcp/export.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
Path(GAE).resolve(strict=True)
88
with open(GAE, "r") as f:
99
GAE = f.read()
10-
except Exception as e:
10+
except Exception:
1111
pass
1212
DB_PD = os.environ["POLICYENGINE_DB_PASSWORD"]
1313
GITHUB_MICRODATA_TOKEN = os.environ["POLICYENGINE_GITHUB_MICRODATA_AUTH_TOKEN"]
@@ -17,7 +17,7 @@
1717
GATEWAY_AUTH_ISSUER = os.environ["GATEWAY_AUTH_ISSUER"]
1818
GATEWAY_AUTH_AUDIENCE = os.environ["GATEWAY_AUTH_AUDIENCE"]
1919
GATEWAY_AUTH_CLIENT_ID = os.environ["GATEWAY_AUTH_CLIENT_ID"]
20-
GATEWAY_AUTH_CLIENT_SECRET = os.environ["GATEWAY_AUTH_CLIENT_SECRET"]
20+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE = os.environ["GATEWAY_AUTH_CLIENT_SECRET_RESOURCE"]
2121

2222
# Export GAE to to .gac.json and DB_PD to .dbpw in the current directory
2323

@@ -45,7 +45,8 @@
4545
".gateway_auth_client_id", GATEWAY_AUTH_CLIENT_ID
4646
)
4747
dockerfile = dockerfile.replace(
48-
".gateway_auth_client_secret", GATEWAY_AUTH_CLIENT_SECRET
48+
".gateway_auth_client_secret_resource",
49+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE,
4950
)
5051

5152
with open(dockerfile_location, "w") as f:

gcp/policyengine_api/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ ENV ANTHROPIC_API_KEY .anthropic_api_key
99
ENV OPENAI_API_KEY .openai_api_key
1010
ENV HUGGING_FACE_TOKEN .hugging_face_token
1111
ENV CREDENTIALS_JSON_API_V2 .credentials_json_api_v2
12+
ENV GATEWAY_AUTH_REQUIRED 1
1213
ENV GATEWAY_AUTH_ISSUER .gateway_auth_issuer
1314
ENV GATEWAY_AUTH_AUDIENCE .gateway_auth_audience
1415
ENV GATEWAY_AUTH_CLIENT_ID .gateway_auth_client_id
15-
ENV GATEWAY_AUTH_CLIENT_SECRET .gateway_auth_client_secret
16+
ENV GATEWAY_AUTH_CLIENT_SECRET_RESOURCE .gateway_auth_client_secret_resource
1617

1718
WORKDIR /app
1819

policyengine_api/libs/gateway_auth.py

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313
import threading
1414
import time
15+
from functools import lru_cache
1516
from typing import Optional
1617

1718
import httpx
@@ -21,29 +22,85 @@
2122
GATEWAY_AUTH_AUDIENCE_ENV = "GATEWAY_AUTH_AUDIENCE"
2223
GATEWAY_AUTH_CLIENT_ID_ENV = "GATEWAY_AUTH_CLIENT_ID"
2324
GATEWAY_AUTH_CLIENT_SECRET_ENV = "GATEWAY_AUTH_CLIENT_SECRET"
25+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV = "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE"
26+
GATEWAY_AUTH_REQUIRED_ENV = "GATEWAY_AUTH_REQUIRED"
2427

25-
GATEWAY_AUTH_ENV_VARS = (
28+
GATEWAY_AUTH_CORE_ENV_VARS = (
2629
GATEWAY_AUTH_ISSUER_ENV,
2730
GATEWAY_AUTH_AUDIENCE_ENV,
2831
GATEWAY_AUTH_CLIENT_ID_ENV,
32+
)
33+
34+
GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS = (
2935
GATEWAY_AUTH_CLIENT_SECRET_ENV,
36+
GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV,
37+
)
38+
39+
GATEWAY_AUTH_ENV_VARS = (
40+
*GATEWAY_AUTH_CORE_ENV_VARS,
41+
*GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS,
3042
)
3143

3244

3345
class GatewayAuthError(RuntimeError):
3446
"""Raised when the gateway auth config is missing or the token fetch fails."""
3547

3648

49+
def gateway_auth_required() -> bool:
50+
"""True iff this runtime requires gateway auth to be configured."""
51+
return os.environ.get(GATEWAY_AUTH_REQUIRED_ENV, "").lower() in (
52+
"1",
53+
"true",
54+
"yes",
55+
"on",
56+
)
57+
58+
59+
@lru_cache(maxsize=None)
60+
def _load_secret_from_secret_manager(resource_name: str) -> str:
61+
"""Fetch one secret payload from Google Secret Manager."""
62+
from google.cloud import secretmanager
63+
64+
client = secretmanager.SecretManagerServiceClient()
65+
response = client.access_secret_version(request={"name": resource_name})
66+
return response.payload.data.decode("utf-8")
67+
68+
3769
def _require_all_or_none_gateway_auth_env() -> None:
38-
"""Refuse startup when the four GATEWAY_AUTH_* env vars are partially set."""
39-
present = [name for name in GATEWAY_AUTH_ENV_VARS if os.environ.get(name)]
40-
if present and len(present) != len(GATEWAY_AUTH_ENV_VARS):
41-
missing = [name for name in GATEWAY_AUTH_ENV_VARS if not os.environ.get(name)]
70+
"""Refuse startup when gateway auth is partially or ambiguously configured."""
71+
present_core = [name for name in GATEWAY_AUTH_CORE_ENV_VARS if os.environ.get(name)]
72+
present_secret_sources = [
73+
name for name in GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS if os.environ.get(name)
74+
]
75+
if len(present_secret_sources) > 1:
4276
raise GatewayAuthError(
43-
"Gateway auth is partially configured: "
44-
f"{', '.join(present)} set but {', '.join(missing)} missing. "
45-
"Set all four or none."
77+
"Gateway auth is ambiguously configured: both "
78+
f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} and "
79+
f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV} are set. "
80+
"Set exactly one secret source."
4681
)
82+
if present_core or present_secret_sources:
83+
missing_core = [
84+
name for name in GATEWAY_AUTH_CORE_ENV_VARS if not os.environ.get(name)
85+
]
86+
if missing_core or not present_secret_sources:
87+
missing = [
88+
*missing_core,
89+
*(
90+
[]
91+
if present_secret_sources
92+
else [
93+
f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} or "
94+
f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV}"
95+
]
96+
),
97+
]
98+
present = [*present_core, *present_secret_sources]
99+
raise GatewayAuthError(
100+
"Gateway auth is partially configured: "
101+
f"{', '.join(present)} set but {', '.join(missing)} missing. "
102+
"Set issuer, audience, client ID, and exactly one secret source."
103+
)
47104

48105

49106
class GatewayAuthTokenProvider:
@@ -57,6 +114,7 @@ def __init__(
57114
audience: Optional[str] = None,
58115
client_id: Optional[str] = None,
59116
client_secret: Optional[str] = None,
117+
client_secret_resource: Optional[str] = None,
60118
*,
61119
http_timeout: float = 10.0,
62120
):
@@ -80,6 +138,11 @@ def __init__(
80138
if client_secret is not None
81139
else os.environ.get(GATEWAY_AUTH_CLIENT_SECRET_ENV, "")
82140
)
141+
self._client_secret_resource = (
142+
client_secret_resource
143+
if client_secret_resource is not None
144+
else os.environ.get(GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV, "")
145+
)
83146
self._http_timeout = http_timeout
84147
self._token: Optional[str] = None
85148
self._expires_at: float = 0.0
@@ -93,7 +156,7 @@ def configured(self) -> bool:
93156
self._issuer,
94157
self._audience,
95158
self._client_id,
96-
self._client_secret,
159+
self._client_secret or self._client_secret_resource,
97160
)
98161
)
99162

@@ -103,8 +166,9 @@ def get_token(self) -> str:
103166
raise GatewayAuthError(
104167
"Gateway auth not configured: set "
105168
f"{GATEWAY_AUTH_ISSUER_ENV}, {GATEWAY_AUTH_AUDIENCE_ENV}, "
106-
f"{GATEWAY_AUTH_CLIENT_ID_ENV}, and "
107-
f"{GATEWAY_AUTH_CLIENT_SECRET_ENV}."
169+
f"{GATEWAY_AUTH_CLIENT_ID_ENV}, and either "
170+
f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} or "
171+
f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV}."
108172
)
109173

110174
with self._lock:
@@ -118,12 +182,13 @@ def get_token(self) -> str:
118182

119183
def _fetch_locked(self) -> None:
120184
"""Call Auth0's /oauth/token. Caller must hold _lock."""
185+
client_secret = self._get_client_secret_locked()
121186
try:
122187
response = httpx.post(
123188
f"{self._issuer}/oauth/token",
124189
json={
125190
"client_id": self._client_id,
126-
"client_secret": self._client_secret,
191+
"client_secret": client_secret,
127192
"audience": self._audience,
128193
"grant_type": "client_credentials",
129194
},
@@ -153,6 +218,31 @@ def _fetch_locked(self) -> None:
153218
self._token = token
154219
self._expires_at = time.time() + expires_in
155220

221+
def _get_client_secret_locked(self) -> str:
222+
"""Resolve the client secret from env or Secret Manager."""
223+
if self._client_secret:
224+
return self._client_secret
225+
if not self._client_secret_resource:
226+
raise GatewayAuthError(
227+
"Gateway auth client secret not configured: set "
228+
f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} or "
229+
f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV}."
230+
)
231+
try:
232+
self._client_secret = _load_secret_from_secret_manager(
233+
self._client_secret_resource
234+
)
235+
except Exception as exc:
236+
raise GatewayAuthError(
237+
"Failed to load gateway auth client secret from Secret Manager "
238+
f"resource {self._client_secret_resource}: {exc}"
239+
) from exc
240+
if not self._client_secret:
241+
raise GatewayAuthError(
242+
"Secret Manager returned an empty gateway auth client secret."
243+
)
244+
return self._client_secret
245+
156246
def invalidate(self) -> None:
157247
"""Drop the cached token so the next call refetches it."""
158248
with self._lock:

policyengine_api/libs/simulation_api_modal.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
GatewayAuthTokenProvider,
1919
GatewayBearerAuth,
2020
_require_all_or_none_gateway_auth_env,
21+
gateway_auth_required,
2122
)
2223

2324

@@ -62,20 +63,20 @@ def __init__(self):
6263
else None
6364
)
6465
if auth is None:
65-
if os.environ.get("FLASK_DEBUG") == "1":
66-
print(
67-
"SimulationAPIModal initialised without gateway auth; "
68-
"all GATEWAY_AUTH_* env vars are unset.",
69-
file=sys.stderr,
70-
flush=True,
71-
)
72-
else:
66+
if gateway_auth_required():
7367
raise GatewayAuthError(
74-
"Gateway auth is required outside local debug mode: set "
68+
"Gateway auth is required in this runtime: set "
7569
"GATEWAY_AUTH_ISSUER, GATEWAY_AUTH_AUDIENCE, "
7670
"GATEWAY_AUTH_CLIENT_ID, and "
7771
"GATEWAY_AUTH_CLIENT_SECRET."
7872
)
73+
print(
74+
"SimulationAPIModal initialised without gateway auth; "
75+
"all GATEWAY_AUTH_* env vars are unset and "
76+
"GATEWAY_AUTH_REQUIRED is not enabled.",
77+
file=sys.stderr,
78+
flush=True,
79+
)
7980
self.client = httpx.Client(timeout=30.0, auth=auth)
8081

8182
def run(self, payload: dict) -> ModalSimulationExecution:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"flask-cors>=5,<6",
3131
"Flask-Caching>=2,<3",
3232
"google-cloud-logging>=3,<4",
33+
"google-cloud-secret-manager>=2,<3",
3334
"gunicorn",
3435
"httpx>=0.27.0",
3536
"markupsafe>=3,<4",

scripts/smoke_test_local_boot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
REPO_ROOT = Path(__file__).resolve().parents[1]
2424
HEALTH_PATHS = ("/liveness-check", "/readiness-check")
25+
BOOT_TIMEOUT_SECONDS = 600
2526
HTTP_TIMEOUT_SECONDS = 2
2627

2728

@@ -111,8 +112,9 @@ def main() -> int:
111112
)
112113

113114
try:
115+
deadline = time.time() + BOOT_TIMEOUT_SECONDS
114116
ready = False
115-
while True:
117+
while time.time() < deadline:
116118
if process.poll() is not None:
117119
break
118120

0 commit comments

Comments
 (0)