events: add custom manager with helpers for metrics

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-12-14 21:33:19 +01:00
parent 64a10e9a46
commit 30386cd899
6 changed files with 181 additions and 72 deletions

View File

@ -1,13 +1,6 @@
"""authentik administration metrics""" """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 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.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request from rest_framework.request import Request
@ -15,31 +8,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import Event, EventAction from authentik.events.models import 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
class CoordinateSerializer(PassiveSerializer): class CoordinateSerializer(PassiveSerializer):
@ -58,12 +27,22 @@ class LoginMetricsSerializer(PassiveSerializer):
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_per_1h(self, _): def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours""" """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)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _): def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours""" """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): class AdministrationMetricsViewSet(APIView):
@ -75,4 +54,5 @@ class AdministrationMetricsViewSet(APIView):
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Login Metrics per 1h""" """Login Metrics per 1h"""
serializer = LoginMetricsSerializer(True) serializer = LoginMetricsSerializer(True)
serializer.context["user"] = request.user
return Response(serializer.data) return Response(serializer.data)

View File

@ -5,6 +5,7 @@ from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema 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.decorators import action
from rest_framework.fields import ReadOnlyField from rest_framework.fields import ReadOnlyField
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
@ -15,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger 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.api.decorators import permission_required
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
@ -231,7 +232,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
app.save() app.save()
return Response({}) 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)}) @extend_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True, pagination_class=None, filter_backends=[]) @action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument # pylint: disable=unused-argument
@ -239,8 +240,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Metrics for application logins""" """Metrics for application logins"""
app = self.get_object() app = self.get_object()
return Response( return Response(
get_events_per_1h( get_objects_for_user(request.user, "authentik_events.view_event")
.filter(
action=EventAction.AUTHORIZE_APPLICATION, action=EventAction.AUTHORIZE_APPLICATION,
context__authorized_application__pk=app.pk.hex, context__authorized_application__pk=app.pk.hex,
) )
.get_events_per_hour()
) )

View File

@ -38,7 +38,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger 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.api.decorators import permission_required
from authentik.core.api.groups import GroupSerializer from authentik.core.api.groups import GroupSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
@ -184,19 +184,31 @@ class UserMetricsSerializer(PassiveSerializer):
def get_logins_per_1h(self, _): def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours""" """Get successful logins per hour for the last 24 hours"""
user = self.context["user"] 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)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _): def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per hour for the last 24 hours"""
user = self.context["user"] 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)) @extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations_per_1h(self, _): def get_authorizations_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per hour for the last 24 hours"""
user = self.context["user"] 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): class UsersFilter(FilterSet):

View File

@ -1,4 +1,6 @@
"""Events API Views""" """Events API Views"""
from json import loads
import django_filters import django_filters
from django.db.models.aggregates import Count from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform 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.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -116,7 +119,6 @@ class EventViewSet(ModelViewSet):
"action", "action",
type=OpenApiTypes.STR, type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
enum=[action for action in EventAction],
required=False, required=False,
), ),
OpenApiParameter( OpenApiParameter(
@ -124,7 +126,7 @@ class EventViewSet(ModelViewSet):
type=OpenApiTypes.INT, type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
required=False, required=False,
) ),
], ],
) )
@action(detail=False, methods=["GET"], pagination_class=None) @action(detail=False, methods=["GET"], pagination_class=None)
@ -145,6 +147,40 @@ class EventViewSet(ModelViewSet):
.order_by("-counted_events")[:top_n] .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)}) @extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[]) @action(detail=False, pagination_class=None, filter_backends=[])
def actions(self, request: Request) -> Response: def actions(self, request: Request) -> Response:

View File

@ -1,4 +1,6 @@
"""authentik events models""" """authentik events models"""
import time
from collections import Counter
from datetime import timedelta from datetime import timedelta
from inspect import getmodule, stack from inspect import getmodule, stack
from smtplib import SMTPException from smtplib import SMTPException
@ -7,6 +9,12 @@ from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.db import models 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 import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.timezone import now from django.utils.timezone import now
@ -91,6 +99,72 @@ class EventAction(models.TextChoices):
CUSTOM_PREFIX = "custom_" 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): class Event(ExpiringModel):
"""An individual Audit/Metrics/Notification/Error Event""" """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 # Shadow the expires attribute from ExpiringModel to override the default duration
expires = models.DateTimeField(default=default_event_duration) expires = models.DateTimeField(default=default_event_duration)
objects = EventManager()
@staticmethod @staticmethod
def _get_app_from_request(request: HttpRequest) -> str: def _get_app_from_request(request: HttpRequest) -> str:
if not isinstance(request, HttpRequest): if not isinstance(request, HttpRequest):

View File

@ -3909,6 +3909,36 @@ paths:
$ref: '#/components/schemas/ValidationError' $ref: '#/components/schemas/ValidationError'
'403': '403':
$ref: '#/components/schemas/GenericError' $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/: /events/events/top_per_user/:
get: get:
operationId: events_events_top_per_user_list operationId: events_events_top_per_user_list
@ -3918,34 +3948,6 @@ paths:
name: action name: action
schema: schema:
type: string 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 - in: query
name: top_n name: top_n
schema: schema: