authentik.events.middleware

Events middleware

  1"""Events middleware"""
  2
  3from collections.abc import Callable
  4from contextlib import contextmanager
  5from contextvars import ContextVar
  6from functools import partial
  7from threading import Thread
  8from typing import Any
  9
 10from django.conf import settings
 11from django.contrib.sessions.models import Session
 12from django.core.exceptions import SuspiciousOperation
 13from django.db.models import Model
 14from django.db.models.signals import m2m_changed, post_save, pre_delete
 15from django.http import HttpRequest, HttpResponse
 16from structlog.stdlib import BoundLogger, get_logger
 17
 18from authentik.blueprints.v1.importer import excluded_models
 19from authentik.core.models import Group, User
 20from authentik.events.models import Event, EventAction, Notification
 21from authentik.events.utils import model_to_dict
 22from authentik.lib.models import InternallyManagedMixin
 23from authentik.lib.sentry import should_ignore_exception
 24from authentik.lib.utils.errors import exception_to_dict
 25from authentik.stages.authenticator_static.models import StaticToken
 26
 27IGNORED_MODELS = tuple(
 28    excluded_models()
 29    + (
 30        Event,
 31        Notification,
 32        StaticToken,
 33        Session,
 34    )
 35)
 36
 37_CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None)
 38_CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False)
 39_CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", default=None)
 40
 41
 42def should_log_model(model: Model) -> bool:
 43    """Return true if operation on `model` should be logged"""
 44    return model.__class__ not in IGNORED_MODELS and not isinstance(model, InternallyManagedMixin)
 45
 46
 47def should_log_m2m(model: Model) -> bool:
 48    """Return true if m2m operation should be logged"""
 49    if model.__class__ in [User, Group]:
 50        return True
 51    return False
 52
 53
 54@contextmanager
 55def audit_overwrite_user(user: User):
 56    """Overwrite user being logged for model AuditMiddleware. Commonly used
 57    for example in flows where a pending user is given, but the request is not authenticated yet"""
 58    _CTX_OVERWRITE_USER.set(user)
 59    try:
 60        yield
 61    finally:
 62        _CTX_OVERWRITE_USER.set(None)
 63
 64
 65@contextmanager
 66def audit_ignore():
 67    """Ignore model operations in the block. Useful for objects which need to be modified
 68    but are not excluded (e.g. WebAuthn devices)"""
 69    _CTX_IGNORE.set(True)
 70    try:
 71        yield
 72    finally:
 73        _CTX_IGNORE.set(False)
 74
 75
 76class EventNewThread(Thread):
 77    """Create Event in background thread"""
 78
 79    action: str
 80    request: HttpRequest
 81    kwargs: dict[str, Any]
 82    user: User | None = None
 83
 84    def __init__(self, action: str, request: HttpRequest, user: User | None = None, **kwargs):
 85        super().__init__()
 86        self.action = action
 87        self.request = request
 88        self.user = user
 89        self.kwargs = kwargs
 90
 91    def run(self):
 92        Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user)
 93
 94
 95class AuditMiddleware:
 96    """Register handlers for duration of request-response that log creation/update/deletion
 97    of models"""
 98
 99    get_response: Callable[[HttpRequest], HttpResponse]
