fix(api): recalc tenant compliance summary after provider deletion (#10172)

This commit is contained in:
Adrián Peña
2026-02-26 11:18:15 +01:00
committed by GitHub
parent c151d08712
commit eacb3430cb
4 changed files with 106 additions and 3 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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: