Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,3 +905,57 @@ def get_service_links(self, obj):
}
entry["roles"][link.role] = {"scope": link.scope or {}}
return list(by_service.values())

class MetricAccountSerializer(serializers.ModelSerializer):
"""Lightweight serializer for account in metric context."""

class Meta:
model = models.Account
fields = ["id", "email", "external_id", "type"]
read_only_fields = fields


class MetricOrganizationSerializer(serializers.ModelSerializer):
"""Lightweight serializer for organization in metric context."""

class Meta:
model = models.Organization
fields = ["id", "name"]
read_only_fields = fields


class MetricSerializer(serializers.ModelSerializer):
"""Serialize metrics with account and organization details."""

account = MetricAccountSerializer(read_only=True)
organization = MetricOrganizationSerializer(read_only=True)

class Meta:
model = models.Metric
fields = ["id", "key", "value", "timestamp", "account", "organization"]
read_only_fields = fields


class AggregatedMetricSerializer(serializers.Serializer):
"""Serialize aggregated metric result."""

key = serializers.CharField(help_text="Metric key")
service_id = serializers.UUIDField(help_text="Service ID")
aggregation = serializers.ChoiceField(
choices=["sum", "avg"],
help_text="Aggregation type applied",
)
value = serializers.DecimalField(
max_digits=20,
decimal_places=6,
help_text="Aggregated value",
)
count = serializers.IntegerField(help_text="Number of metrics aggregated")

def create(self, validated_data):
"""Not implemented - this serializer is read-only."""
raise NotImplementedError("This serializer is read-only")

def update(self, instance, validated_data):
"""Not implemented - this serializer is read-only."""
raise NotImplementedError("This serializer is read-only")
171 changes: 171 additions & 0 deletions src/backend/core/api/viewsets/metrics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
"""Metrics API viewsets."""
"""
API endpoints for Metrics model.
"""

from django.db.models import Avg, Sum

from rest_framework import status, viewsets
from rest_framework.settings import api_settings

from core import models
from core.authentication import ExternalManagementApiKeyAuthentication

from .. import permissions, serializers as core_serializers

from rest_framework import serializers
from rest_framework.response import Response
Expand Down Expand Up @@ -62,3 +75,161 @@ def get(self, request):
"results": results,
}
)

class OperatorMetricsViewSet(viewsets.ViewSet):
"""ViewSet for Metrics model nested under Operator.

GET /api/v1.0/operators/<operator_id>/metrics/
Return the list of metrics for the given operator based on filters.
Supports filtering by key, service, organizations, accounts, account_type.
Supports aggregation via agg=sum|avg query param.

Required query params:
- key: Metric key to filter on (single value)
- service: Service ID to filter on (single value)

Optional query params:
- organizations: Comma-separated organization IDs. Defaults to all orgs the operator has access to.
- accounts: Comma-separated account IDs. If omitted, returns all accounts (including null).
- account_type: Filter by account type (e.g., "user", "mailbox").
- agg: Aggregation type (sum|avg). If provided, returns aggregated value.
- group_by: Group results by 'organization'. Returns sum per organization.
"""

authentication_classes = [
ExternalManagementApiKeyAuthentication,
] + list(api_settings.DEFAULT_AUTHENTICATION_CLASSES)
permission_classes = [
permissions.IsAuthenticatedWithAnyMethod,
permissions.OperatorAccessPermission,
]

def _get_operator_organizations(self, operator_id):
"""Get all organization IDs that the operator has access to."""
return models.Organization.objects.filter(
operator_roles__operator_id=operator_id
).values_list("id", flat=True)

def _parse_comma_separated_ids(self, param_value):
"""Parse comma-separated IDs from query param."""
if not param_value:
return None
return [id.strip() for id in param_value.split(",") if id.strip()]

def list(self, request, operator_id=None):
"""List metrics with filtering and optional aggregation."""
# Validate required params
key = request.query_params.get("key")
service_id = request.query_params.get("service")

if not key:
return Response(
{"error": "Query parameter 'key' is required."},
status=status.HTTP_400_BAD_REQUEST,
)

if not service_id:
return Response(
{"error": "Query parameter 'service' is required."},
status=status.HTTP_400_BAD_REQUEST,
)

# Validate service exists
try:
service = models.Service.objects.get(id=service_id)
except models.Service.DoesNotExist:
return Response(
{"error": f"Service with id '{service_id}' not found."},
status=status.HTTP_404_NOT_FOUND,
)

Comment on lines +137 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce that service belongs to the operator scope.

Current validation only checks existence. At Line 139, a service outside the operator perimeter is accepted, which weakens endpoint scoping.

Proposed fix
-        try:
-            service = models.Service.objects.get(id=service_id)
-        except models.Service.DoesNotExist:
+        try:
+            service = models.Service.objects.get(id=service_id)
+        except models.Service.DoesNotExist:
             return Response(
                 {"error": f"Service with id '{service_id}' not found."},
                 status=status.HTTP_404_NOT_FOUND,
             )
