From a4f5566589ec256bfccb23d86098ffbf1cd180be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Mon, 24 Mar 2025 10:34:43 +0100 Subject: [PATCH] feat(compliance): Add endpoint to retrieve compliance overviews metadata (#7333) --- api/CHANGELOG.md | 1 + api/src/backend/api/specs/v1.yaml | 104 ++++++++++++++++++++++++ api/src/backend/api/tests/test_views.py | 28 +++++++ api/src/backend/api/v1/serializers.py | 11 +++ api/src/backend/api/v1/views.py | 47 +++++++++++ ui/actions/compliances/compliances.ts | 41 ++++++++++ ui/app/(prowler)/compliance/page.tsx | 6 +- 7 files changed, 235 insertions(+), 3 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b5159a6228..00d6601ed6 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file. - Support for developing new integrations [(#7167)](https://github.com/prowler-cloud/prowler/pull/7167). - HTTP Security Headers [(#7289)](https://github.com/prowler-cloud/prowler/pull/7289). +- New endpoint to get the compliance overviews metadata [(#7333)](https://github.com/prowler-cloud/prowler/pull/7333). --- diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 99f2ce68b2..36f67c0e75 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -233,6 +233,42 @@ paths: schema: $ref: '#/components/schemas/ComplianceOverviewFullResponse' description: '' + /api/v1/compliance-overviews/metadata: + get: + operationId: compliance_overviews_metadata_retrieve + description: Fetch unique metadata values from a set of compliance overviews. + This is useful for dynamic filtering. + summary: Retrieve metadata values from compliance overviews + parameters: + - in: query + name: fields[compliance-overviews-metadata] + schema: + type: array + items: + type: string + enum: + - regions + 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[scan_id] + schema: + type: string + format: uuid + description: Related scan ID. + required: true + tags: + - Compliance Overview + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ComplianceOverviewMetadataResponse' + description: '' /api/v1/findings: get: operationId: findings_list @@ -3674,6 +3710,7 @@ paths: - name - manage_users - manage_account + - manage_integrations - manage_providers - manage_scans - permission_state @@ -3792,6 +3829,8 @@ paths: - -manage_users - manage_account - -manage_account + - manage_integrations + - -manage_integrations - manage_providers - -manage_providers - manage_scans @@ -3867,6 +3906,7 @@ paths: - name - manage_users - manage_account + - manage_integrations - manage_providers - manage_scans - permission_state @@ -4236,6 +4276,17 @@ paths: description: |- * `scheduled` - Scheduled * `manual` - Manual + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false - name: page[number] required: false in: query @@ -4350,6 +4401,17 @@ paths: format: uuid description: A UUID string identifying this scan. required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false tags: - Scan security: @@ -6105,6 +6167,40 @@ components: $ref: '#/components/schemas/ComplianceOverviewFull' required: - data + ComplianceOverviewMetadata: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/ComplianceOverviewMetadataTypeEnum' + 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: + regions: + type: array + items: + type: string + required: + - regions + ComplianceOverviewMetadataResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ComplianceOverviewMetadata' + required: + - data + ComplianceOverviewMetadataTypeEnum: + type: string + enum: + - compliance-overviews-metadata Finding: type: object required: @@ -8398,6 +8494,8 @@ components: type: boolean manage_account: type: boolean + manage_integrations: + type: boolean manage_providers: type: boolean manage_scans: @@ -10050,6 +10148,8 @@ components: type: boolean manage_account: type: boolean + manage_integrations: + type: boolean manage_providers: type: boolean manage_scans: @@ -10179,6 +10279,8 @@ components: type: boolean manage_account: type: boolean + manage_integrations: + type: boolean manage_providers: type: boolean manage_scans: @@ -10313,6 +10415,8 @@ components: type: boolean manage_account: type: boolean + manage_integrations: + type: boolean manage_providers: type: boolean manage_scans: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 312ed3f8fc..c11c0dec24 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -14,6 +14,7 @@ from django.urls import reverse from rest_framework import status from api.models import ( + ComplianceOverview, Integration, Invitation, Membership, @@ -4508,6 +4509,33 @@ class TestComplianceOverviewViewSet: 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") + ) + + 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 + + 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" + @pytest.mark.django_db class TestOverviewViewSet: diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 333a6474bf..8d45e495ea 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -851,6 +851,10 @@ class ScanSerializer(RLSSerializer): "url", ] + included_serializers = { + "provider": "api.v1.serializers.ProviderIncludeSerializer", + } + class ScanIncludeSerializer(RLSSerializer): trigger = serializers.ChoiceField( @@ -1897,6 +1901,13 @@ class ComplianceOverviewFullSerializer(ComplianceOverviewSerializer): return obj.requirements +class ComplianceOverviewMetadataSerializer(serializers.Serializer): + regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) + + class Meta: + resource_name = "compliance-overviews-metadata" + + # Overviews diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index cfe403c8c4..e8dd81ac29 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -102,6 +102,7 @@ from api.rls import Tenant from api.utils import CustomOAuth2Client, validate_invitation from api.v1.serializers import ( ComplianceOverviewFullSerializer, + ComplianceOverviewMetadataSerializer, ComplianceOverviewSerializer, FindingDynamicFilterSerializer, FindingMetadataSerializer, @@ -2057,6 +2058,21 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Fetch detailed information about a specific compliance overview by its ID, including detailed " "requirement information and check's status.", ), + metadata=extend_schema( + tags=["Compliance Overview"], + summary="Retrieve metadata values from compliance overviews", + description="Fetch unique metadata values from a set of compliance overviews. This is useful for dynamic " + "filtering.", + parameters=[ + OpenApiParameter( + name="filter[scan_id]", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Related scan ID.", + ), + ], + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -2118,6 +2134,8 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): def get_serializer_class(self): if self.action == "retrieve": return ComplianceOverviewFullSerializer + elif self.action == "metadata": + return ComplianceOverviewMetadataSerializer return super().get_serializer_class() def list(self, request, *args, **kwargs): @@ -2134,6 +2152,35 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): ) return super().list(request, *args, **kwargs) + @action(detail=False, methods=["get"], url_name="metadata") + def metadata(self, request): + scan_id = request.query_params.get("filter[scan_id]") + if not scan_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + tenant_id = self.request.tenant_id + + regions = list( + ComplianceOverview.objects.filter(tenant_id=tenant_id, scan_id=scan_id) + .values_list("region", flat=True) + .order_by("region") + .distinct() + ) + result = {"regions": regions} + + serializer = self.get_serializer(data=result) + serializer.is_valid(raise_exception=True) + return Response(serializer.data, status=status.HTTP_200_OK) + @extend_schema(tags=["Overview"]) @extend_schema_view( diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index a89c932d91..77b7e76696 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -43,3 +43,44 @@ export const getCompliancesOverview = async ({ return undefined; } }; + +export const getComplianceOverviewMetadataInfo = async ({ + query = "", + sort = "", + filters = {}, +}) => { + const session = await auth(); + + const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`); + + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + // Define filters to exclude + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch compliance overview metadata info: ${response.statusText}`); + } + + const parsedData = parseStringify(await response.json()); + + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching compliance overview metadata info:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 68789e6900..3c1ff0cd0a 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -4,7 +4,7 @@ import { Spacer } from "@nextui-org/react"; import { Suspense } from "react"; import { getCompliancesOverview } from "@/actions/compliances"; -import { getMetadataInfo } from "@/actions/findings"; +import { getComplianceOverviewMetadataInfo } from "@/actions/compliances"; import { getProvider } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { @@ -70,10 +70,10 @@ export default async function Compliance({ searchParams.scanId || expandedScansData[0]?.id || null; const query = (filters["filter[search]"] as string) || ""; - const metadataInfoData = await getMetadataInfo({ + const metadataInfoData = await getComplianceOverviewMetadataInfo({ query, filters: { - "filter[scan]": selectedScanId, + "filter[scan_id]": selectedScanId, }, });