diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4b64e64..d4120b0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -29,6 +29,7 @@ Supported environment overrides: - `SCIOT_CLIENT_DEVICE_ID`: static-client device ID. - `SCIOT_CLIENT_MAX_CYCLES`: bounded client cycle count. - `SCIOT_CLIENT_RUN_DURATION_SECONDS`: bounded client run duration. +- `SCIOT_API_KEY`: server API key for `communication.http.auth`, overrides `auth.api_key` when set. ## Validated fields @@ -40,6 +41,7 @@ Server validation covers: - local-inference probability in the inclusive range `0.0..1.0`; - delay sections and delay distributions; - boolean flags such as `verbose`, `debug_cprofiler`, compression, and input saving; +- `communication.http.auth.enabled` (boolean) and `communication.http.auth.api_key` (string), gating the registration/device-data routes behind an `X-API-Key` header; - `evaluation.split.max_rows` and `evaluation.split.interval_minutes` (non-negative); - `evaluation.outputs.inference_cycles` and `evaluation.outputs.offloading_decisions` (booleans); - optional Firestore telemetry publishing under `evaluation.firestore`. diff --git a/docs/config/server.full.yaml b/docs/config/server.full.yaml index 604f536..c40a241 100644 --- a/docs/config/server.full.yaml +++ b/docs/config/server.full.yaml @@ -8,6 +8,12 @@ communication: timeout_keep_alive: 30 backlog: 2048 save_device_input: true + auth: + # When true, the registration/device_input/device_inference_result + # routes require an X-API-Key header matching api_key (or the + # SCIOT_API_KEY env var, which takes precedence over api_key). + enabled: false + api_key: null compression: enabled: false endpoints: diff --git a/src/sciot/config.py b/src/sciot/config.py index e347990..11ced2f 100644 --- a/src/sciot/config.py +++ b/src/sciot/config.py @@ -546,6 +546,20 @@ def _validate_http_server_transport( errors, path="communication.http.compression.enabled", ) + auth = config.get("auth") + if auth is not None: + if not isinstance(auth, dict): + errors.append("communication.http.auth: must be a mapping") + else: + _optional_bool( + auth, + "enabled", + errors, + path="communication.http.auth.enabled", + ) + api_key = auth.get("api_key") + if api_key is not None and not isinstance(api_key, str): + errors.append("communication.http.auth.api_key: must be a string") _optional_positive_int( config, "timeout_keep_alive", diff --git a/src/server/communication/http_server.py b/src/server/communication/http_server.py index 08f9748..d38e2d4 100644 --- a/src/server/communication/http_server.py +++ b/src/server/communication/http_server.py @@ -6,6 +6,7 @@ from server.logger.log import logger import asyncio import ntplib +import os import threading import time from pathlib import Path @@ -16,6 +17,9 @@ if TYPE_CHECKING: from server.communication.request_handler import RequestHandler +# Header devices must send their API key in when auth is enabled. +API_KEY_HEADER = "X-API-Key" + class HttpServer: def __init__( @@ -49,6 +53,28 @@ def __init__( self.port = port self.endpoints = endpoints + # ===================================================== + # API-key authentication for the registration/device-data routes. + # Enabled via settings.yaml -> communication.http.auth.enabled. + # The key can be set in settings.yaml (auth.api_key) or via the + # SCIOT_API_KEY env var, which takes precedence. + # ===================================================== + auth_cfg = http_cfg.get("auth", {}) or {} + self.auth_enabled = bool(auth_cfg.get("enabled", False)) + self.api_key = os.environ.get("SCIOT_API_KEY") or auth_cfg.get("api_key") + if self.auth_enabled and not self.api_key: + raise ValueError( + "communication.http.auth.enabled is true but no API key is " + "configured (set communication.http.auth.api_key or the " + "SCIOT_API_KEY environment variable)" + ) + self._protected_paths = { + endpoints.get("registration"), + endpoints.get("device_input"), + endpoints.get("device_inference_result"), + } + self.app.middleware("http")(self._enforce_api_key) + self.devices = set() # Load verbose setting @@ -461,6 +487,19 @@ async def split_inference(request: Request): }, ) + async def _enforce_api_key(self, request: Request, call_next): + """Reject unauthenticated requests to the registration/device-data routes.""" + if self.auth_enabled and request.url.path in self._protected_paths: + if request.headers.get(API_KEY_HEADER) != self.api_key: + return JSONResponse( + status_code=401, + content={ + "code": "invalid_api_key", + "message": "Missing or invalid API key", + }, + ) + return await call_next(request) + def _load_verbose_config(self): """Load verbose configuration from cached settings.""" return self._settings().get("verbose", False) diff --git a/src/server/settings.yaml b/src/server/settings.yaml index b39c168..88d333e 100644 --- a/src/server/settings.yaml +++ b/src/server/settings.yaml @@ -1,5 +1,11 @@ communication: http: + auth: + # When true, the registration/device_input/device_inference_result + # routes require an X-API-Key header matching api_key (or the + # SCIOT_API_KEY env var, which takes precedence over api_key). + enabled: false + api_key: null compression: enabled: false endpoints: diff --git a/tests/integration/test_http_api_key_auth.py b/tests/integration/test_http_api_key_auth.py new file mode 100644 index 0000000..657cabd --- /dev/null +++ b/tests/integration/test_http_api_key_auth.py @@ -0,0 +1,121 @@ +"""Coverage for the optional API-key authentication on the HTTP server.""" + +import pytest +from fastapi.testclient import TestClient + +from server.communication.http_server import HttpServer + + +def _make_server(monkeypatch, *, enabled, api_key=None, env_key=None): + monkeypatch.setattr(HttpServer, "_sync_with_ntp", lambda self: 0.0) + if env_key is None: + monkeypatch.delenv("SCIOT_API_KEY", raising=False) + else: + monkeypatch.setenv("SCIOT_API_KEY", env_key) + + class DummyRequestHandler: + device_profiles = {} + + def handle_registration(self, device_id, model_hash=""): + return device_id + + def handle_device_input(self, body, height, width): + return None + + @staticmethod + def handle_offloading_layer(best_offloading_layer): + return best_offloading_layer + + endpoints = { + "registration": "/api/registration", + "device_input": "/api/device_input", + "offloading_layer": "/api/offloading_layer", + "device_inference_result": "/api/device_inference_result", + } + return HttpServer( + host="127.0.0.1", + port=0, + endpoints=endpoints, + ntp_server="unused.invalid", + input_height=1, + input_width=1, + last_offloading_layer=1, + request_handler=DummyRequestHandler(), + settings={ + "verbose": False, + "communication": { + "http": { + "auth": {"enabled": enabled, "api_key": api_key}, + } + }, + }, + ) + + +def test_auth_disabled_by_default_allows_requests_without_key(monkeypatch): + server = _make_server(monkeypatch, enabled=False) + with TestClient(server.app) as client: + response = client.post("/api/registration", json={"device_id": "dev-1"}) + assert response.status_code == 200 + + +def test_auth_enabled_rejects_missing_key(monkeypatch): + server = _make_server(monkeypatch, enabled=True, api_key="secret") + with TestClient(server.app) as client: + response = client.post("/api/registration", json={"device_id": "dev-1"}) + assert response.status_code == 401 + + +def test_auth_enabled_rejects_wrong_key(monkeypatch): + server = _make_server(monkeypatch, enabled=True, api_key="secret") + with TestClient(server.app) as client: + response = client.post( + "/api/registration", + json={"device_id": "dev-1"}, + headers={"X-API-Key": "wrong"}, + ) + assert response.status_code == 401 + + +def test_auth_enabled_accepts_correct_key(monkeypatch): + server = _make_server(monkeypatch, enabled=True, api_key="secret") + with TestClient(server.app) as client: + response = client.post( + "/api/registration", + json={"device_id": "dev-1"}, + headers={"X-API-Key": "secret"}, + ) + assert response.status_code == 200 + + +def test_env_var_api_key_takes_precedence_over_settings(monkeypatch): + server = _make_server( + monkeypatch, enabled=True, api_key="settings-key", env_key="env-key" + ) + with TestClient(server.app) as client: + rejected = client.post( + "/api/registration", + json={"device_id": "dev-1"}, + headers={"X-API-Key": "settings-key"}, + ) + accepted = client.post( + "/api/registration", + json={"device_id": "dev-1"}, + headers={"X-API-Key": "env-key"}, + ) + assert rejected.status_code == 401 + assert accepted.status_code == 200 + + +def test_unprotected_routes_are_not_gated_by_api_key(monkeypatch): + server = _make_server(monkeypatch, enabled=True, api_key="secret") + with TestClient(server.app) as client: + response = client.get("/api/offloading_layer") + assert response.status_code == 200 + + +def test_enabling_auth_without_a_key_fails_fast(monkeypatch): + monkeypatch.setattr(HttpServer, "_sync_with_ntp", lambda self: 0.0) + monkeypatch.delenv("SCIOT_API_KEY", raising=False) + with pytest.raises(ValueError): + _make_server(monkeypatch, enabled=True, api_key=None) diff --git a/tests/unit/test_config_validation.py b/tests/unit/test_config_validation.py index bbd9651..1987949 100644 --- a/tests/unit/test_config_validation.py +++ b/tests/unit/test_config_validation.py @@ -162,6 +162,22 @@ def test_documented_example_configs_are_valid(path, loader): ), "offloading_algo: unsupported keys", ), + ( + lambda cfg: cfg["communication"]["http"].update(auth="not-a-mapping"), + "communication.http.auth: must be a mapping", + ), + ( + lambda cfg: cfg["communication"]["http"].update( + auth={"enabled": "yes"} + ), + "communication.http.auth.enabled: must be true or false", + ), + ( + lambda cfg: cfg["communication"]["http"].update( + auth={"api_key": 12345} + ), + "communication.http.auth.api_key: must be a string", + ), ], ) def test_invalid_server_config_reports_field_errors(mutate, expected_error):