251 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			251 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """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()
 | 
