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

Return true if m2m operation should be logged

@contextmanager
def audit_overwrite_user(user: authentik.core.models.User):
57@contextmanager
58def audit_overwrite_user(user: User):
59    """Overwrite user being logged for model AuditMiddleware. Commonly used
60    for example in flows where a pending user is given, but the request is not authenticated yet"""
61    _CTX_OVERWRITE_USER.set(user)
62    try:
63        yield
64    finally:
65        _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():
68@contextmanager
69def audit_ignore():
70    """Ignore model operations in the block. Useful for objects which need to be modified
71    but are not excluded (e.g. WebAuthn devices)"""
72    _CTX_IGNORE.set(True)
73    try:
74        yield
75    finally:
76        _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):
79class EventNewThread(Thread):
80    """Create Event in background thread"""
81
82    action: str
83    request: HttpRequest
84    kwargs: dict[str, Any]
85    user: User | None = None
86
87    def __init__(self, action: str, request: HttpRequest, user: User | None = None, **kwargs):
88        super().__init__()
89        self.action = action
90        self.request = request
91        self.user = user
92        self.kwargs = kwargs
93
94    def run(self):
95        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)
87    def __init__(self, action: str, request: HttpRequest, user: User | None = None, **kwargs):
88        super().__init__()
89        self.action = action
90        self.request = request
91        self.user = user
92        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):
94    def run(self):
95        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:
 98class AuditMiddleware:
 99    """Register handlers for duration of request-response that log creation/update/deletion
100    of models"""
101
102    get_response: Callable[[HttpRequest], HttpResponse]
103    anonymous_user: User = None
104    logger: BoundLogger
105
106    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
107        self.get_response = get_response
108        self.logger = get_logger().bind()
109
110    def _ensure_fallback_user(self):
111        """Defer fetching anonymous user until we have to"""
112        if self.anonymous_user:
113            return
114        from guardian.shortcuts import get_anonymous_user
115
116        self.anonymous_user = get_anonymous_user()
117
118    def get_user(self, request: HttpRequest) -> User:
119        user = _CTX_OVERWRITE_USER.get()
120        if user:
121            return user
122        user = getattr(request, "user", self.anonymous_user)
123        if not user.is_authenticated:
124            self._ensure_fallback_user()
125            return self.anonymous_user
126        return user
127
128    def connect(self, request: HttpRequest):
129        """Connect signal for automatic logging"""
130        if not hasattr(request, "request_id"):
131            return
132        post_save.connect(
133            partial(self.post_save_handler, request=request),
134            dispatch_uid=request.request_id,
135            weak=False,
136        )
137        pre_delete.connect(
138            partial(self.pre_delete_handler, request=request),
139            dispatch_uid=request.request_id,
140            weak=False,
141        )
142        m2m_changed.connect(
143            partial(self.m2m_changed_handler, request=request),
144            dispatch_uid=request.request_id,
145            weak=False,
146        )
147
148    def disconnect(self, request: HttpRequest):
149        """Disconnect signals"""
150        if not hasattr(request, "request_id"):
151            return
152        post_save.disconnect(dispatch_uid=request.request_id)
153        pre_delete.disconnect(dispatch_uid=request.request_id)
154        m2m_changed.disconnect(dispatch_uid=request.request_id)
155
156    def __call__(self, request: HttpRequest) -> HttpResponse:
157        _CTX_REQUEST.set(request)
158        self.connect(request)
159
160        response = self.get_response(request)
161
162        self.disconnect(request)
163        _CTX_REQUEST.set(None)
164        return response
165
166    def process_exception(self, request: HttpRequest, exception: Exception):
167        """Disconnect handlers in case of exception"""
168        self.disconnect(request)
169
170        if settings.DEBUG:
171            return
172        # Special case for SuspiciousOperation, we have a special event action for that
173        if isinstance(exception, SuspiciousOperation):
174            thread = EventNewThread(
175                EventAction.SUSPICIOUS_REQUEST,
176                request,
177                message=str(exception),
178                exception=exception_to_dict(exception),
179            )
180            thread.run()
181        elif not should_ignore_exception(exception):
182            thread = EventNewThread(
183                EventAction.SYSTEM_EXCEPTION,
184                request,
185                message=str(exception),
186                exception=exception_to_dict(exception),
187            )
188            thread.run()
189
190    def post_save_handler(
191        self,
192        request: HttpRequest,
193        sender,
194        instance: Model,
195        created: bool,
196        thread_kwargs: dict | None = None,
197        **_,
198    ):
199        """Signal handler for all object's post_save"""
200        if not should_log_model(instance):
201            return
202        if _CTX_IGNORE.get():
203            return
204        current_request = _CTX_REQUEST.get()
205        if current_request is None or request.request_id != current_request.request_id:
206            return
207        user = self.get_user(request)
208
209        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
210        thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
211        thread.kwargs.update(thread_kwargs or {})
212        thread.run()
213
214    def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
215        """Signal handler for all object's pre_delete"""
216        if not should_log_model(instance):  # pragma: no cover
217            return
218        if _CTX_IGNORE.get():
219            return
220        current_request = _CTX_REQUEST.get()
221        if current_request is None or request.request_id != current_request.request_id:
222            return
223        user = self.get_user(request)
224
225        EventNewThread(
226            EventAction.MODEL_DELETED,
227            request,
228            user=user,
229            model=model_to_dict(instance),
230        ).run()
231
232    def m2m_changed_handler(
233        self,
234        request: HttpRequest,
235        sender,
236        instance: Model,
237        action: str,
238        thread_kwargs: dict | None = None,
239        **_,
240    ):
241        """Signal handler for all object's m2m_changed"""
242        if action not in ["pre_add", "pre_remove", "post_clear"]:
243            return
244        if not should_log_m2m(instance):
245            return
246        if _CTX_IGNORE.get():
247            return
248        current_request = _CTX_REQUEST.get()
249        if current_request is None or request.request_id != current_request.request_id:
250            return
251        user = self.get_user(request)
252
253        EventNewThread(
254            EventAction.MODEL_UPDATED,
255            request,
256            user=user,
257            model=model_to_dict(instance),
258            **thread_kwargs,
259        ).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])
106    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
107        self.get_response = get_response
108        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:
118    def get_user(self, request: HttpRequest) -> User:
119        user = _CTX_OVERWRITE_USER.get()
120        if user:
121            return user
122        user = getattr(request, "user", self.anonymous_user)
123        if not user.is_authenticated:
124            self._ensure_fallback_user()
125            return self.anonymous_user
126        return user
def connect(self, request: django.http.request.HttpRequest):
128    def connect(self, request: HttpRequest):
129        """Connect signal for automatic logging"""
130        if not hasattr(request, "request_id"):
131            return
132        post_save.connect(
133            partial(self.post_save_handler, request=request),
134            dispatch_uid=request.request_id,
135            weak=False,
136        )
137        pre_delete.connect(
138            partial(self.pre_delete_handler, request=request),
139            dispatch_uid=request.request_id,
140            weak=False,
141        )
142        m2m_changed.connect(
143            partial(self.m2m_changed_handler, request=request),
144            dispatch_uid=request.request_id,
145            weak=False,
146        )