100    anonymous_user: User = None
101    logger: BoundLogger
102
103    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
104        self.get_response = get_response
105        self.logger = get_logger().bind()
106
107    def _ensure_fallback_user(self):
108        """Defer fetching anonymous user until we have to"""
109        if self.anonymous_user:
110            return
111        from guardian.shortcuts import get_anonymous_user
112
113        self.anonymous_user = get_anonymous_user()
114
115    def get_user(self, request: HttpRequest) -> User:
116        user = _CTX_OVERWRITE_USER.get()
117        if user:
118            return user
119        user = getattr(request, "user", self.anonymous_user)
120        if not user.is_authenticated:
121            self._ensure_fallback_user()
122            return self.anonymous_user
123        return user
124
125    def connect(self, request: HttpRequest):
126        """Connect signal for automatic logging"""
127        if not hasattr(request, "request_id"):
128            return
129        post_save.connect(
130            partial(self.post_save_handler, request=request),
131            dispatch_uid=request.request_id,
132            weak=False,
133        )
134        pre_delete.connect(
135            partial(self.pre_delete_handler, request=request),
136            dispatch_uid=request.request_id,
137            weak=False,
138        )
139        m2m_changed.connect(
140            partial(self.m2m_changed_handler, request=request),
141            dispatch_uid=request.request_id,
142            weak=False,
143        )
144
145    def disconnect(self, request: HttpRequest):
146        """Disconnect signals"""
147        if not hasattr(request, "request_id"):
148            return
149        post_save.disconnect(dispatch_uid=request.request_id)
150        pre_delete.disconnect(dispatch_uid=request.request_id)
151        m2m_changed.disconnect(dispatch_uid=request.request_id)
152
153    def __call__(self, request: HttpRequest) -> HttpResponse:
154        _CTX_REQUEST.set(request)
155        self.connect(request)
156
157        response = self.get_response(request)
158
159        self.disconnect(request)
160        _CTX_REQUEST.set(None)
161        return response
162
163    def process_exception(self, request: HttpRequest, exception: Exception):
164        """Disconnect handlers in case of exception"""
165        self.disconnect(request)
166
167        if settings.DEBUG:
168            return
169        # Special case for SuspiciousOperation, we have a special event action for that
170        if isinstance(exception, SuspiciousOperation):
171            thread = EventNewThread(
172                EventAction.SUSPICIOUS_REQUEST,
173                request,
174                message=str(exception),
175                exception=exception_to_dict(exception),
176            )
177            thread.run()
178        elif not should_ignore_exception(exception):
179            thread = EventNewThread(
180                EventAction.SYSTEM_EXCEPTION,
181                request,
182                message=str(exception),
183                exception=exception_to_dict(exception),
184            )
185            thread.run()
186
187    def post_save_handler(
188        self,
189        request: HttpRequest,
190        sender,
191        instance: Model,
192        created: bool,
193        thread_kwargs: dict | None = None,
194        **_,
195    ):
196        """Signal handler for all object's post_save"""
197        if not should_log_model(instance):
198            return
199        if _CTX_IGNORE.get():
200            return
201        current_request = _CTX_REQUEST.get()
202        if current_request is None or request.request_id != current_request.request_id:
203            return
204        user = self.get_user(request)
205
206        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
207        thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
208        thread.kwargs.update(thread_kwargs or {})
209        thread.run()
210
211    def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
212        """Signal handler for all object's pre_delete"""
213        if not should_log_model(instance):  # pragma: no cover
214            return
215        if _CTX_IGNORE.get():
216            return
217        current_request = _CTX_REQUEST.get()
218        if current_request is None or request.request_id != current_request.request_id:
219            return
220        user = self.get_user(request)
221
222        EventNewThread(
223            EventAction.MODEL_DELETED,
224            request,
225            user=user,
226            model=model_to_dict(instance),
227        ).run()
228
229    def m2m_changed_handler(
230        self,
231        request: HttpRequest,
232        sender,
233        instance: Model,
234        action: str,
235        thread_kwargs: dict | None = None,
236        **_,
237    ):
238        """Signal handler for all object's m2m_changed"""
239        if action not in ["pre_add", "pre_remove", "post_clear"]:
240            return
241        if not should_log_m2m(instance):
242            return
243        if _CTX_IGNORE.get():
244            return
245        current_request = _CTX_REQUEST.get()
246        if current_request is None or request.request_id != current_request.request_id:
247            return
248        user = self.get_user(request)
249
250        EventNewThread(
251            EventAction.MODEL_UPDATED,
252            request,
253            user=user,
254            model=model_to_dict(instance),
255            **thread_kwargs,
256        ).run()
IGNORED_MODELS = (<class 'django.contrib.auth.models.User'>, <class 'django.contrib.auth.models.Group'>, <class 'django.contrib.contenttypes.models.ContentType'>, <class 'django.contrib.auth.models.Permission'>, <class 'guardian.models.RoleObjectPermission'>, <class 'authentik.core.models.Provider'>, <class 'authentik.core.models.Source'>, <class 'authentik.core.models.PropertyMapping'>, <class 'authentik.core.models.UserSourceConnection'>, <class 'authentik.core.models.GroupSourceConnection'>, <class 'authentik.flows.models.Stage'>, <class 'authentik.outposts.models.OutpostServiceConnection'>, <class 'authentik.policies.models.Policy'>, <class 'authentik.policies.models.PolicyBindingModel'>, <class 'authentik.endpoints.models.Connector'>, <class 'authentik.core.models.Session'>, <class 'authentik.core.models.AuthenticatedSession'>, <class 'authentik.events.models.Event'>, <class 'authentik.events.models.Notification'>, <class 'authentik.stages.authenticator_static.models.StaticToken'>, <class 'django.contrib.sessions.models.Session'>)
def should_log_model(model: django.db.models.base.Model) -> bool:
43def should_log_model(model: Model) -> bool:
44    """Return true if operation on `model` should be logged"""
45    return model.__class__ not in IGNORED_MODELS and not isinstance(model, InternallyManagedMixin)

