Skip to content

Commit 16f38aa

Browse files
committed
Add anonymization feature for private user data
1 parent 8d3634e commit 16f38aa

File tree

7 files changed

+253
-3
lines changed

7 files changed

+253
-3
lines changed

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ All Contents
1010

1111
usage
1212
templates
13+
privacy
1314
customizing
1415
settings
1516
contributing

docs/privacy.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Privacy
2+
========
3+
4+
Anonymization
5+
-------------
6+
7+
User privacy is important, not only to meet local regulations, but also to
8+
protect your users and allow them to exercise their rights. However,
9+
it's not always practical to delete users, especially if they have dependent
10+
objects, that are relevant for statistical analysis.
11+
12+
Anonymization is a process of removing the user's personal data whilst keeping
13+
related data intact. This is done by using the ``anomymize`` method.
14+
15+
16+
17+
.. automethod:: mailauth.contrib.user.models.AbstractEmailUser.anonymize
18+
:noindex:
19+
20+
This method may be overwritten to provide anonymization for you custom user model.
21+
22+
Related objects may also listen to the anonymize signal.
23+
24+
.. autoclass:: mailauth.contrib.user.models.AnonymizeUserSignal
25+
26+
All those methods can be conveniently triggered via the ``anonymize`` admin action.
27+
28+
.. autoclass:: mailauth.contrib.user.admin.AnonymizableAdminMixin
29+
:members:
30+
31+
Liability Waiver
32+
----------------
33+
34+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40+
SOFTWARE.

mailauth/contrib/user/admin.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
11
from django.contrib import admin
22
from django.contrib.auth.models import Group, Permission
3+
from django.utils.translation import gettext_lazy as _, ngettext
34

45
from . import models
56

67

8+
class AnonymizableAdminMixin:
9+
"""
10+
Mixin for admin classes that provides a `anonymize` action.
11+
12+
This mixin calls the `anonymize` method of all user model instances.
13+
"""
14+
15+
actions = ["anonymize"]
16+
17+
@admin.action(
18+
permissions=["anonymize"],
19+
description=_("Anonymize selected %(verbose_name_plural)s"),
20+
)
21+
def anonymize(self, request, queryset):
22+
count = queryset.count()
23+
for user in queryset.iterator():
24+
user.anonymize()
25+
26+
self.message_user(
27+
request,
28+
ngettext(
29+
"%(count)s %(obj_name)s has successfully been anonymized.",
30+
"%(count)s %(obj_name)s have successfully been anonymized.",
31+
count,
32+
)
33+
% {
34+
"count": count,
35+
"obj_name": self.model._meta.verbose_name_plural
36+
if count > 1
37+
else self.model._meta.verbose_name,
38+
},
39+
fail_silently=True,
40+
)
41+
42+
def has_anonymize_permission(self, request, obj=None):
43+
return request.user.has_perm(f"{self.opts.app_label}.anonymize", obj=obj)
44+
45+
746
@admin.register(models.EmailUser)
8-
class EmailUserAdmin(admin.ModelAdmin):
9-
app_label = "asdf"
47+
class EmailUserAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
1048
list_display = ("email", "first_name", "last_name", "is_staff")
1149
list_filter = ("is_staff", "is_superuser", "is_active", "groups")
1250
search_fields = ("first_name", "last_name", "email")
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from django.db import migrations, models
2+
3+
try:
4+
from django.contrib.postgres.fields import CIEmailField
5+
except ImportError:
6+
CIEmailField = models.EmailField
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("mailauth_user", "0004_auto_20200812_0722"),
13+
]
14+
15+
operations = [
16+
# add new permissions
17+
migrations.AlterModelOptions(
18+
name="emailuser",
19+
options={
20+
"permissions": [("anonymize", "Can anonymize user")],
21+
"verbose_name": "user",
22+
"verbose_name_plural": "users",
23+
},
24+
),
25+
# email is now nullable, since it's no longer required for authentication
26+
migrations.AlterField(
27+
model_name="emailuser",
28+
name="email",
29+
field=CIEmailField(
30+
blank=True,
31+
db_index=True,
32+
max_length=254,
33+
null=True,
34+
unique=True,
35+
verbose_name="email address",
36+
),
37+
),
38+
]

mailauth/contrib/user/models.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.contrib.auth.base_user import BaseUserManager
33
from django.contrib.auth.models import AbstractUser
44
from django.db import models
5+
from django.dispatch import Signal
56
from django.utils.crypto import get_random_string, salted_hmac
67
from django.utils.translation import gettext_lazy as _
78

@@ -11,6 +12,22 @@
1112
from django.db.models import EmailField as CIEmailField
1213

1314

15+
AnonymizeUserSignal = Signal()
16+
"""
17+
Signal that is emitted when a user and all their data should be anonymized.
18+
19+
Usage::
20+
21+
from mailauth.contrib.user.models import AnonymizeUserSignal
22+
23+
@receiver(AnonymizeUserSignal)
24+
def anonymize_user(sender, user, **kwargs):
25+
# Do something with related user data
26+
user.related_model.delete()
27+
28+
"""
29+
30+
1431
class EmailUserManager(BaseUserManager):
1532
use_in_migrations = True
1633

