mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
fix(api): recalc tenant compliance summary after provider deletion (#10172)
This commit is contained in:
@@ -35,6 +35,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
- Attack Paths: Orphaned temporary Neo4j databases are now cleaned up on scan failure and provider deletion [(#10101)](https://github.com/prowler-cloud/prowler/pull/10101)
|
||||
- Attack Paths: scan no longer raises `DatabaseError` when provider is deleted mid-scan [(#10116)](https://github.com/prowler-cloud/prowler/pull/10116)
|
||||
- Tenant compliance summaries recalculated after provider deletion [(#10172)](https://github.com/prowler-cloud/prowler/pull/10172)
|
||||
- Security Hub export retries transient replica conflicts without failing integrations [(#10144)](https://github.com/prowler-cloud/prowler/pull/10144)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.db import DatabaseError
|
||||
from tasks.jobs.queries import (
|
||||
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
|
||||
COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
|
||||
)
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_router import MainRouter
|
||||
@@ -8,6 +12,7 @@ from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
Provider,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
@@ -17,6 +22,28 @@ from api.models import (
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _recalculate_tenant_compliance_summary(tenant_id: str, compliance_ids: list[str]):
|
||||
if not compliance_ids:
|
||||
return
|
||||
|
||||
compliance_ids = sorted(set(compliance_ids))
|
||||
|
||||
with rls_transaction(tenant_id, using=MainRouter.default_db) as cursor:
|
||||
# Serialize tenant-level summary updates to avoid concurrent recomputes
|
||||
cursor.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtext(%s))",
|
||||
[tenant_id],
|
||||
)
|
||||
cursor.execute(
|
||||
COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
|
||||
[tenant_id, tenant_id, compliance_ids],
|
||||
)
|
||||
cursor.execute(
|
||||
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
|
||||
[tenant_id, compliance_ids],
|
||||
)
|
||||
|
||||
|
||||
def delete_provider(tenant_id: str, pk: str):
|
||||
"""
|
||||
Gracefully deletes an instance of a provider along with its related data.
|
||||
@@ -39,6 +66,12 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
logger.info(f"Provider `{pk}` already deleted, skipping")
|
||||
return {}
|
||||
|
||||
compliance_ids = list(
|
||||
ProviderComplianceScore.objects.filter(provider=instance)
|
||||
.values_list("compliance_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
attack_paths_scan_ids = list(
|
||||
AttackPathsScan.all_objects.filter(provider=instance).values_list(
|
||||
"id", flat=True
|
||||
@@ -90,6 +123,15 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
logger.error(f"Error deleting Provider: {db_error}")
|
||||
raise
|
||||
|
||||
try:
|
||||
_recalculate_tenant_compliance_summary(tenant_id, compliance_ids)
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
"Error recalculating tenant compliance summary after provider delete: %s",
|
||||
db_error,
|
||||
)
|
||||
raise
|
||||
|
||||
return deletion_summary
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,20 @@ COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL = """
|
||||
updated_at = NOW()
|
||||
"""
|
||||
|
||||
# Delete tenant compliance summaries with no remaining provider scores.
|
||||
# Parameters: [tenant_id, compliance_ids_array]
|
||||
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL = """
|
||||
DELETE FROM tenant_compliance_summaries tcs
|
||||
WHERE tcs.tenant_id = %s
|
||||
AND tcs.compliance_id = ANY(%s)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM provider_compliance_scores pcs
|
||||
WHERE pcs.tenant_id = tcs.tenant_id
|
||||
AND pcs.compliance_id = tcs.compliance_id
|
||||
)
|
||||
"""
|
||||
|
||||
# Upsert tenant compliance summary for ALL compliance IDs in tenant.
|
||||
# Used by backfill when recalculating entire tenant summary.
|
||||
# Parameters: [tenant_id, tenant_id]
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.models import Provider, Tenant
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
from api.models import Provider, Tenant, TenantComplianceSummary
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -104,6 +103,53 @@ class TestDeleteProvider:
|
||||
assert result
|
||||
assert not Provider.all_objects.filter(pk=instance.id).exists()
|
||||
|
||||
def test_delete_provider_recalculates_tenant_compliance_summary(
|
||||
self,
|
||||
providers_fixture,
|
||||
provider_compliance_scores_fixture,
|
||||
):
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = instance.tenant_id
|
||||
|
||||
TenantComplianceSummary.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
compliance_id="aws_cis_2.0",
|
||||
requirements_passed=99,
|
||||
requirements_failed=99,
|
||||
requirements_manual=99,
|
||||
total_requirements=99,
|
||||
)
|
||||
TenantComplianceSummary.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
compliance_id="gdpr_aws",
|
||||
requirements_passed=99,
|
||||
requirements_failed=99,
|
||||
requirements_manual=99,
|
||||
total_requirements=99,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
|
||||
):
|
||||
delete_provider(str(tenant_id), instance.id)
|
||||
|
||||
updated_summary = TenantComplianceSummary.objects.get(
|
||||
tenant_id=tenant_id,
|
||||
compliance_id="aws_cis_2.0",
|
||||
)
|
||||
assert updated_summary.requirements_passed == 1
|
||||
assert updated_summary.requirements_failed == 1
|
||||
assert updated_summary.requirements_manual == 0
|
||||
assert updated_summary.total_requirements == 2
|
||||
assert not TenantComplianceSummary.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
compliance_id="gdpr_aws",
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteTenant:
|
||||
|
||||
Reference in New Issue
Block a user