Return true if operation on model should be logged

def should_log_m2m(model: django.db.models.base.Model) -> bool:
48def should_log_m2m(model: Model) -> bool:
49    """Return true if m2m operation should be logged"""
50    if model.__class__ in [User, Group]:
51        return True
52    return False

Return true if m2m operation should be logged

@contextmanager
def audit_overwrite_user(user: authentik.core.models.User):
55@contextmanager
56def audit_overwrite_user(user: User):
57    """Overwrite user being logged for model AuditMiddleware. Commonly used
58    for example in flows where a pending user is given, but the request is not authenticated yet"""
59    _CTX_OVERWRITE_USER.set(user)
60    try:
61        yield
62    finally:
63        _CTX_OVERWRITE_USER.set(None)

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

@contextmanager
def audit_ignore():
66@contextmanager
67def audit_ignore():
68    """Ignore model operations in the block. Useful for objects which need to be modified
69    but are not excluded (e.g. WebAuthn devices)"""
70    _CTX_IGNORE.set(True)
71    try:
72        yield
73    finally:
74        _CTX_IGNORE.set(False)

Ignore model operations in the block. Useful for objects which need to be modified but are not excluded (e.g. WebAuthn devices)

class EventNewThread(threading.Thread):
77class EventNewThread(Thread):
78    """Create Event in background thread"""
79
80    action: str
81    request: HttpRequest
82    kwargs: dict[str, Any]
83    user: User | None = None
84
85    def __init__(self, action: str, request: HttpRequest, user: User | None = None, **kwargs):
86        super().__init__()
87        self.action = action
88        self.request = request
89        self.user = user
90        self.kwargs = kwargs
91
92    def run(self):
93        Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user)

Create Event in background thread

EventNewThread( action: str, request: django.http.request.HttpRequest, user: authentik.core.models.User | None = None, **kwargs)
85    def __init__(self, action: str, request: HttpRequest, user: User | None = None, **kwargs):
86        super().__init__()
87        self.action = action
88        self.request = request
89        self.user = user
90        self.kwargs = kwargs

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

context is the contextvars.Context value to use for the thread. The default value is None, which means to check sys.flags.thread_inherit_context. If that flag is true, use a copy of the context of the caller. If false, use an empty context. To explicitly start with an empty context, pass a new instance of contextvars.Context(). To explicitly start with a copy of the current context, pass the value from contextvars.copy_context().

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

action: str
request: django.http.request.HttpRequest
kwargs: dict[str, typing.Any]
user: authentik.core.models.User | None = None
def run(self):
92    def run(self):
93        Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user)

Method representing the thread's activity.

You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.

class AuditMiddleware:
 96class AuditMiddleware:
 97    """Register handlers for duration of request-response that log creation/update/deletion
 98    of models"""
 99
