diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 937c090f85..4d0fbce45b 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,9 +6,11 @@ All notable changes to the **Prowler API** are documented in this file. ### Added - Support GCP Service Account key. [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824) +- Added new `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877). ### Changed - Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) +- Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877). ### Fixed - Fixed the connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831) @@ -21,6 +23,7 @@ All notable changes to the **Prowler API** are documented in this file. - Fixed task lookup to use task_kwargs instead of task_args for scan report resolution. [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830) - Fixed Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871) - Fixed a race condition when creating background tasks [(#7876)](https://github.com/prowler-cloud/prowler/pull/7876). +- Fixed an error when modifying or retrieving tenants due to missing user UUID in transaction context [(#7890)](https://github.com/prowler-cloud/prowler/pull/7890). --- diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 3e965482df..605afe332d 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -109,16 +109,6 @@ class BaseTenantViewset(BaseViewSet): pass # Tenant might not exist, handle gracefully def initial(self, request, *args, **kwargs): - if ( - request.resolver_match.url_name != "tenant-detail" - and request.method != "DELETE" - ): - user_id = str(request.user.id) - - with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR): - return super().initial(request, *args, **kwargs) - - # TODO: DRY this when we have time if request.auth is None: raise NotAuthenticated @@ -126,8 +116,8 @@ class BaseTenantViewset(BaseViewSet): if tenant_id is None: raise NotAuthenticated("Tenant ID is not present in token") - with rls_transaction(tenant_id): - self.request.tenant_id = tenant_id + user_id = str(request.user.id) + with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR): return super().initial(request, *args, **kwargs) diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index ca98b6b592..d163a02fd3 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -1,3 +1,4 @@ +import re import secrets import uuid from contextlib import contextmanager @@ -152,6 +153,28 @@ def delete_related_daily_task(provider_id: str): PeriodicTask.objects.filter(name=task_name).delete() +def create_objects_in_batches( + tenant_id: str, model, objects: list, batch_size: int = 500 +): + """ + Bulk-create model instances in repeated, per-tenant RLS transactions. + + All chunks execute in their own transaction, so no single transaction + grows too large. + + Args: + tenant_id (str): UUID string of the tenant under which to set RLS. + model: Django model class whose `.objects.bulk_create()` will be called. + objects (list): List of model instances (unsaved) to bulk-create. + batch_size (int): Maximum number of objects per bulk_create call. + """ + total = len(objects) + for i in range(0, total, batch_size): + chunk = objects[i : i + batch_size] + with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR): + model.objects.bulk_create(chunk, batch_size) + + # Postgres Enums @@ -227,6 +250,72 @@ def register_enum(apps, schema_editor, enum_class): # noqa: F841 register_adapter(enum_class, enum_adapter) +def _should_create_index_on_partition( + partition_name: str, all_partitions: bool = False +) -> bool: + """ + Determine if we should create an index on this partition. + + Args: + partition_name: The name of the partition (e.g., "findings_2025_aug", "findings_default") + all_partitions: If True, create on all partitions. If False, only current/future partitions. + + Returns: + bool: True if index should be created on this partition, False otherwise. + """ + if all_partitions: + return True + + # Extract date from partition name if it follows the pattern + # Partition names look like: findings_2025_aug, findings_2025_jul, etc. + date_pattern = r"(\d{4})_([a-z]{3})$" + match = re.search(date_pattern, partition_name) + + if not match: + # If we can't parse the date, include it to be safe (e.g., default partition) + return True + + try: + year_str, month_abbr = match.groups() + year = int(year_str) + + # Map month abbreviations to numbers + month_map = { + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + } + + month = month_map.get(month_abbr.lower()) + if month is None: + # Unknown month abbreviation, include it to be safe + return True + + partition_date = datetime(year, month, 1, tzinfo=timezone.utc) + + # Get current month start + now = datetime.now(timezone.utc) + current_month_start = now.replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + # Include current month and future partitions + return partition_date >= current_month_start + + except (ValueError, TypeError): + # If date parsing fails, include it to be safe + return True + + def create_index_on_partitions( apps, # noqa: F841 schema_editor, @@ -235,16 +324,39 @@ def create_index_on_partitions( columns: str, method: str = "BTREE", where: str = "", + all_partitions: bool = True, ): """ - Create an index on every existing partition of `parent_table`. + Create an index on existing partitions of `parent_table`. Args: parent_table: The name of the root table (e.g. "findings"). index_name: A short name for the index (will be prefixed per-partition). columns: The parenthesized column list, e.g. "tenant_id, scan_id, status". - method: The index method—BTREE, GIN, etc. Defaults to BTREE. - where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'". + method: The index method—BTREE, GIN, etc. Defaults to BTREE. + where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'". + all_partitions: Whether to create indexes on all partitions or just current/future ones. + Defaults to False (current/future only) to avoid maintenance overhead + on old partitions where the index may not be needed. + + Examples: + # Create index only on current and future partitions (recommended for new indexes) + create_index_on_partitions( + apps, schema_editor, + parent_table="findings", + index_name="new_performance_idx", + columns="tenant_id, status, severity", + all_partitions=False # Default behavior + ) + + # Create index on all partitions (use when migrating existing critical indexes) + create_index_on_partitions( + apps, schema_editor, + parent_table="findings", + index_name="critical_existing_idx", + columns="tenant_id, scan_id", + all_partitions=True + ) """ with connection.cursor() as cursor: cursor.execute( @@ -259,13 +371,14 @@ def create_index_on_partitions( where_sql = f" WHERE {where}" if where else "" for partition in partitions: - idx_name = f"{partition.replace('.', '_')}_{index_name}" - sql = ( - f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} " - f"ON {partition} USING {method} ({columns})" - f"{where_sql};" - ) - schema_editor.execute(sql) + if _should_create_index_on_partition(partition, all_partitions): + idx_name = f"{partition.replace('.', '_')}_{index_name}" + sql = ( + f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} " + f"ON {partition} USING {method} ({columns})" + f"{where_sql};" + ) + schema_editor.execute(sql) def drop_index_on_partitions( @@ -279,7 +392,7 @@ def drop_index_on_partitions( Args: parent_table: The name of the root table (e.g. "findings"). - index_name: The same short name used when creating them. + index_name: The same short name used when creating them. """ with connection.cursor() as cursor: cursor.execute( diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 12bc788d68..14f7227d9a 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -3,7 +3,7 @@ from rest_framework import status from rest_framework.exceptions import APIException from rest_framework_json_api.exceptions import exception_handler from rest_framework_json_api.serializers import ValidationError -from rest_framework_simplejwt.exceptions import TokenError, InvalidToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError class ModelValidationError(ValidationError): @@ -32,6 +32,31 @@ class InvitationTokenExpiredException(APIException): default_code = "token_expired" +# Task Management Exceptions (non-HTTP) +class TaskManagementError(Exception): + """Base exception for task management errors.""" + + def __init__(self, task=None): + self.task = task + super().__init__() + + +class TaskFailedException(TaskManagementError): + """Raised when a task has failed.""" + + +class TaskNotFoundException(TaskManagementError): + """Raised when a task is not found.""" + + +class TaskInProgressException(TaskManagementError): + """Raised when a task is running but there's no related Task object to return.""" + + def __init__(self, task_result=None): + self.task_result = task_result + super().__init__() + + def custom_exception_handler(exc, context): if isinstance(exc, django_validation_error): if hasattr(exc, "error_dict"): @@ -39,7 +64,12 @@ def custom_exception_handler(exc, context): else: exc = ValidationError(detail=exc.messages[0], code=exc.code) elif isinstance(exc, (TokenError, InvalidToken)): - exc.detail["messages"] = [ - message_item["message"] for message_item in exc.detail["messages"] - ] + if ( + hasattr(exc, "detail") + and isinstance(exc.detail, dict) + and "messages" in exc.detail + ): + exc.detail["messages"] = [ + message_item["message"] for message_item in exc.detail["messages"] + ] return exception_handler(exc, context) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 9e95016e1d..35ebb6a611 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -22,7 +22,7 @@ from api.db_utils import ( StatusEnumField, ) from api.models import ( - ComplianceOverview, + ComplianceRequirementOverview, Finding, Integration, Invitation, @@ -637,12 +637,11 @@ class RoleFilter(FilterSet): class ComplianceOverviewFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - provider_type = ChoiceFilter(choices=Provider.ProviderChoices.choices) - provider_type__in = ChoiceInFilter(choices=Provider.ProviderChoices.choices) - scan_id = UUIDFilter(field_name="scan__id") + scan_id = UUIDFilter(field_name="scan_id") + region = CharFilter(field_name="region") class Meta: - model = ComplianceOverview + model = ComplianceRequirementOverview fields = { "inserted_at": ["date", "gte", "lte"], "compliance_id": ["exact", "icontains"], diff --git a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py new file mode 100644 index 0000000000..82bbb136a5 --- /dev/null +++ b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py @@ -0,0 +1,124 @@ +# Generated by Django 5.1.8 on 2025-05-21 11:37 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.db_utils +import api.rls +from api.rls import RowLevelSecurityConstraint + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0026_provider_secret_gcp_service_account"), + ] + + operations = [ + migrations.CreateModel( + name="ComplianceRequirementOverview", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("compliance_id", models.TextField(blank=False)), + ("framework", models.TextField(blank=False)), + ("version", models.TextField(blank=True)), + ("description", models.TextField(blank=True)), + ("region", models.TextField(blank=False)), + ("requirement_id", models.TextField(blank=False)), + ( + "requirement_status", + api.db_utils.StatusEnumField( + choices=[ + ("FAIL", "Fail"), + ("PASS", "Pass"), + ("MANUAL", "Manual"), + ] + ), + ), + ("passed_checks", models.IntegerField(default=0)), + ("failed_checks", models.IntegerField(default=0)), + ("total_checks", models.IntegerField(default=0)), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compliance_requirements_overviews", + related_query_name="compliance_requirements_overview", + to="api.scan", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "compliance_requirements_overviews", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx" + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id"], + name="cro_scan_comp_idx", + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id", "region"], + name="cro_scan_comp_reg_idx", + ), + models.Index( + fields=[ + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + ], + name="cro_scan_comp_req_idx", + ), + models.Index( + fields=[ + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ], + name="cro_scan_comp_req_reg_idx", + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=( + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ), + name="unique_tenant_compliance_requirement_overview", + ) + ], + }, + ), + migrations.AddConstraint( + model_name="ComplianceRequirementOverview", + constraint=RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_compliancerequirementoverview", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py new file mode 100644 index 0000000000..ad61f3004f --- /dev/null +++ b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py @@ -0,0 +1,29 @@ +from functools import partial + +from django.db import migrations + +from api.db_utils import create_index_on_partitions, drop_index_on_partitions + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0027_compliance_requirement_overviews"), + ] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_check_idx", + columns="tenant_id, scan_id, check_id", + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_check_idx", + ), + ) + ] diff --git a/api/src/backend/api/migrations/0029_findings_check_index_parent.py b/api/src/backend/api/migrations/0029_findings_check_index_parent.py new file mode 100644 index 0000000000..8b975782f1 --- /dev/null +++ b/api/src/backend/api/migrations/0029_findings_check_index_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0028_findings_check_index_partitions"), + ] + + operations = [ + migrations.AddIndex( + model_name="finding", + index=models.Index( + fields=["tenant_id", "scan_id", "check_id"], + name="find_tenant_scan_check_idx", + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 9564399868..b4a5c3a894 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -802,6 +802,10 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): GinIndex(fields=["resource_services"], name="gin_find_service_idx"), GinIndex(fields=["resource_regions"], name="gin_find_region_idx"), GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"), + models.Index( + fields=["tenant_id", "scan_id", "check_id"], + name="find_tenant_scan_check_idx", + ), ] class JSONAPIMeta: @@ -1183,6 +1187,78 @@ class ComplianceOverview(RowLevelSecurityProtectedModel): resource_name = "compliance-overviews" +class ComplianceRequirementOverview(RowLevelSecurityProtectedModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + compliance_id = models.TextField(blank=False) + framework = models.TextField(blank=False) + version = models.TextField(blank=True) + description = models.TextField(blank=True) + region = models.TextField(blank=False) + + requirement_id = models.TextField(blank=False) + requirement_status = StatusEnumField(choices=StatusChoices) + passed_checks = models.IntegerField(default=0) + failed_checks = models.IntegerField(default=0) + total_checks = models.IntegerField(default=0) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="compliance_requirements_overviews", + related_query_name="compliance_requirements_overview", + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "compliance_requirements_overviews" + + constraints = [ + models.UniqueConstraint( + fields=( + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ), + name="unique_tenant_compliance_requirement_overview", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "DELETE"], + ), + ] + indexes = [ + models.Index(fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id"], + name="cro_scan_comp_idx", + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id", "region"], + name="cro_scan_comp_reg_idx", + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id", "requirement_id"], + name="cro_scan_comp_req_idx", + ), + models.Index( + fields=[ + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ], + name="cro_scan_comp_req_reg_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "compliance-requirements-overviews" + + class ScanSummary(RowLevelSecurityProtectedModel): objects = ActiveProviderManager() all_objects = models.Manager() diff --git a/api/src/backend/api/pagination.py b/api/src/backend/api/pagination.py index 8f37c9ba78..b9742416bb 100644 --- a/api/src/backend/api/pagination.py +++ b/api/src/backend/api/pagination.py @@ -1,4 +1,4 @@ -from rest_framework_json_api.pagination import JsonApiPageNumberPagination +from drf_spectacular_jsonapi.schemas.pagination import JsonApiPageNumberPagination class ComplianceOverviewPagination(JsonApiPageNumberPagination): diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index c7c20b5d56..30c64e31a4 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -10,9 +10,7 @@ paths: /api/v1/compliance-overviews: get: operationId: compliance_overviews_list - description: Retrieve an overview of all the compliance in a given scan. If - no region filters are provided, the region with the most fails will be returned - by default. + description: Retrieve an overview of all the compliance in a given scan. summary: List compliance overviews for a scan parameters: - in: query @@ -22,15 +20,13 @@ paths: items: type: string enum: - - inserted_at - - compliance_id + - id - framework - version - - requirements_status - - region - - provider_type - - scan - - url + - requirements_passed + - requirements_failed + - requirements_manual + - total_requirements description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -74,44 +70,6 @@ paths: schema: type: string format: date-time - - in: query - name: filter[provider_type] - schema: - type: string - enum: - - aws - - azure - - gcp - - kubernetes - - m365 - description: |- - * `aws` - AWS - * `azure` - Azure - * `gcp` - GCP - * `kubernetes` - Kubernetes - * `m365` - M365 - - in: query - name: filter[provider_type__in] - schema: - type: array - items: - type: string - enum: - - aws - - azure - - gcp - - kubernetes - - m365 - description: |- - Multiple values may be separated by commas. - - * `aws` - AWS - * `azure` - Azure - * `gcp` - GCP - * `kubernetes` - Kubernetes - * `m365` - M365 - explode: false - style: form - in: query name: filter[region] schema: @@ -171,14 +129,8 @@ paths: items: type: string enum: - - inserted_at - - -inserted_at - compliance_id - -compliance_id - - framework - - -framework - - region - - -region explode: false tags: - Compliance Overview @@ -190,41 +142,43 @@ paths: application/vnd.api+json: schema: $ref: '#/components/schemas/PaginatedComplianceOverviewList' - description: '' - /api/v1/compliance-overviews/{id}: + description: Compliance overviews obtained successfully + '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedTaskList' + description: The task is in progress + '500': + description: Compliance overviews generation task failed + /api/v1/compliance-overviews/attributes: get: - operationId: compliance_overviews_retrieve - description: Fetch detailed information about a specific compliance overview - by its ID, including detailed requirement information and check's status. - summary: Retrieve data from a specific compliance overview + operationId: compliance_overviews_attributes_retrieve + description: Retrieve detailed attribute information for all requirements in + a specific compliance framework along with the associated check IDs for each + requirement. + summary: Get compliance requirement attributes parameters: - in: query - name: fields[compliance-overviews] + name: fields[compliance-requirements-attributes] schema: type: array items: type: string enum: - - inserted_at - - compliance_id + - id - framework - version - - requirements_status - - region - - provider_type - - scan - - url - description - - requirements + - attributes description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false - - in: path - name: id + - in: query + name: filter[compliance_id] schema: type: string - format: uuid - description: A UUID string identifying this compliance overview. + description: Compliance framework ID to get attributes for. required: true tags: - Compliance Overview @@ -235,8 +189,8 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ComplianceOverviewFullResponse' - description: '' + $ref: '#/components/schemas/PaginatedComplianceOverviewAttributesList' + description: Compliance attributes obtained successfully /api/v1/compliance-overviews/metadata: get: operationId: compliance_overviews_metadata_retrieve @@ -271,8 +225,142 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ComplianceOverviewMetadataResponse' - description: '' + $ref: '#/components/schemas/OpenApiResponseResponse' + description: Compliance overviews metadata obtained successfully + '202': + description: The task is in progress + '500': + description: Compliance overviews generation task failed + /api/v1/compliance-overviews/requirements: + get: + operationId: compliance_overviews_requirements_retrieve + description: Retrieve a detailed overview of compliance requirements in a given + scan, grouped by compliance framework. This endpoint provides requirement-level + details and aggregates status across regions. + summary: List compliance requirements overview for a scan + parameters: + - in: query + name: fields[compliance-requirements-details] + schema: + type: array + items: + type: string + enum: + - id + - framework + - version + - description + - status + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[compliance_id] + schema: + type: string + description: Compliance ID. + required: true + - in: query + name: filter[compliance_id__icontains] + schema: + type: string + - in: query + name: filter[framework] + schema: + type: string + - in: query + name: filter[framework__icontains] + schema: + type: string + - in: query + name: filter[framework__iexact] + schema: + type: string + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan_id] + schema: + type: string + format: uuid + description: Related scan ID. + required: true + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[version] + schema: + type: string + - in: query + name: filter[version__icontains] + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - compliance_id + - -compliance_id + explode: false + tags: + - Compliance Overview + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedComplianceOverviewDetailList' + description: Compliance requirement details obtained successfully + '202': + description: The task is in progress + '500': + description: Compliance overviews generation task failed /api/v1/findings: get: operationId: findings_list @@ -6839,80 +6927,37 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/Type7f7Enum' + - $ref: '#/components/schemas/ComplianceOverviewTypeEnum' description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. - id: - type: string - format: uuid + id: {} attributes: type: object properties: - inserted_at: + id: type: string - format: date-time - readOnly: true - compliance_id: - type: string - maxLength: 100 framework: type: string - maxLength: 100 version: type: string - maxLength: 50 - requirements_status: - type: object - properties: - passed: - type: integer - failed: - type: integer - manual: - type: integer - total: - type: integer - readOnly: true - region: - type: string - maxLength: 50 - provider_type: - type: string - nullable: true - readOnly: true + requirements_passed: + type: integer + requirements_failed: + type: integer + requirements_manual: + type: integer + total_requirements: + type: integer required: - - compliance_id + - id - framework - relationships: - type: object - properties: - scan: - type: object - properties: - data: - type: object - properties: - id: - type: string - format: uuid - type: - type: string - enum: - - scans - title: Resource Type Name - description: The [type](https://jsonapi.org/format/#document-resource-object-identification) - member is used to describe resource objects that share common - attributes and relationships. - required: - - id - - type - required: - - data - description: The identifier of the related object. - title: Resource Identifier - nullable: true - ComplianceOverviewFull: + - version + - requirements_passed + - requirements_failed + - requirements_manual + - total_requirements + ComplianceOverviewAttributes: type: object required: - type @@ -6921,134 +6966,78 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/Type7f7Enum' + - $ref: '#/components/schemas/ComplianceOverviewAttributesTypeEnum' description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. - id: - type: string - format: uuid + id: {} attributes: type: object properties: - inserted_at: + id: type: string - format: date-time - readOnly: true - compliance_id: - type: string - maxLength: 100 framework: type: string - maxLength: 100 version: type: string - maxLength: 50 - requirements_status: - type: object - properties: - passed: - type: integer - failed: - type: integer - manual: - type: integer - total: - type: integer - readOnly: true - region: - type: string - maxLength: 50 - provider_type: - type: string - nullable: true - readOnly: true description: type: string - requirements: - type: object - properties: - requirement_id: - type: object - properties: - name: - type: string - checks: - type: object - properties: - check_name: - type: object - properties: - status: - type: string - enum: - - PASS - - FAIL - - null - description: Each key in the 'checks' object is a check name, - with values as 'PASS', 'FAIL', or null. - status: - type: string - enum: - - PASS - - FAIL - - MANUAL - attributes: - type: array - items: - type: object - description: - type: string - checks_status: - type: object - properties: - total: - type: integer - pass: - type: integer - fail: - type: integer - manual: - type: integer - readOnly: true + attributes: {} required: - - compliance_id + - id - framework - relationships: + - version + - description + - attributes + ComplianceOverviewAttributesTypeEnum: + type: string + enum: + - compliance-requirements-attributes + ComplianceOverviewDetail: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/ComplianceOverviewDetailTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: {} + attributes: type: object properties: - scan: - type: object - properties: - data: - type: object - properties: - id: - type: string - format: uuid - type: - type: string - enum: - - scans - title: Resource Type Name - description: The [type](https://jsonapi.org/format/#document-resource-object-identification) - member is used to describe resource objects that share common - attributes and relationships. - required: - - id - - type - required: - - data - description: The identifier of the related object. - title: Resource Identifier - nullable: true - ComplianceOverviewFullResponse: - type: object - properties: - data: - $ref: '#/components/schemas/ComplianceOverviewFull' - required: - - data + id: + type: string + framework: + type: string + version: + type: string + description: + type: string + status: + enum: + - FAIL + - PASS + - MANUAL + type: string + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + required: + - id + - framework + - version + - description + - status + ComplianceOverviewDetailTypeEnum: + type: string + enum: + - compliance-requirements-details ComplianceOverviewMetadata: type: object required: @@ -7072,17 +7061,14 @@ components: type: string required: - regions - ComplianceOverviewMetadataResponse: - type: object - properties: - data: - $ref: '#/components/schemas/ComplianceOverviewMetadata' - required: - - data ComplianceOverviewMetadataTypeEnum: type: string enum: - compliance-overviews-metadata + ComplianceOverviewTypeEnum: + type: string + enum: + - compliance-overviews Finding: type: object required: @@ -8386,7 +8372,7 @@ components: type: object properties: data: - $ref: '#/components/schemas/Membership' + $ref: '#/components/schemas/ComplianceOverviewMetadata' required: - data OverviewFinding: @@ -8601,29 +8587,33 @@ components: type: string enum: - findings-severity-overview + PaginatedComplianceOverviewAttributesList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComplianceOverviewAttributes' + required: + - data + PaginatedComplianceOverviewDetailList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComplianceOverviewDetail' + required: + - data PaginatedComplianceOverviewList: type: object - required: - - count - - results properties: - count: - type: integer - example: 123 - next: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page[number]=4 - previous: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page[number]=2 - results: + data: type: array items: $ref: '#/components/schemas/ComplianceOverview' + required: + - data PaginatedFindingList: type: object properties: @@ -11904,6 +11894,7 @@ components: type: object required: - type + - id additionalProperties: false properties: type: @@ -11912,6 +11903,9 @@ components: description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + id: + type: string + format: uuid attributes: type: object properties: @@ -12326,10 +12320,6 @@ components: type: string enum: - roles - Type7f7Enum: - type: string - enum: - - compliance-overviews Type8cdEnum: type: string enum: diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index e22b1417bc..3373dafed0 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -3,9 +3,13 @@ from enum import Enum from unittest.mock import patch import pytest +from django.conf import settings +from freezegun import freeze_time from api.db_utils import ( + _should_create_index_on_partition, batch_delete, + create_objects_in_batches, enum_to_choices, generate_random_token, one_week_from_now, @@ -138,3 +142,88 @@ class TestBatchDelete: ) assert Provider.objects.all().count() == 0 assert summary == {"api.Provider": create_test_providers} + + +class TestShouldCreateIndexOnPartition: + @freeze_time("2025-05-15 00:00:00Z") + @pytest.mark.parametrize( + "partition_name, all_partitions, expected", + [ + ("any_name", True, True), + ("findings_default", True, True), + ("findings_2022_jan", True, True), + ("foo_bar", False, True), + ("findings_2025_MAY", False, True), + ("findings_2025_may", False, True), + ("findings_2025_jun", False, True), + ("findings_2025_apr", False, False), + ("findings_2025_xyz", False, True), + ], + ) + def test_partition_inclusion_logic(self, partition_name, all_partitions, expected): + assert ( + _should_create_index_on_partition(partition_name, all_partitions) + is expected + ) + + @freeze_time("2025-05-15 00:00:00Z") + def test_invalid_date_components(self): + # even if regex matches but int conversion fails, we fallback True + # (e.g. year too big, month number parse error) + bad_name = "findings_99999_jan" + assert _should_create_index_on_partition(bad_name, False) is True + + bad_name2 = "findings_2025_abc" + # abc not in month_map → fallback True + assert _should_create_index_on_partition(bad_name2, False) is True + + +@pytest.mark.django_db +class TestCreateObjectsInBatches: + @pytest.fixture + def tenant(self, tenants_fixture): + return tenants_fixture[0] + + def make_provider_instances(self, tenant, count): + """ + Return a list of `count` unsaved Provider instances for the given tenant. + """ + base_uid = 1000 + return [ + Provider( + tenant=tenant, + uid=str(base_uid + i), + provider=Provider.ProviderChoices.AWS, + ) + for i in range(count) + ] + + def test_exact_multiple_of_batch(self, tenant): + total = 6 + batch_size = 3 + objs = self.make_provider_instances(tenant, total) + + create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size) + + qs = Provider.objects.filter(tenant=tenant) + assert qs.count() == total + + def test_non_multiple_of_batch(self, tenant): + total = 7 + batch_size = 3 + objs = self.make_provider_instances(tenant, total) + + create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size) + + qs = Provider.objects.filter(tenant=tenant) + assert qs.count() == total + + def test_batch_size_default(self, tenant): + default_size = settings.DJANGO_DELETION_BATCH_SIZE + total = default_size + 2 + objs = self.make_provider_instances(tenant, total) + + create_objects_in_batches(str(tenant.id), Provider, objs) + + qs = Provider.objects.filter(tenant=tenant) + assert qs.count() == total diff --git a/api/src/backend/api/tests/test_mixins.py b/api/src/backend/api/tests/test_mixins.py new file mode 100644 index 0000000000..7daf9d5ff6 --- /dev/null +++ b/api/src/backend/api/tests/test_mixins.py @@ -0,0 +1,379 @@ +import json +from uuid import uuid4 + +import pytest +from django_celery_results.models import TaskResult +from rest_framework import status +from rest_framework.response import Response + +from api.exceptions import ( + TaskFailedException, + TaskInProgressException, + TaskNotFoundException, +) +from api.models import Task, User +from api.rls import Tenant +from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin + + +@pytest.mark.django_db +class TestPaginateByPkMixin: + @pytest.fixture + def tenant(self): + return Tenant.objects.create(name="Test Tenant") + + @pytest.fixture + def users(self, tenant): + # Create 5 users with proper email field + users = [] + for i in range(5): + user = User.objects.create(email=f"user{i}@example.com", name=f"User {i}") + users.append(user) + return users + + class DummyView(PaginateByPkMixin): + def __init__(self, page): + self._page = page + + def paginate_queryset(self, qs): + return self._page + + def get_serializer(self, queryset, many): + class S: + def __init__(self, data): + # serialize to list of ids + self.data = [obj.id for obj in data] if many else queryset.id + + return S(queryset) + + def get_paginated_response(self, data): + return Response({"results": data}, status=status.HTTP_200_OK) + + def test_no_pagination(self, users): + base_qs = User.objects.all().order_by("id") + view = self.DummyView(page=None) + resp = view.paginate_by_pk( + request=None, base_queryset=base_qs, manager=User.objects + ) + # since no pagination, should return all ids in order + expected = [u.id for u in base_qs] + assert isinstance(resp, Response) + assert resp.data == expected + + def test_with_pagination(self, users): + base_qs = User.objects.all().order_by("id") + # simulate paging to first 2 ids + page = [base_qs[1].id, base_qs[3].id] + view = self.DummyView(page=page) + resp = view.paginate_by_pk( + request=None, base_queryset=base_qs, manager=User.objects + ) + # should fetch only those two users, in the same order as page + assert resp.status_code == status.HTTP_200_OK + assert resp.data == {"results": page} + + +@pytest.mark.django_db +class TestTaskManagementMixin: + class DummyView(TaskManagementMixin): + pass + + @pytest.fixture + def tenant(self): + return Tenant.objects.create(name="Test Tenant") + + @pytest.fixture(autouse=True) + def cleanup(self): + Task.objects.all().delete() + TaskResult.objects.all().delete() + + def test_no_task_and_no_taskresult_raises_not_found(self): + view = self.DummyView() + with pytest.raises(TaskNotFoundException): + view.check_task_status("task_xyz", {"foo": "bar"}) + + def test_no_task_and_no_taskresult_returns_none_when_not_raising(self): + view = self.DummyView() + result = view.check_task_status( + "task_xyz", {"foo": "bar"}, raise_on_not_found=False + ) + assert result is None + + def test_taskresult_pending_raises_in_progress(self): + task_kwargs = {"foo": "bar"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_xyz", + task_kwargs=json.dumps(task_kwargs), + status="PENDING", + ) + view = self.DummyView() + with pytest.raises(TaskInProgressException) as excinfo: + view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False) + assert hasattr(excinfo.value, "task_result") + assert excinfo.value.task_result == tr + + def test_taskresult_started_raises_in_progress(self): + task_kwargs = {"foo": "bar"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_xyz", + task_kwargs=json.dumps(task_kwargs), + status="STARTED", + ) + view = self.DummyView() + with pytest.raises(TaskInProgressException) as excinfo: + view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False) + assert hasattr(excinfo.value, "task_result") + assert excinfo.value.task_result == tr + + def test_taskresult_progress_raises_in_progress(self): + task_kwargs = {"foo": "bar"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_xyz", + task_kwargs=json.dumps(task_kwargs), + status="PROGRESS", + ) + view = self.DummyView() + with pytest.raises(TaskInProgressException) as excinfo: + view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False) + assert hasattr(excinfo.value, "task_result") + assert excinfo.value.task_result == tr + + def test_taskresult_failure_raises_failed(self): + task_kwargs = {"a": 1} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_fail", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + view = self.DummyView() + with pytest.raises(TaskFailedException): + view.check_task_status("task_fail", task_kwargs, raise_on_not_found=False) + + def test_taskresult_failure_returns_none_when_not_raising(self): + task_kwargs = {"a": 1} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_fail", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + view = self.DummyView() + result = view.check_task_status( + "task_fail", task_kwargs, raise_on_failed=False, raise_on_not_found=False + ) + assert result is None + + def test_taskresult_success_returns_none(self): + task_kwargs = {"x": 2} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_ok", + task_kwargs=json.dumps(task_kwargs), + status="SUCCESS", + ) + view = self.DummyView() + # should not raise, and returns None + assert ( + view.check_task_status("task_ok", task_kwargs, raise_on_not_found=False) + is None + ) + + def test_taskresult_revoked_returns_none(self): + task_kwargs = {"x": 2} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_revoked", + task_kwargs=json.dumps(task_kwargs), + status="REVOKED", + ) + view = self.DummyView() + # should not raise, and returns None + assert ( + view.check_task_status( + "task_revoked", task_kwargs, raise_on_not_found=False + ) + is None + ) + + def test_task_with_failed_status_raises_failed(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + with pytest.raises(TaskFailedException) as excinfo: + view.check_task_status("scan_task", task_kwargs) + # Check that the exception contains the expected task + assert hasattr(excinfo.value, "task") + assert excinfo.value.task == task + + def test_task_with_cancelled_status_raises_failed(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="REVOKED", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + with pytest.raises(TaskFailedException) as excinfo: + view.check_task_status("scan_task", task_kwargs) + # Check that the exception contains the expected task + assert hasattr(excinfo.value, "task") + assert excinfo.value.task == task + + def test_task_with_failed_status_returns_task_when_not_raising(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs, raise_on_failed=False) + assert result == task + + def test_task_with_completed_status_returns_none(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="SUCCESS", + ) + Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs) + assert result is None + + def test_task_with_executing_status_returns_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="STARTED", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs) + assert result is not None + assert result.pk == task.pk + + def test_task_with_pending_status_returns_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="PENDING", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs) + assert result is not None + assert result.pk == task.pk + + def test_get_task_response_if_running_returns_none_for_completed_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="SUCCESS", + ) + Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.get_task_response_if_running("scan_task", task_kwargs) + assert result is None + + def test_get_task_response_if_running_returns_none_for_no_task(self): + view = self.DummyView() + result = view.get_task_response_if_running( + "nonexistent", {"foo": "bar"}, raise_on_not_found=False + ) + assert result is None + + def test_get_task_response_if_running_returns_202_for_executing_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="STARTED", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.get_task_response_if_running("scan_task", task_kwargs) + + assert isinstance(result, Response) + assert result.status_code == status.HTTP_202_ACCEPTED + assert "Content-Location" in result.headers + # The response should contain the serialized task data + assert result.data is not None + assert "id" in result.data + assert str(result.data["id"]) == str(task.id) + + def test_get_task_response_if_running_returns_none_for_available_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="PENDING", + ) + Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.get_task_response_if_running("scan_task", task_kwargs) + # PENDING maps to AVAILABLE, which is not EXECUTING, so should return None + assert result is None + + def test_kwargs_filtering_works_correctly(self, tenant): + # Create tasks with different kwargs + task_kwargs_1 = {"provider_id": "test1", "scan_type": "full"} + task_kwargs_2 = {"provider_id": "test2", "scan_type": "quick"} + + tr1 = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs_1), + status="STARTED", + ) + tr2 = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs_2), + status="STARTED", + ) + + task1 = Task.objects.create(tenant=tenant, task_runner_task=tr1) + task2 = Task.objects.create(tenant=tenant, task_runner_task=tr2) + + view = self.DummyView() + + # Should find task1 when searching for its kwargs + result1 = view.check_task_status("scan_task", {"provider_id": "test1"}) + assert result1 is not None + assert result1.pk == task1.pk + + # Should find task2 when searching for its kwargs + result2 = view.check_task_status("scan_task", {"provider_id": "test2"}) + assert result2 is not None + assert result2.pk == task2.pk + + # Should not find anything when searching for non-existent kwargs + result3 = view.check_task_status( + "scan_task", {"provider_id": "test3"}, raise_on_not_found=False + ) + assert result3 is None diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index ac6c6b51a6..512ba79a93 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -15,10 +15,10 @@ from django.conf import settings from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status +from rest_framework.response import Response from api.compliance import get_compliance_frameworks from api.models import ( - ComplianceOverview, Integration, Invitation, Membership, @@ -35,6 +35,7 @@ from api.models import ( UserRoleRelationship, ) from api.rls import Tenant +from api.v1.views import ComplianceOverviewViewSet TODAY = str(datetime.today().date()) @@ -4761,210 +4762,248 @@ class TestComplianceOverviewViewSet: assert len(response.json()["data"]) == 0 def test_compliance_overview_list( - self, authenticated_client, compliance_overviews_fixture + self, authenticated_client, compliance_requirements_overviews_fixture ): # List compliance overviews with existing data - compliance_overview1, compliance_overview2 = compliance_overviews_fixture - scan_id = str(compliance_overview1.scan.id) + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) response = authenticated_client.get( reverse("complianceoverview-list"), {"filter[scan_id]": scan_id}, ) assert response.status_code == status.HTTP_200_OK - assert ( - len(response.json()["data"]) == 1 - ) # Due to the custom get_queryset method, only one compliance_id + data = response.json()["data"] + assert len(data) == 2 # Two compliance frameworks - def test_compliance_overview_list_missing_scan_id(self, authenticated_client): - # Attempt to list compliance overviews without providing filter[scan_id] - response = authenticated_client.get(reverse("complianceoverview-list")) + # Check that we get aggregated data for each compliance framework + framework_ids = [item["id"] for item in data] + assert "aws_account_security_onboarding_aws" in framework_ids + assert "cis_1.4_aws" in framework_ids + + # Check structure of response + for item in data: + assert "id" in item + assert "attributes" in item + attributes = item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "requirements_passed" in attributes + assert "requirements_failed" in attributes + assert "requirements_manual" in attributes + assert "total_requirements" in attributes + + def test_compliance_overview_metadata( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + response = authenticated_client.get( + reverse("complianceoverview-metadata"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert "attributes" in data + assert "regions" in data["attributes"] + assert isinstance(data["attributes"]["regions"], list) + + def test_compliance_overview_requirements( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + compliance_id = requirement_overview1.compliance_id + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[scan_id]": scan_id, + "filter[compliance_id]": compliance_id, + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + + # Check structure of requirements response + for item in data: + assert "id" in item + assert "attributes" in item + attributes = item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "description" in attributes + assert "status" in attributes + + def test_compliance_overview_requirements_missing_scan_id( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]" - assert response.json()["errors"][0]["code"] == "required" + + def test_compliance_overview_requirements_missing_compliance_id( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_compliance_overview_attributes(self, authenticated_client): + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + + # Check structure of attributes response + for item in data: + assert "id" in item + assert "attributes" in item + attributes = item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "description" in attributes + assert "attributes" in attributes + assert "metadata" in attributes["attributes"] + assert "check_ids" in attributes["attributes"] + + def test_compliance_overview_attributes_missing_compliance_id( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_compliance_overview_task_management_integration( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + """Test that task management mixin is properly integrated""" + from unittest.mock import patch + + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + # Mock a running task + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_response = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + mock_task_response.return_value = mock_response + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_task_response.assert_called_once() + + def test_compliance_overview_task_failed_exception( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + """Test handling of TaskFailedException""" + from unittest.mock import patch + + from api.exceptions import TaskFailedException + + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + # Mock a failed task + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.side_effect = TaskFailedException("Task failed") + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "Task failed to generate compliance overview data" in str( + response.data + ) @pytest.mark.parametrize( - "filter_name, filter_value, expected_count", + "filter_name, filter_value_attr, expected_count_min", [ - ("compliance_id", "aws_account_security_onboarding_aws", 1), - ("compliance_id.icontains", "security_onboarding", 1), - ("framework", "AWS-Account-Security-Onboarding", 1), - ("framework.icontains", "security-onboarding", 1), - ("version", "1.0", 1), - ("version", "2.0", 0), - ("version.icontains", "0", 1), - ("region", "eu-west-1", 1), - ("region.icontains", "west-1", 1), - ("region.in", "eu-west-1,eu-west-2", 1), - ("inserted_at.date", "2024-01-01", 0), - ("inserted_at.date", TODAY, 1), - ("inserted_at.gte", "2024-01-01", 1), + ("scan_id", "scan.id", 1), + ("compliance_id", "compliance_id", 1), + ("framework", "framework", 1), + ("version", "version", 1), + ("region", "region", 1), ], ) def test_compliance_overview_filters( self, authenticated_client, - compliance_overviews_fixture, + compliance_requirements_overviews_fixture, filter_name, - filter_value, - expected_count, + filter_value_attr, + expected_count_min, ): - # Test filtering compliance overviews - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) + requirement_overview = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview.scan.id) + + filter_value = requirement_overview + for attr in filter_value_attr.split("."): + filter_value = getattr(filter_value, attr) + + filter_value = str(filter_value) + + query_params = { + "filter[scan_id]": scan_id, + f"filter[{filter_name}]": filter_value, + } + + if filter_name == "scan_id": + query_params = {"filter[scan_id]": filter_value} response = authenticated_client.get( reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - f"filter[{filter_name}]": filter_value, - }, - ) - assert response.status_code == status.HTTP_200_OK - assert len(response.json()["data"]) == expected_count - - @pytest.mark.parametrize( - "filter_name", - ["invalid_filter", "unknown_field"], - ) - def test_compliance_overview_filters_invalid( - self, authenticated_client, compliance_overviews_fixture, filter_name - ): - # Test handling of invalid filters - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - f"filter[{filter_name}]": "some_value", - }, - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - @pytest.mark.parametrize( - "sort_field", - ["inserted_at", "-inserted_at", "compliance_id", "-compliance_id"], - ) - def test_compliance_overview_sort( - self, authenticated_client, compliance_overviews_fixture, sort_field - ): - # Test sorting compliance overviews - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - "sort": sort_field, - }, - ) - assert response.status_code == status.HTTP_200_OK - - def test_compliance_overview_sort_invalid( - self, authenticated_client, compliance_overviews_fixture - ): - # Test handling of invalid sort parameters - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - "sort": "invalid_field", - }, - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["code"] == "invalid" - assert "invalid sort parameter" in response.json()["errors"][0]["detail"] - - def test_compliance_overview_retrieve( - self, authenticated_client, compliance_overviews_fixture - ): - # Retrieve a specific compliance overview - compliance_overview1 = compliance_overviews_fixture[0] - - response = authenticated_client.get( - reverse( - "complianceoverview-detail", - kwargs={"pk": compliance_overview1.id}, - ), - ) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - assert data["id"] == str(compliance_overview1.id) - attributes = data["attributes"] - assert attributes["compliance_id"] == compliance_overview1.compliance_id - assert attributes["framework"] == compliance_overview1.framework - assert attributes["version"] == compliance_overview1.version - assert attributes["region"] == compliance_overview1.region - assert attributes["description"] == compliance_overview1.description - assert "requirements" in attributes - - def test_compliance_overview_invalid_retrieve(self, authenticated_client): - # Attempt to retrieve a compliance overview with an invalid ID - response = authenticated_client.get( - reverse( - "complianceoverview-detail", - kwargs={"pk": "invalid-id"}, - ), - ) - assert response.status_code == status.HTTP_404_NOT_FOUND - - def test_compliance_overview_list_queryset( - self, authenticated_client, compliance_overviews_fixture - ): - compliance_overview1, compliance_overview2 = compliance_overviews_fixture - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - {"filter[scan_id]": scan_id}, - ) - # No filters, most fails should be returned - assert len(response.json()["data"]) == 1 - assert response.json()["data"][0]["id"] == str(compliance_overview2.id) - - compliance_overview1.requirements_failed = 5 - compliance_overview1.save() - - response = authenticated_client.get( - reverse("complianceoverview-list"), - {"filter[scan_id]": scan_id}, - ) - # No filters, now compliance_overview1 has more fails - assert len(response.json()["data"]) == 1 - assert response.json()["data"][0]["id"] == str(compliance_overview1.id) - - def test_compliance_overview_metadata( - self, authenticated_client, compliance_overviews_fixture - ): - response = authenticated_client.get( - reverse("complianceoverview-metadata"), - {"filter[scan_id]": str(compliance_overviews_fixture[0].scan_id)}, - ) - data = response.json() - - expected_regions = set( - ComplianceOverview.objects.all() - .values_list("region", flat=True) - .distinct("region") + query_params, ) assert response.status_code == status.HTTP_200_OK - assert data["data"]["type"] == "compliance-overviews-metadata" - assert data["data"]["id"] is None - assert set(data["data"]["attributes"]["regions"]) == expected_regions + response_data = response.json() - def test_compliance_overview_metadata_missing_scan_id(self, authenticated_client): - # Attempt to list compliance overviews without providing filter[scan_id] - response = authenticated_client.get(reverse("complianceoverview-metadata")) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]" - assert response.json()["errors"][0]["code"] == "required" + assert len(response_data["data"]) >= expected_count_min + + if response_data["data"]: + first_item = response_data["data"][0] + assert "id" in first_item + assert "type" in first_item + assert first_item["type"] == "compliance-overviews" + assert "attributes" in first_item + + attributes = first_item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "requirements_passed" in attributes + assert "requirements_failed" in attributes + assert "requirements_manual" in attributes + assert "total_requirements" in attributes + + if filter_name == "compliance_id": + assert first_item["id"] == filter_value + elif filter_name == "framework": + assert attributes["framework"] == filter_value + elif filter_name == "version": + assert attributes["version"] == filter_value @pytest.mark.django_db diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index 85250c0eef..fde14a23c5 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -1,5 +1,16 @@ +from django.urls import reverse +from django_celery_results.models import TaskResult +from rest_framework import status from rest_framework.response import Response +from api.exceptions import ( + TaskFailedException, + TaskInProgressException, + TaskNotFoundException, +) +from api.models import StateChoices, Task +from api.v1.serializers import TaskSerializer + class PaginateByPkMixin: """ @@ -31,3 +42,181 @@ class PaginateByPkMixin: serialized = self.get_serializer(queryset, many=True).data return self.get_paginated_response(serialized) + + +class TaskManagementMixin: + """ + Mixin to manage task status checking. + + This mixin provides functionality to check if a task with specific parameters + is running, completed, failed, or doesn't exist. It returns the task when running + and raises specific exceptions for failed/not found scenarios that can be handled + at the view level. + """ + + def check_task_status( + self, + task_name: str, + task_kwargs: dict, + raise_on_failed: bool = True, + raise_on_not_found: bool = True, + ) -> Task | None: + """ + Check the status of a task with given name and kwargs. + + This method first checks for a related Task object, and if not found, + checks TaskResult directly. If a TaskResult is found and running but + there's no related Task, it raises TaskInProgressException. + + Args: + task_name (str): The name of the task to check + task_kwargs (dict): The kwargs to match against the task + raise_on_failed (bool): Whether to raise exception if task failed + raise_on_not_found (bool): Whether to raise exception if task not found + + Returns: + Task | None: The task instance if found (regardless of state), None if not found and raise_on_not_found=False + + Raises: + TaskFailedException: If task failed and raise_on_failed=True + TaskNotFoundException: If task not found and raise_on_not_found=True + TaskInProgressException: If task is running but no related Task object exists + """ + # First, try to find a Task object with related TaskResult + try: + # Build the filter for task kwargs + task_filter = { + "task_runner_task__task_name": task_name, + } + + # Add kwargs filters - we need to check if the task kwargs contain our parameters + for key, value in task_kwargs.items(): + task_filter["task_runner_task__task_kwargs__contains"] = str(value) + + task = ( + Task.objects.filter(**task_filter) + .select_related("task_runner_task") + .order_by("-inserted_at") + .first() + ) + + if task: + # Get task state using the same logic as TaskSerializer + task_state_mapping = { + "PENDING": StateChoices.AVAILABLE, + "STARTED": StateChoices.EXECUTING, + "PROGRESS": StateChoices.EXECUTING, + "SUCCESS": StateChoices.COMPLETED, + "FAILURE": StateChoices.FAILED, + "REVOKED": StateChoices.CANCELLED, + } + + celery_status = ( + task.task_runner_task.status if task.task_runner_task else None + ) + task_state = task_state_mapping.get( + celery_status or "", StateChoices.AVAILABLE + ) + + # Check task state and raise exceptions accordingly + if task_state in (StateChoices.FAILED, StateChoices.CANCELLED): + if raise_on_failed: + raise TaskFailedException(task=task) + return task + elif task_state == StateChoices.COMPLETED: + return None + + return task + + except Task.DoesNotExist: + pass + + # If no Task found, check TaskResult directly + try: + # Build the filter for TaskResult + task_result_filter = { + "task_name": task_name, + } + + # Add kwargs filters - check if the task kwargs contain our parameters + for key, value in task_kwargs.items(): + task_result_filter["task_kwargs__contains"] = str(value) + + task_result = ( + TaskResult.objects.filter(**task_result_filter) + .order_by("-date_created") + .first() + ) + + if task_result: + # Check if the TaskResult indicates a running task + if task_result.status in ["PENDING", "STARTED", "PROGRESS"]: + # Task is running but no related Task object exists + raise TaskInProgressException(task_result=task_result) + elif task_result.status == "FAILURE": + if raise_on_failed: + raise TaskFailedException(task=None) + # For other statuses (SUCCESS, REVOKED), we don't have a Task to return, + # so we treat it as not found + + except TaskResult.DoesNotExist: + pass + + # No task found at all + if raise_on_not_found: + raise TaskNotFoundException() + return None + + def get_task_response_if_running( + self, + task_name: str, + task_kwargs: dict, + raise_on_failed: bool = True, + raise_on_not_found: bool = True, + ) -> Response | None: + """ + Get a 202 response with task details if the task is currently running. + + This method is useful for endpoints that should return task status when + a background task is in progress, similar to the compliance overview endpoints. + + Args: + task_name (str): The name of the task to check + task_kwargs (dict): The kwargs to match against the task + + Returns: + Response | None: 202 response with task details if running, None otherwise + """ + task = self.check_task_status( + task_name=task_name, + task_kwargs=task_kwargs, + raise_on_failed=raise_on_failed, + raise_on_not_found=raise_on_not_found, + ) + + if not task: + return None + + # Get task state + task_state_mapping = { + "PENDING": StateChoices.AVAILABLE, + "STARTED": StateChoices.EXECUTING, + "PROGRESS": StateChoices.EXECUTING, + "SUCCESS": StateChoices.COMPLETED, + "FAILURE": StateChoices.FAILED, + "REVOKED": StateChoices.CANCELLED, + } + + celery_status = task.task_runner_task.status if task.task_runner_task else None + task_state = task_state_mapping.get(celery_status or "", StateChoices.AVAILABLE) + + if task_state == StateChoices.EXECUTING: + self.response_serializer_class = TaskSerializer + serializer = TaskSerializer(task) + return Response( + data=serializer.data, + status=status.HTTP_202_ACCEPTED, + headers={ + "Content-Location": reverse("task-detail", kwargs={"pk": task.id}) + }, + ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 7beb311d5f..ad3cf71813 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -14,7 +14,6 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.tokens import RefreshToken from api.models import ( - ComplianceOverview, Finding, Integration, IntegrationProviderRelationship, @@ -31,6 +30,7 @@ from api.models import ( RoleProviderGroupRelationship, Scan, StateChoices, + StatusChoices, Task, User, UserRoleRelationship, @@ -1679,130 +1679,61 @@ class RoleProviderGroupRelationshipSerializer(RLSSerializer, BaseWriteSerializer # Compliance overview -class ComplianceOverviewSerializer(RLSSerializer): +class ComplianceOverviewSerializer(serializers.Serializer): """ - Serializer for the ComplianceOverview model. + Serializer for compliance requirement status aggregated by compliance framework. + + This serializer is used to format aggregated compliance framework data, + providing counts of passed, failed, and manual requirements along with + an overall global status for each framework. """ - requirements_status = serializers.SerializerMethodField( - read_only=True, method_name="get_requirements_status" - ) - provider_type = serializers.SerializerMethodField(read_only=True) + # Add ID field which will be used for resource identification + id = serializers.CharField() + framework = serializers.CharField() + version = serializers.CharField() + requirements_passed = serializers.IntegerField() + requirements_failed = serializers.IntegerField() + requirements_manual = serializers.IntegerField() + total_requirements = serializers.IntegerField() - class Meta: - model = ComplianceOverview - fields = [ - "id", - "inserted_at", - "compliance_id", - "framework", - "version", - "requirements_status", - "region", - "provider_type", - "scan", - "url", - ] - - @extend_schema_field( - { - "type": "object", - "properties": { - "passed": {"type": "integer"}, - "failed": {"type": "integer"}, - "manual": {"type": "integer"}, - "total": {"type": "integer"}, - }, - } - ) - def get_requirements_status(self, obj): - return { - "passed": obj.requirements_passed, - "failed": obj.requirements_failed, - "manual": obj.requirements_manual, - "total": obj.total_requirements, - } - - @extend_schema_field(serializers.CharField(allow_null=True)) - def get_provider_type(self, obj): - """ - Retrieves the provider_type from scan.provider.provider_type. - """ - try: - return obj.scan.provider.provider - except AttributeError: - return None + class JSONAPIMeta: + resource_name = "compliance-overviews" -class ComplianceOverviewFullSerializer(ComplianceOverviewSerializer): - requirements = serializers.SerializerMethodField(read_only=True) +class ComplianceOverviewDetailSerializer(serializers.Serializer): + """ + Serializer for detailed compliance requirement information. - class Meta(ComplianceOverviewSerializer.Meta): - fields = ComplianceOverviewSerializer.Meta.fields + [ - "description", - "requirements", - ] + This serializer formats the aggregated requirement data, showing detailed status + and counts for each requirement across all regions. + """ - @extend_schema_field( - { - "type": "object", - "properties": { - "requirement_id": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "checks": { - "type": "object", - "properties": { - "check_name": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["PASS", "FAIL", None], - }, - }, - } - }, - "description": "Each key in the 'checks' object is a check name, with values as " - "'PASS', 'FAIL', or null.", - }, - "status": { - "type": "string", - "enum": ["PASS", "FAIL", "MANUAL"], - }, - "attributes": { - "type": "array", - "items": { - "type": "object", - }, - }, - "description": {"type": "string"}, - "checks_status": { - "type": "object", - "properties": { - "total": {"type": "integer"}, - "pass": {"type": "integer"}, - "fail": {"type": "integer"}, - "manual": {"type": "integer"}, - }, - }, - }, - } - }, - } - ) - def get_requirements(self, obj): - """ - Returns the detailed structure of requirements. - """ - return obj.requirements + id = serializers.CharField() + framework = serializers.CharField() + version = serializers.CharField() + description = serializers.CharField() + status = serializers.ChoiceField(choices=StatusChoices.choices) + + class JSONAPIMeta: + resource_name = "compliance-requirements-details" + + +class ComplianceOverviewAttributesSerializer(serializers.Serializer): + id = serializers.CharField() + framework = serializers.CharField() + version = serializers.CharField() + description = serializers.CharField() + attributes = serializers.JSONField() + + class JSONAPIMeta: + resource_name = "compliance-requirements-attributes" class ComplianceOverviewMetadataSerializer(serializers.Serializer): regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) - class Meta: + class JSONAPIMeta: resource_name = "compliance-overviews-metadata" diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 8fc87d2808..394913cb6c 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -17,7 +17,7 @@ from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.search import SearchQuery from django.db import transaction -from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Subquery, Sum +from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Sum from django.db.models.functions import Coalesce from django.http import HttpResponse from django.urls import reverse @@ -26,10 +26,10 @@ from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from django_celery_beat.models import PeriodicTask from drf_spectacular.settings import spectacular_settings +from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, - OpenApiTypes, extend_schema, extend_schema_view, ) @@ -58,8 +58,12 @@ from tasks.tasks import ( ) from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset -from api.compliance import get_compliance_frameworks +from api.compliance import ( + PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, + get_compliance_frameworks, +) from api.db_router import MainRouter +from api.exceptions import TaskFailedException from api.filters import ( ComplianceOverviewFilter, FindingFilter, @@ -81,6 +85,7 @@ from api.filters import ( ) from api.models import ( ComplianceOverview, + ComplianceRequirementOverview, Finding, Integration, Invitation, @@ -111,9 +116,10 @@ from api.utils import ( validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start -from api.v1.mixins import PaginateByPkMixin +from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin from api.v1.serializers import ( - ComplianceOverviewFullSerializer, + ComplianceOverviewAttributesSerializer, + ComplianceOverviewDetailSerializer, ComplianceOverviewMetadataSerializer, ComplianceOverviewSerializer, FindingDynamicFilterSerializer, @@ -2391,8 +2397,7 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): list=extend_schema( tags=["Compliance Overview"], summary="List compliance overviews for a scan", - description="Retrieve an overview of all the compliance in a given scan. If no region filters are provided, the" - " region with the most fails will be returned by default.", + description="Retrieve an overview of all the compliance in a given scan.", parameters=[ OpenApiParameter( name="filter[scan_id]", @@ -2402,12 +2407,18 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Related scan ID.", ), ], - ), - retrieve=extend_schema( - tags=["Compliance Overview"], - summary="Retrieve data from a specific compliance overview", - description="Fetch detailed information about a specific compliance overview by its ID, including detailed " - "requirement information and check's status.", + responses={ + 200: OpenApiResponse( + description="Compliance overviews obtained successfully", + response=ComplianceOverviewSerializer(many=True), + ), + 202: OpenApiResponse( + description="The task is in progress", response=TaskSerializer + ), + 500: OpenApiResponse( + description="Compliance overviews generation task failed" + ), + }, ), metadata=extend_schema( tags=["Compliance Overview"], @@ -2423,19 +2434,84 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Related scan ID.", ), ], + responses={ + 200: OpenApiResponse( + description="Compliance overviews metadata obtained successfully", + response=ComplianceOverviewMetadataSerializer, + ), + 202: OpenApiResponse(description="The task is in progress"), + 500: OpenApiResponse( + description="Compliance overviews generation task failed" + ), + }, + ), + requirements=extend_schema( + tags=["Compliance Overview"], + summary="List compliance requirements overview for a scan", + description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance " + "framework. This endpoint provides requirement-level details and aggregates status across regions.", + parameters=[ + OpenApiParameter( + name="filter[scan_id]", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Related scan ID.", + ), + OpenApiParameter( + name="filter[compliance_id]", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Compliance ID.", + ), + ], + responses={ + 200: OpenApiResponse( + description="Compliance requirement details obtained successfully", + response=ComplianceOverviewDetailSerializer(many=True), + ), + 202: OpenApiResponse(description="The task is in progress"), + 500: OpenApiResponse( + description="Compliance overviews generation task failed" + ), + }, + filters=True, + ), + attributes=extend_schema( + tags=["Compliance Overview"], + summary="Get compliance requirement attributes", + description="Retrieve detailed attribute information for all requirements in a specific compliance framework " + "along with the associated check IDs for each requirement.", + parameters=[ + OpenApiParameter( + name="filter[compliance_id]", + required=True, + type=str, + location=OpenApiParameter.QUERY, + description="Compliance framework ID to get attributes for.", + ), + ], + responses={ + 200: OpenApiResponse( + description="Compliance attributes obtained successfully", + response=ComplianceOverviewAttributesSerializer(many=True), + ), + }, ), ) @method_decorator(CACHE_DECORATOR, name="list") -@method_decorator(CACHE_DECORATOR, name="retrieve") -class ComplianceOverviewViewSet(BaseRLSViewSet): +@method_decorator(CACHE_DECORATOR, name="requirements") +@method_decorator(CACHE_DECORATOR, name="attributes") +class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): pagination_class = ComplianceOverviewPagination - queryset = ComplianceOverview.objects.all() + queryset = ComplianceRequirementOverview.objects.all() serializer_class = ComplianceOverviewSerializer filterset_class = ComplianceOverviewFilter http_method_names = ["get"] search_fields = ["compliance_id"] ordering = ["compliance_id"] - ordering_fields = ["inserted_at", "compliance_id", "framework", "region"] + ordering_fields = ["compliance_id"] # RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of # the provider through the provider group) required_permissions = [] @@ -2446,51 +2522,44 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): role, Permissions.UNLIMITED_VISIBILITY.value, False ) - if self.action == "retrieve": - if unlimited_visibility: - # User has unlimited visibility, return all compliance - return ComplianceOverview.objects.filter( - tenant_id=self.request.tenant_id - ) - - providers = get_providers(role) - return ComplianceOverview.objects.filter( - tenant_id=self.request.tenant_id, scan__provider__in=providers - ) - if unlimited_visibility: base_queryset = self.filter_queryset( - ComplianceOverview.objects.filter(tenant_id=self.request.tenant_id) + ComplianceRequirementOverview.objects.filter( + tenant_id=self.request.tenant_id + ) ) else: providers = Provider.objects.filter( provider_groups__in=role.provider_groups.all() ).distinct() base_queryset = self.filter_queryset( - ComplianceOverview.objects.filter( + ComplianceRequirementOverview.objects.filter( tenant_id=self.request.tenant_id, scan__provider__in=providers ) ) - max_failed_ids = ( - base_queryset.filter(compliance_id=OuterRef("compliance_id")) - .order_by("-requirements_failed") - .values("id")[:1] - ) - - return base_queryset.filter(id__in=Subquery(max_failed_ids)).order_by( - "compliance_id" - ) + return base_queryset def get_serializer_class(self): - if self.action == "retrieve": - return ComplianceOverviewFullSerializer + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class + elif self.action == "list": + return ComplianceOverviewSerializer elif self.action == "metadata": return ComplianceOverviewMetadataSerializer + elif self.action == "attributes": + return ComplianceOverviewAttributesSerializer + elif self.action == "requirements": + return ComplianceOverviewDetailSerializer return super().get_serializer_class() + @extend_schema(exclude=True) + def retrieve(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + def list(self, request, *args, **kwargs): - if not request.query_params.get("filter[scan_id]"): + scan_id = request.query_params.get("filter[scan_id]") + if not scan_id: raise ValidationError( [ { @@ -2501,7 +2570,82 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): } ] ) - return super().list(request, *args, **kwargs) + try: + if task := self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ): + return task + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + queryset = self.filter_queryset(self.filter_queryset(self.get_queryset())) + + requirement_status_subquery = queryset.values( + "compliance_id", "requirement_id" + ).annotate( + fail_count=Count("id", filter=Q(requirement_status="FAIL")), + pass_count=Count("id", filter=Q(requirement_status="PASS")), + total_count=Count("id"), + ) + + compliance_data = {} + framework_info = {} + + for item in queryset.values("compliance_id", "framework", "version").distinct(): + framework_info[item["compliance_id"]] = { + "framework": item["framework"], + "version": item["version"], + } + + for item in requirement_status_subquery: + compliance_id = item["compliance_id"] + + if item["fail_count"] > 0: + req_status = "FAIL" + elif item["pass_count"] == item["total_count"]: + req_status = "PASS" + else: + req_status = "MANUAL" + + if compliance_id not in compliance_data: + compliance_data[compliance_id] = { + "total_requirements": 0, + "requirements_passed": 0, + "requirements_failed": 0, + "requirements_manual": 0, + } + + compliance_data[compliance_id]["total_requirements"] += 1 + if req_status == "PASS": + compliance_data[compliance_id]["requirements_passed"] += 1 + elif req_status == "FAIL": + compliance_data[compliance_id]["requirements_failed"] += 1 + else: + compliance_data[compliance_id]["requirements_manual"] += 1 + + response_data = [] + for compliance_id, data in compliance_data.items(): + framework = framework_info.get(compliance_id, {}) + + response_data.append( + { + "id": compliance_id, + "compliance_id": compliance_id, + "framework": framework.get("framework", ""), + "version": framework.get("version", ""), + "requirements_passed": data["requirements_passed"], + "requirements_failed": data["requirements_failed"], + "requirements_manual": data["requirements_manual"], + "total_requirements": data["total_requirements"], + } + ) + + serializer = self.get_serializer(response_data, many=True) + return Response(serializer.data) @action(detail=False, methods=["get"], url_name="metadata") def metadata(self, request): @@ -2517,11 +2661,21 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): } ] ) - - tenant_id = self.request.tenant_id - + try: + if task := self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ): + return task + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) regions = list( - ComplianceOverview.objects.filter(tenant_id=tenant_id, scan_id=scan_id) + self.get_queryset() + .filter(scan_id=scan_id) .values_list("region", flat=True) .order_by("region") .distinct() @@ -2532,6 +2686,152 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): serializer.is_valid(raise_exception=True) return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=False, methods=["get"], url_name="requirements") + def requirements(self, request): + scan_id = request.query_params.get("filter[scan_id]") + compliance_id = request.query_params.get("filter[compliance_id]") + + if not scan_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + if not compliance_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[compliance_id]"}, + "code": "required", + } + ] + ) + try: + if task := self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ): + return task + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + filtered_queryset = self.filter_queryset(self.get_queryset()) + + all_requirements = ( + filtered_queryset.values( + "requirement_id", "framework", "version", "description" + ) + .distinct() + .annotate(total_instances=Count("id")) + ) + + passed_instances = ( + filtered_queryset.filter(requirement_status="PASS") + .values("requirement_id") + .annotate(pass_count=Count("id")) + ) + + passed_counts = { + item["requirement_id"]: item["pass_count"] for item in passed_instances + } + + requirements_summary = [] + for requirement in all_requirements: + requirement_id = requirement["requirement_id"] + total_instances = requirement["total_instances"] + passed_count = passed_counts.get(requirement_id, 0) + + requirement_status = "PASS" if passed_count == total_instances else "FAIL" + + requirements_summary.append( + { + "id": requirement_id, + "framework": requirement["framework"], + "version": requirement["version"], + "description": requirement["description"], + "status": requirement_status, + } + ) + + serializer = self.get_serializer(requirements_summary, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_name="attributes") + def attributes(self, request): + compliance_id = request.query_params.get("filter[compliance_id]") + if not compliance_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[compliance_id]"}, + "code": "required", + } + ] + ) + + provider_type = None + try: + sample_requirement = ( + self.get_queryset().filter(compliance_id=compliance_id).first() + ) + + if sample_requirement: + provider_type = sample_requirement.scan.provider.provider + except Exception: + pass + + # If we couldn't determine from database, try each provider type + if not provider_type: + for pt in Provider.ProviderChoices.values: + if compliance_id in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(pt, {}): + provider_type = pt + break + + if not provider_type: + raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.") + + compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get( + provider_type, {} + ) + compliance_framework = compliance_template.get(compliance_id) + + if not compliance_framework: + raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.") + + attribute_data = [] + for requirement_id, requirement in compliance_framework.get( + "requirements", {} + ).items(): + check_ids = list(requirement.get("checks", {}).keys()) + + metadata = requirement.get("attributes", []) + + attribute_data.append( + { + "id": requirement_id, + "framework": compliance_framework.get("framework", ""), + "version": compliance_framework.get("version", ""), + "description": requirement.get("description", ""), + "attributes": {"metadata": metadata, "check_ids": check_ids}, + } + ) + + serializer = self.get_serializer(attribute_data, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + @extend_schema(tags=["Overview"]) @extend_schema_view( @@ -2578,7 +2878,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): class OverviewViewSet(BaseRLSViewSet): queryset = ComplianceOverview.objects.all() http_method_names = ["get"] - ordering = ["-id"] + ordering = ["-inserted_at"] # RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of # the provider through the provider group) required_permissions = [] diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 8f3f0bb42e..5de1d9ca71 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -26,6 +26,7 @@ INSTALLED_APPS = [ "rest_framework", "corsheaders", "drf_spectacular", + "drf_spectacular_jsonapi", "django_guid", "rest_framework_json_api", "django_celery_results", diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 8f58c447de..be215ee59c 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -15,6 +15,7 @@ from tasks.jobs.backfill import backfill_resource_scan_summaries from api.db_utils import rls_transaction from api.models import ( ComplianceOverview, + ComplianceRequirementOverview, Finding, Integration, IntegrationProviderRelationship, @@ -29,6 +30,7 @@ from api.models import ( Scan, ScanSummary, StateChoices, + StatusChoices, Task, User, UserRoleRelationship, @@ -777,6 +779,98 @@ def compliance_overviews_fixture(scans_fixture, tenants_fixture): return compliance_overview1, compliance_overview2 +@pytest.fixture +def compliance_requirements_overviews_fixture(scans_fixture, tenants_fixture): + """Fixture for ComplianceRequirementOverview objects used by the new ComplianceOverviewViewSet.""" + tenant = tenants_fixture[0] + scan1, scan2, scan3 = scans_fixture + + # Create ComplianceRequirementOverview objects for scan1 + requirement_overview1 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-1", + requirement_id="requirement1", + requirement_status=StatusChoices.PASS, + passed_checks=2, + failed_checks=0, + total_checks=2, + ) + + requirement_overview2 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-1", + requirement_id="requirement2", + requirement_status=StatusChoices.PASS, + passed_checks=2, + failed_checks=0, + total_checks=2, + ) + + requirement_overview3 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-2", + requirement_id="requirement1", + requirement_status=StatusChoices.PASS, + passed_checks=2, + failed_checks=0, + total_checks=2, + ) + + requirement_overview4 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-2", + requirement_id="requirement2", + requirement_status=StatusChoices.FAIL, + passed_checks=1, + failed_checks=1, + total_checks=2, + ) + + # Create a different compliance framework for testing + requirement_overview5 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="cis_1.4_aws", + framework="CIS-1.4-AWS", + version="1.4", + description="CIS AWS Foundations Benchmark v1.4.0", + region="eu-west-1", + requirement_id="cis_requirement1", + requirement_status=StatusChoices.FAIL, + passed_checks=0, + failed_checks=3, + total_checks=3, + ) + + return ( + requirement_overview1, + requirement_overview2, + requirement_overview3, + requirement_overview4, + requirement_overview5, + ) + + def get_api_tokens( api_client, user_email: str, user_password: str, tenant_id: str = None ) -> tuple[str, str]: diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 1f4f9a8b94..684fbfd759 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -13,9 +13,9 @@ from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, generate_scan_compliance, ) -from api.db_utils import rls_transaction +from api.db_utils import create_objects_in_batches, rls_transaction from api.models import ( - ComplianceOverview, + ComplianceRequirementOverview, Finding, Provider, Resource, @@ -119,7 +119,6 @@ def perform_prowler_scan( ValueError: If the provider cannot be connected. """ - check_status_by_region = {} exception = None unique_resources = set() scan_resource_cache: set[tuple[str, str, str, str]] = set() @@ -293,16 +292,6 @@ def perform_prowler_scan( ) finding_instance.add_resources([resource_instance]) - # Update compliance data if applicable - if finding.status.value == "MUTED": - continue - - region_dict = check_status_by_region.setdefault(finding.region, {}) - current_status = region_dict.get(finding.check_id) - if current_status == "FAIL": - continue - region_dict[finding.check_id] = finding.status.value - # Update scan resource summaries scan_resource_cache.add( ( @@ -335,63 +324,6 @@ def perform_prowler_scan( if exception is not None: raise exception - try: - regions = prowler_provider.get_regions() - except AttributeError: - regions = set() - - compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ - provider_instance.provider - ] - compliance_overview_by_region = { - region: deepcopy(compliance_template) for region in regions - } - - for region, check_status in check_status_by_region.items(): - compliance_data = compliance_overview_by_region.setdefault( - region, deepcopy(compliance_template) - ) - for check_name, status in check_status.items(): - generate_scan_compliance( - compliance_data, - provider_instance.provider, - check_name, - status, - ) - - # Prepare compliance overview objects - compliance_overview_objects = [] - for region, compliance_data in compliance_overview_by_region.items(): - for compliance_id, compliance in compliance_data.items(): - compliance_overview_objects.append( - ComplianceOverview( - tenant_id=tenant_id, - scan=scan_instance, - region=region, - compliance_id=compliance_id, - framework=compliance["framework"], - version=compliance["version"], - description=compliance["description"], - requirements=compliance["requirements"], - requirements_passed=compliance["requirements_status"]["passed"], - requirements_failed=compliance["requirements_status"]["failed"], - requirements_manual=compliance["requirements_status"]["manual"], - total_requirements=compliance["total_requirements"], - ) - ) - try: - with rls_transaction(tenant_id): - ComplianceOverview.objects.bulk_create( - compliance_overview_objects, batch_size=500 - ) - except Exception as overview_exception: - import sentry_sdk - - sentry_sdk.capture_exception(overview_exception) - logger.error( - f"Error storing compliance overview for scan {scan_id}: {overview_exception}" - ) - try: resource_scan_summaries = [ ResourceScanSummary( @@ -570,3 +502,114 @@ def aggregate_findings(tenant_id: str, scan_id: str): for agg in aggregation } ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000) + + +def create_compliance_requirements(tenant_id: str, scan_id: str): + """ + Create detailed compliance requirement overview records for a scan. + + This function processes the compliance data collected during a scan and creates + individual records for each compliance requirement in each region. These detailed + records provide a granular view of compliance status. + + Args: + tenant_id (str): The ID of the tenant for which to create records. + scan_id (str): The ID of the scan for which to create records. + + Returns: + dict: A dictionary containing the number of requirements created and the regions processed. + + Raises: + ValidationError: If tenant_id is not a valid UUID. + """ + try: + with rls_transaction(tenant_id): + scan_instance = Scan.objects.get(pk=scan_id) + provider_instance = scan_instance.provider + prowler_provider = initialize_prowler_provider(provider_instance) + + # Get check status data by region from findings + check_status_by_region = {} + with rls_transaction(tenant_id): + findings = Finding.objects.filter(scan_id=scan_id, muted=False) + for finding in findings: + # Get region from resources + for resource in finding.resources.all(): + region = resource.region + region_dict = check_status_by_region.setdefault(region, {}) + current_status = region_dict.get(finding.check_id) + if current_status == "FAIL": + continue + region_dict[finding.check_id] = finding.status + + try: + # Try to get regions from provider + regions = prowler_provider.get_regions() + except (AttributeError, Exception): + # If not available, use regions from findings + regions = set(check_status_by_region.keys()) + + # Get compliance template for the provider + compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ + provider_instance.provider + ] + + # Create compliance data by region + compliance_overview_by_region = { + region: deepcopy(compliance_template) for region in regions + } + + # Apply check statuses to compliance data + for region, check_status in check_status_by_region.items(): + compliance_data = compliance_overview_by_region.setdefault( + region, deepcopy(compliance_template) + ) + for check_name, status in check_status.items(): + generate_scan_compliance( + compliance_data, + provider_instance.provider, + check_name, + status, + ) + + # Prepare compliance requirement objects + compliance_requirement_objects = [] + for region, compliance_data in compliance_overview_by_region.items(): + for compliance_id, compliance in compliance_data.items(): + # Create an overview record for each requirement within each compliance framework + for requirement_id, requirement in compliance["requirements"].items(): + compliance_requirement_objects.append( + ComplianceRequirementOverview( + tenant_id=tenant_id, + scan=scan_instance, + region=region, + compliance_id=compliance_id, + framework=compliance["framework"], + version=compliance["version"], + requirement_id=requirement_id, + description=requirement["description"], + passed_checks=requirement["checks_status"]["pass"], + failed_checks=requirement["checks_status"]["fail"], + total_checks=requirement["checks_status"]["total"], + requirement_status=requirement["status"], + ) + ) + + # Bulk create requirement records + create_objects_in_batches( + tenant_id, ComplianceRequirementOverview, compliance_requirement_objects + ) + + return { + "requirements_created": len(compliance_requirement_objects), + "regions_processed": list(regions), + "compliance_frameworks": ( + list(compliance_overview_by_region.get(list(regions)[0], {}).keys()) + if regions + else [] + ), + } + + except Exception as e: + logger.error(f"Error creating compliance requirements for scan {scan_id}: {e}") + raise e diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 090c0c33d3..4f30b5fc68 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -17,7 +17,11 @@ from tasks.jobs.export import ( _generate_output_directory, _upload_to_s3, ) -from tasks.jobs.scan import aggregate_findings, perform_prowler_scan +from tasks.jobs.scan import ( + aggregate_findings, + create_compliance_requirements, + perform_prowler_scan, +) from tasks.utils import batched, get_next_execution_datetime from api.compliance import get_compliance_frameworks @@ -101,6 +105,7 @@ def perform_scan_task( chain( perform_scan_summary_task.si(tenant_id, scan_id), + create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id), generate_outputs.si( scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id ), @@ -211,6 +216,9 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): chain( perform_scan_summary_task.si(tenant_id, scan_instance.id), + create_compliance_requirements_task.si( + tenant_id=tenant_id, scan_id=str(scan_instance.id) + ), generate_outputs.si( scan_id=str(scan_instance.id), provider_id=provider_id, tenant_id=tenant_id ), @@ -371,3 +379,19 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str): scan_id (str): The scan identifier. """ return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(base=RLSTask, name="scan-compliance-overviews") +def create_compliance_requirements_task(tenant_id: str, scan_id: str): + """ + Creates detailed compliance requirement records for a scan. + + This task processes the compliance data collected during a scan and creates + individual records for each compliance requirement in each region. These detailed + records provide a granular view of compliance status. + + Args: + tenant_id (str): The tenant ID for which to create records. + scan_id (str): The ID of the scan for which to create records. + """ + return create_compliance_requirements(tenant_id=tenant_id, scan_id=scan_id) diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index a5fde62963..e4ec0d4d41 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -7,11 +7,13 @@ import pytest from tasks.jobs.scan import ( _create_finding_delta, _store_resources, + create_compliance_requirements, perform_prowler_scan, ) from tasks.utils import CustomEncoder from api.models import ( + ComplianceRequirementOverview, Finding, Provider, Resource, @@ -235,7 +237,7 @@ class TestPerformScan: ): tenant_id = uuid.uuid4() provider_instance = MagicMock() - provider_instance.id = "provider456" + provider_instance.id = "provider123" finding = MagicMock() finding.resource_uid = "resource_uid_123" @@ -250,15 +252,16 @@ class TestPerformScan: resource_instance.region = finding.region mock_get_or_create_resource.return_value = (resource_instance, True) + tag_instance = MagicMock() mock_get_or_create_tag.return_value = (tag_instance, True) resource, resource_uid_tuple = _store_resources( - finding, tenant_id, provider_instance + finding, str(tenant_id), provider_instance ) mock_get_or_create_resource.assert_called_once_with( - tenant_id=tenant_id, + tenant_id=str(tenant_id), provider=provider_instance, uid=finding.resource_uid, defaults={ @@ -305,11 +308,11 @@ class TestPerformScan: mock_get_or_create_tag.return_value = (tag_instance, True) resource, resource_uid_tuple = _store_resources( - finding, tenant_id, provider_instance + finding, str(tenant_id), provider_instance ) mock_get_or_create_resource.assert_called_once_with( - tenant_id=tenant_id, + tenant_id=str(tenant_id), provider=provider_instance, uid=finding.resource_uid, defaults={ @@ -363,14 +366,14 @@ class TestPerformScan: ] resource, resource_uid_tuple = _store_resources( - finding, tenant_id, provider_instance + finding, str(tenant_id), provider_instance ) mock_get_or_create_tag.assert_any_call( - tenant_id=tenant_id, key="tag1", value="value1" + tenant_id=str(tenant_id), key="tag1", value="value1" ) mock_get_or_create_tag.assert_any_call( - tenant_id=tenant_id, key="tag2", value="value2" + tenant_id=str(tenant_id), key="tag2", value="value2" ) resource_instance.upsert_or_delete_tags.assert_called_once() tags_passed = resource_instance.upsert_or_delete_tags.call_args[1]["tags"] @@ -382,3 +385,808 @@ class TestPerformScan: # TODO Add tests for aggregations + + +@pytest.mark.django_db +class TestCreateComplianceRequirements: + def test_create_compliance_requirements_success( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + findings_fixture, + resources_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch("tasks.jobs.scan.generate_scan_compliance"), + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = [ + "us-east-1", + "us-west-2", + ] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "cis_1.4_aws": { + "framework": "CIS AWS Foundations Benchmark", + "version": "1.4.0", + "requirements": { + "1.1": { + "description": "Ensure root access key does not exist", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + "1.2": { + "description": "Ensure MFA is enabled for root account", + "checks_status": { + "pass": 0, + "fail": 1, + "manual": 0, + "total": 1, + }, + "status": "FAIL", + }, + }, + }, + "aws_account_security_onboarding_aws": { + "framework": "AWS Account Security Onboarding", + "version": "1.0", + "requirements": { + "requirement1": { + "description": "Basic security requirement", + "checks_status": { + "pass": 1, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + mock_findings_filter.return_value = [] + + result = create_compliance_requirements(tenant_id, scan_id) + + assert "requirements_created" in result + assert "regions_processed" in result + assert "compliance_frameworks" in result + assert result["regions_processed"] == ["us-east-1", "us-west-2"] + assert result["requirements_created"] == 6 + assert len(result["compliance_frameworks"]) == 2 + + mock_create_objects.assert_called_once() + call_args = mock_create_objects.call_args[0] + assert call_args[0] == tenant_id + assert call_args[1] == ComplianceRequirementOverview + assert len(call_args[2]) == 6 + + compliance_objects = call_args[2] + for obj in compliance_objects: + assert isinstance(obj, ComplianceRequirementOverview) + assert obj.tenant.id == tenant.id + assert obj.scan == scan + assert obj.region in ["us-east-1", "us-west-2"] + assert obj.compliance_id in [ + "cis_1.4_aws", + "aws_account_security_onboarding_aws", + ] + + def test_create_compliance_requirements_with_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "PASS" + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.resources.all.return_value = [mock_resource1] + + mock_finding2 = MagicMock() + mock_finding2.check_id = "check2" + mock_finding2.status = "FAIL" + mock_resource2 = MagicMock() + mock_resource2.region = "us-west-2" + mock_finding2.resources.all.return_value = [mock_resource2] + + mock_findings_filter.return_value = [mock_finding1, mock_finding2] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = [ + "us-east-1", + "us-west-2", + ] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + }, + "req_2": { + "description": "Test Requirement 2", + "checks": {"check_2": None}, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + }, + }, + } + } + + result = create_compliance_requirements(tenant_id, scan_id) + + mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False) + assert mock_generate_compliance.call_count == 2 + assert result["requirements_created"] == 4 + assert set(result["regions_processed"]) == {"us-east-1", "us-west-2"} + + def test_create_compliance_requirements_no_provider_regions( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch("tasks.jobs.scan.generate_scan_compliance"), + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.KUBERNETES + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_finding = MagicMock() + mock_finding.check_id = "check1" + mock_finding.status = "PASS" + mock_resource = MagicMock() + mock_resource.region = "default" + mock_finding.resources.all.return_value = [mock_resource] + mock_findings_filter.return_value = [mock_finding] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.side_effect = AttributeError( + "No get_regions method" + ) + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "kubernetes_cis": { + "framework": "CIS Kubernetes Benchmark", + "version": "1.6.0", + "requirements": { + "1.1": { + "description": "Test requirement", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + result = create_compliance_requirements(tenant_id, scan_id) + + assert result["regions_processed"] == ["default"] + + def test_create_compliance_requirements_empty_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_findings_filter.return_value = [] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "cis_1.4_aws": { + "framework": "CIS AWS Foundations Benchmark", + "version": "1.4.0", + "requirements": { + "1.1": { + "description": "Test requirement", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + mock_findings_filter.return_value = [] + + result = create_compliance_requirements(tenant_id, scan_id) + + assert result["regions_processed"] == ["us-east-1"] + assert result["requirements_created"] == 1 + mock_generate_compliance.assert_not_called() + + def test_create_compliance_requirements_error_handling( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_initialize_prowler_provider.side_effect = Exception( + "Provider initialization failed" + ) + + with pytest.raises(Exception, match="Provider initialization failed"): + create_compliance_requirements(tenant_id, scan_id) + + def test_create_compliance_requirements_muted_findings_excluded( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch("tasks.jobs.scan.generate_scan_compliance"), + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_findings_filter.return_value = [] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = {} + + mock_findings_filter.return_value = [] + + create_compliance_requirements(tenant_id, scan_id) + + mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False) + + def test_create_compliance_requirements_check_status_priority( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "PASS" + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.resources.all.return_value = [mock_resource1] + + mock_finding2 = MagicMock() + mock_finding2.check_id = "check1" + mock_finding2.status = "FAIL" + mock_resource2 = MagicMock() + mock_resource2.region = "us-east-1" + mock_finding2.resources.all.return_value = [mock_resource2] + + mock_findings_filter.return_value = [mock_finding1, mock_finding2] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "cis_1.4_aws": { + "framework": "CIS AWS Foundations Benchmark", + "version": "1.4.0", + "requirements": { + "1.1": { + "description": "Test requirement", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + create_compliance_requirements(tenant_id, scan_id) + + assert mock_generate_compliance.call_count == 1 + + def test_compliance_overview_aggregation_requirement_fail_priority( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + mock_findings_filter.return_value = [] + + mock_prowler_provider = MagicMock() + mock_prowler_provider.get_regions.return_value = [ + "us-east-1", + "us-west-2", + "eu-west-1", + ] + mock_initialize_prowler_provider.return_value = mock_prowler_provider + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + } + }, + } + } + + mock_generate_compliance.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": { + "check_1": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "FAIL"}, + "eu-west-1": {"status": "PASS"}, + } + }, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + } + }, + } + } + + created_objects = [] + mock_create_objects.side_effect = ( + lambda tenant_id, model, objs, batch_size=500: created_objects.extend( + objs + ) + ) + + create_compliance_requirements(str(tenant.id), str(scan.id)) + + assert len(created_objects) == 3 + assert all(obj.requirement_status == "FAIL" for obj in created_objects) + + def test_compliance_overview_aggregation_requirement_pass_all_regions( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + mock_findings_filter.return_value = [] + + mock_prowler_provider = MagicMock() + mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"] + mock_initialize_prowler_provider.return_value = mock_prowler_provider + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + } + }, + } + } + + mock_generate_compliance.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": { + "check_1": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "PASS"}, + } + }, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + } + }, + } + } + + created_objects = [] + mock_create_objects.side_effect = ( + lambda tenant_id, model, objs, batch_size=500: created_objects.extend( + objs + ) + ) + + create_compliance_requirements(str(tenant.id), str(scan.id)) + + assert len(created_objects) == 2 + assert all(obj.requirement_status == "PASS" for obj in created_objects) + + def test_compliance_overview_aggregation_multiple_requirements_mixed_status( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + mock_findings_filter.return_value = [] + + mock_prowler_provider = MagicMock() + mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"] + mock_initialize_prowler_provider.return_value = mock_prowler_provider + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + }, + "req_2": { + "description": "Test Requirement 2", + "checks": {"check_2": None}, + "checks_status": { + "pass": 1, + "fail": 1, + "manual": 0, + "total": 2, + }, + "status": "FAIL", + }, + }, + } + } + + mock_generate_compliance.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": { + "check_1": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "PASS"}, + } + }, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + }, + "req_2": { + "description": "Test Requirement 2", + "checks": { + "check_2": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "FAIL"}, + } + }, + "checks_status": { + "pass": 1, + "fail": 1, + "manual": 0, + "total": 2, + }, + "status": "FAIL", + }, + }, + } + } + + created_objects = [] + mock_create_objects.side_effect = ( + lambda tenant_id, model, objs, batch_size=500: created_objects.extend( + objs + ) + ) + + create_compliance_requirements(str(tenant.id), str(scan.id)) + + assert len(created_objects) == 4 + req_1_objects = [ + obj for obj in created_objects if obj.requirement_id == "req_1" + ] + req_2_objects = [ + obj for obj in created_objects if obj.requirement_id == "req_2" + ] + assert len(req_1_objects) == 2 + assert len(req_2_objects) == 2 + assert all(obj.requirement_status == "PASS" for obj in req_1_objects) + assert all(obj.requirement_status == "FAIL" for obj in req_2_objects) diff --git a/docs/img/AAD-permissions.png b/docs/img/AAD-permissions.png index f530293bfe..f2f37e354d 100644 Binary files a/docs/img/AAD-permissions.png and b/docs/img/AAD-permissions.png differ diff --git a/docs/tutorials/azure/getting-started-azure.md b/docs/tutorials/azure/getting-started-azure.md index 5dead442d7..3fabdcc3a5 100644 --- a/docs/tutorials/azure/getting-started-azure.md +++ b/docs/tutorials/azure/getting-started-azure.md @@ -111,7 +111,7 @@ Assign the following Microsoft Graph permissions: - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` -  +  4. Click `Add permissions`, then grant admin consent diff --git a/docs/tutorials/azure/img/directory-permission.png b/docs/tutorials/azure/img/directory-permission.png deleted file mode 100644 index 34dc81abeb..0000000000 Binary files a/docs/tutorials/azure/img/directory-permission.png and /dev/null differ diff --git a/docs/tutorials/azure/img/domain-permission.png b/docs/tutorials/azure/img/domain-permission.png new file mode 100644 index 0000000000..467ec8ff36 Binary files /dev/null and b/docs/tutorials/azure/img/domain-permission.png differ diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index 5e0364faaf..2fde79fe84 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -95,11 +95,10 @@ With this done you will have all the needed keys, summarized in the following ta ### Grant required API permissions Assign the following Microsoft Graph permissions: - +- `AuditLog.Read.All`: Required for Entra service. - `Domain.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. -- `AuditLog.Read.All`: Required for Entra service. - `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in. Follow these steps to assign the permissions: @@ -113,11 +112,11 @@ Follow these steps to assign the permissions:  3. Search and select every permission below and once all are selected click on `Add permissions`: - + - `AuditLog.Read.All`: Required for Entra service. - `Domain.Read.All` - `Policy.Read.All` - `SharePointTenantSettings.Read.All` - - `AuditLog.Read.All`: Required for Entra service. +  diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png b/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png index aa2e96ce70..d87903e271 100644 Binary files a/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png and b/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png differ diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent.png b/docs/tutorials/microsoft365/img/grant-admin-consent.png index 2258d31b8e..2250e41c97 100644 Binary files a/docs/tutorials/microsoft365/img/grant-admin-consent.png and b/docs/tutorials/microsoft365/img/grant-admin-consent.png differ diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 34e9fc3037..2d4a622c30 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -23,12 +23,15 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add NIS 2 compliance framework for Azure. [(7857)](https://github.com/prowler-cloud/prowler/pull/7857) - Add search bar in Dashboard Overview page. [(#7804)](https://github.com/prowler-cloud/prowler/pull/7804) -### Fixed +--- + +### [v5.7.2] Fixed - Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) - Fix `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license. [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779) - Fix `m365_powershell` to close the PowerShell sessions in msgraph services. [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816) - Fix `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity. [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862) - Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive. [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888) +- Split calls to list Azure Functions attributes. [(#7778)](https://github.com/prowler-cloud/prowler/pull/7778) --- diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs index 8c32ae0804..01d6afe1ac 100644 --- a/ui/.eslintrc.cjs +++ b/ui/.eslintrc.cjs @@ -22,7 +22,8 @@ module.exports = { }, }, rules: { - "no-console": 1, + // console.error are allowed but no console.log + "no-console": ["error", { allow: ["error"] }], eqeqeq: 2, quotes: ["error", "double", "avoid-escape"], "@typescript-eslint/no-explicit-any": "off", diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 77450fee10..35814bfd14 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -9,14 +9,24 @@ All notable changes to the **Prowler UI** are documented in this file. - New profile page with details about the user and their roles. [(#7780)](https://github.com/prowler-cloud/prowler/pull/7780) - Improved `SnippetChip` component and show resource name in new findings table. [(#7813)](https://github.com/prowler-cloud/prowler/pull/7813) - Possibility to edit the organization name. [(#7829)](https://github.com/prowler-cloud/prowler/pull/7829) +- Add GCP credential method (Account Service Key). [(#7872)](https://github.com/prowler-cloud/prowler/pull/7872) + +### 🔄 Changed + - Add `Provider UID` filter to scans page. [(#7820)](https://github.com/prowler-cloud/prowler/pull/7820) -- Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758) -- Missing KISA and ProwlerThreat icons added to the compliance page. [(#7860)(https://github.com/prowler-cloud/prowler/pull/7860)] -- Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)] - Improve `Scan ID` filter by adding more context and enhancing the UI/UX. [(#7827)](https://github.com/prowler-cloud/prowler/pull/7827/) +--- + +## [v1.7.2] (Prowler v5.7.2) + ### 🐞 Fixes + +- Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758) +- Compliace detail page, now available for ENS. [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853) +- Missing KISA and ProwlerThreat icons added to the compliance page. [(#7860)(https://github.com/prowler-cloud/prowler/pull/7860)] - Retrieve more than 10 scans in /compliance page. [(#7865)](https://github.com/prowler-cloud/prowler/pull/7865) +- Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)] --- diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 21c65765e0..cdc947fd8c 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -30,7 +30,6 @@ export const getCompliancesOverview = async ({ }); const data = await compliances.json(); const parsedData = parseStringify(data); - revalidatePath("/compliance"); return parsedData; } catch (error) { @@ -79,3 +78,77 @@ export const getComplianceOverviewMetadataInfo = async ({ return undefined; } }; + +export const getComplianceAttributes = async (complianceId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); + url.searchParams.append("filter[compliance_id]", complianceId); + + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch compliance attributes: ${response.statusText}`, + ); + } + + const data = await response.json(); + + const parsedData = parseStringify(data); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching compliance attributes:", error); + return undefined; + } + // */ +}; + +export const getComplianceRequirements = async ({ + complianceId, + scanId, + region, +}: { + complianceId: string; + scanId: string; + region?: string | string[]; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`); + url.searchParams.append("filter[compliance_id]", complianceId); + url.searchParams.append("filter[scan_id]", scanId); + + if (region) { + const regionValue = Array.isArray(region) ? region.join(",") : region; + url.searchParams.append("filter[region__in]", regionValue); + //remove page param + } + url.searchParams.delete("page"); + + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch compliance requirements: ${response.statusText}`, + ); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching compliance requirements:", error); + return undefined; + } + // */ +}; diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index f4891677ee..9bf9826c47 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -10,7 +10,7 @@ import { parseStringify, wait, } from "@/lib"; -import { ProvidersApiResponse } from "@/types/providers"; +import { ProvidersApiResponse, ProviderType } from "@/types/providers"; export const getProviders = async ({ page = 1, @@ -110,7 +110,7 @@ export const updateProvider = async (formData: FormData) => { export const addProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const providerType = formData.get("providerType") as string; + const providerType = formData.get("providerType") as ProviderType; const providerUid = formData.get("providerUid") as string; const providerAlias = formData.get("providerAlias") as string; @@ -152,9 +152,10 @@ export const addCredentialsProvider = async (formData: FormData) => { const secretName = formData.get("secretName"); const providerId = formData.get("providerId"); - const providerType = formData.get("providerType"); + const providerType = formData.get("providerType") as ProviderType; const isRole = formData.get("role_arn") !== null; + const isServiceAccount = formData.get("service_account_key") !== null; let secret = {}; let secretType = "static"; // Default to static credentials @@ -199,19 +200,36 @@ export const addCredentialsProvider = async (formData: FormData) => { password: formData.get("password"), }; } else if (providerType === "gcp") { - // Static credentials configuration for GCP - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - refresh_token: formData.get("refresh_token"), - }; + if (isServiceAccount) { + // Service account configuration for GCP + secretType = "service_account"; + const serviceAccountKeyRaw = formData.get( + "service_account_key", + ) as string; + + try { + const serviceAccountKey = JSON.parse(serviceAccountKeyRaw); + secret = { + service_account_key: serviceAccountKey, + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("error", error); + } + } else { + // Static credentials configuration for GCP + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + refresh_token: formData.get("refresh_token"), + }; + } } else if (providerType === "kubernetes") { // Static credentials configuration for Kubernetes secret = { kubeconfig_content: formData.get("kubeconfig_content"), }; } - const bodyData = { data: { type: "provider-secrets", @@ -257,9 +275,10 @@ export const updateCredentialsProvider = async ( const url = new URL(`${apiBaseUrl}/providers/secrets/${credentialsId}`); const secretName = formData.get("secretName"); - const providerType = formData.get("providerType"); + const providerType = formData.get("providerType") as ProviderType; const isRole = formData.get("role_arn") !== null; + const isServiceAccount = formData.get("service_account_key") !== null; let secret = {}; @@ -302,12 +321,30 @@ export const updateCredentialsProvider = async ( password: formData.get("password"), }; } else if (providerType === "gcp") { - // Static credentials configuration for GCP - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - refresh_token: formData.get("refresh_token"), - }; + if (isServiceAccount) { + // Service account configuration for GCP + const serviceAccountKeyRaw = formData.get( + "service_account_key", + ) as string; + + try { + // Parse the service account key as JSON + const serviceAccountKey = JSON.parse(serviceAccountKeyRaw); + secret = { + service_account_key: serviceAccountKey, + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("error", error); + } + } else { + // Static credentials configuration for GCP + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + refresh_token: formData.get("refresh_token"), + }; + } } else if (providerType === "kubernetes") { // Static credentials configuration for Kubernetes secret = { diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx new file mode 100644 index 0000000000..eeb3f1a588 --- /dev/null +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -0,0 +1,269 @@ +import { Spacer } from "@nextui-org/react"; +import Image from "next/image"; +import { Suspense } from "react"; + +import { + getComplianceAttributes, + getComplianceOverviewMetadataInfo, + getComplianceRequirements, +} from "@/actions/compliances"; +import { getProvider } from "@/actions/providers"; +import { getScans } from "@/actions/scans"; +import { ClientAccordionWrapper } from "@/components/compliance/compliance-accordion/client-accordion-wrapper"; +import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; +import { SkeletonAccordion } from "@/components/compliance/compliance-skeleton-accordion"; +import { FailedSectionsChart } from "@/components/compliance/failed-sections-chart"; +import { FailedSectionsChartSkeleton } from "@/components/compliance/failed-sections-chart-skeleton"; +import { RequirementsChart } from "@/components/compliance/requirements-chart"; +import { RequirementsChartSkeleton } from "@/components/compliance/requirements-chart-skeleton"; +import { ContentLayout } from "@/components/ui"; +import { mapComplianceData, toAccordionItems } from "@/lib/compliance/ens"; +import { ScanProps } from "@/types"; +import { + FailedSection, + MappedComplianceData, + RequirementsTotals, +} from "@/types/compliance"; + +interface ComplianceDetailSearchParams { + complianceId: string; + version?: string; + scanId?: string; + "filter[region__in]"?: string; +} + +const Logo = ({ logoPath }: { logoPath: string }) => { + return ( +
- To update provider credentials,{" "} - - the same type that was originally configured must be used. - -
-- If the provider was configured with static credentials, updates - must also use static credentials. If it was configured with a - role, updates must use a role. -
-- To switch from static credentials to a role (or vice versa), the - provider must be deleted and set up again. -
-There are no failed sections
++ To update provider credentials,{" "} + + the same type that was originally configured must be used. + +
++ If the provider was configured with static credentials, updates must + also use static credentials. If it was configured with a role in AWS + (or service account in GCP),{" "} + updates must use the same type. +
++ To switch from one type to another, the provider must be deleted and set + up again. +
+ {renderSelectComponent()} +Rows per page
-