feat(Overview): PRWLR-5433 Add /overviews/providers endpoint (#88)

* feat(Overview): PRWLR-5433 add overviews/providers views and serializers

* test(Overview): PRWLR-5433 add unit tests

* chore(Schema): update API schema

* feat(Overview): PRWLR-5433 order by -findings_failed by default

* test(Tenant): PRWLR-5433 fix unit test
This commit is contained in:
Víctor Fernández Poyatos
2024-11-21 17:59:21 +01:00
committed by GitHub
parent ad949632b4
commit 1c6d42e60d
6 changed files with 258 additions and 2 deletions
+85
View File
@@ -780,6 +780,38 @@ paths:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/overviews/providers:
get:
operationId: overviews_providers_retrieve
description: Fetch aggregated summaries of the latest findings and resources
for each provider. This includes counts of passed, failed, and manual findings,
as well as the total number of resources managed by each provider.
summary: List aggregated overview data for providers
parameters:
- in: query
name: fields[provider-overviews]
schema:
type: array
items:
type: string
enum:
- id
- findings
- resources
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
tags:
- Overview
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OverviewProviderResponse'
description: ''
/api/v1/provider_groups:
get:
operationId: provider_groups_list
@@ -4368,6 +4400,56 @@ components:
$ref: '#/components/schemas/Membership'
required:
- data
OverviewProvider:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/OverviewProviderTypeEnum'
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:
id:
type: string
findings:
type: object
properties:
pass:
type: integer
fail:
type: integer
manual:
type: integer
total:
type: integer
readOnly: true
resources:
type: object
properties:
total:
type: integer
readOnly: true
required:
- id
OverviewProviderResponse:
type: object
properties:
data:
$ref: '#/components/schemas/OverviewProvider'
required:
- data
OverviewProviderTypeEnum:
type: string
enum:
- provider-overviews
PaginatedComplianceOverviewList:
type: object
required:
@@ -6858,6 +6940,9 @@ tags:
- name: Finding
description: Endpoints for managing findings, allowing retrieval and filtering of
findings that result from scans.
- name: Overview
description: Endpoints for retrieving aggregated summaries of resources from the
system.
- name: Compliance Overview
description: Endpoints for checking the compliance overview, allowing filtering
by scan, provider or compliance framework ID.
@@ -6,7 +6,7 @@ from django.urls import reverse
from conftest import TEST_USER, TEST_PASSWORD, get_api_tokens, get_authorization_header
@patch("tasks.beat.schedule_provider_scan")
@patch("api.v1.views.schedule_provider_scan")
@pytest.mark.django_db
def test_check_resources_between_different_tenants(
schedule_mock,
+24
View File
@@ -3238,3 +3238,27 @@ class TestComplianceOverviewViewSet:
# No filters, now compliance_overview1 has more fails
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["id"] == str(compliance_overview1.id)
@pytest.mark.django_db
class TestOverviewViewSet:
def test_overview_list_invalid_method(self, authenticated_client):
response = authenticated_client.put(reverse("overview-list"))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
def test_overview_providers_list(
self, authenticated_client, findings_fixture, resources_fixture
):
response = authenticated_client.get(reverse("overview-providers"))
assert response.status_code == status.HTTP_200_OK
# Only findings from one provider
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["attributes"]["findings"]["total"] == len(
findings_fixture
)
assert response.json()["data"][0]["attributes"]["findings"]["pass"] == 0
assert response.json()["data"][0]["attributes"]["findings"]["fail"] == 2
assert response.json()["data"][0]["attributes"]["findings"]["manual"] == 0
assert response.json()["data"][0]["attributes"]["resources"]["total"] == len(
resources_fixture
)
+47
View File
@@ -1223,3 +1223,50 @@ class ComplianceOverviewFullSerializer(ComplianceOverviewSerializer):
Returns the detailed structure of requirements.
"""
return obj.requirements
# Overviews
class OverviewProviderSerializer(serializers.Serializer):
id = serializers.CharField(source="provider")
findings = serializers.SerializerMethodField(read_only=True)
resources = serializers.SerializerMethodField(read_only=True)
class JSONAPIMeta:
resource_name = "provider-overviews"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
@extend_schema_field(
{
"type": "object",
"properties": {
"pass": {"type": "integer"},
"fail": {"type": "integer"},
"manual": {"type": "integer"},
"total": {"type": "integer"},
},
}
)
def get_findings(self, obj):
return {
"pass": obj["findings_passed"],
"fail": obj["findings_failed"],
"manual": obj["findings_manual"],
"total": obj["total_findings"],
}
@extend_schema_field(
{
"type": "object",
"properties": {
"total": {"type": "integer"},
},
}
)
def get_resources(self, obj):
return {
"total": obj["total_resources"],
}
+2
View File
@@ -19,6 +19,7 @@ from api.v1.views import (
ProviderSecretViewSet,
InvitationViewSet,
InvitationAcceptViewSet,
OverviewViewSet,
ComplianceOverviewViewSet,
)
@@ -35,6 +36,7 @@ router.register(r"findings", FindingViewSet, basename="finding")
router.register(
r"compliance-overviews", ComplianceOverviewViewSet, basename="complianceoverview"
)
router.register(r"overviews", OverviewViewSet, basename="overview")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+99 -1
View File
@@ -2,7 +2,7 @@ from celery.result import AsyncResult
from django.conf import settings as django_settings
from django.contrib.postgres.search import SearchQuery
from django.db import transaction
from django.db.models import F, Q, Prefetch, OuterRef, Subquery
from django.db.models import Prefetch, Subquery, OuterRef, Count, Q, F
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
@@ -45,6 +45,7 @@ from api.filters import (
ComplianceOverviewFilter,
)
from api.models import (
StatusChoices,
User,
Membership,
Provider,
@@ -91,6 +92,7 @@ from api.v1.serializers import (
InvitationAcceptSerializer,
ComplianceOverviewSerializer,
ComplianceOverviewFullSerializer,
OverviewProviderSerializer,
)
from tasks.beat import schedule_provider_scan
from tasks.tasks import (
@@ -202,6 +204,10 @@ class SchemaView(SpectacularAPIView):
"description": "Endpoints for managing findings, allowing retrieval and filtering of "
"findings that result from scans.",
},
{
"name": "Overview",
"description": "Endpoints for retrieving aggregated summaries of resources from the system.",
},
{
"name": "Compliance Overview",
"description": "Endpoints for checking the compliance overview, allowing filtering by scan, provider or"
@@ -1284,3 +1290,95 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
]
)
return super().list(request, *args, **kwargs)
@extend_schema(tags=["Overview"])
@extend_schema_view(
providers=extend_schema(
summary="List aggregated overview data for providers",
description="Fetch aggregated summaries of the latest findings and resources for each provider. "
"This includes counts of passed, failed, and manual findings, as well as the total number "
"of resources managed by each provider.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
class OverviewViewSet(BaseRLSViewSet):
queryset = ComplianceOverview.objects.all()
http_method_names = ["get"]
ordering = ["compliance_id"]
def get_queryset(self):
return Finding.objects.all()
def get_serializer_class(self):
if self.action == "providers":
return OverviewProviderSerializer
return super().get_serializer_class()
@extend_schema(exclude=True)
def list(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@extend_schema(exclude=True)
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@action(detail=False, methods=["get"], url_name="providers")
def providers(self, request):
# Subquery to get the most recent finding for each uid
latest_finding_ids = (
Finding.objects.filter(
uid=OuterRef("uid"), scan__provider=OuterRef("scan__provider")
)
.order_by("-id") # Most recent by id
.values("id")[:1]
)
# Filter findings to only include the most recent for each uid
recent_findings = Finding.objects.filter(id__in=Subquery(latest_finding_ids))
# Aggregate findings by provider
findings_aggregated = (
recent_findings.values("scan__provider__provider")
.annotate(
findings_passed=Count("id", filter=Q(status=StatusChoices.PASS.value)),
findings_failed=Count("id", filter=Q(status=StatusChoices.FAIL.value)),
findings_manual=Count(
"id", filter=Q(status=StatusChoices.MANUAL.value)
),
total_findings=Count("id"),
)
.order_by("-findings_failed")
)
# Aggregate total resources by provider
resources_aggregated = Resource.objects.values("provider__provider").annotate(
total_resources=Count("id")
)
# Combine findings and resources data
overview = []
for findings in findings_aggregated:
provider = findings["scan__provider__provider"]
total_resources = next(
(
res["total_resources"]
for res in resources_aggregated
if res["provider__provider"] == provider
),
0, # Default to 0 if no resources are found
)
overview.append(
{
"provider": provider,
"total_resources": total_resources,
"total_findings": findings["total_findings"],
"findings_passed": findings["findings_passed"],
"findings_failed": findings["findings_failed"],
"findings_manual": findings["findings_manual"],
}
)
serializer = OverviewProviderSerializer(overview, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)