Skip to content

Commit c2ffd53

Browse files
committed
Add mtls support
1 parent 0508c73 commit c2ffd53

File tree

10 files changed

+126
-10
lines changed

10 files changed

+126
-10
lines changed

app/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,9 @@ class ConfigMcsd(BaseModel):
267267
)
268268
request_count: int = Field(default=20)
269269
strict_validation: bool = Field(default=False)
270+
mtls_client_cert_path: str | None = Field(default=None)
271+
mtls_client_key_path: str | None = Field(default=None)
272+
mtls_server_ca_path: str | None = Field(default=None)
270273

271274
@field_validator("request_count", mode="before")
272275
def validate_request_count(cls, v: Any) -> int:

app/container.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def container_config(binder: inject.Binder) -> None:
4747
auth=auth,
4848
cache_provider=cache_provider,
4949
retries=config.directory_api.retries,
50+
mtls_cert=config.mcsd.mtls_client_cert_path,
51+
mtls_key=config.mcsd.mtls_client_key_path,
52+
mtls_ca=config.mcsd.mtls_server_ca_path,
5053
)
5154
binder.bind(UpdateClientService, update_service)
5255

app/services/directory_provider/factory.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class DirectoryProviderFactory:
2020

2121
def __init__(self, config: Config, database: Database, auth: Authenticator) -> None:
2222
self.__directory_config = config.directory_api
23+
self.__mcsd_config = config.mcsd
2324
self.__db = database
2425
self.__auth = auth
2526

@@ -46,6 +47,9 @@ def create(self) -> DirectoryProvider:
4647
request_count=5,
4748
strict_validation=False,
4849
retries=10,
50+
mtls_cert=self.__mcsd_config.mtls_client_cert_path,
51+
mtls_key=self.__mcsd_config.mtls_client_key_path,
52+
mtls_ca=self.__mcsd_config.mtls_server_ca_path,
4953
),
5054
ignored_directory_service=IgnoredDirectoryService(self.__db),
5155
)