@@ -50,7 +67,9 @@ class AbstractEmailUser(AbstractUser):
5067
username = None
5168
password = None
5269

53-
email = CIEmailField(_("email address"), unique=True, db_index=True)
70+
email = CIEmailField(
71+
_("email address"), blank=True, null=True, unique=True, db_index=True
72+
)
5473
"""Unique and case insensitive to serve as a better username."""
5574

5675
session_salt = models.CharField(
@@ -67,6 +86,9 @@ def has_usable_password(self):
6786

6887
class Meta(AbstractUser.Meta):
6988
abstract = True
89+
permissions = [
90+
("anonymize", "Can anonymize user"),
91+
]
7092

7193
def _legacy_get_session_auth_hash(self):
7294
# RemovedInDjango40Warning: pre-Django 3.1 hashes will be invalid.
@@ -92,6 +114,27 @@ def get_session_auth_hash(self):
92114
algorithm=algorithm,
93115
).hexdigest()
94116

117+
def anonymize(self, commit=True):
118+
"""
119+
Anonymize the user data for privacy purposes.
120+
121+
This method will erase the email address, the email hash, the password.
122+
You may overwrite this method to add additional fields to anonymize::
123+
124+
class MyUser(AbstractEmailUser):
125+
def anonymize(self, commit=True):
126+
super().anonymize(commit=False) # do not commit yet
127+
self.phone_number = None
128+
if commit:
129+
self.save()
130+
"""
131+
self.email = None
132+
self.first_name = ""
133+
self.last_name = ""
134+
if commit:
135+
self.save(update_fields=["email", "first_name", "last_name"])
136+
AnonymizeUserSignal.send(sender=self.__class__, user=self)
137+
95138

96139
delattr(AbstractEmailUser, "password")
97140

tests/contrib/auth/test_admin.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from django.contrib import admin
5+
from django.contrib.auth.models import Permission
6+
7+
from mailauth.contrib.user.admin import AnonymizableAdminMixin
8+
from mailauth.contrib.user.models import EmailUser
9+
10+
11+
class TestAnonymizableAdminMixin:
12+
def test_anonymize__none(self, rf):
13+
class MyUserModel(EmailUser):
14+
class Meta:
15+
app_label = "test"
16+
verbose_name = "singular"
17+
verbose_name_plural = "plural"
18+
19+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
20+
pass
21+
22+
request = rf.get("/")
23+
MyModelAdmin(MyUserModel, admin.site).anonymize(
24+
request, MyUserModel.objects.none()
25+
)
26+
27+
@pytest.mark.django_db
28+
def test_anonymize__one(self, rf, user, monkeypatch):
29+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
30+
pass
31+
32+
monkeypatch.setattr(EmailUser, "anonymize", Mock())
33+
34+
request = rf.get("/")
35+
MyModelAdmin(type(user), admin.site).anonymize(
36+
request, type(user).objects.all()
37+
)
38+
assert EmailUser.anonymize.was_called_once_with(user)
39+
40+
@pytest.mark.django_db
41+
def test_anonymize__many(self, rf, user, monkeypatch):
42+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
43+
pass
44+
45+
monkeypatch.setattr(EmailUser, "anonymize", Mock())
46+
47+
request = rf.get("/")
48+
MyModelAdmin(type(user), admin.site).anonymize(
49+
request, type(user).objects.all()
50+
)
51+
assert EmailUser.anonymize.was_called_once_with(user)
52+
53+
def test_has_anonymize_permission(self, rf, user):
54+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
55+
pass
56+
57+
user.is_staff = True
58+
user.save()
59+
request = rf.get("/")
60+
request.user = user
61+
assert not MyModelAdmin(type(user), admin.site).has_anonymize_permission(
62+
request
63+
)
64+
65+
permission = Permission.objects.get(
66+
codename="anonymize",
67+
)
68+
user.user_permissions.add(permission)
69+
del user._perm_cache
70+
del user._user_perm_cache
71+
assert MyModelAdmin(type(user), admin.site).has_anonymize_permission(request)

tests/test_models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,22 @@ def test_email__ci_unique(self, db):
2020
models.EmailUser.objects.create_user("IronMan@avengers.com")
2121
with pytest.raises(IntegrityError):
2222
models.EmailUser.objects.create_user("ironman@avengers.com")
23+
24+
@pytest.mark.django_db
25+
def test_anonymize(self):
26+
user = models.EmailUser.objects.create_user(
27+
email="ironman@avengers.com", first_name="Tony", last_name="Stark"
28+
)
29+
user.anonymize()
30+
assert not user.first_name
31+
assert not user.last_name
32+
assert not user.email
33+
34+
def test_anonymize__no_commit(self):
35+
user = models.EmailUser(
36+
email="ironman@avengers.com", first_name="Tony", last_name="Stark"
37+
)
38+
user.anonymize(commit=False)
39+
assert not user.first_name
40+
assert not user.last_name
41+
assert not user.email

0 commit comments

Comments
 (0)