Skip to content

Commit 7e003cc

Browse files
MakerNetworkclaude
andcommitted
fix(deploy): default CORS_ORIGINS to "*" in deployment configs
src/config/settings.py defaults CORS_ORIGINS to an empty list (deny all) in production when unset, which makes Starlette's CORSMiddleware return 400 for every browser CORS preflight before the request reaches a route handler. None of the GCP/AWS/Azure deployment config paths (from_dict() for deployment.yaml, with_defaults() for the deploy_gcp.py CLI script) set it, so any deploy following deployment.yaml.example as-is would hit this. supply-graph-ai is meant to be a public API, so default to "*" centrally via apply_cors_origins_default() rather than relying on every call site to remember it. Also documents how to fix a running Azure container without a redeploy (az containerapp / az webapp) in cloud-deployment-prerequisites.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6058385 commit 7e003cc

8 files changed

Lines changed: 209 additions & 3 deletions

File tree

.repo-map.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ REPOSITORY MAP (Aider Style)
33
================================================================================
44
Repository: supply-graph-ai
55

6-
Total Python files: 422
6+
Total Python files: 423
77

88
├── deploy/
99
│ ├── __init__.py
@@ -14,6 +14,7 @@ Total Python files: 422
1414
│ │ │ class DeploymentConfigError
1515
│ │ │ class ServiceConfig (validate)
1616
│ │ │ class BaseDeploymentConfig (validate, from_dict, to_dict)
17+
│ │ │ def apply_cors_origins_default()
1718
│ │ └── deployer.py
1819
│ │ class BaseDeployer (__init__, setup, deploy...)
1920
│ ├── docker/
@@ -1755,6 +1756,14 @@ Total Python files: 422
17551756
│ def test_validate_includes_component_count_zero()
17561757
│ def test_validate_includes_component_count_nonzero()
17571758
│ def test_validate_field_presence_tracks_components()
1759+
├── test_deploy_cors_default.py
1760+
│ def _data()
1761+
│ def test_cors_origins_defaults_to_wildcard_when_omitted()
1762+
│ def test_cors_origins_respects_explicit_value()
1763+
│ def test_cors_origins_default_applies_regardless_of_environment()
1764+
│ def test_gcp_with_defaults_defaults_cors_origins()
1765+
│ def test_azure_with_defaults_defaults_cors_origins()
1766+
│ def test_aws_with_defaults_defaults_cors_origins()
17581767
├── test_geographic_facility_filtering.py
17591768
│ def _make_facility()
17601769
│ def matches_filters()
@@ -1963,7 +1972,7 @@ Total Python files: 422
19631972

19641973
## Overview
19651974

1966-
Total files analyzed: 422
1975+
Total files analyzed: 423
19671976

19681977
## Entry Points
19691978

@@ -3858,7 +3867,7 @@ safe to...
38583867
38593868
Provides common configuration structures and validation for ...
38603869

3861-
**Exports:** DeploymentProvider, DeploymentConfigError, ServiceConfig, BaseDeploymentConfig
3870+
**Exports:** DeploymentProvider, DeploymentConfigError, apply_cors_origins_default, ServiceConfig, BaseDeploymentConfig
38623871

38633872
**Classes:**
38643873
- `DeploymentProvider` (inherits: str, Enum)
@@ -3872,6 +3881,12 @@ Provides common configuration structures and validation for ...
38723881
- Methods: validate, from_dict, to_dict
38733882
- Base deployment configuration for all providers....
38743883

3884+
**Functions:**
3885+
- `apply_cors_origins_default(environment_vars)`
3886+
- Default CORS_ORIGINS to "*" in-place if a deployment.yaml omits it.
3887+
3888+
The app its...
3889+
38753890
### `deploy/providers/aws/config.py`
38763891
> AWS-specific deployment configuration.
38773892
@@ -7598,6 +7613,22 @@ This module generates JSON reports combining test r...
75987613

75997614
**Internal Dependencies:** 13 imports
76007615