+        if not models.OperatorServiceConfig.objects.filter(
+            operator_id=operator_id,
+            service_id=service_id,
+        ).exists():
+            return Response(
+                {"error": "Service is not accessible for this operator."},
+                status=status.HTTP_403_FORBIDDEN,
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/metrics.py` around lines 137 - 145, After
fetching the Service with models.Service.objects.get(id=service_id) you must
verify it belongs to the current operator scope (e.g., compare service.operator
or service.operator_id against the request's operator/context operator) and
return a 404/403 if it does not; update the validation in the same block that
defines service so that after retrieving the object you check membership (e.g.,
service.operator != request.operator or service.operator_id not in operator
scope) and respond with an appropriate error response to prevent accepting
services outside the operator perimeter.

# Get allowed organizations for this operator (convert UUIDs to strings for comparison)
allowed_org_ids = set(
str(org_id) for org_id in self._get_operator_organizations(operator_id)
)

# Parse optional organization filter
org_ids_param = request.query_params.get("organizations")
if org_ids_param:
requested_org_ids = set(self._parse_comma_separated_ids(org_ids_param))
# Filter to only allowed organizations
org_ids = list(requested_org_ids & allowed_org_ids)
if not org_ids:
return Response(
{"error": "No valid organizations found for the given IDs."},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Default to all organizations the operator has access to
org_ids = list(allowed_org_ids)

# Build base queryset
queryset = models.Metric.objects.filter(
key=key,
service=service,
organization_id__in=org_ids,
)

# Filter by account_type if provided
account_type = request.query_params.get("account_type")
if account_type:
queryset = queryset.filter(account__type=account_type)

# Filter by accounts if provided
accounts_param = request.query_params.get("accounts")
if accounts_param:
account_ids = self._parse_comma_separated_ids(accounts_param)
queryset = queryset.filter(account_id__in=account_ids)

Comment on lines +179 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate accounts IDs before applying UUID filter.

At Line 182, malformed IDs can bubble into DB errors instead of returning a controlled 400 response.

Proposed fix
+import uuid
...
         accounts_param = request.query_params.get("accounts")
         if accounts_param:
-            account_ids = self._parse_comma_separated_ids(accounts_param)
+            account_ids = self._parse_comma_separated_ids(accounts_param)
+            try:
+                account_ids = [str(uuid.UUID(v)) for v in account_ids]
+            except (TypeError, ValueError):
+                return Response(
+                    {"error": "Query parameter 'accounts' must contain valid UUIDs."},
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
             queryset = queryset.filter(account_id__in=account_ids)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/metrics.py` around lines 179 - 183, The code
currently passes potentially malformed IDs from accounts_param into
queryset.filter(account_id__in=account_ids) which can raise DB errors; update
the handling around self._parse_comma_separated_ids(accounts_param) to validate
each ID as a UUID and return a controlled 400 on invalid input: call
_parse_comma_separated_ids inside a try/except that catches invalid
UUID/ValueError (or change _parse_comma_separated_ids to raise a ValueError on
invalid tokens), and on exception raise rest_framework.exceptions.ParseError (or
rest_framework.exceptions.ValidationError) with a clear message like "Invalid
account id(s)" so malformed IDs never reach queryset.filter; ensure you import
the chosen exception and keep the variables accounts_param, account_ids, and the
queryset.filter(account_id__in=account_ids) usage intact.

# Handle aggregation
agg = request.query_params.get("agg")
if agg:
if agg not in ("sum", "avg"):
return Response(
{"error": "Query parameter 'agg' must be 'sum' or 'avg'."},
status=status.HTTP_400_BAD_REQUEST,
)

if agg == "sum":
result = queryset.aggregate(value=Sum("value"))
else: # avg
result = queryset.aggregate(value=Avg("value"))

serializer = core_serializers.AggregatedMetricSerializer(
{
"key": key,
"service_id": service_id,
"aggregation": agg,
"value": result["value"] or 0,
"count": queryset.count(),
}
)
return Response(serializer.data)

# Handle group_by parameter
group_by = request.query_params.get("group_by")
if group_by == "organization":
# Group by organization and sum values
grouped = (
queryset.values("organization__id", "organization__name")
.annotate(value=Sum("value"))
.order_by("organization__name")
)
results = [
{
"organization": {
"id": str(item["organization__id"]),
"name": item["organization__name"],
},
"value": str(item["value"]),
}
for item in grouped
]
return Response({"results": results, "grouped_by": "organization"})

# Return list of metrics
queryset = queryset.select_related("account", "organization").order_by(
"organization__name", "account__email"
)
serializer = core_serializers.MetricSerializer(queryset, many=True)
return Response({"results": serializer.data})
Loading
Loading