diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 13ee74063..1e7df4b98 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -7,6 +7,8 @@ from dacite.config import Config from dacite.core import from_dict from dacite.exceptions import DaciteError from deepmerge import always_merger +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError from django.db.models import Model from django.db.models.query_utils import Q @@ -57,8 +59,11 @@ def excluded_models() -> list[type[Model]]: from django.contrib.auth.models import User as DjangoUser return ( + # Django only classes DjangoUser, DjangoGroup, + ContentType, + Permission, # Base classes Provider, Source, diff --git a/authentik/enterprise/apps.py b/authentik/enterprise/apps.py index a0b9bed6d..b2b06306f 100644 --- a/authentik/enterprise/apps.py +++ b/authentik/enterprise/apps.py @@ -1,4 +1,6 @@ """Enterprise app config""" +from django.conf import settings + from authentik.blueprints.apps import ManagedAppConfig @@ -17,3 +19,9 @@ class AuthentikEnterpriseConfig(EnterpriseConfig): def reconcile_load_enterprise_signals(self): """Load enterprise signals""" self.import_module("authentik.enterprise.signals") + + def reconcile_install_middleware(self): + """Install enterprise audit middleware""" + orig_import = "authentik.events.middleware.AuditMiddleware" + new_import = "authentik.enterprise.middleware.EnterpriseAuditMiddleware" + settings.MIDDLEWARE = [new_import if x == orig_import else x for x in settings.MIDDLEWARE] diff --git a/authentik/enterprise/middleware.py b/authentik/enterprise/middleware.py new file mode 100644 index 000000000..d4f012dcc --- /dev/null +++ b/authentik/enterprise/middleware.py @@ -0,0 +1,96 @@ +"""Enterprise audit middleware""" +from copy import deepcopy +from functools import partial +from typing import Callable + +from deepdiff import DeepDiff +from django.core.files import File +from django.db import connection +from django.db.models import Model +from django.db.models.expressions import BaseExpression, Combinable +from django.db.models.signals import post_init +from django.http import HttpRequest, HttpResponse + +from authentik.core.models import User +from authentik.enterprise.models import LicenseKey +from authentik.events.middleware import AuditMiddleware, should_log_model + + +class EnterpriseAuditMiddleware(AuditMiddleware): + """Enterprise audit middleware""" + + _enabled = False + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + super().__init__(get_response) + self._enabled = LicenseKey.get_total().is_valid() + + def connect(self, request: HttpRequest): + super().connect(request) + if not self._enabled: + return + user = getattr(request, "user", self.anonymous_user) + if not user.is_authenticated: + user = self.anonymous_user + if not hasattr(request, "request_id"): + return + post_init.connect( + partial(self.post_init_handler, user=user, request=request), + dispatch_uid=request.request_id, + weak=False, + ) + + def disconnect(self, request: HttpRequest): + super().disconnect(request) + if not self._enabled: + return + if not hasattr(request, "request_id"): + return + post_init.disconnect(dispatch_uid=request.request_id) + + def serialize_simple(self, model: Model) -> dict: + data = {} + deferred_fields = model.get_deferred_fields() + for field in model._meta.concrete_fields: + value = None + if field.remote_field: + continue + + if field.get_attname() in deferred_fields: + continue + + field_value = getattr(model, field.attname) + if isinstance(value, File): + field_value = value.name + + # If current field value is an expression, we are not evaluating it + if isinstance(field_value, (BaseExpression, Combinable)): + continue + field_value = field.to_python(field_value) + data[field.name] = deepcopy(field_value) + return data + + def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_): + if not should_log_model(instance): + return + if hasattr(instance, "_previous_state"): + return + before = len(connection.queries) + setattr(instance, "_previous_state", self.serialize_simple(instance)) + after = len(connection.queries) + if after > before: + raise AssertionError("More queries generated by serialize_simple") + + def post_save_handler( + self, user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ + ): + thread_kwargs = {} + if hasattr(instance, "_previous_state") or created: + # Get current state + prev_state = getattr(instance, "_previous_state", {}) + new_state = self.serialize_simple(instance) + diff = DeepDiff(prev_state, new_state) + thread_kwargs["diff"] = diff + return super().post_save_handler( + user, request, sender, instance, created, thread_kwargs, **_ + ) diff --git a/authentik/events/middleware.py b/authentik/events/middleware.py index 9843402ab..a2120f165 100644 --- a/authentik/events/middleware.py +++ b/authentik/events/middleware.py @@ -10,52 +10,36 @@ 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 guardian.models import UserObjectPermission +from structlog.stdlib import BoundLogger, get_logger -from authentik.core.models import ( - AuthenticatedSession, - Group, - PropertyMapping, - Provider, - Source, - User, - UserSourceConnection, -) +from authentik.blueprints.v1.importer import excluded_models +from authentik.core.models import Group, User from authentik.enterprise.providers.rac.models import ConnectionToken from authentik.events.models import Event, EventAction, Notification from authentik.events.utils import model_to_dict -from authentik.flows.models import FlowToken, Stage from authentik.lib.sentry import before_send from authentik.lib.utils.errors import exception_to_string -from authentik.outposts.models import OutpostServiceConnection -from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.reputation.models import Reputation from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.scim.models import SCIMGroup, SCIMUser from authentik.stages.authenticator_static.models import StaticToken -IGNORED_MODELS = ( - Event, - Notification, - UserObjectPermission, - AuthenticatedSession, - StaticToken, - Session, - FlowToken, - Provider, - Source, - PropertyMapping, - UserSourceConnection, - Stage, - OutpostServiceConnection, - Policy, - PolicyBindingModel, - AuthorizationCode, - AccessToken, - RefreshToken, - SCIMUser, - SCIMGroup, - Reputation, - ConnectionToken, +IGNORED_MODELS = tuple( + excluded_models() + + ( + Event, + Notification, + UserObjectPermission, + StaticToken, + Session, + AuthorizationCode, + AccessToken, + RefreshToken, + SCIMUser, + SCIMGroup, + Reputation, + ConnectionToken, + ) ) @@ -96,9 +80,11 @@ class AuditMiddleware: 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""" @@ -116,21 +102,18 @@ class AuditMiddleware: user = self.anonymous_user if not hasattr(request, "request_id"): return - post_save_handler = partial(self.post_save_handler, user=user, request=request) - pre_delete_handler = partial(self.pre_delete_handler, user=user, request=request) - m2m_changed_handler = partial(self.m2m_changed_handler, user=user, request=request) post_save.connect( - post_save_handler, + partial(self.post_save_handler, user=user, request=request), dispatch_uid=request.request_id, weak=False, ) pre_delete.connect( - pre_delete_handler, + partial(self.pre_delete_handler, user=user, request=request), dispatch_uid=request.request_id, weak=False, ) m2m_changed.connect( - m2m_changed_handler, + partial(self.m2m_changed_handler, user=user, request=request), dispatch_uid=request.request_id, weak=False, ) @@ -173,19 +156,26 @@ class AuditMiddleware: ) thread.run() - @staticmethod def post_save_handler( - user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ + self, + user: User, + request: HttpRequest, + sender, + instance: Model, + created: bool, + thread_kwargs: Optional[dict] = None, + **_, ): """Signal handler for all object's post_save""" if not should_log_model(instance): return action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED - EventNewThread(action, request, user=user, model=model_to_dict(instance)).run() + thread = EventNewThread(action, request, user=user, model=model_to_dict(instance)) + thread.kwargs.update(thread_kwargs or {}) + thread.run() - @staticmethod - def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_): + def pre_delete_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_): """Signal handler for all object's pre_delete""" if not should_log_model(instance): # pragma: no cover return @@ -197,9 +187,8 @@ class AuditMiddleware: model=model_to_dict(instance), ).run() - @staticmethod def m2m_changed_handler( - user: User, request: HttpRequest, sender, instance: Model, action: str, **_ + self, user: User, request: HttpRequest, sender, instance: Model, action: str, **_ ): """Signal handler for all object's m2m_changed""" if action not in ["pre_add", "pre_remove", "post_clear"]: diff --git a/poetry.lock b/poetry.lock index 3f21176cd..8ecdd27f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1057,6 +1057,24 @@ files = [ {file = "debugpy-1.8.0.zip", hash = "sha256:12af2c55b419521e33d5fb21bd022df0b5eb267c3e178f1d374a63a2a6bdccd0"}, ] +[[package]] +name = "deepdiff" +version = "6.7.1" +description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." +optional = false +python-versions = ">=3.7" +files = [ + {file = "deepdiff-6.7.1-py3-none-any.whl", hash = "sha256:58396bb7a863cbb4ed5193f548c56f18218060362311aa1dc36397b2f25108bd"}, + {file = "deepdiff-6.7.1.tar.gz", hash = "sha256:b367e6fa6caac1c9f500adc79ada1b5b1242c50d5f716a1a4362030197847d30"}, +] + +[package.dependencies] +ordered-set = ">=4.0.2,<4.2.0" + +[package.extras] +cli = ["click (==8.1.3)", "pyyaml (==6.0.1)"] +optimize = ["orjson"] + [[package]] name = "deepmerge" version = "1.1.1" @@ -2473,6 +2491,20 @@ files = [ {file = "opencontainers-0.0.14.tar.gz", hash = "sha256:fde3b8099b56b5c956415df8933e2227e1914e805a277b844f2f9e52341738f2"}, ] +[[package]] +name = "ordered-set" +version = "4.1.0" +description = "An OrderedSet is a custom MutableSet that remembers its order, so that every" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"}, + {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"}, +] + +[package.extras] +dev = ["black", "mypy", "pytest"] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -4488,4 +4520,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.12" -content-hash = "6dcbc2c6d02643a72285e075528ec0841b9c8fda244632386ec19efb7350d4cd" +content-hash = "b5127f147f007d9fd1fa661ae66f02f85d9143dda27e1ea5fe4568230c12b7b2" diff --git a/pyproject.toml b/pyproject.toml index e38b0bdbd..c1d02b081 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ channels-redis = "*" codespell = "*" colorama = "*" dacite = "*" +deepdiff = "*" deepmerge = "*" defusedxml = "*" django = "*" @@ -151,6 +152,7 @@ lxml = [ # 4.9.x works with previous libxml2 versions, which is what we get on linux { version = "4.9.4", platform = "linux" }, ] +jsonpatch = "*" opencontainers = { extras = ["reggie"], version = "*" } packaging = "*" paramiko = "*" @@ -176,7 +178,6 @@ webauthn = "*" wsproto = "*" xmlsec = "*" zxcvbn = "*" -jsonpatch = "*" [tool.poetry.dev-dependencies] bandit = "*" diff --git a/web/src/admin/events/EventViewPage.ts b/web/src/admin/events/EventViewPage.ts index b5351840a..8e1c12bf7 100644 --- a/web/src/admin/events/EventViewPage.ts +++ b/web/src/admin/events/EventViewPage.ts @@ -154,6 +154,12 @@ export class EventViewPage extends AKElement {
+
+
${msg("Raw event info")}
+
+
${JSON.stringify(this.event, null, 4)}
+
+
`; } diff --git a/web/src/components/ak-event-info.ts b/web/src/components/ak-event-info.ts index e728958c2..a94ec9a41 100644 --- a/web/src/components/ak-event-info.ts +++ b/web/src/components/ak-event-info.ts @@ -248,6 +248,7 @@ export class EventInfo extends AKElement {
${this.getModelInfo(this.event.context?.model as EventModel)}
+ ${this.renderDefaultResponse()} `; }