100    get_response: Callable[[HttpRequest], HttpResponse]
101    anonymous_user: User = None
102    logger: BoundLogger
103
104    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
105        self.get_response = get_response
106        self.logger = get_logger().bind()
107
108    def _ensure_fallback_user(self):
109        """Defer fetching anonymous user until we have to"""
110        if self.anonymous_user:
111            return
112        from guardian.shortcuts import get_anonymous_user
113
114        self.anonymous_user = get_anonymous_user()
115
116    def get_user(self, request: HttpRequest) -> User:
117        user = _CTX_OVERWRITE_USER.get()
118        if user:
119            return user
120        user = getattr(request, "user", self.anonymous_user)
121        if not user.is_authenticated:
122            self._ensure_fallback_user()
123            return self.anonymous_user
124        return user
125
126    def connect(self, request: HttpRequest):
127        """Connect signal for automatic logging"""
128        if not hasattr(request, "request_id"):
129            return
130        post_save.connect(
131            partial(self.post_save_handler, request=request),
132            dispatch_uid=request.request_id,
133            weak=False,
134        )
135        pre_delete.connect(
136            partial(self.pre_delete_handler, request=request),
137            dispatch_uid=request.request_id,
138            weak=False,
139        )
140        m2m_changed.connect(
141            partial(self.m2m_changed_handler, request=request),
142            dispatch_uid=request.request_id,
143            weak=False,
144        )
145
146    def disconnect(self, request: HttpRequest):
147        """Disconnect signals"""
148        if not hasattr(request, "request_id"):
149            return
150        post_save.disconnect(dispatch_uid=request.request_id)
151        pre_delete.disconnect(dispatch_uid=request.request_id)
152        m2m_changed.disconnect(dispatch_uid=request.request_id)
153
154    def __call__(self, request: HttpRequest) -> HttpResponse:
155        _CTX_REQUEST.set(request)
156        self.connect(request)
157
158        response = self.get_response(request)
159
160        self.disconnect(request)
161        _CTX_REQUEST.set(None)
162        return response
163
164    def process_exception(self, request: HttpRequest, exception: Exception):
165        """Disconnect handlers in case of exception"""
166        self.disconnect(request)
167
168        if settings.DEBUG:
169            return
170        # Special case for SuspiciousOperation, we have a special event action for that
171        if isinstance(exception, SuspiciousOperation):
172            thread = EventNewThread(
173                EventAction.SUSPICIOUS_REQUEST,
174                request,
175                message=str(exception),
176                exception=exception_to_dict(exception),
177            )
178            thread.run()
179        elif not should_ignore_exception(exception):
180            thread = EventNewThread(
181                EventAction.SYSTEM_EXCEPTION,
182                request,
183                message=str(exception),
184                exception=exception_to_dict(exception),
185            )
186            thread.run()
187
188    def post_save_handler(
189        self,
190        request: HttpRequest,
191        sender,
192        instance: Model,
193        created: bool,
194        thread_kwargs: dict | None = None,
195        **_,
196    ):
197        """Signal handler for all object's post_save"""
198        if not should_log_model(instance):
199            return
200        if _CTX_IGNORE.get():
201            return
202        current_request = _CTX_REQUEST.get()
203        if current_request is None or request.request_id != current_request.request_id:
204            return
205        user = self.get_user(request)
206
207        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
208        thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
209        thread.kwargs.update(thread_kwargs or {})
210        thread.run()
211
212    def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
213        """Signal handler for all object's pre_delete"""
214        if not should_log_model(instance):  # pragma: no cover
215            return
216        if _CTX_IGNORE.get():
217            return
218        current_request = _CTX_REQUEST.get()
219        if current_request is None or request.request_id != current_request.request_id:
220            return
221        user = self.get_user(request)
222
223        EventNewThread(
224            EventAction.MODEL_DELETED,
225            request,
226            user=user,
227            model=model_to_dict(instance),
228        ).run()
229
230    def m2m_changed_handler(
231        self,
232        request: HttpRequest,
233        sender,
234        instance: Model,
235        action: str,
236        thread_kwargs: dict | None = None,
237        **_,
238    ):
239        """Signal handler for all object's m2m_changed"""
240        if action not in ["pre_add", "pre_remove", "post_clear"]:
241            return
242        if not should_log_m2m(instance):
243            return
244        if _CTX_IGNORE.get():
245            return
246        current_request = _CTX_REQUEST.get()
247        if current_request is None or request.request_id != current_request.request_id:
248            return
249        user = self.get_user(request)
250
251        EventNewThread(
252            EventAction.MODEL_UPDATED,
253            request,
254            user=user,
255            model=model_to_dict(instance),
256            **thread_kwargs,
257        ).run()

Register handlers for duration of request-response that log creation/update/deletion of models

AuditMiddleware( get_response: Callable[[django.http.request.HttpRequest], django.http.response.HttpResponse])
104    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
105        self.get_response = get_response
106        self.logger = get_logger().bind()
get_response: Callable[[django.http.request.HttpRequest], django.http.response.HttpResponse]
anonymous_user: authentik.core.models.User = None
logger: structlog.stdlib.BoundLogger
def get_user( self, request: django.http.request.HttpRequest) -> authentik.core.models.User:
116    def get_user(self, request: HttpRequest) -> User:
117        user = _CTX_OVERWRITE_USER.get()
118        if user:
119            return user
120        user = getattr(request, "user", self.anonymous_user)
121        if not user.is_authenticated:
122            self._ensure_fallback_user()
123            return self.anonymous_user
124        return user
def connect(self, request: django.http.request.HttpRequest):
126    def connect(self, request: HttpRequest):
127        """Connect signal for automatic logging"""
128        if not hasattr(request, "request_id"):
129            return
130        post_save.connect(
131            partial(self.post_save_handler, request=request),
132            dispatch_uid=request.request_id,
133            weak=False,
134        )
135        pre_delete.connect(
136            partial(self.pre_delete_handler, request=request),
137            dispatch_uid=request.request_id,
138            weak=False,
139        )
140        m2m_changed.connect(
141            partial(self.m2m_changed_handler, request=request),
142            dispatch_uid=request.request_id,
143            weak=False,
144        )

