diff --git a/auditlog/models.py b/auditlog/models.py index 0b86318..61ea7af 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -58,10 +58,7 @@ class LogEntryManager(models.Manager): "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) @@ -104,10 +101,7 @@ class LogEntryManager(models.Manager): "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) @@ -118,7 +112,9 @@ class LogEntryManager(models.Manager): 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", @@ -566,7 +562,7 @@ class AbstractLogEntry(models.Model): 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})" @@ -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 diff --git a/auditlog/registry.py b/auditlog/registry.py index 033ec96..cfc8693 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -68,6 +68,7 @@ class AuditlogModelRegistry: 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, @@ -82,6 +83,8 @@ class AuditlogModelRegistry: :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 @@ -125,6 +128,7 @@ class AuditlogModelRegistry: "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, @@ -177,6 +181,7 @@ class AuditlogModelRegistry: "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): diff --git a/auditlog_tests/test_app/models.py b/auditlog_tests/test_app/models.py index 0ee7852..5663dbe 100644 --- a/auditlog_tests/test_app/models.py +++ b/auditlog_tests/test_app/models.py @@ -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) diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 6764195..902600d 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -45,6 +45,7 @@ from test_app.models import ( ModelPrimaryKeyModel, NoDeleteHistoryModel, NullableJSONModel, + ObjectReprOverrideModel, ProxyModel, RelatedModel, RelatedModelParent, @@ -417,6 +418,87 @@ class ModelPrimaryKeyTest(TransactionTestCase): 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.") @@ -1399,7 +1481,7 @@ class RegisterModelSettingsTest(TestCase): 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( diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 28a5c6c..e639bc6 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -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: