From bc6d393390e5f746df18b07c5dfd82d6fac09c74 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 23 Dec 2022 09:09:32 -0500 Subject: [PATCH] Added support for Correlation ID Co-authored-by: Hasan Ramezani --- CHANGELOG.md | 4 ++ auditlog/admin.py | 19 +++++-- auditlog/cid.py | 66 +++++++++++++++++++++++ auditlog/conf.py | 7 +++ auditlog/filters.py | 16 ++++++ auditlog/middleware.py | 3 ++ auditlog/migrations/0014_logentry_cid.py | 24 +++++++++ auditlog/mixins.py | 19 +++++++ auditlog/models.py | 10 ++++ auditlog_tests/fixtures/custom_get_cid.py | 2 + auditlog_tests/tests.py | 53 +++++++++++++++++- docs/source/internals.rst | 6 +++ docs/source/usage.rst | 32 +++++++++-- 13 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 auditlog/cid.py create mode 100644 auditlog/migrations/0014_logentry_cid.py create mode 100644 auditlog_tests/fixtures/custom_get_cid.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b7eb9..ff21e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next Release +#### Improvements + +- feat: Added support for Correlation ID + #### Fixes - fix: Make sure `LogEntry.changes_dict()` returns an empty dict instead of `None` when `json.loads()` returns `None`. ([#472](https://github.com/jazzband/django-auditlog/pull/472)) diff --git a/auditlog/admin.py b/auditlog/admin.py index a2e8511..454a0f1 100644 --- a/auditlog/admin.py +++ b/auditlog/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ -from auditlog.filters import ResourceTypeFilter +from auditlog.filters import CIDFilter, ResourceTypeFilter from auditlog.mixins import LogEntryAdminMixin from auditlog.models import LogEntry @@ -10,7 +10,14 @@ from auditlog.models import LogEntry @admin.register(LogEntry) class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin): list_select_related = ["content_type", "actor"] - list_display = ["created", "resource_url", "action", "msg_short", "user_url"] + list_display = [ + "created", + "resource_url", + "action", + "msg_short", + "user_url", + "cid_url", + ] search_fields = [ "timestamp", "object_repr", @@ -19,10 +26,10 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin): "actor__last_name", f"actor__{get_user_model().USERNAME_FIELD}", ] - list_filter = ["action", ResourceTypeFilter] + list_filter = ["action", ResourceTypeFilter, CIDFilter] readonly_fields = ["created", "resource_url", "action", "user_url", "msg"] fieldsets = [ - (None, {"fields": ["created", "user_url", "resource_url"]}), + (None, {"fields": ["created", "user_url", "resource_url", "cid"]}), (_("Changes"), {"fields": ["action", "msg"]}), ] @@ -34,3 +41,7 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin): def has_delete_permission(self, request, obj=None): return False + + def get_queryset(self, request): + self.request = request + return super().get_queryset(request=request) diff --git a/auditlog/cid.py b/auditlog/cid.py new file mode 100644 index 0000000..e530869 --- /dev/null +++ b/auditlog/cid.py @@ -0,0 +1,66 @@ +from contextvars import ContextVar +from typing import Optional + +from django.conf import settings +from django.http import HttpRequest +from django.utils.module_loading import import_string + +correlation_id = ContextVar("auditlog_correlation_id", default=None) + + +def set_cid(request: Optional[HttpRequest] = None) -> None: + """ + A function to read the cid from a request. + If the header is not in the request, then we set it to `None`. + + Note: we look for the header in `request.headers` and `request.META`. + + :param request: The request to get the cid from. + :return: None + """ + cid = None + header = settings.AUDITLOG_CID_HEADER + + if header and request: + if header in request.headers: + cid = request.headers.get(header) + elif header in request.META: + cid = request.META.get(header) + + # Ideally, this line should be nested inside the if statement. + # However, because the tests do not run requests in multiple threads, + # we have to always set the value of the cid, + # even if the request does not have the header present, + # in which case it will be set to None + correlation_id.set(cid) + + +def _get_cid() -> Optional[str]: + return correlation_id.get() + + +def get_cid() -> Optional[str]: + """ + Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER` + + If the setting value is: + + * None: then it calls the default getter (which retrieves the value set in `set_cid`) + * callable: then it calls the function + * type(str): then it imports the function and then call it + + The result is then returned to the caller. + + If your custom getter does not depend on `set_header()`, + then we recommend setting `settings.AUDITLOG_CID_GETTER` to `None`. + + :return: The correlation ID + """ + method = settings.AUDITLOG_CID_GETTER + if not method: + return _get_cid() + + if callable(method): + return method() + + return import_string(method)() diff --git a/auditlog/conf.py b/auditlog/conf.py index 4df8d87..a56a165 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -20,3 +20,10 @@ settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr( settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr( settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False ) + +# CID + +settings.AUDITLOG_CID_HEADER = getattr( + settings, "AUDITLOG_CID_HEADER", "x-correlation-id" +) +settings.AUDITLOG_CID_GETTER = getattr(settings, "AUDITLOG_CID_GETTER", None) diff --git a/auditlog/filters.py b/auditlog/filters.py index d2323c9..18a4c86 100644 --- a/auditlog/filters.py +++ b/auditlog/filters.py @@ -15,3 +15,19 @@ class ResourceTypeFilter(SimpleListFilter): if self.value() is None: return queryset return queryset.filter(content_type_id=self.value()) + + +class CIDFilter(SimpleListFilter): + title = _("Correlation ID") + parameter_name = "cid" + + def lookups(self, request, model_admin): + return [] + + def has_output(self): + return True + + def queryset(self, request, queryset): + if self.value() is None: + return queryset + return queryset.filter(cid=self.value()) diff --git a/auditlog/middleware.py b/auditlog/middleware.py index 00745bc..9d07ed7 100644 --- a/auditlog/middleware.py +++ b/auditlog/middleware.py @@ -1,5 +1,6 @@ import contextlib +from auditlog.cid import set_cid from auditlog.context import set_actor @@ -32,6 +33,8 @@ class AuditlogMiddleware: def __call__(self, request): remote_addr = self._get_remote_addr(request) + set_cid(request) + if hasattr(request, "user") and request.user.is_authenticated: context = set_actor(actor=request.user, remote_addr=remote_addr) else: diff --git a/auditlog/migrations/0014_logentry_cid.py b/auditlog/migrations/0014_logentry_cid.py new file mode 100644 index 0000000..57da8bc --- /dev/null +++ b/auditlog/migrations/0014_logentry_cid.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.4 on 2022-12-18 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auditlog", "0013_alter_logentry_timestamp"), + ] + + operations = [ + migrations.AddField( + model_name="logentry", + name="cid", + field=models.CharField( + blank=True, + db_index=True, + max_length=255, + null=True, + verbose_name="Correlation ID", + ), + ), + ] diff --git a/auditlog/mixins.py b/auditlog/mixins.py index 84c18d0..e7b6155 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -3,6 +3,7 @@ from django.conf import settings from django.contrib import admin from django.core.exceptions import FieldDoesNotExist from django.forms.utils import pretty_name +from django.http import HttpRequest from django.urls.exceptions import NoReverseMatch from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe @@ -17,6 +18,9 @@ MAX = 75 class LogEntryAdminMixin: + request: HttpRequest + CID_TITLE = _("Click to filter by records with this correlation id") + @admin.display(description=_("Created")) def created(self, obj): return localtime(obj.timestamp) @@ -112,6 +116,15 @@ class LogEntryAdminMixin: return mark_safe("".join(msg)) + @admin.display(description="Correlation ID") + def cid_url(self, obj): + cid = obj.cid + if cid: + url = self._add_query_parameter("cid", cid) + return format_html( + '{}', url, self.CID_TITLE, cid + ) + def _format_header(self, *labels): return format_html( "".join(["", "{}" * len(labels), ""]), *labels @@ -138,6 +151,12 @@ class LogEntryAdminMixin: except FieldDoesNotExist: return pretty_name(field_name) + def _add_query_parameter(self, key: str, value: str): + full_path = self.request.get_full_path() + delimiter = "&" if "?" in full_path else "?" + + return f"{full_path}{delimiter}{key}={value}" + class LogAccessMixin: def render_to_response(self, context, **response_kwargs): diff --git a/auditlog/models.py b/auditlog/models.py index 7925773..d5b04ff 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -37,6 +37,8 @@ class LogEntryManager(models.Manager): :return: The new log entry or `None` if there were no changes. :rtype: LogEntry """ + from auditlog.cid import get_cid + changes = kwargs.get("changes", None) pk = self._get_pk_value(instance) @@ -76,6 +78,9 @@ class LogEntryManager(models.Manager): content_type=kwargs.get("content_type"), object_pk=kwargs.get("object_pk", ""), ).delete() + + # set correlation id + kwargs.setdefault("cid", get_cid()) return self.create(**kwargs) return None @@ -96,6 +101,7 @@ class LogEntryManager(models.Manager): :return: The new log entry or `None` if there were no changes. :rtype: LogEntry """ + from auditlog.cid import get_cid pk = self._get_pk_value(instance) if changed_queryset is not None: @@ -123,6 +129,7 @@ class LogEntryManager(models.Manager): } } ) + kwargs.setdefault("cid", get_cid()) return self.create(**kwargs) return None @@ -352,6 +359,9 @@ class LogEntry(models.Model): related_name="+", verbose_name=_("actor"), ) + cid = models.CharField( + max_length=255, db_index=True, blank=True, verbose_name=_("Correlation ID") + ) remote_addr = models.GenericIPAddressField( blank=True, null=True, verbose_name=_("remote address") ) diff --git a/auditlog_tests/fixtures/custom_get_cid.py b/auditlog_tests/fixtures/custom_get_cid.py new file mode 100644 index 0000000..a8e7045 --- /dev/null +++ b/auditlog_tests/fixtures/custom_get_cid.py @@ -0,0 +1,2 @@ +def get_cid(): + return "my custom get_cid" diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 1aa5369..290fdc6 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -21,11 +21,13 @@ from django.utils import dateformat, formats from django.utils import timezone as django_timezone from auditlog.admin import LogEntryAdmin +from auditlog.cid import get_cid from auditlog.context import disable_auditlog, set_actor from auditlog.diff import model_instance_diff from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog +from auditlog_tests.fixtures.custom_get_cid import get_cid as custom_get_cid from auditlog_tests.models import ( AdditionalDataIncludedModel, AltPrimaryKeyModel, @@ -481,6 +483,38 @@ class MiddlewareTest(TestCase): self.middleware._get_remote_addr(request), expected_remote_addr ) + def test_cid(self): + header = str(settings.AUDITLOG_CID_HEADER).lstrip("HTTP_").replace("_", "-") + header_meta = "HTTP_" + header.upper().replace("-", "_") + cid = "random_CID" + + _settings = [ + # these tuples test reading the cid from the header defined in the settings + ({"AUDITLOG_CID_HEADER": header}, cid), # x-correlation-id + ({"AUDITLOG_CID_HEADER": header_meta}, cid), # HTTP_X_CORRELATION_ID + ({"AUDITLOG_CID_HEADER": None}, None), + # these two tuples test using a custom getter. + # Here, we don't necessarily care about the cid that was set in set_cid + ( + { + "AUDITLOG_CID_GETTER": "auditlog_tests.fixtures.custom_get_cid.get_cid" + }, + custom_get_cid(), + ), + ({"AUDITLOG_CID_GETTER": custom_get_cid}, custom_get_cid()), + ] + for setting, expected_result in _settings: + with self.subTest(): + with self.settings(**setting): + request = self.factory.get("/", **{header_meta: cid}) + self.middleware(request) + + obj = SimpleModel.objects.create(text="I am not difficult.") + history = obj.history.get(action=LogEntry.Action.CREATE) + + self.assertEqual(history.cid, expected_result) + self.assertEqual(get_cid(), expected_result) + class SimpleIncludeModelTest(TestCase): """Log only changes in include_fields""" @@ -593,7 +627,7 @@ class SimpleMappingModelTest(TestCase): ) -class SimpeMaskedFieldsModelTest(TestCase): +class SimpleMaskedFieldsModelTest(TestCase): """Log masked changes for fields in mask_fields""" def test_register_mask_fields(self): @@ -1214,7 +1248,7 @@ class ChoicesFieldModelTest(TestCase): assert "related_models" in history.changes_display_dict -class CharfieldTextfieldModelTest(TestCase): +class CharFieldTextFieldModelTest(TestCase): def setUp(self): self.PLACEHOLDER_LONGCHAR = "s" * 255 self.PLACEHOLDER_LONGTEXTFIELD = "s" * 1000 @@ -1332,6 +1366,21 @@ class AdminPanelTest(TestCase): created = self.admin.created(log_entry) self.assertEqual(created.strftime("%Y-%m-%d %H:%M:%S"), timestamp) + def test_cid(self): + self.client.force_login(self.user) + expected_response = ( + '123' + ) + + log_entry = self.obj.history.latest() + log_entry.cid = "123" + log_entry.save() + + res = self.client.get("/admin/auditlog/logentry/") + self.assertEqual(res.status_code, 200) + self.assertIn(expected_response, res.rendered_content) + class DiffMsgTest(TestCase): def setUp(self): diff --git a/docs/source/internals.rst b/docs/source/internals.rst index 4964a06..57163d2 100644 --- a/docs/source/internals.rst +++ b/docs/source/internals.rst @@ -19,6 +19,12 @@ Middleware .. automodule:: auditlog.middleware :members: AuditlogMiddleware +Correlation ID +-------------- + +.. automodule:: auditlog.cid + :members: get_cid, set_cid + Signal receivers ---------------- diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 7ff6605..4961be2 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -147,8 +147,8 @@ Objects are serialized using the Django core serializer. Keyword arguments may b .. code-block:: python auditlog.register( - MyModel, - serialize_data=True, + MyModel, + serialize_data=True, serialize_kwargs={"fields": ["foo", "bar", "biz", "baz"]} ) @@ -163,8 +163,18 @@ Note that all fields on the object will be serialized unless restricted with one serialize_auditlog_fields_only=True ) -Field masking is supported in object serialization. Any value belonging to a field whose name is found in the ``mask_fields`` list will be masked in the serialized object data. Masked values are obfuscated with asterisks in the same way as they are in the ``LogEntry.changes`` field. +Field masking is supported in object serialization. Any value belonging to a field whose name is found in the ``mask_fields`` list will be masked in the serialized object data. Masked values are obfuscated with asterisks in the same way as they are in the ``LogEntry.changes`` field. +Correlation ID +-------------- + +You can store a correlation ID (cid) in the log entries by: + +1. Reading from a request header (specified by `AUDITLOG_CID_HEADER`) +2. Using a custom cid getter (specified by `AUDITLOG_CID_GETTER`) + +Using the custom getter is helpful for integrating with a third-party cid package +such as `django-cid `_. Settings -------- @@ -214,7 +224,7 @@ It must be a list or tuple. Each item in this setting can be a: }, "mask_fields": ["field5", "field6"], "m2m_fields": ["field7", "field8"], - "serialize_data": True, + "serialize_data": True, "serialize_auditlog_fields_only": False, "serialize_kwargs": {"fields": ["foo", "bar", "biz", "baz"]}, }, @@ -234,6 +244,20 @@ Disables logging during raw save. (I.e. for instance using loaddata) .. versionadded:: 2.2.0 +**AUDITLOG_CID_HEADER** + +The request header containing the Correlation ID value to use in all log entries created as a result of the request. +The value can of in the format `HTTP_MY_HEADER` or `my-header`. + +.. versionadded:: 3.0.0 + +**AUDITLOG_CID_GETTER** + +The function to use to retrieve the Correlation ID. The value can be a callable or a string import path. + +If the value is `None`, the default getter will be used. + +.. versionadded:: 3.0.0 Actors ------