diff --git a/authentik/admin/api/metrics.py b/authentik/admin/api/metrics.py index 739fdba31..c7529d536 100644 --- a/authentik/admin/api/metrics.py +++ b/authentik/admin/api/metrics.py @@ -1,13 +1,6 @@ """authentik administration metrics""" -import time -from collections import Counter -from datetime import timedelta - -from django.db.models import Count, ExpressionWrapper, F -from django.db.models.fields import DurationField -from django.db.models.functions import ExtractHour -from django.utils.timezone import now from drf_spectacular.utils import extend_schema, extend_schema_field +from guardian.shortcuts import get_objects_for_user from rest_framework.fields import IntegerField, SerializerMethodField from rest_framework.permissions import IsAdminUser from rest_framework.request import Request @@ -15,31 +8,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from authentik.core.api.utils import PassiveSerializer -from authentik.events.models import Event, EventAction - - -def get_events_per_1h(**filter_kwargs) -> list[dict[str, int]]: - """Get event count by hour in the last day, fill with zeros""" - date_from = now() - timedelta(days=1) - result = ( - Event.objects.filter(created__gte=date_from, **filter_kwargs) - .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) - .annotate(age_hours=ExtractHour("age")) - .values("age_hours") - .annotate(count=Count("pk")) - .order_by("age_hours") - ) - data = Counter({int(d["age_hours"]): d["count"] for d in result}) - results = [] - _now = now() - for hour in range(0, -24, -1): - results.append( - { - "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, - "y_cord": data[hour * -1], - } - ) - return results +from authentik.events.models import EventAction class CoordinateSerializer(PassiveSerializer): @@ -58,12 +27,22 @@ class LoginMetricsSerializer(PassiveSerializer): @extend_schema_field(CoordinateSerializer(many=True)) def get_logins_per_1h(self, _): """Get successful logins per hour for the last 24 hours""" - return get_events_per_1h(action=EventAction.LOGIN) + user = self.context["user"] + return ( + get_objects_for_user(user, "authentik_events.view_event") + .filter(action=EventAction.LOGIN) + .get_events_per_hour() + ) @extend_schema_field(CoordinateSerializer(many=True)) def get_logins_failed_per_1h(self, _): """Get failed logins per hour for the last 24 hours""" - return get_events_per_1h(action=EventAction.LOGIN_FAILED) + user = self.context["user"] + return ( + get_objects_for_user(user, "authentik_events.view_event") + .filter(action=EventAction.LOGIN_FAILED) + .get_events_per_hour() + ) class AdministrationMetricsViewSet(APIView): @@ -75,4 +54,5 @@ class AdministrationMetricsViewSet(APIView): def get(self, request: Request) -> Response: """Login Metrics per 1h""" serializer = LoginMetricsSerializer(True) + serializer.context["user"] = request.user return Response(serializer.data) diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index e8a92e3a6..1bbf042f8 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -5,6 +5,7 @@ from django.http.response import HttpResponseBadRequest from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import ReadOnlyField from rest_framework.parsers import MultiPartParser @@ -15,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework_guardian.filters import ObjectPermissionsFilter from structlog.stdlib import get_logger -from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h +from authentik.admin.api.metrics import CoordinateSerializer from authentik.api.decorators import permission_required from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin @@ -231,7 +232,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): app.save() return Response({}) - @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) + @permission_required("authentik_core.view_application") @extend_schema(responses={200: CoordinateSerializer(many=True)}) @action(detail=True, pagination_class=None, filter_backends=[]) # pylint: disable=unused-argument @@ -239,8 +240,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): """Metrics for application logins""" app = self.get_object() return Response( - get_events_per_1h( + get_objects_for_user(request.user, "authentik_events.view_event") + .filter( action=EventAction.AUTHORIZE_APPLICATION, context__authorized_application__pk=app.pk.hex, ) + .get_events_per_hour() ) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index c22a63da7..f5dd9c290 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -38,7 +38,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework_guardian.filters import ObjectPermissionsFilter from structlog.stdlib import get_logger -from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h +from authentik.admin.api.metrics import CoordinateSerializer from authentik.api.decorators import permission_required from authentik.core.api.groups import GroupSerializer from authentik.core.api.used_by import UsedByMixin @@ -184,19 +184,31 @@ class UserMetricsSerializer(PassiveSerializer): def get_logins_per_1h(self, _): """Get successful logins per hour for the last 24 hours""" user = self.context["user"] - return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk) + return ( + get_objects_for_user(user, "authentik_events.view_event") + .filter(action=EventAction.LOGIN, user__pk=user.pk) + .get_events_per_hour() + ) @extend_schema_field(CoordinateSerializer(many=True)) def get_logins_failed_per_1h(self, _): """Get failed logins per hour for the last 24 hours""" user = self.context["user"] - return get_events_per_1h(action=EventAction.LOGIN_FAILED, context__username=user.username) + return ( + get_objects_for_user(user, "authentik_events.view_event") + .filter(action=EventAction.LOGIN_FAILED, context__username=user.username) + .get_events_per_hour() + ) @extend_schema_field(CoordinateSerializer(many=True)) def get_authorizations_per_1h(self, _): """Get failed logins per hour for the last 24 hours""" user = self.context["user"] - return get_events_per_1h(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk) + return ( + get_objects_for_user(user, "authentik_events.view_event") + .filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk) + .get_events_per_hour() + ) class UsersFilter(FilterSet): diff --git a/authentik/events/api/event.py b/authentik/events/api/event.py index 682d12449..836789604 100644 --- a/authentik/events/api/event.py +++ b/authentik/events/api/event.py @@ -1,4 +1,6 @@ """Events API Views""" +from json import loads + import django_filters from django.db.models.aggregates import Count from django.db.models.fields.json import KeyTextTransform @@ -12,6 +14,7 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.admin.api.metrics import CoordinateSerializer from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer from authentik.events.models import Event, EventAction @@ -116,7 +119,6 @@ class EventViewSet(ModelViewSet): "action", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, - enum=[action for action in EventAction], required=False, ), OpenApiParameter( @@ -124,7 +126,7 @@ class EventViewSet(ModelViewSet): type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, - ) + ), ], ) @action(detail=False, methods=["GET"], pagination_class=None) @@ -145,6 +147,40 @@ class EventViewSet(ModelViewSet): .order_by("-counted_events")[:top_n] ) + @extend_schema( + methods=["GET"], + responses={200: CoordinateSerializer(many=True)}, + filters=[], + parameters=[ + OpenApiParameter( + "action", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + ), + OpenApiParameter( + "query", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + ), + ], + ) + @action(detail=False, methods=["GET"], pagination_class=None) + def per_month(self, request: Request): + """Get the count of events per month""" + filtered_action = request.query_params.get("action", EventAction.LOGIN) + try: + query = loads(request.query_params.get("query", "{}")) + except ValueError: + return Response(status=400) + return Response( + get_objects_for_user(request.user, "authentik_events.view_event") + .filter(action=filtered_action) + .filter(**query) + .get_events_per_day() + ) + @extend_schema(responses={200: TypeCreateSerializer(many=True)}) @action(detail=False, pagination_class=None, filter_backends=[]) def actions(self, request: Request) -> Response: diff --git a/authentik/events/models.py b/authentik/events/models.py index 79df70289..d08ab1e99 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -1,4 +1,6 @@ """authentik events models""" +import time +from collections import Counter from datetime import timedelta from inspect import getmodule, stack from smtplib import SMTPException @@ -7,6 +9,12 @@ from uuid import uuid4 from django.conf import settings from django.db import models +from django.db.models import Count, ExpressionWrapper, F +from django.db.models.fields import DurationField +from django.db.models.functions import ExtractHour +from django.db.models.functions.datetime import ExtractDay +from django.db.models.manager import Manager +from django.db.models.query import QuerySet from django.http import HttpRequest from django.http.request import QueryDict from django.utils.timezone import now @@ -91,6 +99,72 @@ class EventAction(models.TextChoices): CUSTOM_PREFIX = "custom_" +class EventQuerySet(QuerySet): + """Custom events query set with helper functions""" + + def get_events_per_hour(self) -> list[dict[str, int]]: + """Get event count by hour in the last day, fill with zeros""" + date_from = now() - timedelta(days=1) + result = ( + self.filter(created__gte=date_from) + .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) + .annotate(age_hours=ExtractHour("age")) + .values("age_hours") + .annotate(count=Count("pk")) + .order_by("age_hours") + ) + data = Counter({int(d["age_hours"]): d["count"] for d in result}) + results = [] + _now = now() + for hour in range(0, -24, -1): + results.append( + { + "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, + "y_cord": data[hour * -1], + } + ) + return results + + def get_events_per_day(self) -> list[dict[str, int]]: + """Get event count by hour in the last day, fill with zeros""" + date_from = now() - timedelta(weeks=4) + result = ( + self.filter(created__gte=date_from) + .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) + .annotate(age_days=ExtractDay("age")) + .values("age_days") + .annotate(count=Count("pk")) + .order_by("age_days") + ) + data = Counter({int(d["age_days"]): d["count"] for d in result}) + results = [] + _now = now() + for day in range(0, -30, -1): + results.append( + { + "x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000, + "y_cord": data[day * -1], + } + ) + return results + + +class EventManager(Manager): + """Custom helper methods for Events""" + + def get_queryset(self) -> QuerySet: + """use custom queryset""" + return EventQuerySet(self.model, using=self._db) + + def get_events_per_hour(self) -> list[dict[str, int]]: + """Wrap method from queryset""" + return self.get_queryset().get_events_per_hour() + + def get_events_per_day(self) -> list[dict[str, int]]: + """Wrap method from queryset""" + return self.get_queryset().get_events_per_day() + + class Event(ExpiringModel): """An individual Audit/Metrics/Notification/Error Event""" @@ -106,6 +180,8 @@ class Event(ExpiringModel): # Shadow the expires attribute from ExpiringModel to override the default duration expires = models.DateTimeField(default=default_event_duration) + objects = EventManager() + @staticmethod def _get_app_from_request(request: HttpRequest) -> str: if not isinstance(request, HttpRequest): diff --git a/schema.yml b/schema.yml index 14059356e..158dcd37a 100644 --- a/schema.yml +++ b/schema.yml @@ -3909,6 +3909,36 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' + /events/events/per_month/: + get: + operationId: events_events_per_month_list + description: Get the count of events per month + parameters: + - in: query + name: action + schema: + type: string + - in: query + name: query + schema: + type: string + tags: + - events + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Coordinate' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' /events/events/top_per_user/: get: operationId: events_events_top_per_user_list @@ -3918,34 +3948,6 @@ paths: name: action schema: type: string - enum: - - authorize_application - - configuration_error - - custom_ - - email_sent - - flow_execution - - impersonation_ended - - impersonation_started - - invitation_used - - login - - login_failed - - logout - - model_created - - model_deleted - - model_updated - - password_set - - policy_exception - - policy_execution - - property_mapping_exception - - secret_rotate - - secret_view - - source_linked - - suspicious_request - - system_exception - - system_task_exception - - system_task_execution - - update_available - - user_write - in: query name: top_n schema: