Skip to content

Commit faf47a2

Browse files
committed
Protect AI and tracing routes with API key
1 parent 5e49396 commit faf47a2

7 files changed

Lines changed: 175 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Protect AI and tracing routes with the shared API key.

policyengine_api/routes/ai_prompt_routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from flask import Blueprint, Response, request
22
from copy import deepcopy
33
from policyengine_api.services.ai_prompt_service import AIPromptService
4+
from policyengine_api.security import require_simulation_analysis_api_key
45
from policyengine_api.utils.payload_validators import validate_country
56
from policyengine_api.utils.payload_validators.ai import (
67
validate_sim_analysis_payload,
@@ -12,11 +13,12 @@
1213
ai_prompt_service = AIPromptService()
1314

1415

15-
@validate_country
1616
@ai_prompt_bp.route(
1717
"/<country_id>/ai-prompts/<string:prompt_name>",
1818
methods=["POST"],
1919
)
20+
@validate_country
21+
@require_simulation_analysis_api_key
2022
def generate_ai_prompt(country_id, prompt_name: str) -> Response:
2123
"""
2224
Get an AI prompt with a given name, filled with the given data.

policyengine_api/routes/simulation_analysis_routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from policyengine_api.utils.payload_validators.ai import (
1111
validate_sim_analysis_payload,
1212
)
13+
from policyengine_api.security import require_simulation_analysis_api_key
1314
import json
1415

1516
simulation_analysis_bp = Blueprint("simulation_analysis", __name__)
@@ -18,6 +19,7 @@
1819

1920
@simulation_analysis_bp.route("/<country_id>/simulation-analysis", methods=["POST"])
2021
@validate_country
22+
@require_simulation_analysis_api_key
2123
def execute_simulation_analysis(country_id):
2224
print("Got POST request for simulation analysis")
2325

policyengine_api/routes/tracer_analysis_routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
validate_country,
55
validate_tracer_analysis_payload,
66
)
7+
from policyengine_api.security import require_simulation_analysis_api_key
78
from policyengine_api.services.tracer_analysis_service import (
89
TracerAnalysisService,
910
)
@@ -17,6 +18,7 @@
1718

1819
@tracer_analysis_bp.route("/<country_id>/tracer-analysis", methods=["POST"])
1920
@validate_country
21+
@require_simulation_analysis_api_key
2022
def execute_tracer_analysis(country_id):
2123

2224
payload = request.json

policyengine_api/security.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Security helpers for sensitive API routes."""
2+
3+
import os
4+
from functools import wraps
5+
6+
from flask import request
7+
from werkzeug.exceptions import Unauthorized
8+
9+
_LOCAL_CLIENT_HOSTS = {"127.0.0.1", "::1", "localhost"}
10+
11+
12+
def require_simulation_analysis_api_key(view):
13+
"""Require a shared API key for non-local simulation analysis requests."""
14+
15+
@wraps(view)
16+
def wrapped(*args, **kwargs):
17+
client_host = request.remote_addr
18+
if client_host in _LOCAL_CLIENT_HOSTS:
19+
return view(*args, **kwargs)
20+
21+
expected_key = os.getenv(
22+
"POLICYENGINE_API_AI_ANALYSIS_API_KEY", ""
23+
).strip()
24+
if expected_key and request.headers.get("X-PolicyEngine-Api-Key") == expected_key:
25+
return view(*args, **kwargs)
26+
27+
raise Unauthorized("API key required for simulation analysis")
28+
29+
return wrapped
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import os
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
os.environ.setdefault("FLASK_DEBUG", "1")
7+
8+
from policyengine_api.api import app
9+
from tests.fixtures.simulation_analysis_prompt_fixtures import valid_input_us
10+
11+
12+
@pytest.fixture
13+
def client():
14+
app.config["TESTING"] = True
15+
with app.test_client() as test_client:
16+
yield test_client
17+
18+
19+
def test_ai_prompt_rejects_requests_without_api_key(client, monkeypatch):
20+
monkeypatch.setenv("POLICYENGINE_API_AI_ANALYSIS_API_KEY", "secret-key")
21+
22+
response = client.post(
23+
"/us/ai-prompts/simulation_analysis",
24+
json=valid_input_us,
25+
environ_base={"REMOTE_ADDR": "203.0.113.10"},
26+
)
27+
28+
assert response.status_code == 401
29+
assert "API key required" in response.json["message"]
30+
31+
32+
def test_ai_prompt_allows_requests_with_api_key(client, monkeypatch):
33+
monkeypatch.setenv("POLICYENGINE_API_AI_ANALYSIS_API_KEY", "secret-key")
34+
35+
with patch(
36+
"policyengine_api.routes.ai_prompt_routes.ai_prompt_service.get_prompt",
37+
return_value="Prompt text",
38+
) as mock_get_prompt:
39+
response = client.post(
40+
"/us/ai-prompts/simulation_analysis",
41+
json=valid_input_us,
42+
headers={"X-PolicyEngine-Api-Key": "secret-key"},
43+
environ_base={"REMOTE_ADDR": "203.0.113.10"},
44+
)
45+
46+
assert response.status_code == 200
47+
assert response.json["result"] == "Prompt text"
48+
mock_get_prompt.assert_called_once()
49+
50+
51+
def test_tracer_analysis_rejects_requests_without_api_key(client, monkeypatch):
52+
monkeypatch.setenv("POLICYENGINE_API_AI_ANALYSIS_API_KEY", "secret-key")
53+
54+
response = client.post(
55+
"/us/tracer-analysis",
56+
json={
57+
"household_id": 1500,
58+
"policy_id": 2,
59+
"variable": "disposable_income",
60+
},
61+
environ_base={"REMOTE_ADDR": "203.0.113.10"},
62+
)
63+
64+
assert response.status_code == 401
65+
assert "API key required" in response.json["message"]
66+
67+
68+
def test_tracer_analysis_allows_requests_with_api_key(client, monkeypatch):
69+
monkeypatch.setenv("POLICYENGINE_API_AI_ANALYSIS_API_KEY", "secret-key")
70+
71+
with patch(
72+
"policyengine_api.routes.tracer_analysis_routes.tracer_analysis_service.execute_analysis",
73+
return_value=("Existing analysis", "static"),
74+
) as mock_execute_analysis:
75+
response = client.post(
76+
"/us/tracer-analysis",
77+
json={
78+
"household_id": 1500,
79+
"policy_id": 2,
80+
"variable": "disposable_income",
81+
},
82+
headers={"X-PolicyEngine-Api-Key": "secret-key"},
83+
environ_base={"REMOTE_ADDR": "203.0.113.10"},
84+
)
85+
86+
assert response.status_code == 200
87+
assert response.json["result"] == "Existing analysis"
88+
mock_execute_analysis.assert_called_once_with(
89+
"us", 1500, 2, "disposable_income"
90+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import os
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
os.environ.setdefault("FLASK_DEBUG", "1")
7+
8+
from policyengine_api.api import app
9+
from tests.to_refactor.fixtures.simulation_analysis_fixtures import test_json
10+
11+
12+
@pytest.fixture
13+
def client():
14+
app.config["TESTING"] = True
15+
with app.test_client() as test_client:
16+
yield test_client
17+
18+
19+
def test_simulation_analysis_rejects_requests_without_api_key(client, monkeypatch):
20+
monkeypatch.setenv("POLICYENGINE_API_AI_ANALYSIS_API_KEY", "secret-key")
21+
22+
response = client.post(
23+
"/us/simulation-analysis",
24+
json=test_json,
25+
environ_base={"REMOTE_ADDR": "203.0.113.10"},
26+
)
27+
28+
assert response.status_code == 401
29+
assert "API key required" in response.json["message"]
30+
31+
32+
def test_simulation_analysis_allows_requests_with_api_key(client, monkeypatch):
33+
monkeypatch.setenv("POLICYENGINE_API_AI_ANALYSIS_API_KEY", "secret-key")
34+
35+
with patch(
36+
"policyengine_api.routes.simulation_analysis_routes.simulation_analysis_service.execute_analysis",
37+
return_value=("Existing analysis", "static"),
38+
) as mock_execute_analysis:
39+
response = client.post(
40+
"/us/simulation-analysis",
41+
json=test_json,
42+
headers={"X-PolicyEngine-Api-Key": "secret-key"},
43+
environ_base={"REMOTE_ADDR": "203.0.113.10"},
44+
)
45+
46+
assert response.status_code == 200
47+
assert response.json["result"] == "Existing analysis"
48+
mock_execute_analysis.assert_called_once()

0 commit comments

Comments
 (0)