Skip to content

Commit 2518c80

Browse files
committed
feat: security api key
1 parent 9b682fa commit 2518c80

7 files changed

Lines changed: 54 additions & 12 deletions

File tree

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ API_VERSION=0.1.0
1818

1919
# CRITICAL, FATAL, ERROR, WARN, WARNING, INFO, DEBUG, NOTSET
2020
LOG_LEVEL=DEBUG
21-
LOG_FORMAT=json
21+
LOG_FORMAT=json
22+
23+
INTERNAL_API_KEY=xxx
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
API dependencies package.
3+
"""
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
Authentication dependencies for API routes.
3+
"""
4+
from fastapi import HTTPException, Security, status
5+
from fastapi.security import APIKeyHeader
6+
from ...config.settings import settings
7+
8+
9+
api_key_header = APIKeyHeader(name="X-Internal-API-Key", auto_error=False)
10+
11+
12+
async def verify_api_key(api_key: str = Security(api_key_header)) -> None:
13+
"""
14+
Verify internal API key sent in request headers.
15+
"""
16+
if not api_key or api_key != settings.INTERNAL_API_KEY:
17+
raise HTTPException(
18+
status_code=status.HTTP_401_UNAUTHORIZED,
19+
detail="Invalid or missing API key"
20+
)

src/sum_impact_assessment/api/main.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
FastAPI application instance and configuration.
33
"""
44
import time
5-
from fastapi import FastAPI, Request
5+
from fastapi import Depends, FastAPI, Request
66
from fastapi.middleware.cors import CORSMiddleware
77
from .routes import kpis, jobs, simulation, health
8+
from .dependencies.auth import verify_api_key
89
from ..config.settings import settings
910
from ..utils.logger import get_logger
1011
from ..database.connection import Base, engine
@@ -74,9 +75,9 @@ async def log_requests(request: Request, call_next):
7475

7576
# Include routers
7677
app.include_router(health.router, tags=["Health"])
77-
app.include_router(kpis.router, tags=["KPIs"])
78-
app.include_router(jobs.router, tags=["Jobs"])
79-
app.include_router(simulation.router, tags=["Simulation (NON-prod only)⚠️"])
78+
app.include_router(kpis.router, tags=["KPIs"], dependencies=[Depends(verify_api_key)])
79+
app.include_router(jobs.router, tags=["Jobs"], dependencies=[Depends(verify_api_key)])
80+
app.include_router(simulation.router, tags=["Simulation (NON-prod only)⚠️"], dependencies=[Depends(verify_api_key)])
8081

8182

8283
@app.on_event("startup")

src/sum_impact_assessment/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
2222
API_PORT: int = 8000
2323
API_TITLE: str = "SUM Impact Assessment API"
2424
API_VERSION: str = __version__
25+
INTERNAL_API_KEY: str = ""
2526

2627
# Application configuration
2728
LOG_LEVEL: str = "INFO"

tests/test_api/test_jobs.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
"""
22
Unit tests for job management API.
33
"""
4+
import os
45
import pytest
56
from unittest.mock import Mock, patch, MagicMock
67
from fastapi import status
78
from fastapi.testclient import TestClient
89
from datetime import datetime
10+
11+
os.environ["INTERNAL_API_KEY"] = "test-key"
12+
913
from src.sum_impact_assessment.api.main import app
1014
from sum_impact_assessment.database.models.job import JobRun
1115
from src.sum_impact_assessment.schemas.job import JobNameEnum, JobStatusEnum
1216

1317

1418
# Create test client
1519
client = TestClient(app)
20+
AUTH_HEADERS = {"X-Internal-API-Key": "test-key"}
1621

1722

1823
class TestJobsAPI:
@@ -43,7 +48,7 @@ def test_trigger_job_success(self, mock_get_job_class, mock_job_repo_class):
4348
mock_get_job_class.return_value = mock_job_class
4449

4550
# Make request
46-
response = client.post("/jobs/runs/kpi_measures_analysis")
51+
response = client.post("/jobs/runs/kpi_measures_analysis", headers=AUTH_HEADERS)
4752

4853
# Assertions
4954
assert response.status_code == status.HTTP_201_CREATED
@@ -68,7 +73,7 @@ def test_trigger_job_not_found_in_registry(self, mock_get_job_class, mock_job_re
6873
mock_get_job_class.side_effect = KeyError("Job not found")
6974

7075
# Make request
71-
response = client.post("/jobs/runs/kpi_measures_analysis")
76+
response = client.post("/jobs/runs/kpi_measures_analysis", headers=AUTH_HEADERS)
7277

7378
# Assertions
7479
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -80,7 +85,7 @@ def test_trigger_job_not_found_in_registry(self, mock_get_job_class, mock_job_re
8085
def test_trigger_job_invalid_job_name(self):
8186
"""Test triggering a job with invalid job name returns 422."""
8287
# Make request with invalid job name
83-
response = client.post("/jobs/runs/invalid_job_name")
88+
response = client.post("/jobs/runs/invalid_job_name", headers=AUTH_HEADERS)
8489

8590
# Assertions
8691
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -100,7 +105,7 @@ def test_trigger_job_database_error(self, mock_get_job_class, mock_job_repo_clas
100105
mock_job_repo_class.return_value = mock_repo_instance
101106

102107
# Make request
103-
response = client.post("/jobs/runs/kpi_measures_analysis")
108+
response = client.post("/jobs/runs/kpi_measures_analysis", headers=AUTH_HEADERS)
104109

105110
# Assertions
106111
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -133,6 +138,7 @@ def test_trigger_job_with_params(self, mock_get_job_class, mock_job_repo_class):
133138
# Make request with params
134139
response = client.post(
135140
"/jobs/runs/mcda_analysis_quantitative",
141+
headers=AUTH_HEADERS,
136142
json={"params": {"kpi_group_type": "MCDA_GOALS"}}
137143
)
138144

@@ -168,7 +174,7 @@ def test_trigger_mcda_analysis_job(self, mock_get_job_class, mock_job_repo_class
168174
mock_get_job_class.return_value = mock_job_class
169175

170176
# Make request
171-
response = client.post("/jobs/runs/mcda_analysis_quantitative")
177+
response = client.post("/jobs/runs/mcda_analysis_quantitative", headers=AUTH_HEADERS)
172178

173179
# Assertions
174180
assert response.status_code == status.HTTP_201_CREATED
@@ -207,6 +213,7 @@ def test_trigger_mcda_analysis_job_with_perspective(self, mock_get_job_class, mo
207213
# Make request with perspective parameter
208214
response = client.post(
209215
"/jobs/runs/mcda_analysis_quantitative",
216+
headers=AUTH_HEADERS,
210217
json={"params": {"perspective": "regulatory"}}
211218
)
212219

tests/test_api/test_simulation.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
"""
22
Unit tests for simulation API routes.
33
"""
4+
import os
45
import pytest
56
from unittest.mock import Mock, patch, MagicMock
67
from fastapi import status
78
from fastapi.testclient import TestClient
9+
10+
os.environ["INTERNAL_API_KEY"] = "test-key"
11+
812
from src.sum_impact_assessment.api.main import app
913

1014
# Create test client
1115
client = TestClient(app)
16+
AUTH_HEADERS = {"X-Internal-API-Key": "test-key"}
1217

1318

1419
class TestSimulationAPI:
@@ -23,6 +28,7 @@ def test_simulation_blocked_in_production(self, mock_settings):
2328
# Attempt to run simulation
2429
response = client.post(
2530
"/simulation/kpi-results",
31+
headers=AUTH_HEADERS,
2632
json={
2733
"baseline_years": [2023],
2834
"target_year": 2025,
@@ -55,6 +61,7 @@ def test_simulation_success_in_development(self, mock_service_class, mock_settin
5561
# Run simulation
5662
response = client.post(
5763
"/simulation/kpi-results",
64+
headers=AUTH_HEADERS,
5865
json={
5966
"baseline_years": [2023],
6067
"target_year": 2025,
@@ -87,6 +94,7 @@ def test_simulation_invalid_parameters(self, mock_service_class, mock_settings):
8794
# Run simulation with invalid params
8895
response = client.post(
8996
"/simulation/kpi-results",
97+
headers=AUTH_HEADERS,
9098
json={
9199
"baseline_years": [2025],
92100
"target_year": 2023, # Invalid: target < baseline
@@ -104,7 +112,7 @@ def test_environment_status_development(self, mock_settings):
104112
"""Test environment status endpoint in development."""
105113
mock_settings.ENV = "development"
106114

107-
response = client.get("/simulation/environment")
115+
response = client.get("/simulation/environment", headers=AUTH_HEADERS)
108116

109117
assert response.status_code == status.HTTP_200_OK
110118
data = response.json()
@@ -116,7 +124,7 @@ def test_environment_status_production(self, mock_settings):
116124
"""Test environment status endpoint in production."""
117125
mock_settings.ENV = "production"
118126

119-
response = client.get("/simulation/environment")
127+
response = client.get("/simulation/environment", headers=AUTH_HEADERS)
120128

121129
assert response.status_code == status.HTTP_200_OK
122130
data = response.json()

0 commit comments

Comments
 (0)