Skip to content

Commit 4eeb9c4

Browse files
committed
Add anonymization feature for private user data
1 parent 8d3634e commit 4eeb9c4

File tree

8 files changed

+259
-3
lines changed

8 files changed

+259
-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: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,51 @@
11
from django.contrib import admin
2+
from django.contrib.auth import get_permission_codename
23
from django.contrib.auth.models import Group, Permission
4+
from django.utils.translation import gettext_lazy as _, ngettext
35

46
from . import models
57

68

9+
class AnonymizableAdminMixin:
10+
"""
11+
Mixin for admin classes that provides a `anonymize` action.
12+
13+
This mixin calls the `anonymize` method of all user model instances.
14+
"""
15+
16+
actions = ["anonymize"]
17+
18+
@admin.action(
19+
permissions=["anonymize"],
20+
description=_("Anonymize selected %(verbose_name_plural)s"),
21+
)
22+
def anonymize(self, request, queryset):
23+
count = queryset.count()
24+
for user in queryset.iterator():
25+
user.anonymize()
26+
27+
self.message_user(
28+
request,
29+
ngettext(
30+
"%(count)s %(obj_name)s has successfully been anonymized.",
31+
"%(count)s %(obj_name)s have successfully been anonymized.",
32+
count,
33+
)
34+
% {
35+
"count": count,
36+
"obj_name": self.model._meta.verbose_name_plural
37+
if count > 1
38+
else self.model._meta.verbose_name,
39+
},
40+
fail_silently=True,
41+
)
42+
43+
def has_anonymize_permission(self, request, obj=None):
44+
return request.user.has_perm(f"{self.opts.app_label}.anonymize", obj=obj)
45+
46+
747
@admin.register(models.EmailUser)
8-
class EmailUserAdmin(admin.ModelAdmin):
9-
app_label = "asdf"
48+
class EmailUserAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
1049
list_display = ("email", "first_name", "last_name", "is_staff")
1150
list_filter = ("is_staff", "is_superuser", "is_active", "groups")
1251
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: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import hashlib
2+
13
from django.conf import settings
24
from django.contrib.auth.base_user import BaseUserManager
35
from django.contrib.auth.models import AbstractUser
46
from django.db import models
7+
from django.dispatch import Signal
58
from django.utils.crypto import get_random_string, salted_hmac
69
from django.utils.translation import gettext_lazy as _
710

@@ -11,6 +14,22 @@
1114
from django.db.models import EmailField as CIEmailField
1215

1316

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

@@ -50,7 +69,9 @@ class AbstractEmailUser(AbstractUser):
5069
username = None
5170
password = None
5271

53-
email = CIEmailField(_("email address"), unique=True, db_index=True)
72+
email = CIEmailField(
73+
_("email address"), blank=True, null=True, unique=True, db_index=True
74+
)
5475
"""Unique and case insensitive to serve as a better username."""
5576

5677
session_salt = models.CharField(
@@ -67,6 +88,9 @@ def has_usable_password(self):
6788

6889
class Meta(AbstractUser.Meta):
6990
abstract = True
91+
permissions = [
92+
("anonymize", "Can anonymize user"),
93+
]
7094

7195
def _legacy_get_session_auth_hash(self):
7296
# RemovedInDjango40Warning: pre-Django 3.1 hashes will be invalid.
@@ -92,6 +116,27 @@ def get_session_auth_hash(self):
92116
algorithm=algorithm,
93117
).hexdigest()
94118

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

96141
delattr(AbstractEmailUser, "password")
97142

mailauth/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import hashlib
12
import urllib
23

34
from django import forms
5+
from django.conf import settings
46
from django.contrib.auth import get_user_model
57
from django.contrib.sites.shortcuts import get_current_site
68
from django.core.mail import EmailMultiAlternatives

tests/contrib/auth/test_admin.py

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