Connect signal for automatic logging

def disconnect(self, request: django.http.request.HttpRequest):
146    def disconnect(self, request: HttpRequest):
147        """Disconnect signals"""
148        if not hasattr(request, "request_id"):
149            return
150        post_save.disconnect(dispatch_uid=request.request_id)
151        pre_delete.disconnect(dispatch_uid=request.request_id)
152        m2m_changed.disconnect(dispatch_uid=request.request_id)

Disconnect signals

def process_exception(self, request: django.http.request.HttpRequest, exception: Exception):
164    def process_exception(self, request: HttpRequest, exception: Exception):
165        """Disconnect handlers in case of exception"""
166        self.disconnect(request)
167
168        if settings.DEBUG:
169            return
170        # Special case for SuspiciousOperation, we have a special event action for that
171        if isinstance(exception, SuspiciousOperation):
172            thread = EventNewThread(
173                EventAction.SUSPICIOUS_REQUEST,
174                request,
175                message=str(exception),
176                exception=exception_to_dict(exception),
177            )
178            thread.run()
179        elif not should_ignore_exception(exception):
180            thread = EventNewThread(
181                EventAction.SYSTEM_EXCEPTION,
182                request,
183                message=str(exception),
184                exception=exception_to_dict(exception),
185            )
186            thread.run()

Disconnect handlers in case of exception

def post_save_handler( self, request: django.http.request.HttpRequest, sender, instance: django.db.models.base.Model, created: bool, thread_kwargs: dict | None = None, **_):
188    def post_save_handler(
189        self,
190        request: HttpRequest,
191        sender,
192        instance: Model,
193        created: bool,
194        thread_kwargs: dict | None = None,
195        **_,
196    ):
197        """Signal handler for all object's post_save"""
198        if not should_log_model(instance):
199            return
200        if _CTX_IGNORE.get():
201            return
202        current_request = _CTX_REQUEST.get()
203        if current_request is None or request.request_id != current_request.request_id:
204            return
205        user = self.get_user(request)
206
207        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
208        thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
209        thread.kwargs.update(thread_kwargs or {})
210        thread.run()

Signal handler for all object's post_save

def pre_delete_handler( self, request: django.http.request.HttpRequest, sender, instance: django.db.models.base.Model, **_):
212    def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
213        """Signal handler for all object's pre_delete"""
214        if not should_log_model(instance):  # pragma: no cover
215            return
216        if _CTX_IGNORE.get():
217            return
218        current_request = _CTX_REQUEST.get()
219        if current_request is None or request.request_id != current_request.request_id:
220            return
221        user = self.get_user(request)
222
223        EventNewThread(
224            EventAction.MODEL_DELETED,
225            request,
226            user=user,
227            model=model_to_dict(instance),
228        ).run()

Signal handler for all object's pre_delete

def m2m_changed_handler( self, request: django.http.request.HttpRequest, sender, instance: django.db.models.base.Model, action: str, thread_kwargs: dict | None = None, **_):
230    def m2m_changed_handler(
231        self,
232        request: HttpRequest,
233        sender,
234        instance: Model,
235        action: str,
236        thread_kwargs: dict | None = None,
237        **_,
238    ):
239        """Signal handler for all object's m2m_changed"""
240        if action not in ["pre_add", "pre_remove", "post_clear"]:
241            return
242        if not should_log_m2m(instance):
243            return
244        if _CTX_IGNORE.get():
245            return
246        current_request = _CTX_REQUEST.get()
247        if current_request is None or request.request_id != current_request.request_id:
248            return
249        user = self.get_user(request)
250
251        EventNewThread(
252            EventAction.MODEL_UPDATED,
253            request,
254            user=user,
255            model=model_to_dict(instance),
256            **thread_kwargs,
257        ).run()

Signal handler for all object's m2m_changed