Skip to content

ADR-056: Intelligent Multi-Environment CORS Configuration

Status: Accepted
Date: 2025-10-29
Deciders: Infrastructure Team, Backend Team
Related: ADR-049 (Private IP Networking), ADR-053 (Infrastructure Consolidation), ADR-054 (Application Gateway)

Context

CORS (Cross-Origin Resource Sharing) configuration consistently failed across deployment environments due to:

  1. Hardcoded localhost origins in parameter files (http://localhost:3000)
  2. Missing Application Gateway FQDN in allowed origins
  3. Static configuration requiring container redeployment for changes
  4. No environment awareness - same config used for dev/test/prod
  5. Silent failures - no logging of CORS rejections

Previous State (Broken)

# config.py
cors_origins: list[str] | str  # Required, no default - FAILS if not set
# deploy-apps.sh
CORS_ORIGINS="http://localhost:3000"  # Wrong port (UI is 8080)
# Missing: Application Gateway FQDN from Substrate outputs
// apps.json
"corsOrigins": {
  "value": "http://localhost:3000"  // Hardcoded, never updated
}

Result: Every Azure deployment with Application Gateway resulted in CORS failures because: - Browser loads UI from https://ldfdev8.eastus.cloudapp.azure.com/ - UI calls API at https://ldfdev8.eastus.cloudapp.azure.com/loan-defenders/api/ - API sees Origin: https://ldfdev8.eastus.cloudapp.azure.com - API allows: ["http://localhost:3000"] - CORS REJECTION

Decision

Implement intelligent multi-environment CORS configuration with:

1. Smart Auto-Detection in API Config

# config.py
cors_origins: list[str] | str | None = None  # Optional with smart defaults

@field_validator("cors_origins", mode="before")
@classmethod
def parse_cors_origins(cls, v: Any) -> list[str]:
    if v is not None:
        # Explicit config takes precedence
        return [origin.strip() for origin in v.split(",")]

    # Auto-detect deployment environment
    origins = []

    # Azure: Check for Application Gateway
    if app_gateway_url := os.getenv("APP_GATEWAY_URL"):
        origins.append(app_gateway_url)
        origins.append(app_gateway_url.replace("http://", "https://"))

    # Docker: Check for container environment
    if os.getenv("DOCKER_CONTAINER") or os.path.exists("/.dockerenv"):
        origins.extend([
            "http://ui:8080",
            "http://loan-defenders-ui:8080",
            "http://localhost:8080",
        ])

    # Always include common dev ports
    origins.extend([
        "http://localhost:3000",
        "http://localhost:5173",
        "http://localhost:8080",
    ])

    return list(dict.fromkeys(origins))  # Deduplicate

2. Deployment Script Integration

# deploy-apps.sh
# 1. Retrieve Application Gateway FQDN from Key Vault
APP_GATEWAY_FQDN=$(retrieve_output_robust "appGatewayPublicFqdn" "substrate" "$KEY_VAULT_NAME")

# 2. Build environment-specific CORS origins
CORS_ORIGINS_ARRAY=()
if [ -n "$APP_GATEWAY_FQDN" ]; then
    CORS_ORIGINS_ARRAY+=("https://${APP_GATEWAY_FQDN}")
    CORS_ORIGINS_ARRAY+=("http://${APP_GATEWAY_FQDN}")
fi

# 3. Add dev/test/prod specific origins
if [ "$ENV" == "prod" ]; then
    CORS_ORIGINS_ARRAY+=("https://${CUSTOM_DOMAIN}")
else
    CORS_ORIGINS_ARRAY+=(
        "http://localhost:3000"
        "http://localhost:5173"
        "http://localhost:8080"
    )
fi

# 4. Deduplicate and pass to Bicep
CORS_ORIGINS=$(printf "%s," "${!unique_origins[@]}" | sed 's/,$//')

3. CORS Debugging Middleware

# app.py
@app.middleware("http")
async def cors_debug_middleware(request: Request, call_next):
    origin = request.headers.get("origin")

    # Log all preflight requests
    if request.method == "OPTIONS" and origin:
        logger.info(
            "CORS Preflight",
            extra={
                "origin": origin,
                "path": request.url.path,
                "origin_allowed": origin in settings.cors_origins,
            },
        )

    # Warn on rejected origins
    elif settings.debug and origin:
        if origin not in settings.cors_origins:
            logger.warning(
                "CORS Origin Not Allowed",
                extra={
                    "origin": origin,
                    "allowed_origins_sample": settings.cors_origins[:5],
                },
            )

    return await call_next(request)

4. Environment Variables Strategy

# Bicep sets these environment variables:
APP_CORS_ORIGINS="https://gateway.com,http://localhost:8080"  # Explicit origins
APP_GATEWAY_URL="https://gateway.com"  # For auto-detection in Python

# Docker Compose sets:
DOCKER_CONTAINER="true"  # Enables Docker-specific origins

Consequences

Positive

  1. ✅ Zero CORS failures in Azure deployments with Application Gateway
  2. ✅ Automatic environment detection - no manual configuration needed
  3. ✅ Graceful degradation - falls back to localhost if nothing detected
  4. ✅ Explicit override - can still manually set APP_CORS_ORIGINS
  5. ✅ CORS debugging built-in - logs preflight requests and rejections
  6. ✅ Multi-environment support - dev/test/prod configurations automated
  7. ✅ Secure defaults - restrictive in prod, permissive in dev

Negative

  1. Complexity: More logic in config validation (mitigated by comprehensive docs)
  2. Testing: Need to test across all environments (addressed by test matrix)
  3. Debugging: Auto-detection may be "magical" (mitigated by logging)

Neutral

  1. Backward compatibility: Old explicit APP_CORS_ORIGINS still works
  2. Performance: Negligible (~1ms at startup for validation)

Implementation

Files Changed

  1. apps/api/loan_defenders/api/config.py
  2. Made cors_origins optional with smart defaults
  3. Added environment auto-detection logic
  4. Added comprehensive logging

  5. apps/api/loan_defenders/api/app.py

  6. Added cors_debug_middleware for request logging
  7. Logs preflight requests (OPTIONS)
  8. Warns on CORS rejections in debug mode

  9. infrastructure/scripts/deploy-apps.sh

  10. Retrieves Application Gateway FQDN from Key Vault
  11. Builds environment-specific CORS origins list
  12. Passes both APP_CORS_ORIGINS and APP_GATEWAY_URL

  13. infrastructure/bicep/apps.bicep

  14. Added APP_GATEWAY_URL environment variable
  15. Derives from apiBaseUrl parameter

  16. docker-compose.yml

  17. Removed hardcoded APP_CORS_ORIGINS
  18. Added DOCKER_CONTAINER="true" marker

  19. infrastructure/bicep/environments/*/apps.json

  20. Updated default from port 3000 to 8080

Testing Strategy

# 1. Local development
export APP_CORS_ORIGINS=""  # Test auto-detection
python -m uvicorn loan_defenders.api.app:app

# 2. Docker Compose
docker-compose up
# Check logs: "Auto-detected Docker deployment"

# 3. Azure with Application Gateway
./infrastructure/scripts/deploy-apps.sh dev ldfdev8-rg
az container logs --name <container-group> --container-name api | grep "CORS"
# Check logs: "Auto-detected Azure Application Gateway: https://..."

# 4. CORS preflight test
curl -X OPTIONS https://ldfdev8.eastus.cloudapp.azure.com/loan-defenders/api/chat \
    -H "Origin: https://ldfdev8.eastus.cloudapp.azure.com" \
    -v

Deployment

Rollout Plan

  1. Phase 1: Update API Config (Non-breaking)
  2. Deploy updated config.py with backward-compatible defaults
  3. Existing APP_CORS_ORIGINS still works

  4. Phase 2: Update Deployment Scripts

  5. Enhance deploy-apps.sh with Application Gateway detection
  6. Scripts work with old and new API config

  7. Phase 3: Update Docker Compose

  8. Remove hardcoded CORS, rely on auto-detection
  9. Backward compatible (explicit override still possible)

  10. Phase 4: Update Parameter Files

  11. Fix port 3000 → 8080 in apps.json
  12. Falls back to auto-detection if empty

Rollback

If issues arise:

# Override with explicit CORS at deployment time
export APP_CORS_ORIGINS="https://known-origin.com,http://localhost:8080"
./infrastructure/scripts/deploy-apps.sh dev <rg>

No code rollback needed - explicit APP_CORS_ORIGINS takes precedence.

Validation

Success Criteria

  • Local docker-compose up works without CORS errors
  • Azure deployment with Application Gateway has no CORS errors
  • Bastion Jump Box access via private IP works
  • Production deployment with custom domain works
  • CORS preflight requests logged correctly
  • Rejected origins generate warnings in logs

Monitoring

# Check CORS configuration at startup
az container logs --name <container-group> --container-name api \
    | grep "CORS.*configured"

# Monitor CORS rejections
az monitor log-analytics query \
    --workspace <workspace-id> \
    --analytics-query "ContainerLog | where Message contains 'CORS Origin Not Allowed'"

References

Decision Log

Date Decision Rationale
2025-10-29 Initial implementation Resolve persistent CORS failures in Azure deployments
2025-10-29 Add auto-detection Reduce manual configuration burden
2025-10-29 Add debugging middleware Enable troubleshooting of CORS issues
2025-10-29 Make explicit config optional Support both manual and automatic modes

Supersedes: Previous hardcoded CORS configuration
Superseded By: None
Amendment History: None