Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions auditlog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,7 @@ def log_create(self, instance, force_log: bool = False, **kwargs):
"content_type", ContentType.objects.get_for_model(instance)
)
kwargs.setdefault("object_pk", pk)
try:
object_repr = smart_str(instance)
except ObjectDoesNotExist:
object_repr = DEFAULT_OBJECT_REPR
object_repr = _get_object_repr_value(instance)
kwargs.setdefault("object_repr", object_repr)
kwargs.setdefault(
"serialized_data", self._get_serialized_data_or_none(instance)
Expand Down Expand Up @@ -104,10 +101,7 @@ def log_m2m_changes(
"content_type", ContentType.objects.get_for_model(instance)
)
kwargs.setdefault("object_pk", pk)
try:
object_repr = smart_str(instance)
except ObjectDoesNotExist:
object_repr = DEFAULT_OBJECT_REPR
object_repr = _get_object_repr_value(instance)
kwargs.setdefault("object_repr", object_repr)
kwargs.setdefault("action", LogEntry.Action.UPDATE)

Expand All @@ -118,7 +112,9 @@ def log_m2m_changes(
if callable(get_additional_data):
kwargs.setdefault("additional_data", get_additional_data())

objects = [smart_str(instance) for instance in changed_queryset]
objects = [
_get_object_repr_value(instance) for instance in changed_queryset
]
kwargs["changes"] = {
field_name: {
"type": "m2m",
Expand Down Expand Up @@ -566,7 +562,7 @@ def _get_changes_display_for_fk_field(
try:
related_model_manager = _get_manager_from_settings(field.related_model)

return smart_str(related_model_manager.get(pk=pk_value))
return _get_object_repr_value(related_model_manager.get(pk=pk_value))
# ObjectDoesNotExist will be raised if the object was deleted.
except ObjectDoesNotExist:
return f"Deleted '{field.related_model.__name__}' ({value})"
Expand Down Expand Up @@ -653,3 +649,28 @@ def _get_manager_from_settings(model: type[models.Model]) -> models.Manager:
return model._meta.base_manager
else:
return model._meta.default_manager


def _get_object_repr_value(instance):
"""
Get the object_repr value of the given model instance. The default behavior is to stringify the
instance, unless an explicit object_repr_field is given when registering the model onto the audit log.

:param instance: The model instance to get the object_repr value for.
:type instance: Model
:return: The object_repr value of the given model instance.
"""
from auditlog.registry import auditlog

target = instance
try:
if auditlog.contains(instance.__class__):
model_fields = auditlog.get_model_fields(instance.__class__)
object_repr_field = model_fields["object_repr_field"]
if object_repr_field:
target = getattr(instance, object_repr_field, DEFAULT_OBJECT_REPR)
if callable(target):
target = target()
return smart_str(target)
except ObjectDoesNotExist:
return DEFAULT_OBJECT_REPR
5 changes: 5 additions & 0 deletions auditlog/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def register(
mask_fields: list[str] | None = None,
mask_callable: str | None = None,
m2m_fields: Collection[str] | None = None,
object_repr_field: str | None = None,
serialize_data: bool = False,
serialize_kwargs: dict[str, Any] | None = None,
serialize_auditlog_fields_only: bool = False,
Expand All @@ -82,6 +83,8 @@ def register(
:param mask_fields: The fields to mask for sensitive info.
:param mask_callable: The dotted path to a callable that will be used for masking. If not provided,
the default mask_callable will be used.
:param object_repr_field: The field or method to represent the instance in the log entry's object_repr
field, when absent the instance is stringified
:param m2m_fields: The fields to handle as many to many.
:param serialize_data: Option to include a dictionary of the objects state in the auditlog.
:param serialize_kwargs: Optional kwargs to pass to Django serializer
Expand Down Expand Up @@ -125,6 +128,7 @@ def registrar(cls):
"mask_fields": mask_fields,
"mask_callable": mask_callable,
"m2m_fields": m2m_fields,
"object_repr_field": object_repr_field,
"serialize_data": serialize_data,
"serialize_kwargs": serialize_kwargs,
"serialize_auditlog_fields_only": serialize_auditlog_fields_only,
Expand Down Expand Up @@ -177,6 +181,7 @@ def get_model_fields(self, model: ModelBase):
"mapping_fields": dict(self._registry[model]["mapping_fields"]),
"mask_fields": list(self._registry[model]["mask_fields"]),
"mask_callable": self._registry[model]["mask_callable"],
"object_repr_field": self._registry[model]["object_repr_field"],
}

def get_serialize_options(self, model: ModelBase):
Expand Down
14 changes: 14 additions & 0 deletions auditlog_tests/test_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,20 @@ class NullableFieldModel(models.Model):
history = AuditlogHistoryField(delete_related=True)


class ObjectReprOverrideModel(models.Model):
public_field = models.CharField(max_length=255)
sensitive_field = models.CharField(max_length=255)
related = models.ManyToManyField("self", blank=True)
parent = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True)
history = AuditlogHistoryField()

def some_callable(self):
return f"{self.sensitive_field[0:3]}..."

def __str__(self):
return self.sensitive_field


auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel)
Expand Down
84 changes: 83 additions & 1 deletion auditlog_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
ModelPrimaryKeyModel,
NoDeleteHistoryModel,
NullableJSONModel,
ObjectReprOverrideModel,
ProxyModel,
RelatedModel,
RelatedModelParent,
Expand Down Expand Up @@ -417,6 +418,87 @@ def test_get_pk_value(self):
self.assertEqual(pk, key.pk)


class BaseObjectReprOverrideTests:
object_repr_field = None

def get_expected_repr(self, instance):
raise NotImplementedError

def setUp(self):
auditlog.register(
ObjectReprOverrideModel,
object_repr_field=self.object_repr_field,
m2m_fields=["related"],
)
self.obj = ObjectReprOverrideModel.objects.create(
public_field="public information",
sensitive_field="secret information",
)

def tearDown(self):
auditlog.unregister(ObjectReprOverrideModel)

def test_object_repr_uses_override(self):
self.assertEqual(
self.obj.history.latest().object_repr,
self.get_expected_repr(self.obj),
msg=f"Failed overriding object_repr with config: {self.object_repr_field}",
)

def test_m2m_changes_dict_uses_object_repr_field(self):
second = ObjectReprOverrideModel.objects.create(
public_field="second node", sensitive_field="second secret"
)
self.obj.related.add(second)
log_entry = self.obj.history.latest()
self.assertEqual(
log_entry.changes["related"]["objects"],
[self.get_expected_repr(second)],
msg=f"Failed M2M objects array with config: {self.object_repr_field}",
)

def test_fk_changes_display_dict_uses_object_repr_field(self):
second = ObjectReprOverrideModel.objects.create(
public_field="second node", sensitive_field="second secret"
)
second.parent = self.obj
second.save()
log_entry = second.history.latest()
self.assertEqual(
log_entry.changes_display_dict["parent"][1],
self.get_expected_repr(self.obj),
msg=f"Failed FK changes display dict with config: {self.object_repr_field}",
)


class NoObjectReprOverrideTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = None

def get_expected_repr(self, instance):
return smart_str(instance)


class ObjectReprFromPublicFieldTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = "public_field"

def get_expected_repr(self, instance):
return instance.public_field


class ObjectReprFromCallableTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = "some_callable"

def get_expected_repr(self, instance):
return instance.some_callable()


class ObjectReprFromMissingFieldTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = "bogus_missing_field"

def get_expected_repr(self, instance):
return DEFAULT_OBJECT_REPR


class ProxyModelBase(SimpleModelTest):
def make_object(self):
return ProxyModel.objects.create(text="I am not what you think.")
Expand Down Expand Up @@ -1399,7 +1481,7 @@ def test_register_models_register_app(self):

self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
self.assertEqual(len(self.test_auditlog.get_models()), 36)
self.assertEqual(len(self.test_auditlog.get_models()), 37)

def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models(
Expand Down
12 changes: 12 additions & 0 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@ the first half of the string with asterisks.

Masking fields

**Overriding object_repr**

Some models may leak sensitive information to the audit logs through their builtin ``__str__`` method.

You can customize how the entries in the audit log summarize the objects by setting the ``object_repr_field`` when you register the model::

auditlog.register(MyModel, object_repr_field="safe_summary")

Alternatively you can set the field to a method::

auditlog.register(MyModel, object_repr_field="get_safe_summary")

**Many-to-many fields**

Changes to many-to-many fields are not tracked by default. If you want to enable tracking of a many-to-many field on a model, pass ``m2m_fields`` to the ``register`` method:
Expand Down