Added support for Correlation ID

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
This commit is contained in:
Abdullah Alaqeel 2022-12-23 09:09:32 -05:00 committed by GitHub
parent 63c88829e0
commit bc6d393390
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 251 additions and 10 deletions

View file

@ -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))

View file

@ -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)

66
auditlog/cid.py Normal file
View file

@ -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)()

View file

@ -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)

View file

@ -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())

View file

@ -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:

View file

@ -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",
),
),
]

View file

@ -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(
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
)
def _format_header(self, *labels):
return format_html(
"".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *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):

View file

@ -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")
)

View file

@ -0,0 +1,2 @@
def get_cid():
return "my custom get_cid"

View file

@ -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 = (
'<a href="/admin/auditlog/logentry/?cid=123" '
'title="Click to filter by records with this correlation id">123</a>'
)
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):

View file

@ -19,6 +19,12 @@ Middleware
.. automodule:: auditlog.middleware
:members: AuditlogMiddleware
Correlation ID
--------------
.. automodule:: auditlog.cid
:members: get_cid, set_cid
Signal receivers
----------------

View file

@ -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 <https://pypi.org/project/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
------