Skip to content

Commit 29a1034

Browse files
authored
feat(exception): Add decorator for deleted providers during scans (#9414)
1 parent f5c2146 commit 29a1034

File tree

6 files changed

+214
-4
lines changed

6 files changed

+214
-4
lines changed

api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
66

77
### Added
88
- New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309)
9+
- Exception handler for provider deletions during scans [(#9414)](https://github.com/prowler-cloud/prowler/pull/9414)
910

1011
### Changed
1112
- Restore the compliance overview endpoint's mandatory filters [(#9330)](https://github.com/prowler-cloud/prowler/pull/9330)

api/src/backend/api/decorators.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import uuid
22
from functools import wraps
33

4-
from django.db import connection, transaction
4+
from django.core.exceptions import ObjectDoesNotExist
5+
from django.db import IntegrityError, connection, transaction
56
from rest_framework_json_api.serializers import ValidationError
67

7-
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
8+
from api.db_router import READ_REPLICA_ALIAS
9+
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
10+
from api.exceptions import ProviderDeletedException
11+
from api.models import Provider, Scan
812

913

1014
def set_tenant(func=None, *, keep_tenant=False):
@@ -66,3 +70,49 @@ def wrapper(*args, **kwargs):
6670
return decorator
6771
else:
6872
return decorator(func)
73+
74+
75+
def handle_provider_deletion(func):
76+
"""
77+
Decorator that raises ProviderDeletedException if provider was deleted during execution.
78+
79+
Catches ObjectDoesNotExist and IntegrityError, checks if provider still exists,
80+
and raises ProviderDeletedException if not. Otherwise, re-raises original exception.
81+
82+
Requires tenant_id and provider_id in kwargs.
83+
84+
Example:
85+
@shared_task
86+
@handle_provider_deletion
87+
def scan_task(scan_id, tenant_id, provider_id):
88+
...
89+
"""
90+
91+
@wraps(func)
92+
def wrapper(*args, **kwargs):
93+
try:
94+
return func(*args, **kwargs)
95+
except (ObjectDoesNotExist, IntegrityError):
96+
tenant_id = kwargs.get("tenant_id")
97+
provider_id = kwargs.get("provider_id")
98+
99+
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
100+
if provider_id is None:
101+
scan_id = kwargs.get("scan_id")
102+
if scan_id is None:
103+
raise AssertionError(
104+
"This task does not have provider or scan in the kwargs"
105+
)
106+
scan = Scan.objects.filter(pk=scan_id).first()
107+
if scan is None:
108+
raise ProviderDeletedException(
109+
f"Provider for scan '{scan_id}' was deleted during the scan"
110+
) from None
111+
provider_id = str(scan.provider_id)
112+
if not Provider.objects.filter(pk=provider_id).exists():
113+
raise ProviderDeletedException(
114+
f"Provider '{provider_id}' was deleted during the scan"
115+
) from None
116+
raise
117+
118+
return wrapper

api/src/backend/api/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ class ProviderConnectionError(Exception):
6666
"""Base exception for provider connection errors."""
6767

6868

69+
class ProviderDeletedException(Exception):
70+
"""Raised when a provider has been deleted during scan/task execution."""
71+
72+
6973
def custom_exception_handler(exc, context):
7074
if isinstance(exc, django_validation_error):
7175
if hasattr(exc, "error_dict"):

api/src/backend/api/tests/test_decorators.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
from unittest.mock import call, patch
33

44
import pytest
5+
from django.core.exceptions import ObjectDoesNotExist
6+
from django.db import IntegrityError
57

68
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
7-
from api.decorators import set_tenant
9+
from api.decorators import handle_provider_deletion, set_tenant
10+
from api.exceptions import ProviderDeletedException
811

912

1013
@pytest.mark.django_db
@@ -34,3 +37,142 @@ def random_func(arg):
3437

3538
with pytest.raises(KeyError):
3639
random_func("test_arg")
40+
41+
42+
@pytest.mark.django_db
43+
class TestHandleProviderDeletionDecorator:
44+
def test_success_no_exception(self, tenants_fixture, providers_fixture):
45+
"""Decorated function runs normally when no exception is raised."""
46+
tenant = tenants_fixture[0]
47+
provider = providers_fixture[0]
48+
49+
@handle_provider_deletion
50+
def task_func(**kwargs):
51+
return "success"
52+
53+
result = task_func(
54+
tenant_id=str(tenant.id),
55+
provider_id=str(provider.id),
56+
)
57+
assert result == "success"
58+
59+
@patch("api.decorators.rls_transaction")
60+
@patch("api.decorators.Provider.objects.filter")
61+
def test_provider_deleted_with_provider_id(
62+
self, mock_filter, mock_rls, tenants_fixture
63+
):
64+
"""Raises ProviderDeletedException when provider_id provided and provider deleted."""
65+
tenant = tenants_fixture[0]
66+
deleted_provider_id = str(uuid.uuid4())
67+
68+
mock_rls.return_value.__enter__ = lambda s: None
69+
mock_rls.return_value.__exit__ = lambda s, *args: None
70+
mock_filter.return_value.exists.return_value = False
71+
72+
@handle_provider_deletion
73+
def task_func(**kwargs):
74+
raise ObjectDoesNotExist("Some object not found")
75+
76+
with pytest.raises(ProviderDeletedException) as exc_info:
77+
task_func(tenant_id=str(tenant.id), provider_id=deleted_provider_id)
78+
79+
assert deleted_provider_id in str(exc_info.value)
80+
81+
@patch("api.decorators.rls_transaction")
82+
@patch("api.decorators.Provider.objects.filter")
83+
@patch("api.decorators.Scan.objects.filter")
84+
def test_provider_deleted_with_scan_id(
85+
self, mock_scan_filter, mock_provider_filter, mock_rls, tenants_fixture
86+
):
87+
"""Raises ProviderDeletedException when scan exists but provider deleted."""
88+
tenant = tenants_fixture[0]
89+
scan_id = str(uuid.uuid4())
90+
provider_id = str(uuid.uuid4())
91+
92+
mock_rls.return_value.__enter__ = lambda s: None
93+
mock_rls.return_value.__exit__ = lambda s, *args: None
94+
95+
mock_scan = type("MockScan", (), {"provider_id": provider_id})()
96+
mock_scan_filter.return_value.first.return_value = mock_scan
97+
mock_provider_filter.return_value.exists.return_value = False
98+
99+
@handle_provider_deletion
100+
def task_func(**kwargs):
101+
raise ObjectDoesNotExist("Some object not found")
102+
103+
with pytest.raises(ProviderDeletedException) as exc_info:
104+
task_func(tenant_id=str(tenant.id), scan_id=scan_id)
105+
106+
assert provider_id in str(exc_info.value)
107+
108+
@patch("api.decorators.rls_transaction")
109+
@patch("api.decorators.Scan.objects.filter")
110+
def test_scan_deleted_cascade(self, mock_scan_filter, mock_rls, tenants_fixture):
111+
"""Raises ProviderDeletedException when scan was deleted (CASCADE from provider)."""
112+
tenant = tenants_fixture[0]
113+
scan_id = str(uuid.uuid4())
114+
115+
mock_rls.return_value.__enter__ = lambda s: None
116+
mock_rls.return_value.__exit__ = lambda s, *args: None
117+
mock_scan_filter.return_value.first.return_value = None
118+
119+
@handle_provider_deletion
120+
def task_func(**kwargs):
121+
raise ObjectDoesNotExist("Some object not found")
122+
123+
with pytest.raises(ProviderDeletedException) as exc_info:
124+
task_func(tenant_id=str(tenant.id), scan_id=scan_id)
125+
126+
assert scan_id in str(exc_info.value)
127+
128+
@patch("api.decorators.rls_transaction")
129+
@patch("api.decorators.Provider.objects.filter")
130+
def test_provider_exists_reraises_original(
131+
self, mock_filter, mock_rls, tenants_fixture, providers_fixture
132+
):
133+
"""Re-raises original exception when provider still exists."""
134+
tenant = tenants_fixture[0]
135+
provider = providers_fixture[0]
136+
137+
mock_rls.return_value.__enter__ = lambda s: None
138+
mock_rls.return_value.__exit__ = lambda s, *args: None
139+
mock_filter.return_value.exists.return_value = True
140+
141+
@handle_provider_deletion
142+
def task_func(**kwargs):
143+
raise ObjectDoesNotExist("Actual object missing")
144+
145+
with pytest.raises(ObjectDoesNotExist):
146+
task_func(tenant_id=str(tenant.id), provider_id=str(provider.id))
147+
148+
@patch("api.decorators.rls_transaction")
149+
@patch("api.decorators.Provider.objects.filter")
150+
def test_integrity_error_provider_deleted(
151+
self, mock_filter, mock_rls, tenants_fixture
152+
):
153+
"""Raises ProviderDeletedException on IntegrityError when provider deleted."""
154+
tenant = tenants_fixture[0]
155+
deleted_provider_id = str(uuid.uuid4())
156+
157+
mock_rls.return_value.__enter__ = lambda s: None
158+
mock_rls.return_value.__exit__ = lambda s, *args: None
159+
mock_filter.return_value.exists.return_value = False
160+
161+
@handle_provider_deletion
162+
def task_func(**kwargs):
163+
raise IntegrityError("FK constraint violation")
164+
165+
with pytest.raises(ProviderDeletedException):
166+
task_func(tenant_id=str(tenant.id), provider_id=deleted_provider_id)
167+
168+
def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture):
169+
"""Raises AssertionError when neither provider_id nor scan_id in kwargs."""
170+
171+
@handle_provider_deletion
172+
def task_func(**kwargs):
173+
raise ObjectDoesNotExist("Some object not found")
174+
175+
with pytest.raises(AssertionError) as exc_info:
176+
task_func(tenant_id=str(tenants_fixture[0].id))
177+
178+
assert "provider or scan" in str(exc_info.value)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
# Provider is not connected due to credentials errors
66
"is not connected",
77
"ProviderConnectionError",
8+
# Provider was deleted during a scan
9+
"ProviderDeletedException",
810
# Authentication Errors from AWS
911
"InvalidToken",
1012
"AccessDeniedException",

api/src/backend/tasks/tasks.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from api.compliance import get_compliance_frameworks
4848
from api.db_router import READ_REPLICA_ALIAS
4949
from api.db_utils import rls_transaction
50-
from api.decorators import set_tenant
50+
from api.decorators import handle_provider_deletion, set_tenant
5151
from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices
5252
from api.utils import initialize_prowler_provider
5353
from api.v1.serializers import ScanTaskSerializer
@@ -144,6 +144,7 @@ def delete_provider_task(provider_id: str, tenant_id: str):
144144

145145

146146
@shared_task(base=RLSTask, name="scan-perform", queue="scans")
147+
@handle_provider_deletion
147148
def perform_scan_task(
148149
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
149150
):
@@ -176,6 +177,7 @@ def perform_scan_task(
176177

177178

178179
@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans")
180+
@handle_provider_deletion
179181
def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
180182
"""
181183
Task to perform a scheduled Prowler scan on a given provider.
@@ -281,6 +283,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
281283

282284

283285
@shared_task(name="scan-summary", queue="overview")
286+
@handle_provider_deletion
284287
def perform_scan_summary_task(tenant_id: str, scan_id: str):
285288
return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id)
286289

@@ -296,6 +299,7 @@ def delete_tenant_task(tenant_id: str):
296299
queue="scan-reports",
297300
)
298301
@set_tenant(keep_tenant=True)
302+
@handle_provider_deletion
299303
def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
300304
"""
301305
Process findings in batches and generate output files in multiple formats.
@@ -491,6 +495,7 @@ def get_writer(writer_map, name, factory, is_last):
491495

492496

493497
@shared_task(name="backfill-scan-resource-summaries", queue="backfill")
498+
@handle_provider_deletion
494499
def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str):
495500
"""
496501
Tries to backfill the resource scan summaries table for a given scan.
@@ -503,6 +508,7 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str):
503508

504509

505510
@shared_task(name="backfill-compliance-summaries", queue="backfill")
511+
@handle_provider_deletion
506512
def backfill_compliance_summaries_task(tenant_id: str, scan_id: str):
507513
"""
508514
Tries to backfill compliance overview summaries for a completed scan.
@@ -518,6 +524,7 @@ def backfill_compliance_summaries_task(tenant_id: str, scan_id: str):
518524

519525

520526
@shared_task(base=RLSTask, name="scan-compliance-overviews", queue="compliance")
527+
@handle_provider_deletion
521528
def create_compliance_requirements_task(tenant_id: str, scan_id: str):
522529
"""
523530
Creates detailed compliance requirement records for a scan.
@@ -534,6 +541,7 @@ def create_compliance_requirements_task(tenant_id: str, scan_id: str):
534541

535542

536543
@shared_task(name="scan-attack-surface-overviews", queue="overview")
544+
@handle_provider_deletion
537545
def aggregate_attack_surface_task(tenant_id: str, scan_id: str):
538546
"""
539547
Creates attack surface overview records for a scan.
@@ -586,6 +594,7 @@ def refresh_lighthouse_provider_models_task(
586594

587595

588596
@shared_task(name="integration-check")
597+
@handle_provider_deletion
589598
def check_integrations_task(tenant_id: str, provider_id: str, scan_id: str = None):
590599
"""
591600
Check and execute all configured integrations for a provider.
@@ -650,6 +659,7 @@ def check_integrations_task(tenant_id: str, provider_id: str, scan_id: str = Non
650659
name="integration-s3",
651660
queue="integrations",
652661
)
662+
@handle_provider_deletion
653663
def s3_integration_task(
654664
tenant_id: str,
655665
provider_id: str,
@@ -709,6 +719,7 @@ def jira_integration_task(
709719
name="scan-compliance-reports",
710720
queue="scan-reports",
711721
)
722+
@handle_provider_deletion
712723
def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str):
713724
"""
714725
Optimized task to generate ThreatScore, ENS, and NIS2 reports with shared queries.

0 commit comments

Comments
 (0)