From e4efffa9405e3dd852e73284c44e774b93f78c62 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 1 Oct 2026 14:26:52 +0000 Subject: [PATCH 1/4] ensure hashes are empty for inactive users --- .../0006_client_side_encryption_part_4.py | 2 - .../0007_empty_hash_when_inactive.py | 117 ++++++++++++++++++ codeforlife/user/models/klass.py | 16 ++- codeforlife/user/models/other.py | 18 ++- codeforlife/user/models/school.py | 7 +- codeforlife/user/models/user/user.py | 29 ++++- 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 codeforlife/user/migrations/0007_empty_hash_when_inactive.py diff --git a/codeforlife/user/migrations/0006_client_side_encryption_part_4.py b/codeforlife/user/migrations/0006_client_side_encryption_part_4.py index f658f5e1..12ae50bf 100644 --- a/codeforlife/user/migrations/0006_client_side_encryption_part_4.py +++ b/codeforlife/user/migrations/0006_client_side_encryption_part_4.py @@ -1,7 +1,5 @@ from django.db import migrations -from ...models.fields import EncryptedTextField, Sha256Field - user_migrations = [ # Username migrations.RemoveField( diff --git a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py new file mode 100644 index 00000000..be866cd0 --- /dev/null +++ b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py @@ -0,0 +1,117 @@ +import typing as t + +from django.db import migrations +from django.db.migrations.state import StateApps +from django.db.models import CheckConstraint, Model, Q + + +def set_hash_fields_to_empty(apps: StateApps, _): + """ + Set hash fields to empty for inactive users, classes, schools, and school + teacher invitations. + """ + + User: t.Type[Model] = apps.get_model("user", "User") + Class: t.Type[Model] = apps.get_model("user", "Class") + School: t.Type[Model] = apps.get_model("user", "School") + SchoolTeacherInvitation: t.Type[Model] = apps.get_model( + "user", "SchoolTeacherInvitation" + ) + + is_not_active = Q(is_active=False) + + User.objects.filter(is_not_active).update( + _username_hash="", _email_hash="", _first_name_hash="" + ) + + Class.objects.filter(is_not_active).update( + _name_hash="", _access_code_hash="" + ) + + School.objects.filter(is_not_active).update(_name_hash="") + + SchoolTeacherInvitation.objects.filter(is_not_active).update( + invited_teacher_first_name="", + invited_teacher_last_name="", + invited_teacher_email="", + _token_hash="", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("user", "0006_client_side_encryption_part_4"), + ] + + operations = [ + migrations.RunPython(set_hash_fields_to_empty), + migrations.AddConstraint( + model_name="class", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_name_hash", ""), _connector="OR" + ), + name="klass__name_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="class", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_access_code_hash", ""), + _connector="OR", + ), + name="klass__access_code_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="school", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_name_hash", ""), _connector="OR" + ), + name="school__name_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_token_hash", ""), _connector="OR" + ), + name="school_teacher_invitation__token_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_email_hash", ""), _connector="OR" + ), + name="user__email_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_username_hash", ""), _connector="OR" + ), + name="user__username_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_first_name_hash", ""), + _connector="OR", + ), + name="user__first_name_hash_non_empty_when_inactive", + ), + ), + ] diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index f2b81186..961f0efc 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -234,7 +234,11 @@ def get_requests_message(self): def anonymise(self): """Anonymise the class.""" self.is_active = False - self.save() + self._name_hash = "" + self._access_code_hash = "" + self.save( + update_fields=["is_active", "_name_hash", "_access_code_hash"] + ) # Remove independent students' requests to join this class # pylint: disable-next=no-member @@ -248,6 +252,16 @@ class Meta(TypedModelMeta): condition=~models.Q(_access_code_hash=""), name="unique_access_code_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_name_hash=""), + name="klass__name_hash_non_empty_when_inactive", + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) | models.Q(_access_code_hash="") + ), + name="klass__access_code_hash_non_empty_when_inactive", + ), ] @property diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 5e2b1ee9..604084ee 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -439,6 +439,13 @@ class Meta: fields=["_token_hash"], name="unique_token_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_token_hash=""), + name=( + "school_teacher_invitation__" + "token_hash_non_empty_when_inactive" + ), + ), ] @property @@ -458,8 +465,17 @@ def anonymise(self): self.invited_teacher_first_name = "" self.invited_teacher_last_name = "" self.invited_teacher_email = "" + self._token_hash = "" self.is_active = False - self.save() + self.save( + update_fields=[ + "invited_teacher_first_name", + "invited_teacher_last_name", + "invited_teacher_email", + "_token_hash", + "is_active", + ] + ) @property def dek_aead(self): diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 147f80f7..4717361c 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -139,6 +139,10 @@ class Meta(TypedModelMeta): fields=["_name_hash"], name="unique_name_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_name_hash=""), + name="school__name_hash_non_empty_when_inactive", + ), ] def __str__(self): @@ -168,4 +172,5 @@ def anonymise(self): """Anonymize the school.""" self.dek = None self.is_active = False - self.save(update_fields=["dek", "is_active"]) + self._name_hash = "" + self.save(update_fields=["dek", "is_active", "_name_hash"]) diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 61f69017..6511ad68 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -311,6 +311,22 @@ class Meta(TypedModelMeta): fields=["_username_hash"], name="unique_username_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_email_hash=""), + name="user__email_hash_non_empty_when_inactive", + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) | models.Q(_username_hash="") + ), + name="user__username_hash_non_empty_when_inactive", + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) | models.Q(_first_name_hash="") + ), + name="user__first_name_hash_non_empty_when_inactive", + ), ] # TODO: remove in new schema @@ -427,7 +443,18 @@ def anonymize(self): """Anonymize the user.""" self.dek = None self.is_active = False - self.save(update_fields=["dek", "is_active"]) + self._username_hash = "" + self._email_hash = "" + self._first_name_hash = "" + self.save( + update_fields=[ + "dek", + "is_active", + "_username_hash", + "_email_hash", + "_first_name_hash", + ] + ) # self.userprofile.google_refresh_token = None # self.userprofile.google_sub = None From 050d37b33f0f57f0a01100260b657de134499dd5 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 1 Oct 2026 14:36:23 +0000 Subject: [PATCH 2/4] fix --- codeforlife/user/fixtures/legacy.json | 4 ++-- .../user/migrations/0007_empty_hash_when_inactive.py | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index 50c94ea3..da03e4c5 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -986,10 +986,10 @@ "pk": 20, "fields": { "_first_name_enc": "ZmFrZV9lbmM6kEW6JpB05mjWAPE/xrxMrbFNoyD6ZBNVl+YCZI/q3+LRROg=", - "_first_name_hash": "1dbd289e95b4925fc85eff99d7e879b2033f60182e05872f8665ff271d0d337c", + "_first_name_hash": "", "_last_name_enc": "ZmFrZV9lbmM6gwxaGy9/aDQWDEVUwUverJ6wmk3ohaTkvF5GYq6koNg=", "_username_enc": "ZmFrZV9lbmM6kUOy1ae7Y1ElSiAiDXnKIZxPkOs1saBSjmH+UCEThmkOR7lKe9Nlmza/K+mW1Slr0UutzlVaqMLU3yWT", - "_username_hash": "daa37b0878d74480285044dccc67750ad597b99719dc7323462cbe16e0b0271d", + "_username_hash": "", "date_joined": "2026-02-04T16:02:37.614Z", "dek": "ZmFrZV9lbmM6Ct68dRt74lesvjRcTVtlbUlPC8xt85anig+MaarMMZAUvcFmkJmno57KkQUR8S5/o5ElV9Bpn3RDpxeS", "groups": [], diff --git a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py index be866cd0..53d50f22 100644 --- a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py +++ b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py @@ -30,12 +30,7 @@ def set_hash_fields_to_empty(apps: StateApps, _): School.objects.filter(is_not_active).update(_name_hash="") - SchoolTeacherInvitation.objects.filter(is_not_active).update( - invited_teacher_first_name="", - invited_teacher_last_name="", - invited_teacher_email="", - _token_hash="", - ) + SchoolTeacherInvitation.objects.filter(is_not_active).update(_token_hash="") class Migration(migrations.Migration): From 453913e9a87add8d5a6446e3bab9285681168bf9 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 1 Oct 2026 15:18:51 +0000 Subject: [PATCH 3/4] partial anonymisation --- .../0007_empty_hash_when_inactive.py | 62 ++++++++++++++++++- codeforlife/user/models/klass.py | 20 +++++- codeforlife/user/models/other.py | 42 +++++++++++-- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py index 53d50f22..59d8e76a 100644 --- a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py +++ b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py @@ -25,12 +25,17 @@ def set_hash_fields_to_empty(apps: StateApps, _): ) Class.objects.filter(is_not_active).update( - _name_hash="", _access_code_hash="" + _name_enc=b"", _name_hash="", _access_code_enc=b"", _access_code_hash="" ) School.objects.filter(is_not_active).update(_name_hash="") - SchoolTeacherInvitation.objects.filter(is_not_active).update(_token_hash="") + SchoolTeacherInvitation.objects.filter(is_not_active).update( + _invited_teacher_first_name_enc=b"", + _invited_teacher_last_name_enc=b"", + _invited_teacher_email_enc=b"", + _token_hash="", + ) class Migration(migrations.Migration): @@ -42,6 +47,15 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(set_hash_fields_to_empty), + migrations.AddConstraint( + model_name="class", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_name_enc", b""), _connector="OR" + ), + name="klass__name_enc_non_empty_when_inactive", + ), + ), migrations.AddConstraint( model_name="class", constraint=CheckConstraint( @@ -51,6 +65,17 @@ class Migration(migrations.Migration): name="klass__name_hash_non_empty_when_inactive", ), ), + migrations.AddConstraint( + model_name="class", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_access_code_enc", b""), + _connector="OR", + ), + name="klass__access_code_enc_non_empty_when_inactive", + ), + ), migrations.AddConstraint( model_name="class", constraint=CheckConstraint( @@ -71,6 +96,39 @@ class Migration(migrations.Migration): name="school__name_hash_non_empty_when_inactive", ), ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_invited_teacher_first_name_enc", b""), + _connector="OR", + ), + name="school_teacher_invitation__invited_teacher_first_name_enc_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_invited_teacher_last_name_enc", b""), + _connector="OR", + ), + name="school_teacher_invitation__invited_teacher_last_name_enc_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_invited_teacher_email_enc", b""), + _connector="OR", + ), + name="school_teacher_invitation__invited_teacher_email_enc_non_empty_when_inactive", + ), + ), migrations.AddConstraint( model_name="schoolteacherinvitation", constraint=CheckConstraint( diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 961f0efc..6d345c9d 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -234,10 +234,18 @@ def get_requests_message(self): def anonymise(self): """Anonymise the class.""" self.is_active = False + self._name_enc = b"" self._name_hash = "" + self._access_code_enc = b"" self._access_code_hash = "" self.save( - update_fields=["is_active", "_name_hash", "_access_code_hash"] + update_fields=[ + "is_active", + "_name_enc", + "_name_hash", + "_access_code_enc", + "_access_code_hash", + ] ) # Remove independent students' requests to join this class @@ -252,10 +260,20 @@ class Meta(TypedModelMeta): condition=~models.Q(_access_code_hash=""), name="unique_access_code_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_name_enc=b""), + name="klass__name_enc_non_empty_when_inactive", + ), models.CheckConstraint( condition=models.Q(is_active=True) | models.Q(_name_hash=""), name="klass__name_hash_non_empty_when_inactive", ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) | models.Q(_access_code_enc=b"") + ), + name="klass__access_code_enc_non_empty_when_inactive", + ), models.CheckConstraint( condition=( models.Q(is_active=True) | models.Q(_access_code_hash="") diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 604084ee..b2d72a8b 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -439,6 +439,36 @@ class Meta: fields=["_token_hash"], name="unique_token_hash_non_empty", ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) + | models.Q(_invited_teacher_first_name_enc=b"") + ), + name=( + "school_teacher_invitation__" + "invited_teacher_first_name_enc_non_empty_when_inactive" + ), + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) + | models.Q(_invited_teacher_last_name_enc=b"") + ), + name=( + "school_teacher_invitation__" + "invited_teacher_last_name_enc_non_empty_when_inactive" + ), + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) + | models.Q(_invited_teacher_email_enc=b"") + ), + name=( + "school_teacher_invitation__" + "invited_teacher_email_enc_non_empty_when_inactive" + ), + ), models.CheckConstraint( condition=models.Q(is_active=True) | models.Q(_token_hash=""), name=( @@ -462,16 +492,16 @@ def __str__(self): def anonymise(self): """Anonymise the invitation.""" - self.invited_teacher_first_name = "" - self.invited_teacher_last_name = "" - self.invited_teacher_email = "" + self._invited_teacher_first_name_enc = b"" + self._invited_teacher_last_name_enc = b"" + self._invited_teacher_email_enc = b"" self._token_hash = "" self.is_active = False self.save( update_fields=[ - "invited_teacher_first_name", - "invited_teacher_last_name", - "invited_teacher_email", + "_invited_teacher_first_name_enc", + "_invited_teacher_last_name_enc", + "_invited_teacher_email_enc", "_token_hash", "is_active", ] From 43b7cb198aece4f7ef500b756498c85cf28d046d Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 1 Oct 2026 15:22:12 +0000 Subject: [PATCH 4/4] remove access code --- .../0007_empty_hash_when_inactive.py | 26 +------------------ codeforlife/user/models/klass.py | 24 +---------------- 2 files changed, 2 insertions(+), 48 deletions(-) diff --git a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py index 59d8e76a..7d796719 100644 --- a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py +++ b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py @@ -24,9 +24,7 @@ def set_hash_fields_to_empty(apps: StateApps, _): _username_hash="", _email_hash="", _first_name_hash="" ) - Class.objects.filter(is_not_active).update( - _name_enc=b"", _name_hash="", _access_code_enc=b"", _access_code_hash="" - ) + Class.objects.filter(is_not_active).update(_name_enc=b"", _name_hash="") School.objects.filter(is_not_active).update(_name_hash="") @@ -65,28 +63,6 @@ class Migration(migrations.Migration): name="klass__name_hash_non_empty_when_inactive", ), ), - migrations.AddConstraint( - model_name="class", - constraint=CheckConstraint( - condition=Q( - ("is_active", True), - ("_access_code_enc", b""), - _connector="OR", - ), - name="klass__access_code_enc_non_empty_when_inactive", - ), - ), - migrations.AddConstraint( - model_name="class", - constraint=CheckConstraint( - condition=Q( - ("is_active", True), - ("_access_code_hash", ""), - _connector="OR", - ), - name="klass__access_code_hash_non_empty_when_inactive", - ), - ), migrations.AddConstraint( model_name="school", constraint=CheckConstraint( diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 6d345c9d..84f61bcf 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -236,17 +236,7 @@ def anonymise(self): self.is_active = False self._name_enc = b"" self._name_hash = "" - self._access_code_enc = b"" - self._access_code_hash = "" - self.save( - update_fields=[ - "is_active", - "_name_enc", - "_name_hash", - "_access_code_enc", - "_access_code_hash", - ] - ) + self.save(update_fields=["is_active", "_name_enc", "_name_hash"]) # Remove independent students' requests to join this class # pylint: disable-next=no-member @@ -268,18 +258,6 @@ class Meta(TypedModelMeta): condition=models.Q(is_active=True) | models.Q(_name_hash=""), name="klass__name_hash_non_empty_when_inactive", ), - models.CheckConstraint( - condition=( - models.Q(is_active=True) | models.Q(_access_code_enc=b"") - ), - name="klass__access_code_enc_non_empty_when_inactive", - ), - models.CheckConstraint( - condition=( - models.Q(is_active=True) | models.Q(_access_code_hash="") - ), - name="klass__access_code_hash_non_empty_when_inactive", - ), ] @property