app/services/update/update_client_service.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,18 @@ def __init__(
6464
self.request_count = request_count
6565
self.auth = auth
6666
self.__resource_map_service = resource_map_service
67+
self.mtls_cert = mtls_cert
68+
self.mtls_key = mtls_key
69+
self.mtls_ca = mtls_ca
6770
self.__update_client_fhir_api = FhirApi(
6871
base_url=update_client_url,
6972
auth=auth,
7073
timeout=timeout,
7174
retries=retries,
7275
backoff=backoff,
73-
mtls_cert=mtls_cert,
74-
mtls_key=mtls_key,
75-
mtls_ca=mtls_ca,
76+
mtls_cert=self.mtls_cert,
77+
mtls_key=self.mtls_key,
78+
mtls_ca=self.mtls_ca,
7679
request_count=request_count,
7780
strict_validation=strict_validation,
7881
)
@@ -157,6 +160,9 @@ def update_resource(
157160
strict_validation=self.strict_validation,
158161
base_url=directory.endpoint,
159162
retries=10,
163+
mtls_cert=self.mtls_cert,
164+
mtls_key=self.mtls_key,
165+
mtls_ca=self.mtls_ca,
160166
)
161167

162168
adjacency_map_service = AdjacencyMapService(

example-setup-with-hapi/app.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ ssl_key_file = server.key
6363
[mcsd]
6464
update_client_url = http://hapi-update-client:8081/fhir
6565
authentication = off
66+
mtls_client_cert_path=
67+
mtls_client_key_path=
68+
mtls_server_ca_path_path=
6669
request_count = 20
6770
strict_validation = False
6871

example-setup-with-hapi/app.conf.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ ssl_key_file = server.key
8888
update_client_url = http://addressing-app:8502
8989
# authentication can be either "off", or "azure_oauth2" in case of azure oauth2 authentication
9090
authentication = off
91+
# When configured, all connections to the supplier register and mCSD FHIR directories will use mTLS
92+
# Path to client certificate file for mTLS authentication
93+
mtls_client_cert_path = client.crt
94+
# Path to client private key file for mTLS authentication
95+
mtls_client_key_path = client.key
96+
# Path to CA certificate file for server verification
97+
mtls_server_ca_path = ca.crt
9198

9299
# query parameter for the number of entries in Bundle per request (limit)
93100
# This is usually used in the directory request service and not directory API (LRZa)

tests/services/api/conftest.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from typing import Any, Dict, Final
22
import pytest
3-
from yarl import URL
43

54
from app.config import Config
65
from app.services.api.api_service import (
@@ -43,8 +42,8 @@ def mock_sub_route() -> str:
4342

4443

4544
@pytest.fixture()
46-
def mock_url() -> URL:
47-
return URL("http://example.com")
45+
def mock_url() -> str:
46+
return "http://example.com"
4847

4948

5049
@pytest.fixture()
@@ -70,6 +69,9 @@ def http_service(base_url: str) -> HttpService:
7069
timeout=config.directory_api.timeout,
7170
backoff=config.directory_api.backoff,
7271
retries=1,
72+
mtls_cert=config.mcsd.mtls_client_cert_path,
73+
mtls_key=config.mcsd.mtls_client_key_path,
74+
mtls_ca=config.mcsd.mtls_server_ca_path,
7375
)
7476

7577

tests/services/api/test_api_service.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def test_do_request_with_authenticator_should_succeed(
7878
mock_request.status_code = 200
7979
mock_request.headers = mock_authentication_headers
8080
mock_request.auth = mock_auth
81+
8182
mock_get.return_value = mock_request
8283

8384
actual = http_service.do_request("GET")
@@ -173,6 +174,7 @@ def test_make_target_url_should_succeed_with_only_base_url(
173174
def test_make_target_url_should_work_with_params(
174175
http_service: HttpService,
175176
base_url: str,
177+
mock_url: str,
176178
mock_sub_route: str,
177179
mock_params: Dict[str, Any],
178180
) -> None:
@@ -181,3 +183,85 @@ def test_make_target_url_should_work_with_params(
181183
actual = http_service.make_target_url(sub_route=mock_sub_route, params=mock_params)
182184

183185
assert expected == actual
186+
187+
188+
@patch(PATCHED_MODULE)
189+
def test_do_request_should_use_mtls_cert_when_enabled(
190+
mock_request: MagicMock,
191+
mock_url: str,
192+
) -> None:
193+
api_service = HttpService(
194+
base_url=mock_url,
195+
timeout=10,
196+
backoff=0.1,
197+
retries=1,
198+
mtls_cert="test.crt",
199+
mtls_key="test.key",
200+
mtls_ca="test.ca"
201+
)
202+
203+
mock_response = MagicMock()
204+
mock_response.status_code = 200
205+
mock_request.return_value = mock_response
206+
207+
api_service.do_request("GET", mock_url)
208+
209+
mock_request.assert_called_once()
210+
call_kwargs = mock_request.call_args[1]
211+
assert call_kwargs["cert"] == ("test.crt", "test.key")
212+
assert call_kwargs["verify"] == "test.ca"
213+
214+
215+
@patch(PATCHED_MODULE)
216+
def test_do_request_should_use_ca_file_for_verification_when_provided(
217+
mock_request: MagicMock,
218+
mock_url: str,
219+
) -> None:
220+
api_service = HttpService(
221+
base_url=mock_url,
222+
timeout=10,
223+
backoff=0.1,
224+
retries=1,
225+
mtls_cert="test.crt",
226+
mtls_key="test.key",
227+
mtls_ca="ca.crt"
228+
)
229+
230+
mock_response = MagicMock()
231+
mock_response.status_code = 200
232+
mock_request.return_value = mock_response
233+
234+
api_service.do_request("GET", mock_url)
235+
236+
mock_request.assert_called_once()
237+
call_kwargs = mock_request.call_args[1]
238+
assert call_kwargs["cert"] == ("test.crt", "test.key")
239+
assert call_kwargs["verify"] == "ca.crt"
240+
241+
242+
@patch(PATCHED_MODULE)
243+
def test_do_request_should_not_use_cert_when_mtls_disabled(
244+
mock_request: MagicMock,
245+
mock_url: str,
246+
) -> None:
247+
api_service = HttpService(
248+
base_url=mock_url,
249+
timeout=10,
250+
backoff=0.1,
251+
retries=1,
252+
mtls_cert=None,
253+
mtls_key=None,
254+
mtls_ca=None
255+
)
256+
257+
mock_response = MagicMock()
258+
mock_response.status_code = 200
259+
mock_request.return_value = mock_response
260+
261+
api_service.do_request("GET", mock_url)
262+
263+
mock_request.assert_called_once()
264+
call_kwargs = mock_request.call_args[1]
265+
assert call_kwargs["cert"] is None
266+
assert call_kwargs["verify"] is True
267+

tests/services/api/test_fhir_api_service.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime
22
from typing import Any, Dict
3+
from urllib.parse import urlencode
34
from requests.exceptions import ConnectionError
45
from unittest.mock import patch, MagicMock
56

@@ -215,13 +216,13 @@ def test_search_resource_should_succeed(
215216
mock_org_history_bundle: Dict[str, Any],
216217
org_history_entry_1: Dict[str, Any],
217218
org_history_entry_2: Dict[str, Any],
218-
mock_url: URL,
219+
mock_url: str,
219220
mock_params: Dict[str, Any],
220221
fhir_api: FhirApi,
221222
) -> None:
222-
expected_url = mock_url.with_query(mock_params)
223+
expected_url = f"{mock_url}?{urlencode(mock_params)}"
223224
mock_org_history_bundle["link"] = [
224-
{"relation": "next", "url": mock_url.with_query(mock_params)}
225+
{"relation": "next", "url": f"{mock_url}?{urlencode(mock_params)}"}
225226
]
226227
mock_request = MagicMock()
227228
mock_request.status_code = 200
@@ -236,7 +237,7 @@ def test_search_resource_should_succeed(
236237
resource_type="Organization", params=mock_params
237238
)
238239

239-
assert (expected_url, expected_entries) == (actual_url, actual_entries)
240+
assert (URL(expected_url), expected_entries) == (actual_url, actual_entries)
240241

241242

242243
@patch(PATCHED_MODULE)

tests/test_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def get_test_config() -> Config:
4545
authentication="off",
4646
request_count=20,
4747
strict_validation=False,
48+
mtls_client_cert_path=None,
49+
mtls_client_key_path=None,
50+
mtls_server_ca_path=None
4851
),
4952
telemetry=ConfigTelemetry(
5053
enabled=False,

0 commit comments

Comments
 (0)