Skip to content

Commit 0df24ee

Browse files
authored
fix(api): make Neo4j connection acquisition timeout configurable and enable Sentry tracing (#10873)
1 parent d1fc482 commit 0df24ee

4 files changed

Lines changed: 56 additions & 48 deletions

File tree

api/CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ All notable changes to the **Prowler API** are documented in this file.
1414

1515
---
1616

17+
## [1.25.4] (Prowler UNRELEASED)
18+
19+
### 🚀 Added
20+
21+
- `DJANGO_SENTRY_TRACES_SAMPLE_RATE` env var (default `0.02`) enables Sentry performance tracing for the API [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873)
22+
23+
### 🔄 Changed
24+
25+
- Attack Paths: Neo4j driver `connection_acquisition_timeout` is now configurable via `NEO4J_CONN_ACQUISITION_TIMEOUT` (default lowered from 120 s to 15 s) [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873)
26+
27+
---
28+
1729
## [1.25.3] (Prowler v5.24.3)
1830

1931
### 🚀 Added
@@ -740,4 +752,4 @@ All notable changes to the **Prowler API** are documented in this file.
740752
- Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863)
741753
- Increased the allowed length of the provider UID for Kubernetes providers [(#6869)](https://github.com/prowler-cloud/prowler/pull/6869)
742754

743-
---
755+
---

api/src/backend/api/attack_paths/database.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
2929
)
3030
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
31+
CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
3132
READ_EXCEPTION_CODES = [
3233
"Neo.ClientError.Statement.AccessMode",
3334
"Neo.ClientError.Procedure.ProcedureNotFound",
@@ -62,7 +63,7 @@ def init_driver() -> neo4j.Driver:
6263
auth=(config["USER"], config["PASSWORD"]),
6364
keep_alive=True,
6465
max_connection_lifetime=7200,
65-
connection_acquisition_timeout=120,
66+
connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
6667
max_connection_pool_size=50,
6768
)
6869
_driver.verify_connectivity()

api/src/backend/api/tests/test_attack_paths_database.py

Lines changed: 40 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@
1212
import neo4j
1313
import pytest
1414

15+
import api.attack_paths.database as db_module
16+
1517

1618
class TestLazyInitialization:
1719
"""Test that Neo4j driver is initialized lazily on first use."""
1820

1921
@pytest.fixture(autouse=True)
2022
def reset_module_state(self):
2123
"""Reset module-level singleton state before each test."""
22-
import api.attack_paths.database as db_module
23-
2424
original_driver = db_module._driver
2525

2626
db_module._driver = None
@@ -31,8 +31,6 @@ def reset_module_state(self):
3131

3232
def test_driver_not_initialized_at_import(self):
3333
"""Driver should be None after module import (no eager connection)."""
34-
import api.attack_paths.database as db_module
35-
3634
assert db_module._driver is None
3735

3836
@patch("api.attack_paths.database.settings")
@@ -41,8 +39,6 @@ def test_init_driver_creates_connection_on_first_call(
4139
self, mock_driver_factory, mock_settings
4240
):
4341
"""init_driver() should create connection only when called."""
44-
import api.attack_paths.database as db_module
45-
4642
mock_driver = MagicMock()
4743
mock_driver_factory.return_value = mock_driver
4844
mock_settings.DATABASES = {
@@ -69,8 +65,6 @@ def test_init_driver_returns_cached_driver_on_subsequent_calls(
6965
self, mock_driver_factory, mock_settings
7066
):
7167
"""Subsequent calls should return cached driver without reconnecting."""
72-
import api.attack_paths.database as db_module
73-
7468
mock_driver = MagicMock()
7569
mock_driver_factory.return_value = mock_driver
7670
mock_settings.DATABASES = {
@@ -99,8 +93,6 @@ def test_get_driver_delegates_to_init_driver(
9993
self, mock_driver_factory, mock_settings
10094
):
10195
"""get_driver() should use init_driver() for lazy initialization."""
102-
import api.attack_paths.database as db_module
103-
10496
mock_driver = MagicMock()
10597
mock_driver_factory.return_value = mock_driver
10698
mock_settings.DATABASES = {
@@ -118,14 +110,50 @@ def test_get_driver_delegates_to_init_driver(
118110
mock_driver_factory.assert_called_once()
119111

120112

113+
class TestConnectionAcquisitionTimeout:
114+
"""Test that the connection acquisition timeout is configurable."""
115+
116+
@pytest.fixture(autouse=True)
117+
def reset_module_state(self):
118+
original_driver = db_module._driver
119+
original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
120+
121+
db_module._driver = None
122+
123+
yield
124+
125+
db_module._driver = original_driver
126+
db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
127+
128+
@patch("api.attack_paths.database.settings")
129+
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
130+
def test_driver_receives_configured_timeout(
131+
self, mock_driver_factory, mock_settings
132+
):
133+
"""init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
134+
mock_driver_factory.return_value = MagicMock()
135+
mock_settings.DATABASES = {
136+
"neo4j": {
137+
"HOST": "localhost",
138+
"PORT": 7687,
139+
"USER": "neo4j",
140+
"PASSWORD": "password",
141+
}
142+
}
143+
db_module.CONN_ACQUISITION_TIMEOUT = 42
144+
145+
db_module.init_driver()
146+
147+
_, kwargs = mock_driver_factory.call_args
148+
assert kwargs["connection_acquisition_timeout"] == 42
149+
150+
121151
class TestAtexitRegistration:
122152
"""Test that atexit cleanup handler is registered correctly."""
123153

124154
@pytest.fixture(autouse=True)
125155
def reset_module_state(self):
126156
"""Reset module-level singleton state before each test."""
127-
import api.attack_paths.database as db_module
128-
129157
original_driver = db_module._driver
130158

131159
db_module._driver = None
@@ -141,8 +169,6 @@ def test_atexit_registered_on_first_init(
141169
self, mock_driver_factory, mock_atexit_register, mock_settings
142170
):
143171
"""atexit.register should be called on first initialization."""
144-
import api.attack_paths.database as db_module
145-
146172
mock_driver_factory.return_value = MagicMock()
147173
mock_settings.DATABASES = {
148174
"neo4j": {
@@ -168,8 +194,6 @@ def test_atexit_registered_only_once(
168194
The double-checked locking on _driver ensures the atexit registration
169195
block only executes once (when _driver is first created).
170196
"""
171-
import api.attack_paths.database as db_module
172-
173197
mock_driver_factory.return_value = MagicMock()
174198
mock_settings.DATABASES = {
175199
"neo4j": {
@@ -194,8 +218,6 @@ class TestCloseDriver:
194218
@pytest.fixture(autouse=True)
195219
def reset_module_state(self):
196220
"""Reset module-level singleton state before each test."""
197-
import api.attack_paths.database as db_module
198-
199221
original_driver = db_module._driver
200222

201223
db_module._driver = None
@@ -206,8 +228,6 @@ def reset_module_state(self):
206228

207229
def test_close_driver_closes_and_clears_driver(self):
208230
"""close_driver() should close the driver and set it to None."""
209-
import api.attack_paths.database as db_module
210-
211231
mock_driver = MagicMock()
212232
db_module._driver = mock_driver
213233

@@ -218,8 +238,6 @@ def test_close_driver_closes_and_clears_driver(self):
218238

219239
def test_close_driver_handles_none_driver(self):
220240
"""close_driver() should handle case where driver is None."""
221-
import api.attack_paths.database as db_module
222-
223241
db_module._driver = None
224242

225243
# Should not raise
@@ -229,8 +247,6 @@ def test_close_driver_handles_none_driver(self):
229247

230248
def test_close_driver_clears_driver_even_on_close_error(self):
231249
"""Driver should be cleared even if close() raises an exception."""
232-
import api.attack_paths.database as db_module
233-
234250
mock_driver = MagicMock()
235251
mock_driver.close.side_effect = Exception("Connection error")
236252
db_module._driver = mock_driver
@@ -246,8 +262,6 @@ class TestExecuteReadQuery:
246262
"""Test read query execution helper."""
247263

248264
def test_execute_read_query_calls_read_session_and_returns_result(self):
249-
import api.attack_paths.database as db_module
250-
251265
tx = MagicMock()
252266
expected_graph = MagicMock()
253267
run_result = MagicMock()
@@ -289,8 +303,6 @@ def execute_read_side_effect(fn):
289303
assert result is expected_graph
290304

291305
def test_execute_read_query_defaults_parameters_to_empty_dict(self):
292-
import api.attack_paths.database as db_module
293-
294306
tx = MagicMock()
295307
run_result = MagicMock()
296308
run_result.graph.return_value = MagicMock()
@@ -325,8 +337,6 @@ class TestGetSessionReadOnly:
325337

326338
@pytest.fixture(autouse=True)
327339
def reset_module_state(self):
328-
import api.attack_paths.database as db_module
329-
330340
original_driver = db_module._driver
331341
db_module._driver = None
332342
yield
@@ -341,8 +351,6 @@ def reset_module_state(self):
341351
)
342352
def test_get_session_raises_write_query_not_allowed(self, neo4j_code):
343353
"""Read-mode Neo4j errors should raise `WriteQueryNotAllowedException`."""
344-
import api.attack_paths.database as db_module
345-
346354
mock_session = MagicMock()
347355
neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
348356
code=neo4j_code,
@@ -362,8 +370,6 @@ def test_get_session_raises_write_query_not_allowed(self, neo4j_code):
362370

363371
def test_get_session_raises_generic_exception_for_other_errors(self):
364372
"""Non-read-mode Neo4j errors should raise GraphDatabaseQueryException."""
365-
import api.attack_paths.database as db_module
366-
367373
mock_session = MagicMock()
368374
neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
369375
code="Neo.ClientError.Statement.SyntaxError",
@@ -388,8 +394,6 @@ class TestThreadSafety:
388394
@pytest.fixture(autouse=True)
389395
def reset_module_state(self):
390396
"""Reset module-level singleton state before each test."""
391-
import api.attack_paths.database as db_module
392-
393397
original_driver = db_module._driver
394398

395399
db_module._driver = None
@@ -404,8 +408,6 @@ def test_concurrent_init_creates_single_driver(
404408
self, mock_driver_factory, mock_settings
405409
):
406410
"""Multiple threads calling init_driver() should create only one driver."""
407-
import api.attack_paths.database as db_module
408-
409411
mock_driver = MagicMock()
410412
mock_driver_factory.return_value = mock_driver
411413
mock_settings.DATABASES = {
@@ -448,8 +450,6 @@ class TestHasProviderData:
448450
"""Test has_provider_data helper for checking provider nodes in Neo4j."""
449451

450452
def test_returns_true_when_nodes_exist(self):
451-
import api.attack_paths.database as db_module
452-
453453
mock_session = MagicMock()
454454
mock_result = MagicMock()
455455
mock_result.single.return_value = MagicMock() # non-None record
@@ -468,8 +468,6 @@ def test_returns_true_when_nodes_exist(self):
468468
mock_session.run.assert_called_once()
469469

470470
def test_returns_false_when_no_nodes(self):
471-
import api.attack_paths.database as db_module
472-
473471
mock_session = MagicMock()
474472
mock_result = MagicMock()
475473
mock_result.single.return_value = None
@@ -486,8 +484,6 @@ def test_returns_false_when_no_nodes(self):
486484
assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False
487485

488486
def test_returns_false_when_database_not_found(self):
489-
import api.attack_paths.database as db_module
490-
491487
session_ctx = MagicMock()
492488
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
493489
message="Database does not exist",
@@ -503,8 +499,6 @@ def test_returns_false_when_database_not_found(self):
503499
)
504500

505501
def test_raises_on_other_errors(self):
506-
import api.attack_paths.database as db_module
507-
508502
session_ctx = MagicMock()
509503
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
510504
message="Connection refused",

api/src/backend/config/settings/sentry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def before_send(event, hint):
120120
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
121121
before_send=before_send,
122122
send_default_pii=True,
123+
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
123124
_experiments={
124125
# Set continuous_profiling_auto_start to True
125126
# to automatically start the profiler on when

0 commit comments

Comments
 (0)