"""Events middleware""" from collections.abc import Callable from contextlib import contextmanager from contextvars import ContextVar from functools import partial from threading import Thread from typing import Any from django.conf import settings from django.contrib.sessions.models import Session from django.core.exceptions import SuspiciousOperation from django.db.models import Model from django.db.models.signals import m2m_changed, post_save, pre_delete from django.http import HttpRequest, HttpResponse from structlog.stdlib import BoundLogger, get_logger from authentik.blueprints.v1.importer import excluded_models from authentik.core.models import Group, User from authentik.events.models import Event, EventAction, Notification from authentik.events.utils import model_to_dict from authentik.lib.sentry import before_send from authentik.lib.utils.errors import exception_to_string from authentik.stages.authenticator_static.models import StaticToken IGNORED_MODELS = tuple( excluded_models() + ( Event, Notification, StaticToken, Session, ) ) _CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) _CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False) _CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", default=None) def should_log_model(model: Model) -> bool: """Return true if operation on `model` should be logged""" return model.__class__ not in IGNORED_MODELS def should_log_m2m(model: Model) -> bool: """Return true if m2m operation should be logged""" if model.__class__ in [User, Group]: return True return False @contextmanager def audit_overwrite_user(user: User): """Overwrite user being logged for model AuditMiddleware. Commonly used for example in flows where a pending user is given, but the request is not authenticated yet""" _CTX_OVERWRITE_USER.set(user) try: yield finally: _CTX_OVERWRITE_USER.set(None) @contextmanager def audit_ignore(): """Ignore model operations in the block. Useful for objects which need to be modified but are not excluded (e.g. WebAuthn devices)""" _CTX_IGNORE.set(True) try: yield finally: _CTX_IGNORE.set(False) class EventNewThread(Thread): """Create Event in background thread""" action: str request: HttpRequest kwargs: dict[str, Any] user: User | None = None def __init__(self, action: str, request: HttpRequest, user: User | None = None, **kwargs): super().__init__() self.action = action self.request = request self.user = user self.kwargs = kwargs def run(self): Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user) class AuditMiddleware: """Register handlers for duration of request-response that log creation/update/deletion of models""" get_response: Callable[[HttpRequest], HttpResponse] anonymous_user: User = None logger: BoundLogger def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): self.get_response = get_response self.logger = get_logger().bind() def _ensure_fallback_user(self): """Defer fetching anonymous user until we have to""" if self.anonymous_user: return from guardian.shortcuts import get_anonymous_user self.anonymous_user = get_anonymous_user() def get_user(self, request: HttpRequest) -> User: user = _CTX_OVERWRITE_USER.get() if user: return user user = getattr(request, "user", self.anonymous_user) if not user.is_authenticated: self._ensure_fallback_user() return self.anonymous_user return user def connect(self, request: HttpRequest): """Connect signal for automatic logging""" if not hasattr(request, "request_id"): return post_save.connect( partial(self.post_save_handler, request=request), dispatch_uid=request.request_id, weak=False, ) pre_delete.connect( partial(self.pre_delete_handler, request=request), dispatch_uid=request.request_id, weak=False, ) m2m_changed.connect( partial(self.m2m_changed_handler, request=request), dispatch_uid=request.request_id, weak=False, ) def disconnect(self, request: HttpRequest): """Disconnect signals""" if not hasattr(request, "request_id"): return post_save.disconnect(dispatch_uid=request.request_id) pre_delete.disconnect(dispatch_uid=request.request_id) m2m_changed.disconnect(dispatch_uid=request.request_id) def __call__(self, request: HttpRequest) -> HttpResponse: _CTX_REQUEST.set(request) self.connect(request) response = self.get_response(request) self.disconnect(request) _CTX_REQUEST.set(None) return response def process_exception(self, request: HttpRequest, exception: Exception): """Disconnect handlers in case of exception""" self.disconnect(request) if settings.DEBUG: return # Special case for SuspiciousOperation, we have a special event action for that if isinstance(exception, SuspiciousOperation): thread = EventNewThread( EventAction.SUSPICIOUS_REQUEST, request, message=exception_to_string(exception), ) thread.run() elif before_send({}, {"exc_info": (None, exception, None)}) is not None: thread = EventNewThread( EventAction.SYSTEM_EXCEPTION, request, message=exception_to_string(exception), ) thread.run() def post_save_handler( self, request: HttpRequest, sender, instance: Model, created: bool, thread_kwargs: dict | None = None, **_, ): """Signal handler for all object's post_save""" if not should_log_model(instance): return if _CTX_IGNORE.get(): return if request.request_id != _CTX_REQUEST.get().request_id: return user = self.get_user(request) action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED thread = EventNewThread(action, request, user=user, model=model_to_dict(instance)) thread.kwargs.update(thread_kwargs or {}) thread.run() def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_): """Signal handler for all object's pre_delete""" if not should_log_model(instance): # pragma: no cover return if _CTX_IGNORE.get(): return if request.request_id != _CTX_REQUEST.get().request_id: return user = self.get_user(request) EventNewThread( EventAction.MODEL_DELETED, request, user=user, model=model_to_dict(instance), ).run() def m2m_changed_handler( self, request: HttpRequest, sender, instance: Model, action: str, thread_kwargs: dict | None = None, **_, ): """Signal handler for all object's m2m_changed""" if action not in ["pre_add", "pre_remove", "post_clear"]: return if not should_log_m2m(instance): return if _CTX_IGNORE.get(): return if request.request_id != _CTX_REQUEST.get().request_id: return user = self.get_user(request) EventNewThread( EventAction.MODEL_UPDATED, request, user=user, model=model_to_dict(instance), **thread_kwargs, ).run()