From 2ec8a445c3010d0a60a109de4d03760b750ce359 Mon Sep 17 00:00:00 2001 From: Jens L Date: Tue, 9 Apr 2024 01:42:36 +0200 Subject: [PATCH] events: add context manager to ignore/modify audit events being written (#9181) --- authentik/events/middleware.py | 39 +++++++++++++ authentik/events/tests/test_middleware.py | 57 ++++++++++++++++--- authentik/providers/oauth2/views/token.py | 34 ++++++----- .../authenticator_validate/challenge.py | 4 +- 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/authentik/events/middleware.py b/authentik/events/middleware.py index 2b4705f3a3..eec473202a 100644 --- a/authentik/events/middleware.py +++ b/authentik/events/middleware.py @@ -1,6 +1,8 @@ """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 @@ -31,6 +33,9 @@ IGNORED_MODELS = tuple( ) ) +_CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) +_CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False) + def should_log_model(model: Model) -> bool: """Return true if operation on `model` should be logged""" @@ -44,6 +49,28 @@ def should_log_m2m(model: Model) -> bool: 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""" @@ -158,6 +185,10 @@ class AuditMiddleware: """Signal handler for all object's post_save""" if not should_log_model(instance): return + if _CTX_IGNORE.get(): + return + if _new_user := _CTX_OVERWRITE_USER.get(): + user = _new_user action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED thread = EventNewThread(action, request, user=user, model=model_to_dict(instance)) @@ -168,6 +199,10 @@ class AuditMiddleware: """Signal handler for all object's pre_delete""" if not should_log_model(instance): # pragma: no cover return + if _CTX_IGNORE.get(): + return + if _new_user := _CTX_OVERWRITE_USER.get(): + user = _new_user EventNewThread( EventAction.MODEL_DELETED, @@ -184,6 +219,10 @@ class AuditMiddleware: return if not should_log_m2m(instance): return + if _CTX_IGNORE.get(): + return + if _new_user := _CTX_OVERWRITE_USER.get(): + user = _new_user EventNewThread( EventAction.MODEL_UPDATED, diff --git a/authentik/events/tests/test_middleware.py b/authentik/events/tests/test_middleware.py index 1bd667d06e..906deb030d 100644 --- a/authentik/events/tests/test_middleware.py +++ b/authentik/events/tests/test_middleware.py @@ -5,7 +5,9 @@ from rest_framework.test import APITestCase from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user +from authentik.events.middleware import audit_ignore, audit_overwrite_user from authentik.events.models import Event, EventAction +from authentik.lib.generators import generate_id class TestEventsMiddleware(APITestCase): @@ -15,35 +17,74 @@ class TestEventsMiddleware(APITestCase): super().setUp() self.user = create_test_admin_user() self.client.force_login(self.user) + Event.objects.all().delete() def test_create(self): """Test model creation event""" + uid = generate_id() self.client.post( reverse("authentik_api:application-list"), - data={"name": "test-create", "slug": "test-create"}, + data={"name": uid, "slug": uid}, ) - self.assertTrue(Application.objects.filter(name="test-create").exists()) + self.assertTrue(Application.objects.filter(name=uid).exists()) self.assertTrue( Event.objects.filter( action=EventAction.MODEL_CREATED, context__model__model_name="application", context__model__app="authentik_core", - context__model__name="test-create", + context__model__name=uid, ).exists() ) def test_delete(self): """Test model creation event""" - Application.objects.create(name="test-delete", slug="test-delete") - self.client.delete( - reverse("authentik_api:application-detail", kwargs={"slug": "test-delete"}) - ) + uid = generate_id() + Application.objects.create(name=uid, slug=uid) + self.client.delete(reverse("authentik_api:application-detail", kwargs={"slug": uid})) self.assertFalse(Application.objects.filter(name="test").exists()) self.assertTrue( Event.objects.filter( action=EventAction.MODEL_DELETED, context__model__model_name="application", context__model__app="authentik_core", - context__model__name="test-delete", + context__model__name=uid, + ).exists() + ) + + def test_audit_ignore(self): + """Test audit_ignore context manager""" + uid = generate_id() + with audit_ignore(): + self.client.post( + reverse("authentik_api:application-list"), + data={"name": uid, "slug": uid}, + ) + self.assertTrue(Application.objects.filter(name=uid).exists()) + self.assertFalse( + Event.objects.filter( + action=EventAction.MODEL_CREATED, + context__model__model_name="application", + context__model__app="authentik_core", + context__model__name=uid, + ).exists() + ) + + def test_audit_overwrite_user(self): + """Test audit_overwrite_user context manager""" + uid = generate_id() + new_user = create_test_admin_user() + with audit_overwrite_user(new_user): + self.client.post( + reverse("authentik_api:application-list"), + data={"name": uid, "slug": uid}, + ) + self.assertTrue(Application.objects.filter(name=uid).exists()) + self.assertTrue( + Event.objects.filter( + action=EventAction.MODEL_CREATED, + context__model__model_name="application", + context__model__app="authentik_core", + context__model__name=uid, + user__username=new_user.username, ).exists() ) diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 464df72636..ce535f9905 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -31,6 +31,7 @@ from authentik.core.models import ( User, UserTypes, ) +from authentik.events.middleware import audit_ignore from authentik.events.models import Event, EventAction from authentik.events.signals import get_login_event from authentik.flows.planner import PLAN_CONTEXT_APPLICATION @@ -465,22 +466,25 @@ class TokenParams: def __create_user_from_jwt(self, token: dict[str, Any], app: Application, source: OAuthSource): """Create user from JWT""" - self.user, created = User.objects.update_or_create( - username=f"{self.provider.name}-{token.get('sub')}", - defaults={ - "attributes": { - USER_ATTRIBUTE_GENERATED: True, + with audit_ignore(): + self.user, created = User.objects.update_or_create( + username=f"{self.provider.name}-{token.get('sub')}", + defaults={ + "attributes": { + USER_ATTRIBUTE_GENERATED: True, + }, + "last_login": timezone.now(), + "name": ( + f"Autogenerated user from application {app.name} (client credentials JWT)" + ), + "path": source.get_user_path(), + "type": UserTypes.SERVICE_ACCOUNT, }, - "last_login": timezone.now(), - "name": f"Autogenerated user from application {app.name} (client credentials JWT)", - "path": source.get_user_path(), - "type": UserTypes.SERVICE_ACCOUNT, - }, - ) - exp = token.get("exp") - if created and exp: - self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp - self.user.save() + ) + exp = token.get("exp") + if created and exp: + self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp + self.user.save() @method_decorator(csrf_exempt, name="dispatch") diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 38c7bb28ae..60a6786c52 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -21,6 +21,7 @@ from webauthn.helpers.structs import UserVerificationRequirement from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.models import Application, User from authentik.core.signals import login_failed +from authentik.events.middleware import audit_ignore from authentik.events.models import Event, EventAction from authentik.flows.stage import StageView from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE @@ -167,7 +168,8 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) - ) raise ValidationError("Assertion failed") from exc - device.set_sign_count(authentication_verification.new_sign_count) + with audit_ignore(): + device.set_sign_count(authentication_verification.new_sign_count) return device