7616+
### `tests/unit/test_deploy_cors_default.py`
7617+
> Regression: deployment configs must never silently omit CORS_ORIGINS.
7618+
7619+
src/config/settings.py defaul...
7620+
7621+
**Exports:** test_cors_origins_defaults_to_wildcard_when_omitted, test_cors_origins_respects_explicit_value, test_cors_origins_default_applies_regardless_of_environment, test_gcp_with_defaults_defaults_cors_origins, test_azure_with_defaults_defaults_cors_origins
7622+
7623+
**Functions:**
7624+
- `test_cors_origins_defaults_to_wildcard_when_omitted(config_cls, provider)`
7625+
- `test_cors_origins_respects_explicit_value(config_cls, provider)`
7626+
- `test_cors_origins_default_applies_regardless_of_environment(config_cls, provider)`
7627+
- `test_gcp_with_defaults_defaults_cors_origins()`
7628+
- deploy_gcp.py (the only real, non-test deploy script in this repo)
7629+
constructs co...
7630+
- `test_azure_with_defaults_defaults_cors_origins()`
7631+
76017632
### `tests/unit/test_geographic_facility_filtering.py`
76027633
> Unit tests for geographic facility filtering (issue #172)....
76037634

deploy/base/config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
Provides common configuration structures and validation for all deployment providers.
55
"""
66

7+
import logging
78
from dataclasses import dataclass, field
89
from enum import Enum
910
from typing import Any, Dict, List, Optional
1011

12+
logger = logging.getLogger(__name__)
13+
1114

1215
class DeploymentProvider(str, Enum):
1316
"""Supported deployment providers."""
@@ -25,6 +28,27 @@ class DeploymentConfigError(Exception):
2528
pass
2629

2730

31+
def apply_cors_origins_default(environment_vars: Dict[str, str]) -> None:
32+
"""Default CORS_ORIGINS to "*" in-place if a deployment.yaml omits it.
33+
34+
The app itself defaults CORS_ORIGINS to an empty list (deny all) in
35+
production when unset (src/config/settings.py), which fails every browser
36+
CORS preflight with 400. supply-graph-ai is intended to be a public API,
37+
so deployers should default to "*" rather than let it go unset. Set
38+
CORS_ORIGINS explicitly in a deployment.yaml's environment_vars to
39+
restrict allowed origins instead.
40+
41+
Called by every provider's from_dict() (GCP/AWS/Azure each construct
42+
their own ServiceConfig rather than delegating to the base class).
43+
"""
44+
if "CORS_ORIGINS" not in environment_vars:
45+
environment_vars["CORS_ORIGINS"] = "*"
46+
logger.warning(
47+
"CORS_ORIGINS not set in deployment.yaml service.environment_vars; "
48+
"defaulting to '*' (public API). Set it explicitly to restrict allowed origins."
49+
)
50+
51+
2852
@dataclass
2953
class ServiceConfig:
3054
"""Common service configuration shared across all providers."""
@@ -117,6 +141,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "BaseDeploymentConfig":
117141
labels=service_data.get("labels", {}),
118142
)
119143

144+
apply_cors_origins_default(service.environment_vars)
145+
120146
# Region should be provided in config or set by provider-specific config
121147
# No default region to avoid provider-specific assumptions
122148
config = cls(

deploy/config/deployment.yaml.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ service:
2828
GCP_PROJECT_ID: your-project-id
2929
USE_SECRETS_MANAGER: "true"
3030
SECRETS_PROVIDER: gcp
31+
# supply-graph-ai is a public API; the app itself defaults CORS_ORIGINS to
32+
# an empty list (deny all) in production when unset, which fails every
33+
# browser preflight with 400. BaseDeploymentConfig.from_dict() defaults
34+
# this to "*" automatically if omitted here — set it explicitly only if
35+
# you want to restrict allowed origins instead.
36+
CORS_ORIGINS: "*"
3137

3238
# Secrets (provider-specific format)
3339
secrets:

deploy/providers/aws/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DeploymentConfigError,
1313
DeploymentProvider,
1414
ServiceConfig,
15+
apply_cors_origins_default,
1516
)
1617

1718

@@ -108,6 +109,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "AWSDeploymentConfig":
108109
secrets=service_data.get("secrets", {}),
109110
labels=service_data.get("labels", {}),
110111
)
112+
apply_cors_origins_default(service.environment_vars)
111113

112114
# Get AWS-specific config
113115
aws_config = data.get("providers", {}).get("aws", {})
@@ -156,6 +158,7 @@ def with_defaults(
156158
"secrets": kwargs.pop("secrets", {}),
157159
"labels": kwargs.pop("labels", {}),
158160
}
161+
apply_cors_origins_default(service_kwargs["environment_vars"])
159162

160163
# Build provider config
161164
provider_config = kwargs.pop("provider_config", {})

deploy/providers/azure/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DeploymentConfigError,
1313
DeploymentProvider,
1414
ServiceConfig,
15+
apply_cors_origins_default,
1516
)
1617

1718

@@ -111,6 +112,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "AzureDeploymentConfig":
111112
secrets=service_data.get("secrets", {}),
112113
labels=service_data.get("labels", {}),
113114
)
115+
apply_cors_origins_default(service.environment_vars)
114116

115117
# Get Azure-specific config
116118
azure_config = data.get("providers", {}).get("azure", {})
@@ -163,6 +165,7 @@ def with_defaults(
163165
"secrets": kwargs.pop("secrets", {}),
164166
"labels": kwargs.pop("labels", {}),
165167
}
168+
apply_cors_origins_default(service_kwargs["environment_vars"])
166169

167170
# Build provider config
168171
provider_config = {

deploy/providers/gcp/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DeploymentConfigError,
1313
DeploymentProvider,
1414
ServiceConfig,
15+
apply_cors_origins_default,
1516
)
1617

1718

@@ -126,6 +127,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "GCPDeploymentConfig":
126127
secrets=service_data.get("secrets", {}),
127128
labels=service_data.get("labels", {}),
128129
)
130+
apply_cors_origins_default(service.environment_vars)
129131

130132
# Get GCP-specific config
131133
gcp_config = data.get("providers", {}).get("gcp", {})
@@ -176,6 +178,7 @@ def with_defaults(
176178
"secrets": kwargs.pop("secrets", {}),
177179
"labels": kwargs.pop("labels", {}),
178180
}
181+
apply_cors_origins_default(service_kwargs["environment_vars"])
179182

180183
# Build provider config
181184
provider_config = {

docs/development/cloud-deployment-prerequisites.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,39 @@ python deploy/scripts/test_azure_deployer.py \
365365
**Issue:** "Container App Environment not found"
366366
**Solution:** The deployer will create it automatically, or create it manually first.
367367

368+
**Issue:** Browser requests to the deployed API fail with `OPTIONS ... 400` (CORS preflight),
369+
even though `curl`/server-to-server calls work fine.
370+
**Cause:** `CORS_ORIGINS` was not set when the container was deployed. `src/config/settings.py`
371+
defaults `CORS_ORIGINS` to an empty list in production when unset, and Starlette's
372+
`CORSMiddleware` returns 400 for every preflight when `allow_origins` is empty — no origin
373+
can ever match. Deployments created via `deploy/config/deployment.yaml.example` (or any
374+
`deployment.yaml` predating this note) won't have it set.
375+
**Fix:** As of `BaseDeploymentConfig.from_dict()`, `CORS_ORIGINS` defaults to `"*"`
376+
automatically if omitted from `environment_vars` — redeploying with an up-to-date deployer
377+
picks this up. To fix a **running** container without a redeploy:
378+
```bash
379+
# Azure Container Apps
380+
az containerapp update \
381+
--name <container-app-name> \
382+
--resource-group <resource-group> \
383+
--set-env-vars CORS_ORIGINS="*"
384+
385+
# Azure App Service (if hosted there instead)
386+
az webapp config appsettings set \
387+
--name <app-name> \
388+
--resource-group <resource-group> \
389+
--settings CORS_ORIGINS="*"
390+
```
391+
Use a comma-separated origin list instead of `"*"` (e.g.
392+
`CORS_ORIGINS="https://app.example.com,https://staging.example.com"`) if the API should not
393+
be wildcard-public. Confirm the fix with a preflight check:
394+
```bash
395+
curl -s -o /dev/null -w '%{http_code}\n' -X OPTIONS <api-url>/v1/api/match \
396+
-H 'Origin: https://your-frontend-origin' \
397+
-H 'Access-Control-Request-Method: POST'
398+
# Expect 200, not 400
399+
```
400+
368401
---
369402

370403
## Next Steps
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Regression: deployment configs must never silently omit CORS_ORIGINS.
2+
3+
src/config/settings.py defaults CORS_ORIGINS to an empty list (deny all) in
4+
production when unset, which makes every browser CORS preflight to the
5+
deployed API fail with 400 before the request ever reaches a route handler.
6+
7+
GCP/AWS/Azure each implement their own from_dict() (none delegate to
8+
BaseDeploymentConfig.from_dict() via super()), so the default must be applied
9+
via the shared apply_cors_origins_default() helper in every provider's
10+
from_dict(), not just the base class — exercise all three here, plus the
11+
base class directly since it's also a valid (if currently unused by the
12+
concrete deployers) construction path.
13+
"""
14+
15+
import os
16+
import sys
17+
from pathlib import Path
18+
19+
_REPO_ROOT = Path(__file__).resolve().parents[2]
20+
if str(_REPO_ROOT) not in sys.path:
21+
sys.path.insert(0, str(_REPO_ROOT))
22+
23+
import pytest
24+
25+
from deploy.base.config import BaseDeploymentConfig
26+
from deploy.providers.aws.config import AWSDeploymentConfig
27+
from deploy.providers.azure.config import AzureDeploymentConfig
28+
from deploy.providers.gcp.config import GCPDeploymentConfig
29+
30+
_PROVIDER_CONFIG = {
31+
"gcp": {"project_id": "my-test-project"},
32+
"aws": {},
33+
"azure": {"resource_group": "test-rg", "subscription_id": "test-sub"},
34+
}
35+
36+
37+
def _data(provider, **overrides):
38+
data = {
39+
"provider": provider,
40+
"environment": "production",
41+
"service": {
42+
"name": "supply-graph-ai",
43+
"image": "ghcr.io/example/supply-graph-ai:latest",
44+
"environment_vars": {},
45+
},
46+
"providers": {provider: _PROVIDER_CONFIG[provider]},
47+
}
48+
data.update(overrides)
49+
return data
50+
51+
52+
CONFIG_CLASSES = [
53+
(BaseDeploymentConfig, "gcp"),
54+
(AzureDeploymentConfig, "azure"),
55+
(AWSDeploymentConfig, "aws"),
56+
(GCPDeploymentConfig, "gcp"),
57+
]
58+
59+
60+
@pytest.mark.parametrize("config_cls,provider", CONFIG_CLASSES)
61+
def test_cors_origins_defaults_to_wildcard_when_omitted(config_cls, provider):
62+
config = config_cls.from_dict(_data(provider))
63+
assert config.service.environment_vars["CORS_ORIGINS"] == "*"
64+
65+
66+
@pytest.mark.parametrize("config_cls,provider", CONFIG_CLASSES)
67+
def test_cors_origins_respects_explicit_value(config_cls, provider):
68+
data = _data(provider)
69+
data["service"]["environment_vars"] = {"CORS_ORIGINS": "https://app.example.com"}
70+
config = config_cls.from_dict(data)
71+
assert config.service.environment_vars["CORS_ORIGINS"] == "https://app.example.com"
72+
73+
74+
@pytest.mark.parametrize("config_cls,provider", CONFIG_CLASSES)
75+
def test_cors_origins_default_applies_regardless_of_environment(config_cls, provider):
76+
data = _data(provider, environment="development")
77+
config = config_cls.from_dict(data)
78+
assert config.service.environment_vars["CORS_ORIGINS"] == "*"
79+
80+
81+
def test_gcp_with_defaults_defaults_cors_origins():
82+
"""deploy_gcp.py (the only real, non-test deploy script in this repo)
83+
constructs config via with_defaults(), not from_dict() — must also default."""
84+
config = GCPDeploymentConfig.with_defaults(
85+
project_id="my-test-project",
86+
environment_vars={"ENVIRONMENT": "production"},
87+
)
88+
assert config.service.environment_vars["CORS_ORIGINS"] == "*"
89+
90+
91+
def test_azure_with_defaults_defaults_cors_origins():
92+
config = AzureDeploymentConfig.with_defaults(
93+
resource_group="test-rg",
94+
subscription_id="test-sub",
95+
)
96+
assert config.service.environment_vars["CORS_ORIGINS"] == "*"
97+
98+
99+
def test_aws_with_defaults_defaults_cors_origins():
100+
config = AWSDeploymentConfig.with_defaults()
101+
assert config.service.environment_vars["CORS_ORIGINS"] == "*"

0 commit comments

Comments
 (0)