diff --git a/CHANGELOG.md b/CHANGELOG.md index 767c820..7b4242e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### Improvements - feat: enable use of replica database (delegating the choice to `DATABASES_ROUTER`) ([#359](https://github.com/jazzband/django-auditlog/pull/359)) +- Add `mask_fields` argument in `register` to mask sensitive information when logging ([#3710](https://github.com/jazzband/django-auditlog/pull/310)) #### Important notes - LogEntry no longer save to same database instance is using diff --git a/auditlog/diff.py b/auditlog/diff.py index 508a908..c957a79 100644 --- a/auditlog/diff.py +++ b/auditlog/diff.py @@ -76,6 +76,19 @@ def get_field_value(obj, field): return value +def mask_str(value: str) -> str: + """ + Masks the first half of the input string to remove sensitive data. + + :param value: The value to mask. + :type value: str + :return: The masked version of the string. + :rtype: str + """ + mask_limit = int(len(value) / 2) + return "*" * mask_limit + value[mask_limit:] + + def model_instance_diff(old, new, fields_to_check=None): """ Calculates the differences between two model instances. One of the instances may be ``None`` (i.e., a newly @@ -145,7 +158,13 @@ def model_instance_diff(old, new, fields_to_check=None): new_value = get_field_value(new, field) if old_value != new_value: - diff[field.name] = (smart_str(old_value), smart_str(new_value)) + if model_fields and field.name in model_fields["mask_fields"]: + diff[field.name] = ( + mask_str(smart_str(old_value)), + mask_str(smart_str(new_value)), + ) + else: + diff[field.name] = (smart_str(old_value), smart_str(new_value)) if len(diff) == 0: diff = None diff --git a/auditlog/registry.py b/auditlog/registry.py index edf4352..2b1bb3d 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -40,6 +40,7 @@ class AuditlogModelRegistry: include_fields: Optional[List[str]] = None, exclude_fields: Optional[List[str]] = None, mapping_fields: Optional[Dict[str, str]] = None, + mask_fields: Optional[List[str]] = None, ): """ Register a model with auditlog. Auditlog will then track mutations on this model's instances. @@ -48,6 +49,7 @@ class AuditlogModelRegistry: :param include_fields: The fields to include. Implicitly excludes all other fields. :param exclude_fields: The fields to exclude. Overrides the fields to include. :param mapping_fields: Mapping from field names to strings in diff. + :param mask_fields: The fields to mask for sensitive info. """ @@ -57,6 +59,8 @@ class AuditlogModelRegistry: exclude_fields = [] if mapping_fields is None: mapping_fields = {} + if mask_fields is None: + mask_fields = [] def registrar(cls): """Register models for a given class.""" @@ -67,6 +71,7 @@ class AuditlogModelRegistry: "include_fields": include_fields, "exclude_fields": exclude_fields, "mapping_fields": mapping_fields, + "mask_fields": mask_fields, } self._connect_signals(cls) @@ -114,6 +119,7 @@ class AuditlogModelRegistry: "include_fields": list(self._registry[model]["include_fields"]), "exclude_fields": list(self._registry[model]["exclude_fields"]), "mapping_fields": dict(self._registry[model]["mapping_fields"]), + "mask_fields": list(self._registry[model]["mask_fields"]), } def _connect_signals(self, model): diff --git a/auditlog_tests/models.py b/auditlog_tests/models.py index eddede4..4f5623a 100644 --- a/auditlog_tests/models.py +++ b/auditlog_tests/models.py @@ -115,6 +115,18 @@ class SimpleMappingModel(models.Model): history = AuditlogHistoryField() +@auditlog.register(mask_fields=["address"]) +class SimpleMaskedModel(models.Model): + """ + A simple model used for register's mask_fields kwarg + """ + + address = models.CharField(max_length=100) + text = models.TextField() + + history = AuditlogHistoryField() + + class AdditionalDataIncludedModel(models.Model): """ A model where get_additional_data is defined which allows for logging extra diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 836ceac..eac0ede 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -30,6 +30,7 @@ from auditlog_tests.models import ( SimpleExcludeModel, SimpleIncludeModel, SimpleMappingModel, + SimpleMaskedModel, SimpleModel, UUIDPrimaryKeyModel, ) @@ -413,6 +414,19 @@ class SimpleMappingModelTest(TestCase): ) +class SimpeMaskedFieldsModelTest(TestCase): + """Log masked changes for fields in mask_fields""" + + def test_register_mask_fields(self): + smm = SimpleMaskedModel(address="Sensitive data", text="Looong text") + smm.save() + self.assertEqual( + smm.history.latest().changes_dict["address"][1], + "*******ve data", + msg="The diff function masks 'address' field.", + ) + + class AdditionalDataModelTest(TestCase): """Log additional data if get_additional_data is defined in the model""" diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 101c687..d84c86d 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -76,6 +76,21 @@ during the `register()` call. You do not need to map all the fields of the model, any fields not mapped will fall back on their ``verbose_name``. Django provides a default ``verbose_name`` which is a "munged camel case version" so ``product_name`` would become ``Product Name`` by default. +**Masking fields** + +Fields that contain sensitive info and we want keep track of field change but not to contain the exact change. + +To mask specific fields from the log you can pass ``mask_fields`` to the ``register`` +method. If ``mask_fields`` is specified, the first half value of the fields is masked using ``*``. + +For example, to mask the field ``address``, use:: + + auditlog.register(MyModel, mask_fields=['address']) + +.. versionadded:: 1.1.0 + + Masking fields + Actors ------