Connect signal for automatic logging

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

Disconnect signals

def process_exception(self, request: django.http.request.HttpRequest, exception: Exception):
166    def process_exception(self, request: HttpRequest, exception: Exception):
167        """Disconnect handlers in case of exception"""
168        self.disconnect(request)
169
170        if settings.DEBUG:
171            return
172        # Special case for SuspiciousOperation, we have a special event action for that
173        if isinstance(exception, SuspiciousOperation):
174            thread = EventNewThread(
175                EventAction.SUSPICIOUS_REQUEST,
176                request,
177                message=str(exception),
178                exception=exception_to_dict(exception),
179            )
180            thread.run()
181        elif not should_ignore_exception(exception):
182            thread = EventNewThread(
183                EventAction.SYSTEM_EXCEPTION,
184                request,
185                message=str(exception),
186                exception=exception_to_dict(exception),
187            )
188            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, **_):
190    def post_save_handler(
191        self,
192        request: HttpRequest,
193        sender,
194        instance: Model,
195        created: bool,
196        thread_kwargs: dict | None = None,
197        **_,
198    ):
199        """Signal handler for all object's post_save"""
200        if not should_log_model(instance):
201            return
202        if _CTX_IGNORE.get():
203            return
204        current_request = _CTX_REQUEST.get()
205        if current_request is None or request.request_id != current_request.request_id:
206            return
207        user = self.get_user(request)
208
209        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
210        thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
211        thread.kwargs.update(thread_kwargs or {})
212        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, **_):
214    def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
215        """Signal handler for all object's pre_delete"""
216        if not should_log_model(instance):  # pragma: no cover
217            return
218        if _CTX_IGNORE.get():
219            return
220        current_request = _CTX_REQUEST.get()
221        if current_request is None or request.request_id != current_request.request_id:
222            return
223        user = self.get_user(request)
224
225        EventNewThread(
226            EventAction.MODEL_DELETED,
227            request,
228            user=user,
229            model=model_to_dict(instance),
230        ).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, **_):
232    def m2m_changed_handler(
233        self,
234        request: HttpRequest,
235        sender,
236        instance: Model,
237        action: str,
238        thread_kwargs: dict | None = None,
239        **_,
240    ):
241        """Signal handler for all object's m2m_changed"""
242        if action not in ["pre_add", "pre_remove", "post_clear"]:
243            return
244        if not should_log_m2m(instance):
245            return
246        if _CTX_IGNORE.get():
247            return
248        current_request = _CTX_REQUEST.get()
249        if current_request is None or request.request_id != current_request.request_id:
250            return
251        user = self.get_user(request)
252
253        EventNewThread(
254            EventAction.MODEL_UPDATED,
255            request,
256            user=user,
257            model=model_to_dict(instance),
258            **thread_kwargs,
259        ).run()

Signal handler for all object's m2m_changed