From 490682cdb4eb7487b734a9757766f11f59f74666 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 13 Jan 2026 10:19:04 +0000 Subject: [PATCH 01/45] initial --- Pipfile | 1 + Pipfile.lock | 112 +++++++++++++---- codeforlife/models/__init__.py | 2 + codeforlife/models/base.py | 74 ++++++++++- codeforlife/models/data_encryption_key.py | 49 ++++++++ codeforlife/models/encrypted_binary_field.py | 126 +++++++++++++++++++ codeforlife/user/migrations/0001_initial.py | 53 +++++--- codeforlife/user/models/otp_bypass_token.py | 15 ++- 8 files changed, 383 insertions(+), 49 deletions(-) create mode 100644 codeforlife/models/data_encryption_key.py create mode 100644 codeforlife/models/encrypted_binary_field.py diff --git a/Pipfile b/Pipfile index 5f4dae44..99487ed1 100644 --- a/Pipfile +++ b/Pipfile @@ -36,6 +36,7 @@ rapid-router = "==7.6.12" # TODO: remove phonenumbers = "==8.12.12" # TODO: remove google-auth = "==2.40.3" google-cloud-bigquery = "==3.38.0" +tink = {version = "==1.13.0", extras = ["gcpkms"]} [dev-packages] celery-types = "==0.23.0" diff --git a/Pipfile.lock b/Pipfile.lock index 4ca6a58c..bfa7e0f2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c942206154f02f93001ae1acd6ebb1f4e104eecb47e30c0c3f6dd0e26075e994" + "sha256": "0d7acda6ffa0e971e77aea484d8b08e17d64048bfc45cd38b6e56986b788bea8" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "absl-py": { + "hashes": [ + "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", + "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d" + ], + "markers": "python_version >= '3.8'", + "version": "==2.3.1" + }, "amqp": { "hashes": [ "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", @@ -40,6 +48,13 @@ "markers": "python_version >= '3.8'", "version": "==3.0.1" }, + "bazel-runfiles": { + "hashes": [ + "sha256:558b5f8f90285ba9a7cedbf289fa3c895a2a7abd238ad0245d84586bed224459" + ], + "markers": "python_version >= '3.7'", + "version": "==1.7.0" + }, "billiard": { "hashes": [ "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", @@ -583,11 +598,11 @@ "grpc" ], "hashes": [ - "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8", - "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c" + "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", + "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9" ], "markers": "python_version >= '3.7'", - "version": "==2.28.1" + "version": "==2.29.0" }, "google-auth": { "hashes": [ @@ -615,6 +630,14 @@ "markers": "python_version >= '3.7'", "version": "==2.5.0" }, + "google-cloud-kms": { + "hashes": [ + "sha256:389ed5cf085e212b6e4a55af1cffe06e6a47aa1827782ad8549591285cc2d620", + "sha256:e9a4b2dca4c50a8c74f7ed6ac8b5ef5abc18c416b419a04080908b3270170c22" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.0" + }, "google-crc32c": { "hashes": [ "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", @@ -663,6 +686,9 @@ "version": "==2.8.0" }, "googleapis-common-protos": { + "extras": [ + "grpc" + ], "hashes": [ "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5" @@ -670,6 +696,14 @@ "markers": "python_version >= '3.7'", "version": "==1.72.0" }, + "grpc-google-iam-v1": { + "hashes": [ + "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", + "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389" + ], + "markers": "python_version >= '3.7'", + "version": "==0.14.3" + }, "grpcio": { "hashes": [ "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", @@ -1257,19 +1291,19 @@ }, "protobuf": { "hashes": [ - "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", - "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913", - "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", - "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe", - "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", - "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", - "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", - "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", - "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", - "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4" + "sha256:08a6ca12f60ba99097dd3625ef4275280f99c9037990e47ce9368826b159b890", + "sha256:1fd18f030ae9df97712fbbb0849b6e54c63e3edd9b88d8c3bb4771f84d8db7a4", + "sha256:2756963dcfd414eba46bcbb341f0e2c652036e5d700f112b3bb90fa1a031893a", + "sha256:642fce7187526c98683c79a3ad68e5d646a5ef5eb004582fe123fc9a33a9456b", + "sha256:648b7b0144222eb06cf529a3d7b01333c5f30b4196773b682d388f04db373759", + "sha256:6fa9b5f4baa12257542273e5e6f3c3d3867b30bc2770c14ad9ac8315264bf986", + "sha256:b4046f9f2ede57ad5b1d9917baafcbcad42f8151a73c755a1e2ec9557b0a764f", + "sha256:c2bf221076b0d463551efa2e1319f08d4cffcc5f0d864614ccd3d0e77a637794", + "sha256:c46dcc47b243b299f4f7eabeed21929c07f0d36fffe2ea8431793b53c308ab80", + "sha256:c8794debeb402963fddff41a595e1f649bcd76616ba56c835645cab4539e810e" ], "markers": "python_version >= '3.9'", - "version": "==6.33.2" + "version": "==6.33.3" }, "psutil": { "hashes": [ @@ -1786,6 +1820,36 @@ "markers": "python_version >= '3.9'", "version": "==3.7.0" }, + "tink": { + "extras": [ + "gcpkms" + ], + "hashes": [ + "sha256:011897f8d3c20b094bcf4b2cb2cbf09a8319bd24a19570014022f888c53248bb", + "sha256:0af46b6fe37831e99de16b5159842d53a46317775153258d4e591592abc07f99", + "sha256:0e40472ad3eb344ce822c10298a0ff7430e527a8705d956464ebe0fb93c3a0b0", + "sha256:2045c226c81e23193d3c5eb0f264918ceb06ee9f4de7933427e0f701e088a3c1", + "sha256:27281112714fd07310594a4fcda069d6be215a9eecbc31f86e4e4e01e0c8d860", + "sha256:2753a379fcf21f9055b157e0fc090aa0359b9cb70fe146ff37d8c15933941c38", + "sha256:32bbaee98f3f6065ef9d3e9b7f8ee69a254d35ecb37ca1fe4b3b5b501e4dabf0", + "sha256:4ede7d7ac3dcfd767f8082e6ba2c38164e57aa18dd4881050439367906723475", + "sha256:55bdc12d35d7a332ec6e7ffc2f0a887866ecaa5fdccf2194c1bb176282a59b90", + "sha256:5936a4bbcd9f42df50a371d9575124009e47c9666aec71322192e404992d1bc2", + "sha256:5b7ce1c56a208bf191db2386150bdf42e6cfc2bd3dad44054ed045d8ae6ec7f3", + "sha256:6e50a0ad53d80dedba0b1368977058fa6a6cdf6c05d06a1f88971753b7dcd3c7", + "sha256:71e71df02035f0682237f14ea48325533b3a1ffb7f33d3970f56a8cf56cdcf25", + "sha256:75dcf4bbd5047e94481904a3d52e911ac1d807dda02e406bbcc47b703bec56a7", + "sha256:9b9e5a1b5102c69623b65a57a16fe4bed17e6bda535b6fa7af3d09b40633436b", + "sha256:bf785ad6dc5d98343d23e2a38b26093cd4ef1a7a6228b58cc7789f1ca2f84977", + "sha256:c4c61925014e2b59125e8b6afa6c5ce6f12fbcd05a346fb9bf6de83db2cc2b39", + "sha256:dca4950ffe762367771db1bc79241e6589f889360f3d023b32ba29282c2e4579", + "sha256:f2d39c87a982a43f848807c779828012e888e527e6c5f4152df81cca0134abb5", + "sha256:f7e525198e2fe6d45e7507d3c7af44a8d518c003fd3b135ea9f64283dd8ff2b1", + "sha256:fed609dd558df2a37ed8395d485990e582f69083aa9d3c172c31da5c034cd74c" + ], + "markers": "python_version >= '3.9'", + "version": "==1.13.0" + }, "traitlets": { "hashes": [ "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", @@ -1935,11 +1999,11 @@ }, "botocore-stubs": { "hashes": [ - "sha256:3687cf38a66a3e2b5771d1380592fabeba18b866729b438d1c90d26c123acd3c", - "sha256:5388e98bed5d354e848772ef050afebab4c7aa64ef6b7aa9c03066c8fe9cacee" + "sha256:49d15529002bd1099a9a099a77d70b7b52859153783440e96eb55791e8147d1b", + "sha256:70a8a53ba2684ff462c44d5996acd85fc5c7eb969e2cf3c25274441269524298" ], "markers": "python_version >= '3.9'", - "version": "==1.42.23" + "version": "==1.42.25" }, "celery-types": { "hashes": [ @@ -2400,11 +2464,11 @@ }, "pathspec": { "hashes": [ - "sha256:8870061f22c58e6d83463cfce9a7dd6eca0512c772c1001fb09ac64091816721", - "sha256:e2769b508d0dd47b09af6ee2c75b2744a2cb1f474ae4b1494fd6a1b7a841613c" + "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", + "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c" ], "markers": "python_version >= '3.9'", - "version": "==1.0.1" + "version": "==1.0.3" }, "platformdirs": { "hashes": [ @@ -2557,11 +2621,11 @@ }, "types-awscrt": { "hashes": [ - "sha256:362fd8f5eaebcfcd922cb9fd8274fb375df550319f78031ee3779eac0b9ecc79", - "sha256:8204126e01a00eaa4a746e7a0076538ca0e4e3f52408adec0ab9b471bb0bb64b" + "sha256:009cfe5b9af8c75e8304243490e20a5229e7a56203f1d41481f5522233453f51", + "sha256:aa8b42148af0847be14e2b8ea3637a3518ffab038f8d3be7083950f3ce87d3ff" ], "markers": "python_version >= '3.8'", - "version": "==0.30.0" + "version": "==0.31.0" }, "types-psutil": { "hashes": [ diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 9d38a834..226d533a 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -7,4 +7,6 @@ from .abstract_base_user import AbstractBaseUser from .base import * from .base_session_store import BaseSessionStore +from .data_encryption_key import DataEncryptionKeyModel +from .encrypted_binary_field import EncryptedBinaryField from .encrypted_char_field import EncryptedCharField diff --git a/codeforlife/models/base.py b/codeforlife/models/base.py index 0f974c6e..aae0e470 100644 --- a/codeforlife/models/base.py +++ b/codeforlife/models/base.py @@ -7,22 +7,88 @@ import typing as t -from django.db.models import Manager -from django.db.models import Model as _Model +from django.core.exceptions import ValidationError +from django.db import models if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta + from tink.aead import Aead + + from .encrypted_binary_field import EncryptedBinaryField else: TypedModelMeta = object -class Model(_Model): +# class Manager(models.Manager["AnyModel"], t.Generic["AnyModel"]): +# """Base manager for all models.""" + +# def bulk_create(self, objs, batch_size=None, ignore_conflicts=False): +# """ +# Intercepts bulk_create to encrypt data in memory before saving. +# """ +# for obj in objs: +# if hasattr(obj, "ensure_key_exists"): +# obj.ensure_key_exists() +# if hasattr(obj, "encrypt_all_fields"): +# obj.encrypt_all_fields() + +# return super().bulk_create( +# objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts +# ) + +# def update(self, **kwargs): +# """ +# Block standard update() on encrypted fields because it bypasses the +# per-row unique keys. +# """ +# if hasattr(self.model, "ENCRYPTED_FIELDS"): +# if any(field in kwargs for field in self.model.ENCRYPTED_FIELDS): +# raise NotImplementedError( +# "Cannot use .update() on encrypted fields. " +# "Use .bulk_update() or iterate and save() instead." +# ) + +# return super().update(**kwargs) + + +class Model(models.Model): """Base for all models.""" - objects: Manager[t.Self] + ASSOCIATED_DATA: str + _ENCRYPTED_FIELDS: t.List["EncryptedBinaryField"] = [] + + objects: models.Manager[t.Self] # = Manager() + + def __init_subclass__(cls, *args, **kwargs): + super().__init_subclass__(*args, **kwargs) + + if not cls._meta.abstract and not hasattr(cls, "ASSOCIATED_DATA"): + raise ValidationError( + f"Model '{cls.__name__}' must define an" + " ASSOCIATED_DATA attribute.", + code="no_associated_data", + ) class Meta(TypedModelMeta): abstract = True + @property + def dek_aead(self) -> "Aead": + """Gets the AEAD primitive for this model's DEK.""" + raise NotImplementedError() + + def encrypt_all_fields(self): + """ + Helper called by Manager.bulk_create(). + Forces the encryption of all properties into their DB fields. + """ + for field in self._ENCRYPTED_FIELDS: + plaintext = getattr( + self, field.name, None + ) # field.getter_and_setter.fget(self) + if plaintext: + # The setter on the property will handle encryption + setattr(self, field.name, plaintext) + AnyModel = t.TypeVar("AnyModel", bound=Model) diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py new file mode 100644 index 00000000..28433644 --- /dev/null +++ b/codeforlife/models/data_encryption_key.py @@ -0,0 +1,49 @@ +import typing as t + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ +from tink import ( # type: ignore[import-untyped] + BinaryKeysetReader, + read_keyset_handle, +) +from tink.aead import Aead # type: ignore[import-untyped] +from tink.integration.gcpkms import GcpKmsClient # type: ignore[import-untyped] + +from .base import Model + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + + +class DataEncryptionKeyModel(Model): + """Base model for models that store encrypted DEKs.""" + + dek = models.BinaryField( + verbose_name=_("data encryption key"), + help_text=_("The encrypted data encryption key (DEK) for this model."), + editable=False, + ) + + class Meta(TypedModelMeta): + abstract = True + + def _get_master_key_aead(self): + """Gets the AEAD primitive for the KMS master key.""" + return GcpKmsClient( + settings.KMS_MASTER_KEY_URI, settings.KMS_CREDENTIALS_PATH + ).get_aead(settings.KMS_MASTER_KEY_URI) + + @property + def dek_aead(self): + if not self.dek: + raise ValueError("The data encryption key (DEK) is missing.") + + dek_aead = read_keyset_handle( + keyset_reader=BinaryKeysetReader(self.dek), + master_key_aead=self._get_master_key_aead(), + ).primitive(Aead) + + return dek_aead # TODO: Cache this value. diff --git a/codeforlife/models/encrypted_binary_field.py b/codeforlife/models/encrypted_binary_field.py new file mode 100644 index 00000000..ab41daca --- /dev/null +++ b/codeforlife/models/encrypted_binary_field.py @@ -0,0 +1,126 @@ +""" +© Ocado Group +Created on 12/01/2026 at 09:17:46(+00:00). +""" + +import typing as t +from functools import cached_property + +from django.core.exceptions import ValidationError +from django.db import models + +from ..models import DataEncryptionKeyModel, Model + + +class EncryptedBinaryField(models.BinaryField): + """ + A custom BinaryField that registers itself as an encrypted field on the + model class. + """ + + model: t.Type[Model] + + def __init__(self, associated_data: str, *args, **kwargs): + if not associated_data: + raise ValidationError( + "Associated data cannot be empty.", code="no_associated_data" + ) + self.associated_data = associated_data + + # Set db_column to associated_data by default. + kwargs.setdefault("db_column", associated_data) + + super().__init__(*args, **kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs["associated_data"] = self.associated_data + return name, path, args, kwargs + + def contribute_to_class(self, cls, name, private_only=False): + super().contribute_to_class(cls, name, private_only) + + if not cls._meta.abstract and not hasattr(cls, "ASSOCIATED_DATA"): + raise ValidationError( + f"Model '{cls.__module__}.{cls.__name__}' must define an" + " ASSOCIATED_DATA attribute.", + code="no_associated_data", + ) + + if not issubclass(cls, Model): + raise ValidationError( + f"{cls.__module__}.{cls.__name__} must subclass" + f" {Model.__module__}.{Model.__name__}.", + code="invalid_model_base_class", + ) + + # pylint: disable-next=protected-access + encrypted_fields = cls._ENCRYPTED_FIELDS + + if self in encrypted_fields: + raise ValidationError( + "Encrypted field already registered.", + code="already_registered", + ) + + if any( + self.associated_data == field.associated_data + for field in encrypted_fields + ): + raise ValidationError( + f"Associated data '{self.associated_data}' already used in" + " another encrypted field.", + code="associated_data_already_used", + ) + + encrypted_fields.append(self) + + @cached_property + def getter_and_setter(self): + """Returns a property that gets/sets the decrypted/encrypted value.""" + field = self + + def to_associated_data(model: Model, dek_model: DataEncryptionKeyModel): + """Generates the Associated Data (AD) for encryption/decryption.""" + return ":".join( + [ + dek_model.ASSOCIATED_DATA, + dek_model.pk, + model.ASSOCIATED_DATA, + field.associated_data, + ] + ).encode() + + def decrypt_value(self: Model): + """Decrypts a single value using the DEK and associated data.""" + ciphertext: t.Optional[bytes] = getattr(self, field.name) + if ciphertext is None: + return None + + dek_model = self.get_data_encryption_key_model() + return dek_model.data_key_aead.decrypt( + ciphertext=ciphertext, + associated_data=to_associated_data(self, dek_model), + ).decode() + + def encrypt_value(self: Model, plaintext: t.Optional[str]): + """Encrypts a single value using the DEK and associated data.""" + if plaintext is None: + value = None + else: + dek_model = self.get_data_encryption_key_model() + value = dek_model.data_key_aead.encrypt( + plaintext=plaintext.encode(), + associated_data=to_associated_data(self, dek_model), + ) + + setattr(self, field.name, value) + + # Create property with getter and setter. Cast to str for mypy. + return t.cast(str, property(fget=decrypt_value, fset=encrypt_value)) + + @classmethod + def initialize(cls, associated_data: str, *args, **kwargs): + """Helper to create an EncryptedBinaryField and its property.""" + encrypted_binary_field = cls(associated_data, *args, **kwargs) + return encrypted_binary_field, encrypted_binary_field.getter_and_setter diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index c48171a9..fea5162e 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,7 +1,15 @@ -# Generated by Django 5.1.10 on 2025-08-12 10:29 +# Generated by Django 5.1.15 on 2026-01-13 09:15 -import codeforlife.models.encrypted_char_field -import codeforlife.user.models.user +import codeforlife.models.encrypted_binary_field +import codeforlife.user.models.user.admin_school_teacher +import codeforlife.user.models.user.contactable +import codeforlife.user.models.user.google +import codeforlife.user.models.user.independent +import codeforlife.user.models.user.non_admin_school_teacher +import codeforlife.user.models.user.non_school_teacher +import codeforlife.user.models.user.school_teacher +import codeforlife.user.models.user.student +import codeforlife.user.models.user.teacher import django.contrib.auth.models import django.db.models.deletion from django.db import migrations, models @@ -13,7 +21,7 @@ class Migration(migrations.Migration): dependencies = [ ("auth", "0012_alter_user_first_name_max_length"), - ("common", "0057_teacher_teacher__is_admin"), + ("common", "0058_userprofile_google_refresh_token_and_more"), ] operations = [ @@ -106,10 +114,11 @@ class Migration(migrations.Migration): ), ), ( - "token", - codeforlife.models.encrypted_char_field.EncryptedCharField( + "_token", + codeforlife.models.encrypted_binary_field.EncryptedBinaryField( + associated_data="token", + db_column="token", help_text="The encrypted equivalent of the token.", - max_length=104, verbose_name="token", ), ), @@ -163,7 +172,10 @@ class Migration(migrations.Migration): }, bases=("user.user",), managers=[ - ("objects", codeforlife.user.models.user.ContactableUserManager()), + ( + "objects", + codeforlife.user.models.user.contactable.ContactableUserManager(), + ), ], ), migrations.CreateModel( @@ -176,7 +188,7 @@ class Migration(migrations.Migration): }, bases=("user.user",), managers=[ - ("objects", codeforlife.user.models.user.StudentUserManager()), + ("objects", codeforlife.user.models.user.student.StudentUserManager()), ], ), migrations.CreateModel( @@ -242,7 +254,7 @@ class Migration(migrations.Migration): }, bases=("user.contactableuser",), managers=[ - ("objects", codeforlife.user.models.user.GoogleUserManager()), + ("objects", codeforlife.user.models.user.google.GoogleUserManager()), ], ), migrations.CreateModel( @@ -255,7 +267,10 @@ class Migration(migrations.Migration): }, bases=("user.contactableuser",), managers=[ - ("objects", codeforlife.user.models.user.IndependentUserManager()), + ( + "objects", + codeforlife.user.models.user.independent.IndependentUserManager(), + ), ], ), migrations.CreateModel( @@ -268,7 +283,7 @@ class Migration(migrations.Migration): }, bases=("user.contactableuser",), managers=[ - ("objects", codeforlife.user.models.user.TeacherUserManager()), + ("objects", codeforlife.user.models.user.teacher.TeacherUserManager()), ], ), migrations.CreateModel( @@ -281,7 +296,10 @@ class Migration(migrations.Migration): }, bases=("user.teacheruser",), managers=[ - ("objects", codeforlife.user.models.user.NonSchoolTeacherUserManager()), + ( + "objects", + codeforlife.user.models.user.non_school_teacher.NonSchoolTeacherUserManager(), + ), ], ), migrations.CreateModel( @@ -294,7 +312,10 @@ class Migration(migrations.Migration): }, bases=("user.teacheruser",), managers=[ - ("objects", codeforlife.user.models.user.SchoolTeacherUserManager()), + ( + "objects", + codeforlife.user.models.user.school_teacher.SchoolTeacherUserManager(), + ), ], ), migrations.CreateModel( @@ -309,7 +330,7 @@ class Migration(migrations.Migration): managers=[ ( "objects", - codeforlife.user.models.user.AdminSchoolTeacherUserManager(), + codeforlife.user.models.user.admin_school_teacher.AdminSchoolTeacherUserManager(), ), ], ), @@ -325,7 +346,7 @@ class Migration(migrations.Migration): managers=[ ( "objects", - codeforlife.user.models.user.NonAdminSchoolTeacherUserManager(), + codeforlife.user.models.user.non_admin_school_teacher.NonAdminSchoolTeacherUserManager(), ), ], ), diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index db4a4438..692d0815 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -12,7 +12,7 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ -from ...models import EncryptedCharField +from ...models import EncryptedBinaryField, Model from ...types import Validators from ...validators import CharSetValidatorBuilder from .user import User @@ -23,9 +23,10 @@ TypedModelMeta = object -class OtpBypassToken(models.Model): +class OtpBypassToken(Model): """A single use token to bypass a user's OTP authentication factor.""" + ASSOCIATED_DATA = "otp_bypass_token" length = 8 allowed_chars = string.ascii_lowercase max_count = 10 @@ -72,9 +73,9 @@ def bulk_create(self, user: User): # type: ignore[override] on_delete=models.CASCADE, ) - token = EncryptedCharField( - _("token"), - max_length=100, + _token, token = EncryptedBinaryField.initialize( + associated_data="token", + verbose_name=_("token"), help_text=_("The encrypted equivalent of the token."), ) @@ -82,6 +83,10 @@ class Meta(TypedModelMeta): verbose_name = _("OTP bypass token") verbose_name_plural = _("OTP bypass tokens") + @property + def dek_aead(self): + return self.user.userprofile.dek_aead + def save(self, *args, **kwargs): raise IntegrityError("Cannot create or update a single instance.") From e5a0e88e29525d9c3417fc31298b3a5c752c5012 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 13 Jan 2026 13:42:18 +0000 Subject: [PATCH 02/45] quick save --- codeforlife/models/__init__.py | 2 +- codeforlife/models/data_encryption_key.py | 49 -------- .../models/data_encryption_key_field.py | 106 ++++++++++++++++++ 3 files changed, 107 insertions(+), 50 deletions(-) delete mode 100644 codeforlife/models/data_encryption_key.py create mode 100644 codeforlife/models/data_encryption_key_field.py diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 226d533a..80494f8a 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -7,6 +7,6 @@ from .abstract_base_user import AbstractBaseUser from .base import * from .base_session_store import BaseSessionStore -from .data_encryption_key import DataEncryptionKeyModel +from .data_encryption_key_field import DataEncryptionKeyField from .encrypted_binary_field import EncryptedBinaryField from .encrypted_char_field import EncryptedCharField diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py deleted file mode 100644 index 28433644..00000000 --- a/codeforlife/models/data_encryption_key.py +++ /dev/null @@ -1,49 +0,0 @@ -import typing as t - -from django.conf import settings -from django.db import models -from django.utils.translation import gettext_lazy as _ -from tink import ( # type: ignore[import-untyped] - BinaryKeysetReader, - read_keyset_handle, -) -from tink.aead import Aead # type: ignore[import-untyped] -from tink.integration.gcpkms import GcpKmsClient # type: ignore[import-untyped] - -from .base import Model - -if t.TYPE_CHECKING: - from django_stubs_ext.db.models import TypedModelMeta -else: - TypedModelMeta = object - - -class DataEncryptionKeyModel(Model): - """Base model for models that store encrypted DEKs.""" - - dek = models.BinaryField( - verbose_name=_("data encryption key"), - help_text=_("The encrypted data encryption key (DEK) for this model."), - editable=False, - ) - - class Meta(TypedModelMeta): - abstract = True - - def _get_master_key_aead(self): - """Gets the AEAD primitive for the KMS master key.""" - return GcpKmsClient( - settings.KMS_MASTER_KEY_URI, settings.KMS_CREDENTIALS_PATH - ).get_aead(settings.KMS_MASTER_KEY_URI) - - @property - def dek_aead(self): - if not self.dek: - raise ValueError("The data encryption key (DEK) is missing.") - - dek_aead = read_keyset_handle( - keyset_reader=BinaryKeysetReader(self.dek), - master_key_aead=self._get_master_key_aead(), - ).primitive(Aead) - - return dek_aead # TODO: Cache this value. diff --git a/codeforlife/models/data_encryption_key_field.py b/codeforlife/models/data_encryption_key_field.py new file mode 100644 index 00000000..600b74b4 --- /dev/null +++ b/codeforlife/models/data_encryption_key_field.py @@ -0,0 +1,106 @@ +import typing as t +from functools import cached_property +from io import BytesIO + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.functional import SimpleLazyObject +from django.utils.translation import gettext_lazy as _ +from tink import ( # type: ignore[import-untyped] + BinaryKeysetReader, + BinaryKeysetWriter, + new_keyset_handle, + read_keyset_handle, +) +from tink.aead import Aead, aead_key_templates # type: ignore[import-untyped] +from tink.integration.gcpkms import GcpKmsClient # type: ignore[import-untyped] + +from ..models import Model +from ..types import KwArgs + + +class DataEncryptionKeyField(models.BinaryField): + """ + A custom BinaryField to store a encrypted data encryption key (DEK). + """ + + _master_key_aead: Aead = SimpleLazyObject( + lambda: GcpKmsClient( + settings.KMS_MASTER_KEY_URI, settings.KMS_CREDENTIALS_PATH + ).get_aead(settings.KMS_MASTER_KEY_URI) + ) + + default_verbose_name = "data encryption key" + default_help_text = ( + "The encrypted data encryption key (DEK) for this model." + ) + + @classmethod + def _set_init_kwargs(cls, kwargs: KwArgs): + kwargs["editable"] = False + kwargs["default"] = cls.create_dek + kwargs.setdefault("verbose_name", _(cls.default_verbose_name)) + kwargs.setdefault("help_text", _(cls.default_help_text)) + + def __init__(self, **kwargs): + if kwargs.get("editable", False): + raise ValidationError( + "DataEncryptionKeyField cannot be editable.", + code="editable_not_allowed", + ) + if "default" in kwargs: + raise ValidationError( + "DataEncryptionKeyField cannot have a default value.", + code="default_not_allowed", + ) + + self._set_init_kwargs(kwargs) + super().__init__(**kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + self._set_init_kwargs(kwargs) + return name, path, args, kwargs + + # TODO: inherit EncryptedBinaryField + # def contribute_to_class(self, cls, name, private_only=False): + # super().contribute_to_class(cls, name, private_only) + + @classmethod + def create_dek(cls): + """ + Generates a new random AES-256-GCM key, wraps it with Cloud KMS, + and returns the binary blob for storage. + """ + stream = BytesIO() + new_keyset_handle(key_template=aead_key_templates.AES256_GCM).write( + keyset_writer=BinaryKeysetWriter(stream), + master_key_primitive=cls._master_key_aead, + ) + return stream.getvalue() + + @cached_property + def aead(self): + """Return the AEAD primitive for this data encryption key.""" + + def get_aead(model: Model): + dek: t.Optional[bytes] = getattr(model, self.name, None) + if not dek: + raise ValueError("The data encryption key (DEK) is missing.") + + # dek # TODO: Cache this value. + + return read_keyset_handle( + keyset_reader=BinaryKeysetReader(dek), + master_key_aead=self._master_key_aead, + ).primitive(Aead) + + # Create a property with getter. Cast to Aead for mypy. + return t.cast(Aead, property(fget=get_aead)) + + @classmethod + def initialize(cls, **kwargs): + """Helpers to create a new DEK and return its AEAD primitive.""" + dek = cls(**kwargs) + return dek, dek.aead From 9f45022510e5f91f4405951161e7193bcd0298f4 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 13 Jan 2026 17:28:57 +0000 Subject: [PATCH 03/45] quick save --- codeforlife/models/__init__.py | 2 +- codeforlife/models/base.py | 15 +---- .../models/data_encryption_key_field.py | 13 ++-- ...inary_field.py => encrypted_text_field.py} | 65 +++++++++---------- 4 files changed, 37 insertions(+), 58 deletions(-) rename codeforlife/models/{encrypted_binary_field.py => encrypted_text_field.py} (64%) diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 80494f8a..7579cc53 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -8,5 +8,5 @@ from .base import * from .base_session_store import BaseSessionStore from .data_encryption_key_field import DataEncryptionKeyField -from .encrypted_binary_field import EncryptedBinaryField from .encrypted_char_field import EncryptedCharField +from .encrypted_text_field import EncryptedTextField diff --git a/codeforlife/models/base.py b/codeforlife/models/base.py index aae0e470..d9a33c49 100644 --- a/codeforlife/models/base.py +++ b/codeforlife/models/base.py @@ -7,14 +7,13 @@ import typing as t -from django.core.exceptions import ValidationError from django.db import models if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta from tink.aead import Aead - from .encrypted_binary_field import EncryptedBinaryField + from .encrypted_text_field import EncryptedTextField else: TypedModelMeta = object @@ -55,20 +54,10 @@ class Model(models.Model): """Base for all models.""" ASSOCIATED_DATA: str - _ENCRYPTED_FIELDS: t.List["EncryptedBinaryField"] = [] + _ENCRYPTED_FIELDS: t.List["EncryptedTextField"] = [] objects: models.Manager[t.Self] # = Manager() - def __init_subclass__(cls, *args, **kwargs): - super().__init_subclass__(*args, **kwargs) - - if not cls._meta.abstract and not hasattr(cls, "ASSOCIATED_DATA"): - raise ValidationError( - f"Model '{cls.__name__}' must define an" - " ASSOCIATED_DATA attribute.", - code="no_associated_data", - ) - class Meta(TypedModelMeta): abstract = True diff --git a/codeforlife/models/data_encryption_key_field.py b/codeforlife/models/data_encryption_key_field.py index 600b74b4..d6f69699 100644 --- a/codeforlife/models/data_encryption_key_field.py +++ b/codeforlife/models/data_encryption_key_field.py @@ -36,12 +36,11 @@ class DataEncryptionKeyField(models.BinaryField): "The encrypted data encryption key (DEK) for this model." ) - @classmethod - def _set_init_kwargs(cls, kwargs: KwArgs): + def _set_init_kwargs(self, kwargs: KwArgs): kwargs["editable"] = False - kwargs["default"] = cls.create_dek - kwargs.setdefault("verbose_name", _(cls.default_verbose_name)) - kwargs.setdefault("help_text", _(cls.default_help_text)) + kwargs["default"] = self.create_dek + kwargs.setdefault("verbose_name", _(self.default_verbose_name)) + kwargs.setdefault("help_text", _(self.default_help_text)) def __init__(self, **kwargs): if kwargs.get("editable", False): @@ -63,10 +62,6 @@ def deconstruct(self): self._set_init_kwargs(kwargs) return name, path, args, kwargs - # TODO: inherit EncryptedBinaryField - # def contribute_to_class(self, cls, name, private_only=False): - # super().contribute_to_class(cls, name, private_only) - @classmethod def create_dek(cls): """ diff --git a/codeforlife/models/encrypted_binary_field.py b/codeforlife/models/encrypted_text_field.py similarity index 64% rename from codeforlife/models/encrypted_binary_field.py rename to codeforlife/models/encrypted_text_field.py index ab41daca..24de955a 100644 --- a/codeforlife/models/encrypted_binary_field.py +++ b/codeforlife/models/encrypted_text_field.py @@ -9,10 +9,11 @@ from django.core.exceptions import ValidationError from django.db import models -from ..models import DataEncryptionKeyModel, Model +from ..types import KwArgs +from .base import Model -class EncryptedBinaryField(models.BinaryField): +class EncryptedTextField(models.BinaryField): """ A custom BinaryField that registers itself as an encrypted field on the model class. @@ -20,21 +21,23 @@ class EncryptedBinaryField(models.BinaryField): model: t.Type[Model] - def __init__(self, associated_data: str, *args, **kwargs): + def _set_init_kwargs(self, kwargs: KwArgs): + kwargs.setdefault("db_column", self.associated_data) + + def __init__(self, associated_data: str, **kwargs): if not associated_data: raise ValidationError( "Associated data cannot be empty.", code="no_associated_data" ) self.associated_data = associated_data - # Set db_column to associated_data by default. - kwargs.setdefault("db_column", associated_data) - - super().__init__(*args, **kwargs) + self._set_init_kwargs(kwargs) + super().__init__(**kwargs) def deconstruct(self): name, path, args, kwargs = super().deconstruct() kwargs["associated_data"] = self.associated_data + self._set_init_kwargs(kwargs) return name, path, args, kwargs def contribute_to_class(self, cls, name, private_only=False): @@ -75,46 +78,38 @@ def contribute_to_class(self, cls, name, private_only=False): encrypted_fields.append(self) + @property + def _associated_data(self): + """Returns the fully qualified associated data for this field.""" + return f"{self.model.ASSOCIATED_DATA}:{self.associated_data}".encode() + @cached_property def getter_and_setter(self): """Returns a property that gets/sets the decrypted/encrypted value.""" - field = self - - def to_associated_data(model: Model, dek_model: DataEncryptionKeyModel): - """Generates the Associated Data (AD) for encryption/decryption.""" - return ":".join( - [ - dek_model.ASSOCIATED_DATA, - dek_model.pk, - model.ASSOCIATED_DATA, - field.associated_data, - ] - ).encode() - - def decrypt_value(self: Model): + + def decrypt_value(model: Model): """Decrypts a single value using the DEK and associated data.""" - ciphertext: t.Optional[bytes] = getattr(self, field.name) + ciphertext: t.Optional[bytes] = getattr(model, self.name) if ciphertext is None: return None - dek_model = self.get_data_encryption_key_model() - return dek_model.data_key_aead.decrypt( + return model.dek_aead.decrypt( ciphertext=ciphertext, - associated_data=to_associated_data(self, dek_model), + associated_data=self._associated_data, ).decode() - def encrypt_value(self: Model, plaintext: t.Optional[str]): + def encrypt_value(model: Model, plaintext: t.Optional[str]): """Encrypts a single value using the DEK and associated data.""" - if plaintext is None: - value = None - else: - dek_model = self.get_data_encryption_key_model() - value = dek_model.data_key_aead.encrypt( + value = ( + None + if plaintext is None + else model.dek_aead.encrypt( plaintext=plaintext.encode(), - associated_data=to_associated_data(self, dek_model), + associated_data=self._associated_data, ) + ) - setattr(self, field.name, value) + setattr(model, self.name, value) # Create property with getter and setter. Cast to str for mypy. return t.cast(str, property(fget=decrypt_value, fset=encrypt_value)) @@ -122,5 +117,5 @@ def encrypt_value(self: Model, plaintext: t.Optional[str]): @classmethod def initialize(cls, associated_data: str, *args, **kwargs): """Helper to create an EncryptedBinaryField and its property.""" - encrypted_binary_field = cls(associated_data, *args, **kwargs) - return encrypted_binary_field, encrypted_binary_field.getter_and_setter + encrypted_text_field = cls(associated_data, *args, **kwargs) + return encrypted_text_field, encrypted_text_field.getter_and_setter From ce4f3decda1784dce64f770d728047f11a113814 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 14 Jan 2026 12:02:02 +0000 Subject: [PATCH 04/45] bas encrypted model and field --- codeforlife/models/__init__.py | 2 +- codeforlife/models/base.py | 58 +------- codeforlife/models/base_encrypted_field.py | 156 ++++++++++++++++++++ codeforlife/models/encrypted.py | 71 +++++++++ codeforlife/models/encrypted_char_field.py | 72 --------- codeforlife/models/encrypted_text_field.py | 119 +-------------- codeforlife/user/models/otp_bypass_token.py | 6 +- 7 files changed, 239 insertions(+), 245 deletions(-) create mode 100644 codeforlife/models/base_encrypted_field.py create mode 100644 codeforlife/models/encrypted.py delete mode 100644 codeforlife/models/encrypted_char_field.py diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 7579cc53..21e62892 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -6,7 +6,7 @@ from .abstract_base_session import AbstractBaseSession from .abstract_base_user import AbstractBaseUser from .base import * +from .base_encrypted_field import BaseEncryptedField from .base_session_store import BaseSessionStore from .data_encryption_key_field import DataEncryptionKeyField -from .encrypted_char_field import EncryptedCharField from .encrypted_text_field import EncryptedTextField diff --git a/codeforlife/models/base.py b/codeforlife/models/base.py index d9a33c49..8da23226 100644 --- a/codeforlife/models/base.py +++ b/codeforlife/models/base.py @@ -11,73 +11,17 @@ if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta - from tink.aead import Aead - - from .encrypted_text_field import EncryptedTextField else: TypedModelMeta = object -# class Manager(models.Manager["AnyModel"], t.Generic["AnyModel"]): -# """Base manager for all models.""" - -# def bulk_create(self, objs, batch_size=None, ignore_conflicts=False): -# """ -# Intercepts bulk_create to encrypt data in memory before saving. -# """ -# for obj in objs: -# if hasattr(obj, "ensure_key_exists"): -# obj.ensure_key_exists() -# if hasattr(obj, "encrypt_all_fields"): -# obj.encrypt_all_fields() - -# return super().bulk_create( -# objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts -# ) - -# def update(self, **kwargs): -# """ -# Block standard update() on encrypted fields because it bypasses the -# per-row unique keys. -# """ -# if hasattr(self.model, "ENCRYPTED_FIELDS"): -# if any(field in kwargs for field in self.model.ENCRYPTED_FIELDS): -# raise NotImplementedError( -# "Cannot use .update() on encrypted fields. " -# "Use .bulk_update() or iterate and save() instead." -# ) - -# return super().update(**kwargs) - - class Model(models.Model): """Base for all models.""" - ASSOCIATED_DATA: str - _ENCRYPTED_FIELDS: t.List["EncryptedTextField"] = [] - - objects: models.Manager[t.Self] # = Manager() + objects: models.Manager[t.Self] class Meta(TypedModelMeta): abstract = True - @property - def dek_aead(self) -> "Aead": - """Gets the AEAD primitive for this model's DEK.""" - raise NotImplementedError() - - def encrypt_all_fields(self): - """ - Helper called by Manager.bulk_create(). - Forces the encryption of all properties into their DB fields. - """ - for field in self._ENCRYPTED_FIELDS: - plaintext = getattr( - self, field.name, None - ) # field.getter_and_setter.fget(self) - if plaintext: - # The setter on the property will handle encryption - setattr(self, field.name, plaintext) - AnyModel = t.TypeVar("AnyModel", bound=Model) diff --git a/codeforlife/models/base_encrypted_field.py b/codeforlife/models/base_encrypted_field.py new file mode 100644 index 00000000..aa32e810 --- /dev/null +++ b/codeforlife/models/base_encrypted_field.py @@ -0,0 +1,156 @@ +import typing as t +from functools import cached_property + +from django.core.exceptions import ValidationError +from django.db import models + +from ..types import Args, KwArgs +from .encrypted import EncryptedModel + +T = t.TypeVar("T") + + +class BaseEncryptedField(models.BinaryField, t.Generic[T]): + """Encrypted field base class.""" + + model: t.Type[EncryptedModel] + + # Whether to allows decrypting/encrypting the value via the property. + decrypt_value = True + encrypt_value = True + + def set_init_kwargs(self, kwargs: KwArgs): + """Sets common init kwargs.""" + kwargs.setdefault("db_column", self.associated_data) + + def __init__(self, associated_data: str, **kwargs): + if not associated_data: + raise ValidationError( + "Associated data cannot be empty.", + code="no_associated_data", + ) + self.associated_data = associated_data + + self.set_init_kwargs(kwargs) + super().__init__(**kwargs) + + def deconstruct(self): + name, path, args, kwargs = t.cast( + t.Tuple[str, str, Args, KwArgs], super().deconstruct() + ) + + self.set_init_kwargs(kwargs) + kwargs["associated_data"] = self.associated_data + + return name, path, args, kwargs + + def contribute_to_class(self, cls, name, private_only=False): + super().contribute_to_class(cls, name, private_only) + + # Ensure the model subclasses EncryptedModel. + if not issubclass(cls, EncryptedModel): + raise ValidationError( + f"'{cls.__module__}.{cls.__name__}' must subclass" + f" '{EncryptedModel.__module__}.{EncryptedModel.__name__}'.", + code="invalid_model_base_class", + ) + + # Ensure the model defines associated_data correctly. + if not cls._meta.abstract: + if not hasattr(cls, "associated_data"): + raise ValidationError( + f"'{cls.__module__}.{cls.__name__}' must define an" + " associated_data attribute.", + code="no_associated_data", + ) + + if not isinstance(cls.associated_data, str): + raise ValidationError( + f"'{cls.__module__}.{cls.__name__}.associated_data' must be" + " a string.", + code="invalid_associated_data_type", + ) + + if not cls.associated_data: + raise ValidationError( + f"'{cls.__module__}.{cls.__name__}.associated_data' cannot" + " be empty.", + code="empty_associated_data", + ) + + # Ensure no duplicate encrypted fields. + if self in cls.ENCRYPTED_FIELDS: + raise ValidationError( + "Encrypted field already registered.", + code="already_registered", + ) + + # Ensure no duplicate associated data. + for field in cls.ENCRYPTED_FIELDS: + if self.associated_data == field.associated_data: + raise ValidationError( + f"The encrypted fields '{self.name}' and '{field.name}'" + " have the same associated data.", + code="associated_data_already_used", + ) + + # Register this field as an encrypted field on the model. + cls.ENCRYPTED_FIELDS.append(self) + + def bytes_to_value(self, data: bytes) -> T: + """Converts decrypted bytes to the field value.""" + raise NotImplementedError() + + def value_to_bytes(self, value: T) -> bytes: + """Converts the field value to bytes for encryption.""" + raise NotImplementedError() + + @property + def _associated_data(self): + """Returns the fully qualified associated data for this field.""" + return f"{self.model.associated_data}:{self.associated_data}".encode() + + @cached_property + def value(self): + """Returns a property that gets/sets the decrypted/encrypted value.""" + + def decrypt_value(model: EncryptedModel): + """Decrypts a single value using the DEK and associated data.""" + ciphertext: t.Optional[bytes] = getattr(model, self.name) + if ciphertext is None: + return None + + data = model.dek_aead.decrypt( + ciphertext=ciphertext, + associated_data=self._associated_data, + ) + + return self.bytes_to_value(data) + + def encrypt_value(model: EncryptedModel, plaintext: t.Optional[T]): + """Encrypts a single value using the DEK and associated data.""" + value = ( + None + if plaintext is None + else model.dek_aead.encrypt( + plaintext=self.value_to_bytes(plaintext), + associated_data=self._associated_data, + ) + ) + + setattr(model, self.name, value) + + # Create property with getter and/or setter. Cast to T for mypy. + return t.cast( + T, + property( + fget=decrypt_value if self.decrypt_value else None, + fset=encrypt_value if self.encrypt_value else None, + ), + ) + + @classmethod + def initialize(cls, associated_data: str, **kwargs): + """Helper to create an encrypted field and its value-property.""" + encrypted_field = cls(associated_data=associated_data, **kwargs) + return encrypted_field, encrypted_field.value diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py new file mode 100644 index 00000000..a5c8c489 --- /dev/null +++ b/codeforlife/models/encrypted.py @@ -0,0 +1,71 @@ +import typing as t + +from .base import Model + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta + from tink.aead import Aead + + from .base_encrypted_field import BaseEncryptedField +else: + TypedModelMeta = object + +# class Manager(models.Manager["AnyModel"], t.Generic["AnyModel"]): +# """Base manager for all models.""" + +# def bulk_create(self, objs, batch_size=None, ignore_conflicts=False): +# """ +# Intercepts bulk_create to encrypt data in memory before saving. +# """ +# for obj in objs: +# if hasattr(obj, "ensure_key_exists"): +# obj.ensure_key_exists() +# if hasattr(obj, "encrypt_all_fields"): +# obj.encrypt_all_fields() + +# return super().bulk_create( +# objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts +# ) + +# def update(self, **kwargs): +# """ +# Block standard update() on encrypted fields because it bypasses the +# per-row unique keys. +# """ +# if hasattr(self.model, "ENCRYPTED_FIELDS"): +# if any(field in kwargs for field in self.model.ENCRYPTED_FIELDS): +# raise NotImplementedError( +# "Cannot use .update() on encrypted fields. " +# "Use .bulk_update() or iterate and save() instead." +# ) + +# return super().update(**kwargs) + + +class EncryptedModel(Model): + """Base for all models with encrypted fields.""" + + ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] = [] + + associated_data: str + + class Meta(TypedModelMeta): + abstract = True + + @property + def dek_aead(self) -> "Aead": + """Gets the AEAD primitive for this model's DEK.""" + raise NotImplementedError() + + def encrypt_all_fields(self): + """ + Helper called by Manager.bulk_create(). + Forces the encryption of all properties into their DB fields. + """ + for field in self.ENCRYPTED_FIELDS: + plaintext = getattr( + self, field.name, None + ) # field.getter_and_setter.fget(self) + if plaintext: + # The setter on the property will handle encryption + setattr(self, field.name, plaintext) diff --git a/codeforlife/models/encrypted_char_field.py b/codeforlife/models/encrypted_char_field.py deleted file mode 100644 index 056e94e0..00000000 --- a/codeforlife/models/encrypted_char_field.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -© Ocado Group -Created on 12/08/2025 at 10:28:24(+01:00). -""" - -import typing as t - -from cryptography.fernet import Fernet -from django.conf import settings -from django.db import models - - -class EncryptedCharField(models.CharField): - """ - A custom CharField that encrypts data before saving and decrypts it when - retrieved. - """ - - _fernet = Fernet(settings.SECRET_KEY) - _prefix = "ENC:" - - def __init__(self, *args, **kwargs): - kwargs["max_length"] += len(self._prefix) - super().__init__(*args, **kwargs) - - # pylint: disable-next=unused-argument - def from_db_value(self, value: t.Optional[str], expression, connection): - """ - Converts a value as returned by the database to a Python object. It is - the reverse of get_prep_value(). - - https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects - """ - if isinstance(value, str): - return self.decrypt_value(value) - return value - - def to_python(self, value: t.Optional[str]): - """ - Converts the value into the correct Python object. It acts as the - reverse of value_to_string(), and is also called in clean(). - - https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects - """ - if isinstance(value, str): - return self.decrypt_value(value) - return value - - def get_prep_value(self, value: t.Optional[str]): - """ - 'value' is the current value of the model's attribute, and the method - should return data in a format that has been prepared for use as a - parameter in a query. - - https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-python-objects-to-query-values - """ - if isinstance(value, str): - return self.encrypt_value(value) - return value - - def encrypt_value(self, value: str): - """Encrypt the value if it's not encrypted.""" - if not value.startswith(self._prefix): - return self._prefix + self._fernet.encrypt(value.encode()).decode() - return value - - def decrypt_value(self, value: str): - """Decrpyt the value if it's encrypted..""" - if value.startswith(self._prefix): - value = value[len(self._prefix) :] - return self._fernet.decrypt(value).decode() - return value diff --git a/codeforlife/models/encrypted_text_field.py b/codeforlife/models/encrypted_text_field.py index 24de955a..4756459c 100644 --- a/codeforlife/models/encrypted_text_field.py +++ b/codeforlife/models/encrypted_text_field.py @@ -3,119 +3,14 @@ Created on 12/01/2026 at 09:17:46(+00:00). """ -import typing as t -from functools import cached_property +from .base_encrypted_field import BaseEncryptedField -from django.core.exceptions import ValidationError -from django.db import models -from ..types import KwArgs -from .base import Model +class EncryptedTextField(BaseEncryptedField[str]): + """An encrypted text field.""" + def bytes_to_value(self, data): + return data.decode() -class EncryptedTextField(models.BinaryField): - """ - A custom BinaryField that registers itself as an encrypted field on the - model class. - """ - - model: t.Type[Model] - - def _set_init_kwargs(self, kwargs: KwArgs): - kwargs.setdefault("db_column", self.associated_data) - - def __init__(self, associated_data: str, **kwargs): - if not associated_data: - raise ValidationError( - "Associated data cannot be empty.", code="no_associated_data" - ) - self.associated_data = associated_data - - self._set_init_kwargs(kwargs) - super().__init__(**kwargs) - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - kwargs["associated_data"] = self.associated_data - self._set_init_kwargs(kwargs) - return name, path, args, kwargs - - def contribute_to_class(self, cls, name, private_only=False): - super().contribute_to_class(cls, name, private_only) - - if not cls._meta.abstract and not hasattr(cls, "ASSOCIATED_DATA"): - raise ValidationError( - f"Model '{cls.__module__}.{cls.__name__}' must define an" - " ASSOCIATED_DATA attribute.", - code="no_associated_data", - ) - - if not issubclass(cls, Model): - raise ValidationError( - f"{cls.__module__}.{cls.__name__} must subclass" - f" {Model.__module__}.{Model.__name__}.", - code="invalid_model_base_class", - ) - - # pylint: disable-next=protected-access - encrypted_fields = cls._ENCRYPTED_FIELDS - - if self in encrypted_fields: - raise ValidationError( - "Encrypted field already registered.", - code="already_registered", - ) - - if any( - self.associated_data == field.associated_data - for field in encrypted_fields - ): - raise ValidationError( - f"Associated data '{self.associated_data}' already used in" - " another encrypted field.", - code="associated_data_already_used", - ) - - encrypted_fields.append(self) - - @property - def _associated_data(self): - """Returns the fully qualified associated data for this field.""" - return f"{self.model.ASSOCIATED_DATA}:{self.associated_data}".encode() - - @cached_property - def getter_and_setter(self): - """Returns a property that gets/sets the decrypted/encrypted value.""" - - def decrypt_value(model: Model): - """Decrypts a single value using the DEK and associated data.""" - ciphertext: t.Optional[bytes] = getattr(model, self.name) - if ciphertext is None: - return None - - return model.dek_aead.decrypt( - ciphertext=ciphertext, - associated_data=self._associated_data, - ).decode() - - def encrypt_value(model: Model, plaintext: t.Optional[str]): - """Encrypts a single value using the DEK and associated data.""" - value = ( - None - if plaintext is None - else model.dek_aead.encrypt( - plaintext=plaintext.encode(), - associated_data=self._associated_data, - ) - ) - - setattr(model, self.name, value) - - # Create property with getter and setter. Cast to str for mypy. - return t.cast(str, property(fget=decrypt_value, fset=encrypt_value)) - - @classmethod - def initialize(cls, associated_data: str, *args, **kwargs): - """Helper to create an EncryptedBinaryField and its property.""" - encrypted_text_field = cls(associated_data, *args, **kwargs) - return encrypted_text_field, encrypted_text_field.getter_and_setter + def value_to_bytes(self, value): + return value.encode() diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 692d0815..5ddd2a02 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -12,7 +12,7 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ -from ...models import EncryptedBinaryField, Model +from ...models import EncryptedTextField, Model from ...types import Validators from ...validators import CharSetValidatorBuilder from .user import User @@ -26,7 +26,7 @@ class OtpBypassToken(Model): """A single use token to bypass a user's OTP authentication factor.""" - ASSOCIATED_DATA = "otp_bypass_token" + associated_data = "otp_bypass_token" length = 8 allowed_chars = string.ascii_lowercase max_count = 10 @@ -73,7 +73,7 @@ def bulk_create(self, user: User): # type: ignore[override] on_delete=models.CASCADE, ) - _token, token = EncryptedBinaryField.initialize( + _token, token = EncryptedTextField.initialize( associated_data="token", verbose_name=_("token"), help_text=_("The encrypted equivalent of the token."), From f9f300f461f5616ce4a38e4878316fb95b98739d Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 14 Jan 2026 15:13:51 +0000 Subject: [PATCH 05/45] temp --- codeforlife/models/__init__.py | 1 + codeforlife/models/base_encrypted_field.py | 17 ++-- codeforlife/models/encrypted.py | 94 ++++++++++----------- codeforlife/models/encrypted_test.py | 39 +++++++++ codeforlife/user/migrations/0001_initial.py | 6 +- codeforlife/user/models/otp_bypass_token.py | 16 ++-- 6 files changed, 110 insertions(+), 63 deletions(-) create mode 100644 codeforlife/models/encrypted_test.py diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 21e62892..33ae65ee 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -9,4 +9,5 @@ from .base_encrypted_field import BaseEncryptedField from .base_session_store import BaseSessionStore from .data_encryption_key_field import DataEncryptionKeyField +from .encrypted import EncryptedModel from .encrypted_text_field import EncryptedTextField diff --git a/codeforlife/models/base_encrypted_field.py b/codeforlife/models/base_encrypted_field.py index aa32e810..0faef3be 100644 --- a/codeforlife/models/base_encrypted_field.py +++ b/codeforlife/models/base_encrypted_field.py @@ -47,6 +47,10 @@ def deconstruct(self): def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name, private_only) + # Skip fake models used for migrations. + if cls.__module__ == "__fake__": + return + # Ensure the model subclasses EncryptedModel. if not issubclass(cls, EncryptedModel): raise ValidationError( @@ -140,15 +144,14 @@ def encrypt_value(model: EncryptedModel, plaintext: t.Optional[T]): setattr(model, self.name, value) - # Create property with getter and/or setter. Cast to T for mypy. - return t.cast( - T, - property( - fget=decrypt_value if self.decrypt_value else None, - fset=encrypt_value if self.encrypt_value else None, - ), + # Create property with getter and/or setter. + value = property( + fget=decrypt_value if self.decrypt_value else None, + fset=encrypt_value if self.encrypt_value else None, ) + return t.cast(T, value) # Cast to T for mypy. + @classmethod def initialize(cls, associated_data: str, **kwargs): """Helper to create an encrypted field and its value-property.""" diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index a5c8c489..d201f5c7 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -1,5 +1,8 @@ import typing as t +from django.core.exceptions import ValidationError +from django.db import models + from .base import Model if t.TYPE_CHECKING: @@ -10,41 +13,8 @@ else: TypedModelMeta = object -# class Manager(models.Manager["AnyModel"], t.Generic["AnyModel"]): -# """Base manager for all models.""" - -# def bulk_create(self, objs, batch_size=None, ignore_conflicts=False): -# """ -# Intercepts bulk_create to encrypt data in memory before saving. -# """ -# for obj in objs: -# if hasattr(obj, "ensure_key_exists"): -# obj.ensure_key_exists() -# if hasattr(obj, "encrypt_all_fields"): -# obj.encrypt_all_fields() - -# return super().bulk_create( -# objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts -# ) - -# def update(self, **kwargs): -# """ -# Block standard update() on encrypted fields because it bypasses the -# per-row unique keys. -# """ -# if hasattr(self.model, "ENCRYPTED_FIELDS"): -# if any(field in kwargs for field in self.model.ENCRYPTED_FIELDS): -# raise NotImplementedError( -# "Cannot use .update() on encrypted fields. " -# "Use .bulk_update() or iterate and save() instead." -# ) - -# return super().update(**kwargs) - - -class EncryptedModel(Model): - """Base for all models with encrypted fields.""" +class _EncryptedModel(Model): ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] = [] associated_data: str @@ -52,20 +22,50 @@ class EncryptedModel(Model): class Meta(TypedModelMeta): abstract = True + +AnyEncryptedModel = t.TypeVar("AnyEncryptedModel", bound=_EncryptedModel) + + +class EncryptedModel(_EncryptedModel): + """Base for all models with encrypted fields.""" + + def __init__(self, **kwargs): + for name in kwargs: + if any(field.name == name for field in self.ENCRYPTED_FIELDS): + raise ValidationError( + f"Cannot set encrypted field '{name}' via __init__." + " Set the property after initialization instead.", + code="cannot_set_encrypted_field", + ) + + super().__init__(**kwargs) + + class Manager( + models.Manager[AnyEncryptedModel], t.Generic[AnyEncryptedModel] + ): + """Base manager for models with encrypted fields.""" + + def update(self, **kwargs): + """Ensure encrypted fields are not updated via 'update()'.""" + for name in kwargs: + if any( + field.name == name for field in self.model.ENCRYPTED_FIELDS + ): + raise ValidationError( + f"Cannot update encrypted field '{name}' via" + " 'update()'. Set the property on each instance" + " instead.", + code="cannot_update_encrypted_field", + ) + + return super().update(**kwargs) + + objects: Manager[t.Self] = Manager() # type: ignore[assignment] + + class Meta(TypedModelMeta): + abstract = True + @property def dek_aead(self) -> "Aead": """Gets the AEAD primitive for this model's DEK.""" raise NotImplementedError() - - def encrypt_all_fields(self): - """ - Helper called by Manager.bulk_create(). - Forces the encryption of all properties into their DB fields. - """ - for field in self.ENCRYPTED_FIELDS: - plaintext = getattr( - self, field.name, None - ) # field.getter_and_setter.fget(self) - if plaintext: - # The setter on the property will handle encryption - setattr(self, field.name, plaintext) diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py new file mode 100644 index 00000000..4d27f113 --- /dev/null +++ b/codeforlife/models/encrypted_test.py @@ -0,0 +1,39 @@ +import typing as t + +from ..tests import ModelTestCase +from .encrypted import EncryptedModel +from .encrypted_text_field import EncryptedTextField + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + +# pylint: disable=missing-class-docstring +# pylint: disable=too-few-public-methods + + +# pylint: disable-next=abstract-method +class Person(EncryptedModel): + associated_data = "person" + + _name, name = EncryptedTextField.initialize(associated_data="name") + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + +class EncryptedModelTestCase(ModelTestCase[EncryptedModel]): + def test_init__cannot_set_encrypted_field(self): + """Cannot set encrypted field via __init__.""" + with self.assert_raises_validation_error( + code="cannot_set_encrypted_field" + ): + Person(_name="Alice") + + def test_objects___update__cannot_update_encrypted_field(self): + """Cannot update encrypted field via objects.update().""" + with self.assert_raises_validation_error( + code="cannot_update_encrypted_field" + ): + Person.objects.update(_name="Alice") diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index fea5162e..f9203545 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,6 +1,6 @@ -# Generated by Django 5.1.15 on 2026-01-13 09:15 +# Generated by Django 5.1.15 on 2026-01-14 14:11 -import codeforlife.models.encrypted_binary_field +import codeforlife.models.encrypted_text_field import codeforlife.user.models.user.admin_school_teacher import codeforlife.user.models.user.contactable import codeforlife.user.models.user.google @@ -115,7 +115,7 @@ class Migration(migrations.Migration): ), ( "_token", - codeforlife.models.encrypted_binary_field.EncryptedBinaryField( + codeforlife.models.encrypted_text_field.EncryptedTextField( associated_data="token", db_column="token", help_text="The encrypted equivalent of the token.", diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 5ddd2a02..0ec26fbf 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -12,7 +12,7 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ -from ...models import EncryptedTextField, Model +from ...models import EncryptedModel, EncryptedTextField from ...types import Validators from ...validators import CharSetValidatorBuilder from .user import User @@ -23,7 +23,7 @@ TypedModelMeta = object -class OtpBypassToken(Model): +class OtpBypassToken(EncryptedModel): """A single use token to bypass a user's OTP authentication factor.""" associated_data = "otp_bypass_token" @@ -40,7 +40,7 @@ class OtpBypassToken(Model): ] # pylint: disable-next=missing-class-docstring,too-few-public-methods - class Manager(models.Manager["OtpBypassToken"]): + class Manager(EncryptedModel.Manager["OtpBypassToken"]): def bulk_create(self, user: User): # type: ignore[override] """Bulk create OTP-bypass tokens. @@ -61,9 +61,13 @@ def bulk_create(self, user: User): # type: ignore[override] user.otp_bypass_tokens.all().delete() - return super().bulk_create( - [OtpBypassToken(user=user, token=token) for token in tokens] - ) + otp_bypass_tokens: t.List[OtpBypassToken] = [] + for token in tokens: + otp_bypass_token = OtpBypassToken(user=user) + otp_bypass_token.token = token + otp_bypass_tokens.append(otp_bypass_token) + + return super().bulk_create(otp_bypass_tokens) objects: Manager = Manager() From 70749a09368789560e7a5d065ae59cf39976bb6b Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 15 Jan 2026 16:10:51 +0000 Subject: [PATCH 06/45] encrypted model tests --- codeforlife/models/base_encrypted_field.py | 23 ------- codeforlife/models/encrypted.py | 76 ++++++++++++++++++++++ codeforlife/models/encrypted_test.py | 65 ++++++++++++++++++ codeforlife/tests/model.py | 20 ++++++ 4 files changed, 161 insertions(+), 23 deletions(-) diff --git a/codeforlife/models/base_encrypted_field.py b/codeforlife/models/base_encrypted_field.py index 0faef3be..4987feac 100644 --- a/codeforlife/models/base_encrypted_field.py +++ b/codeforlife/models/base_encrypted_field.py @@ -59,29 +59,6 @@ def contribute_to_class(self, cls, name, private_only=False): code="invalid_model_base_class", ) - # Ensure the model defines associated_data correctly. - if not cls._meta.abstract: - if not hasattr(cls, "associated_data"): - raise ValidationError( - f"'{cls.__module__}.{cls.__name__}' must define an" - " associated_data attribute.", - code="no_associated_data", - ) - - if not isinstance(cls.associated_data, str): - raise ValidationError( - f"'{cls.__module__}.{cls.__name__}.associated_data' must be" - " a string.", - code="invalid_associated_data_type", - ) - - if not cls.associated_data: - raise ValidationError( - f"'{cls.__module__}.{cls.__name__}.associated_data' cannot" - " be empty.", - code="empty_associated_data", - ) - # Ensure no duplicate encrypted fields. if self in cls.ENCRYPTED_FIELDS: raise ValidationError( diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index d201f5c7..31281e94 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -1,5 +1,7 @@ import typing as t +from django.apps import apps +from django.core import checks from django.core.exceptions import ValidationError from django.db import models @@ -40,6 +42,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + # pylint: disable-next=too-few-public-methods class Manager( models.Manager[AnyEncryptedModel], t.Generic[AnyEncryptedModel] ): @@ -65,6 +68,79 @@ def update(self, **kwargs): class Meta(TypedModelMeta): abstract = True + @classmethod + def _check_associated_data(cls, **kwargs): + """ + Check 'associated_data' values are unique across all EncryptedModel + subclasses. + """ + errors: t.List[checks.Error] = [] + + if cls._meta.abstract: + return errors + + # Ensure associated_data is defined. + if not hasattr(cls, "associated_data"): + errors.append( + checks.Error( + "Must define an associated_data attribute.", + hint=f"{cls.__module__}.{cls.__name__}", + obj=cls, + id="codeforlife.user.E001", + ) + ) + # Ensure associated_data is a string. + elif not isinstance(cls.associated_data, str): + errors.append( + checks.Error( + "associated_data must be a string.", + hint=f"{cls.__module__}.{cls.__name__}", + obj=cls, + id="codeforlife.user.E002", + ) + ) + # Ensure associated_data is not empty. + elif not cls.associated_data: + errors.append( + checks.Error( + "associated_data cannot be empty.", + hint=f"{cls.__module__}.{cls.__name__}", + obj=cls, + id="codeforlife.user.E003", + ) + ) + # Ensure associated_data is unique. + else: + for model in apps.get_models(): + if ( + not model is cls + and not model._meta.abstract + and issubclass(model, EncryptedModel) + and model.associated_data == cls.associated_data + ): + errors.append( + checks.Error( + "Duplicate 'associated_data' detected:" + f" '{cls.associated_data}'", + hint=( + f"{cls.__module__}.{cls.__name__}" + " shares this ID with" + f" {model.__module__}.{model.__name__}." + ), + obj=cls, + id="codeforlife.user.E004", + ) + ) + + return errors + + @classmethod + def check(cls, **kwargs): + """Run model checks, including custom checks for encrypted models.""" + errors = super().check(**kwargs) + errors.extend(cls._check_associated_data(**kwargs)) + return errors + @property def dek_aead(self) -> "Aead": """Gets the AEAD primitive for this model's DEK.""" diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index 4d27f113..1bd82232 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -1,6 +1,7 @@ import typing as t from ..tests import ModelTestCase +from ..user.models import OtpBypassToken from .encrypted import EncryptedModel from .encrypted_text_field import EncryptedTextField @@ -37,3 +38,67 @@ def test_objects___update__cannot_update_encrypted_field(self): code="cannot_update_encrypted_field" ): Person.objects.update(_name="Alice") + + def test_dek_aead(self): + """dek_aead raises NotImplementedError.""" + with self.assertRaises(NotImplementedError): + # pylint: disable-next=expression-not-assigned + Person().dek_aead + + def test_check__e001(self): + """Check for missing associated_data.""" + + # pylint: disable-next=abstract-method + class E001(EncryptedModel): + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + self.assert_check( + error_id="codeforlife.user.E001", + model_class=E001, + ) + + def test_check__e002(self): + """Check for string associated_data.""" + + # pylint: disable-next=abstract-method + class E002(EncryptedModel): + associated_data = 123 # type: ignore[assignment] + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + self.assert_check( + error_id="codeforlife.user.E002", + model_class=E002, + ) + + def test_check__e003(self): + """Check for non-empty associated_data.""" + + # pylint: disable-next=abstract-method + class E003(EncryptedModel): + associated_data = "" + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + self.assert_check( + error_id="codeforlife.user.E003", + model_class=E003, + ) + + def test_check__e004(self): + """Check for unique associated_data.""" + + # pylint: disable-next=abstract-method + class E004(EncryptedModel): + associated_data = OtpBypassToken.associated_data + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + self.assert_check( + error_id="codeforlife.user.E004", + model_class=E004, + ) diff --git a/codeforlife/tests/model.py b/codeforlife/tests/model.py index 541e9a2d..74285b18 100644 --- a/codeforlife/tests/model.py +++ b/codeforlife/tests/model.py @@ -94,3 +94,23 @@ def assert_get_queryset( if ordered and not queryset.ordered: queryset = queryset.order_by("pk") self.assertQuerySetEqual(queryset, values, ordered=ordered) + + def assert_check( + self, + error_id: str, + model_class: t.Optional[t.Type[AnyModel]] = None, + **kwargs, + ): + """Assert that the model check returns an error with the given ID. + + https://docs.djangoproject.com/en/5.1/topics/checks/#field-model-manager-template-engine-and-database-checks + + Args: + error_id: The check error ID to assert. + model_class: The model class to check. If None, uses the test case's + model class. + **kwargs: Additional kwargs to pass to the model's check() method. + """ + model_class = model_class or self.get_model_class() + errors = model_class.check(**kwargs) + assert any(error.id == error_id for error in errors) From f8f9883fb664a268b743d2de375e08b31cd0f6e0 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 15 Jan 2026 17:46:22 +0000 Subject: [PATCH 07/45] save tests --- codeforlife/models/base_encrypted_field.py | 6 +- .../models/base_encrypted_field_test.py | 178 ++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 codeforlife/models/base_encrypted_field_test.py diff --git a/codeforlife/models/base_encrypted_field.py b/codeforlife/models/base_encrypted_field.py index 4987feac..2106bb64 100644 --- a/codeforlife/models/base_encrypted_field.py +++ b/codeforlife/models/base_encrypted_field.py @@ -87,7 +87,7 @@ def value_to_bytes(self, value: T) -> bytes: raise NotImplementedError() @property - def _associated_data(self): + def qual_associated_data(self): """Returns the fully qualified associated data for this field.""" return f"{self.model.associated_data}:{self.associated_data}".encode() @@ -103,7 +103,7 @@ def decrypt_value(model: EncryptedModel): data = model.dek_aead.decrypt( ciphertext=ciphertext, - associated_data=self._associated_data, + associated_data=self.qual_associated_data, ) return self.bytes_to_value(data) @@ -115,7 +115,7 @@ def encrypt_value(model: EncryptedModel, plaintext: t.Optional[T]): if plaintext is None else model.dek_aead.encrypt( plaintext=self.value_to_bytes(plaintext), - associated_data=self._associated_data, + associated_data=self.qual_associated_data, ) ) diff --git a/codeforlife/models/base_encrypted_field_test.py b/codeforlife/models/base_encrypted_field_test.py new file mode 100644 index 00000000..26a533dc --- /dev/null +++ b/codeforlife/models/base_encrypted_field_test.py @@ -0,0 +1,178 @@ +import typing as t +from unittest.mock import MagicMock, patch + +from django.db import models + +from ..tests import TestCase +from .base_encrypted_field import BaseEncryptedField +from .encrypted import EncryptedModel + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + +# pylint: disable=missing-class-docstring +# pylint: disable=too-few-public-methods + + +class EncryptedModelTestCase(TestCase): + def setUp(self): + self.associated_data = "field" + self.default = b"default_encrypted_bytes" + self.field = BaseEncryptedField[str]( + associated_data=self.associated_data, default=self.default + ) + + def test_init__no_associated_data(self): + """Cannot create BaseEncryptedField with no associated data.""" + with self.assert_raises_validation_error(code="no_associated_data"): + BaseEncryptedField(associated_data="") + + def test_init(self): + """BaseEncryptedField is constructed correctly.""" + assert self.field.associated_data == self.associated_data + assert self.field.db_column == self.associated_data + + def test_deconstruct(self): + """BaseEncryptedField is deconstructed correctly.""" + _, _, _, kwargs = self.field.deconstruct() + + assert kwargs["associated_data"] == self.associated_data + assert kwargs["db_column"] == self.associated_data + + def test_contribute_to_class__invalid_model_base_class(self): + """Cannot contribute BaseEncryptedField to invalid model base class.""" + with self.assert_raises_validation_error( + code="invalid_model_base_class" + ): + # pylint: disable-next=unused-variable,abstract-method + class InvalidModel(models.Model): + field = self.field + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + def test_contribute_to_class__already_registered(self): + """Cannot contribute BaseEncryptedField that is already registered.""" + with self.assert_raises_validation_error(code="already_registered"): + # pylint: disable-next=unused-variable,abstract-method + class InvalidModel(EncryptedModel): + field = self.field + field2 = self.field + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + def test_contribute_to_class__associated_data_already_used(self): + """ + Cannot contribute BaseEncryptedField with duplicate associated data. + """ + with self.assert_raises_validation_error( + code="associated_data_already_used" + ): + # pylint: disable-next=unused-variable,abstract-method + class InvalidModel(EncryptedModel): + field = self.field + field2 = BaseEncryptedField( + associated_data=self.associated_data + ) + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + def test_bytes_to_value(self): + """bytes_to_value raises NotImplementedError.""" + with self.assertRaises(NotImplementedError): + # pylint: disable-next=expression-not-assigned + self.field.bytes_to_value(b"data") + + def test_value_to_bytes(self): + """value_to_bytes raises NotImplementedError.""" + with self.assertRaises(NotImplementedError): + # pylint: disable-next=expression-not-assigned + self.field.value_to_bytes("value") + + def test_qual_associated_data(self): + """qual_associated_data returns fully qualified associated data.""" + + # pylint: disable-next=abstract-method + class ValidModel(EncryptedModel): + associated_data = "model" + + field = self.field + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + assert ( + self.field.qual_associated_data + == f"{ValidModel.associated_data}:{self.associated_data}".encode() + ) + + def test_value(self): + """value returns a property that encrypts/decrypts the field's value.""" + field = BaseEncryptedField[str]( + associated_data="value", default=self.default + ) + + assert isinstance(field.value, property) + assert field.value.fget is not None + assert field.value.fset is not None + assert field.value.fdel is None + + dek_aead_mock = MagicMock() + dek_aead_decrypt_mock = MagicMock(return_value=b"decrypted_bytes") + dek_aead_mock.decrypt = dek_aead_decrypt_mock + dek_aead_encrypt_mock = MagicMock(return_value=b"encrypted_bytes") + dek_aead_mock.encrypt = dek_aead_encrypt_mock + + class ValidModel(EncryptedModel): + associated_data = "model" + + _field = field + value: str = field.value + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + @property + def dek_aead(self): + return dek_aead_mock + + with patch.object( + field, "bytes_to_value", return_value="value" + ) as bytes_to_value_mock, patch.object( + field, "value_to_bytes", return_value=b"bytes" + ) as value_to_bytes_mock: + instance = ValidModel() + + # Get the value. + value = instance.value + dek_aead_decrypt_mock.assert_called_once_with( + ciphertext=self.default, + associated_data=field.qual_associated_data, + ) + bytes_to_value_mock.assert_called_once_with( + dek_aead_decrypt_mock.return_value + ) + assert value == bytes_to_value_mock.return_value + + # Set the value. + value = "new_value" + instance.value = value + value_to_bytes_mock.assert_called_once_with(value) + dek_aead_encrypt_mock.assert_called_once_with( + plaintext=value_to_bytes_mock.return_value, + associated_data=field.qual_associated_data, + ) + # pylint: disable-next=protected-access + assert instance._field == dek_aead_encrypt_mock.return_value + + def test_initialize(self): + """BaseEncryptedField.initialize creates field and value property.""" + field, value = BaseEncryptedField.initialize( + associated_data=self.associated_data + ) + + assert field.value == value From a84b87ef3e5cfb4de5136ae8cea5b8b32937be20 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 15 Jan 2026 18:10:38 +0000 Subject: [PATCH 08/45] contribute to class --- codeforlife/models/base_encrypted_field_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/codeforlife/models/base_encrypted_field_test.py b/codeforlife/models/base_encrypted_field_test.py index 26a533dc..ce284d6b 100644 --- a/codeforlife/models/base_encrypted_field_test.py +++ b/codeforlife/models/base_encrypted_field_test.py @@ -64,6 +64,20 @@ class InvalidModel(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" + def test_contribute_to_class(self): + """BaseEncryptedField is contributed to class correctly.""" + + # pylint: disable-next=unused-variable,abstract-method + class TestModel(EncryptedModel): + associated_data = "model" + + field = self.field + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + assert self.field in TestModel.ENCRYPTED_FIELDS + def test_contribute_to_class__associated_data_already_used(self): """ Cannot contribute BaseEncryptedField with duplicate associated data. From 3235c348c093a7a2c3e9d9453a8298e687508362 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 15 Jan 2026 18:14:21 +0000 Subject: [PATCH 09/45] EncryptedTextFieldTestCase --- .../models/encrypted_text_field_test.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 codeforlife/models/encrypted_text_field_test.py diff --git a/codeforlife/models/encrypted_text_field_test.py b/codeforlife/models/encrypted_text_field_test.py new file mode 100644 index 00000000..d6f1fb66 --- /dev/null +++ b/codeforlife/models/encrypted_text_field_test.py @@ -0,0 +1,20 @@ +from ..tests import TestCase +from .encrypted_text_field import EncryptedTextField + + +# pylint: disable-next=missing-class-docstring +class EncryptedTextFieldTestCase(TestCase): + def setUp(self): + self.field = EncryptedTextField(associated_data="field") + + def test_bytes_to_value(self): + """bytes_to_value decodes bytes to string.""" + data = b"hello world" + value = self.field.bytes_to_value(data) + assert value == data.decode() + + def test_value_to_bytes(self): + """value_to_bytes encodes string to bytes.""" + value = "hello world" + data = self.field.value_to_bytes(value) + assert data == value.encode() From a790b34c8b44dcdc06ecf4949ab706d29b9e7322 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 16 Jan 2026 16:51:42 +0000 Subject: [PATCH 10/45] data encryption key field --- codeforlife/encryption.py | 118 ++++++++++++++++++ .../models/data_encryption_key_field.py | 62 +++------ .../models/data_encryption_key_field_test.py | 98 +++++++++++++++ codeforlife/settings/__init__.py | 1 + codeforlife/settings/custom.py | 18 --- codeforlife/settings/google.py | 34 +++++ 6 files changed, 269 insertions(+), 62 deletions(-) create mode 100644 codeforlife/encryption.py create mode 100644 codeforlife/models/data_encryption_key_field_test.py create mode 100644 codeforlife/settings/google.py diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py new file mode 100644 index 00000000..d2e0ee81 --- /dev/null +++ b/codeforlife/encryption.py @@ -0,0 +1,118 @@ +import typing as t +from base64 import b64decode, b64encode +from dataclasses import dataclass +from io import BytesIO +from unittest.mock import MagicMock, create_autospec + +from django.conf import settings +from tink import ( # type: ignore[import-untyped] + BinaryKeysetReader, + BinaryKeysetWriter, + new_keyset_handle, + read_keyset_handle, +) +from tink.aead import Aead, aead_key_templates # type: ignore[import-untyped] +from tink.aead import register as aead_register # type: ignore[import-untyped] +from tink.integration import gcpkms # type: ignore[import-untyped] + +# Shortcut to the real GcpKmsClient class. +_GcpKmsClient = gcpkms.GcpKmsClient + + +@dataclass +class FakeAead: + """A fake AEAD primitive for local testing.""" + + @staticmethod + # pylint: disable-next=unused-argument + def encrypt(plaintext: bytes, associated_data: bytes): + """Simulate ciphertext by wrapping in base64 and adding a prefix.""" + return b"fake_enc:" + b64encode(plaintext) + + @staticmethod + # pylint: disable-next=unused-argument + def decrypt(ciphertext: bytes, associated_data: bytes): + """Simulate decryption by removing prefix and base64 decoding.""" + if not ciphertext.startswith(b"fake_enc:"): + raise ValueError("Invalid ciphertext for fake mock") + + return b64decode(ciphertext.replace(b"fake_enc:", b"")) + + @classmethod + def as_mock(cls): + """Factory method to build the mock AEAD.""" + mock: MagicMock = create_autospec(Aead, instance=True) + mock.encrypt.side_effect = cls.encrypt + mock.decrypt.side_effect = cls.decrypt + + return mock + + +@dataclass +class FakeGcpKmsClient: + """A fake GcpKmsClient for local testing.""" + + key_uri: str + + @staticmethod + def register_client( + key_uri: t.Optional[str], credentials_path: t.Optional[str] + ): + """No-op for registering the fake client.""" + + # pylint: disable-next=unused-argument + def get_aead(self, key_uri: str) -> Aead: + """Return the fake AEAD primitive.""" + return FakeAead() + + @classmethod + def as_mock(cls): + """Factory method to build the mock GcpKmsClient.""" + mock: MagicMock = create_autospec(_GcpKmsClient, instance=True) + mock.get_aead.return_value = FakeAead.as_mock() + + return mock + + +def _get_kek_aead(): + """Get the AEAD primitive for the key encryption key (KEK).""" + return GcpKmsClient(key_uri=settings.GCP_KMS_KEY_URI).get_aead( + key_uri=settings.GCP_KMS_KEY_URI + ) + + +def create_dek(): + """ + Creates a new random AES-256-GCM data encryption key (DEK), wraps it with + Cloud KMS, and returns the binary blob for storage. + """ + stream = BytesIO() + new_keyset_handle(key_template=aead_key_templates.AES256_GCM).write( + keyset_writer=BinaryKeysetWriter(stream), + master_key_primitive=_get_kek_aead(), + ) + + return stream.getvalue() + + +def get_dek_aead(dek: bytes) -> Aead: + """Get the AEAD primitive for the given data encryption key (DEK).""" + if not dek: + raise ValueError("The data encryption key (DEK) is missing.") + + return read_keyset_handle( + keyset_reader=BinaryKeysetReader(dek), + master_key_aead=_get_kek_aead(), + ).primitive(Aead) + + +# Ensure Tink AEAD is registered. +aead_register() + +# Get the GcpKmsClient class depending on the environment. +GcpKmsClient = FakeGcpKmsClient if settings.ENV == "local" else _GcpKmsClient + +# Register the GCP KMS client. +GcpKmsClient.register_client( + key_uri=settings.GCP_KMS_KEY_URI, credentials_path=None +) diff --git a/codeforlife/models/data_encryption_key_field.py b/codeforlife/models/data_encryption_key_field.py index d6f69699..d7697e5a 100644 --- a/codeforlife/models/data_encryption_key_field.py +++ b/codeforlife/models/data_encryption_key_field.py @@ -1,44 +1,32 @@ import typing as t from functools import cached_property -from io import BytesIO -from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.utils.functional import SimpleLazyObject from django.utils.translation import gettext_lazy as _ -from tink import ( # type: ignore[import-untyped] - BinaryKeysetReader, - BinaryKeysetWriter, - new_keyset_handle, - read_keyset_handle, -) -from tink.aead import Aead, aead_key_templates # type: ignore[import-untyped] -from tink.integration.gcpkms import GcpKmsClient # type: ignore[import-untyped] +from ..encryption import create_dek, get_dek_aead from ..models import Model from ..types import KwArgs +if t.TYPE_CHECKING: + from tink.aead import Aead # type: ignore[import-untyped] + class DataEncryptionKeyField(models.BinaryField): """ A custom BinaryField to store a encrypted data encryption key (DEK). """ - _master_key_aead: Aead = SimpleLazyObject( - lambda: GcpKmsClient( - settings.KMS_MASTER_KEY_URI, settings.KMS_CREDENTIALS_PATH - ).get_aead(settings.KMS_MASTER_KEY_URI) - ) - default_verbose_name = "data encryption key" default_help_text = ( "The encrypted data encryption key (DEK) for this model." ) - def _set_init_kwargs(self, kwargs: KwArgs): + def set_init_kwargs(self, kwargs: KwArgs): + """Sets common init kwargs.""" kwargs["editable"] = False - kwargs["default"] = self.create_dek + kwargs["default"] = create_dek kwargs.setdefault("verbose_name", _(self.default_verbose_name)) kwargs.setdefault("help_text", _(self.default_help_text)) @@ -53,46 +41,32 @@ def __init__(self, **kwargs): "DataEncryptionKeyField cannot have a default value.", code="default_not_allowed", ) + if kwargs.get("null", False): + raise ValidationError( + "DataEncryptionKeyField cannot be null.", + code="null_not_allowed", + ) - self._set_init_kwargs(kwargs) + self.set_init_kwargs(kwargs) super().__init__(**kwargs) def deconstruct(self): name, path, args, kwargs = super().deconstruct() - self._set_init_kwargs(kwargs) + self.set_init_kwargs(kwargs) return name, path, args, kwargs - @classmethod - def create_dek(cls): - """ - Generates a new random AES-256-GCM key, wraps it with Cloud KMS, - and returns the binary blob for storage. - """ - stream = BytesIO() - new_keyset_handle(key_template=aead_key_templates.AES256_GCM).write( - keyset_writer=BinaryKeysetWriter(stream), - master_key_primitive=cls._master_key_aead, - ) - return stream.getvalue() - @cached_property def aead(self): """Return the AEAD primitive for this data encryption key.""" def get_aead(model: Model): - dek: t.Optional[bytes] = getattr(model, self.name, None) - if not dek: - raise ValueError("The data encryption key (DEK) is missing.") - - # dek # TODO: Cache this value. + dek: bytes = getattr(model, self.name) - return read_keyset_handle( - keyset_reader=BinaryKeysetReader(dek), - master_key_aead=self._master_key_aead, - ).primitive(Aead) + # TODO: Cache this value. + return get_dek_aead(dek) # Create a property with getter. Cast to Aead for mypy. - return t.cast(Aead, property(fget=get_aead)) + return t.cast("Aead", property(fget=get_aead)) @classmethod def initialize(cls, **kwargs): diff --git a/codeforlife/models/data_encryption_key_field_test.py b/codeforlife/models/data_encryption_key_field_test.py new file mode 100644 index 00000000..e701fc46 --- /dev/null +++ b/codeforlife/models/data_encryption_key_field_test.py @@ -0,0 +1,98 @@ +import typing as t +from unittest.mock import MagicMock, patch + +from django.db import models + +from ..encryption import create_dek +from ..tests import TestCase +from .data_encryption_key_field import DataEncryptionKeyField + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + +# pylint: disable=missing-class-docstring +# pylint: disable=too-few-public-methods + + +class DataEncryptionKeyFieldTestCase(TestCase): + def setUp(self): + self.field: DataEncryptionKeyField = DataEncryptionKeyField() + + def test_init__editable_not_allowed(self): + """Cannot create DataEncryptionKeyField with editable=True.""" + with self.assert_raises_validation_error(code="editable_not_allowed"): + DataEncryptionKeyField(editable=True) + + def test_init__default_not_allowed(self): + """Cannot create DataEncryptionKeyField with default value.""" + with self.assert_raises_validation_error(code="default_not_allowed"): + DataEncryptionKeyField(default=b"default_value") + + def test_init__null_not_allowed(self): + """Cannot create DataEncryptionKeyField with null=True.""" + with self.assert_raises_validation_error(code="null_not_allowed"): + DataEncryptionKeyField(null=True) + + def test_init(self): + """DataEncryptionKeyField is constructed correctly.""" + assert self.field.editable is False + # pylint: disable-next=comparison-with-callable + assert self.field.default == create_dek + assert self.field.null is False + assert ( + self.field.verbose_name + == DataEncryptionKeyField.default_verbose_name + ) + assert self.field.help_text == DataEncryptionKeyField.default_help_text + + def test_deconstruct(self): + """DataEncryptionKeyField is deconstructed correctly.""" + _, _, _, kwargs = self.field.deconstruct() + + assert kwargs["editable"] is False + # pylint: disable-next=comparison-with-callable + assert kwargs["default"] == create_dek + assert kwargs["null"] is False + assert ( + kwargs["verbose_name"] + == DataEncryptionKeyField.default_verbose_name + ) + assert kwargs["help_text"] == DataEncryptionKeyField.default_help_text + + @patch( + "codeforlife.models.data_encryption_key_field.create_dek", + return_value=b"mock_dek_bytes", + ) + @patch( + "codeforlife.models.data_encryption_key_field.get_dek_aead", + return_value="mock_dek_aead", + ) + def test_aead( + self, mock_get_dek_aead: MagicMock, mock_create_dek: MagicMock + ): + """AEAD returns a property that gets the AEAD for the DEK.""" + + class ValidModel(models.Model): + associated_data = "model" + + _dek: DataEncryptionKeyField = DataEncryptionKeyField() + dek_aead = _dek.aead + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + instance = ValidModel() + mock_create_dek.assert_called_once_with() + + mock_get_dek_aead.assert_not_called() + dek_aead = instance.dek_aead + mock_get_dek_aead.assert_called_once_with(mock_create_dek.return_value) + assert dek_aead == mock_get_dek_aead.return_value + + def test_initialize(self): + """DataEncryptionKeyField.initialize creates field and aead property.""" + field, aead = DataEncryptionKeyField.initialize() + + assert field.aead == aead diff --git a/codeforlife/settings/__init__.py b/codeforlife/settings/__init__.py index 3b0d4193..0166da8d 100644 --- a/codeforlife/settings/__init__.py +++ b/codeforlife/settings/__init__.py @@ -16,5 +16,6 @@ from .custom import * from .django import * +from .google import * from .otp import * from .third_party import * diff --git a/codeforlife/settings/custom.py b/codeforlife/settings/custom.py index 294083ff..586209a5 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -126,21 +126,3 @@ def get_redis_url(): # The URL to connect to the Redis cache. REDIS_URL = get_redis_url() - -# Our Google OAuth 2.0 client credentials -# https://console.cloud.google.com/auth/clients -GOOGLE_CLIENT_ID = os.getenv( - "GOOGLE_CLIENT_ID", - "354656325390-o5n12nbaivhi4do8lalkh29q403uu9u4.apps.googleusercontent.com", -) -GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "REPLACE_ME") - -# The ID of our GCP project. -GOOGLE_CLOUD_PROJECT_ID = os.getenv( - "GOOGLE_CLOUD_PROJECT_ID", "decent-digit-629" -) - -# The ID of our BigQuery dataset. -GOOGLE_CLOUD_BIGQUERY_DATASET_ID = os.getenv( - "GOOGLE_CLOUD_BIGQUERY_DATASET_ID", "REPLACE_ME" -) diff --git a/codeforlife/settings/google.py b/codeforlife/settings/google.py new file mode 100644 index 00000000..b52197fc --- /dev/null +++ b/codeforlife/settings/google.py @@ -0,0 +1,34 @@ +import os + +# Our Google OAuth 2.0 client credentials +# https://console.cloud.google.com/auth/clients +GOOGLE_CLIENT_ID = os.getenv( + "GOOGLE_CLIENT_ID", + "354656325390-o5n12nbaivhi4do8lalkh29q403uu9u4.apps.googleusercontent.com", +) +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "REPLACE_ME") + +# The ID of our GCP project. +GOOGLE_CLOUD_PROJECT_ID = os.getenv( + "GOOGLE_CLOUD_PROJECT_ID", "decent-digit-629" +) + +# The ID of our BigQuery dataset. +GOOGLE_CLOUD_BIGQUERY_DATASET_ID = os.getenv( + "GOOGLE_CLOUD_BIGQUERY_DATASET_ID", "REPLACE_ME" +) + +# Key management service (KMS) +# https://docs.cloud.google.com/python/docs/reference/cloudkms/latest/summary_overview + +GCP_KMS_KEY_RING_LOCATION = os.getenv("GCP_KMS_KEY_RING_LOCATION", "REPLACE_ME") +GCP_KMS_KEY_RING_NAME = os.getenv("GCP_KMS_KEY_RING_NAME", "REPLACE_ME") +GCP_KMS_KEY_NAME = os.getenv("GCP_KMS_KEY_NAME", "REPLACE_ME") +# The URI of the KMS key encryption key (KEK). +GCP_KMS_KEY_URI = ( + "gcp-kms://" + f"projects/{GOOGLE_CLOUD_PROJECT_ID}/" + f"locations/{GCP_KMS_KEY_RING_LOCATION}/" + f"keyRings/{GCP_KMS_KEY_RING_NAME}/" + f"cryptoKeys/{GCP_KMS_KEY_NAME}" +) From 05778877ecd117ef5cc352aec84168f2d3133153 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 16 Jan 2026 17:18:35 +0000 Subject: [PATCH 11/45] fix --- codeforlife/encryption.py | 4 +-- .../models/base_encrypted_field_test.py | 28 ++++++++----------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py index d2e0ee81..b5a5dc5a 100644 --- a/codeforlife/encryption.py +++ b/codeforlife/encryption.py @@ -25,13 +25,13 @@ class FakeAead: @staticmethod # pylint: disable-next=unused-argument - def encrypt(plaintext: bytes, associated_data: bytes): + def encrypt(plaintext: bytes, associated_data: bytes = b""): """Simulate ciphertext by wrapping in base64 and adding a prefix.""" return b"fake_enc:" + b64encode(plaintext) @staticmethod # pylint: disable-next=unused-argument - def decrypt(ciphertext: bytes, associated_data: bytes): + def decrypt(ciphertext: bytes, associated_data: bytes = b""): """Simulate decryption by removing prefix and base64 decoding.""" if not ciphertext.startswith(b"fake_enc:"): raise ValueError("Invalid ciphertext for fake mock") diff --git a/codeforlife/models/base_encrypted_field_test.py b/codeforlife/models/base_encrypted_field_test.py index ce284d6b..41e97b74 100644 --- a/codeforlife/models/base_encrypted_field_test.py +++ b/codeforlife/models/base_encrypted_field_test.py @@ -1,8 +1,9 @@ import typing as t -from unittest.mock import MagicMock, patch +from unittest.mock import patch from django.db import models +from ..encryption import FakeAead from ..tests import TestCase from .base_encrypted_field import BaseEncryptedField from .encrypted import EncryptedModel @@ -19,7 +20,8 @@ class EncryptedModelTestCase(TestCase): def setUp(self): self.associated_data = "field" - self.default = b"default_encrypted_bytes" + self.decrypted_default = b"default_encrypted_bytes" + self.default = FakeAead.encrypt(self.decrypted_default) self.field = BaseEncryptedField[str]( associated_data=self.associated_data, default=self.default ) @@ -135,12 +137,6 @@ def test_value(self): assert field.value.fset is not None assert field.value.fdel is None - dek_aead_mock = MagicMock() - dek_aead_decrypt_mock = MagicMock(return_value=b"decrypted_bytes") - dek_aead_mock.decrypt = dek_aead_decrypt_mock - dek_aead_encrypt_mock = MagicMock(return_value=b"encrypted_bytes") - dek_aead_mock.encrypt = dek_aead_encrypt_mock - class ValidModel(EncryptedModel): associated_data = "model" @@ -150,9 +146,7 @@ class ValidModel(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - @property - def dek_aead(self): - return dek_aead_mock + dek_aead = FakeAead.as_mock() with patch.object( field, "bytes_to_value", return_value="value" @@ -163,25 +157,25 @@ def dek_aead(self): # Get the value. value = instance.value - dek_aead_decrypt_mock.assert_called_once_with( + instance.dek_aead.decrypt.assert_called_once_with( ciphertext=self.default, associated_data=field.qual_associated_data, ) - bytes_to_value_mock.assert_called_once_with( - dek_aead_decrypt_mock.return_value - ) + bytes_to_value_mock.assert_called_once_with(self.decrypted_default) assert value == bytes_to_value_mock.return_value # Set the value. value = "new_value" instance.value = value value_to_bytes_mock.assert_called_once_with(value) - dek_aead_encrypt_mock.assert_called_once_with( + instance.dek_aead.encrypt.assert_called_once_with( plaintext=value_to_bytes_mock.return_value, associated_data=field.qual_associated_data, ) # pylint: disable-next=protected-access - assert instance._field == dek_aead_encrypt_mock.return_value + assert instance._field == FakeAead.encrypt( + value_to_bytes_mock.return_value + ) def test_initialize(self): """BaseEncryptedField.initialize creates field and value property.""" From c4893a40d8211dcd242ed7f94771fa178601ef40 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 19 Jan 2026 09:41:40 +0000 Subject: [PATCH 12/45] fix imports --- codeforlife/models/__init__.py | 3 --- codeforlife/models/encrypted.py | 6 +++--- codeforlife/models/encrypted_test.py | 2 +- codeforlife/models/fields/__init__.py | 3 +++ .../{base_encrypted_field.py => fields/base_encrypted.py} | 4 ++-- .../base_encrypted_test.py} | 8 ++++---- .../data_encryption_key.py} | 6 +++--- .../data_encryption_key_test.py} | 6 +++--- .../{encrypted_text_field.py => fields/encrypted_text.py} | 2 +- .../encrypted_text_test.py} | 4 ++-- codeforlife/user/migrations/0001_initial.py | 6 +++--- codeforlife/user/models/otp_bypass_token.py | 5 +++-- 12 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 codeforlife/models/fields/__init__.py rename codeforlife/models/{base_encrypted_field.py => fields/base_encrypted.py} (98%) rename codeforlife/models/{base_encrypted_field_test.py => fields/base_encrypted_test.py} (97%) rename codeforlife/models/{data_encryption_key_field.py => fields/data_encryption_key.py} (95%) rename codeforlife/models/{data_encryption_key_field_test.py => fields/data_encryption_key_test.py} (96%) rename codeforlife/models/{encrypted_text_field.py => fields/encrypted_text.py} (84%) rename codeforlife/models/{encrypted_text_field_test.py => fields/encrypted_text_test.py} (87%) diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 33ae65ee..278be46b 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -6,8 +6,5 @@ from .abstract_base_session import AbstractBaseSession from .abstract_base_user import AbstractBaseUser from .base import * -from .base_encrypted_field import BaseEncryptedField from .base_session_store import BaseSessionStore -from .data_encryption_key_field import DataEncryptionKeyField from .encrypted import EncryptedModel -from .encrypted_text_field import EncryptedTextField diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 31281e94..657aebc9 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -9,9 +9,9 @@ if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta - from tink.aead import Aead + from tink.aead import Aead # type: ignore[import] - from .base_encrypted_field import BaseEncryptedField + from .fields import BaseEncryptedField else: TypedModelMeta = object @@ -63,7 +63,7 @@ def update(self, **kwargs): return super().update(**kwargs) - objects: Manager[t.Self] = Manager() # type: ignore[assignment] + objects: Manager["EncryptedModel"] = Manager() # type: ignore[assignment] class Meta(TypedModelMeta): abstract = True diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index 1bd82232..b90c8196 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -3,7 +3,7 @@ from ..tests import ModelTestCase from ..user.models import OtpBypassToken from .encrypted import EncryptedModel -from .encrypted_text_field import EncryptedTextField +from .fields import EncryptedTextField if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta diff --git a/codeforlife/models/fields/__init__.py b/codeforlife/models/fields/__init__.py new file mode 100644 index 00000000..c22b1252 --- /dev/null +++ b/codeforlife/models/fields/__init__.py @@ -0,0 +1,3 @@ +from .base_encrypted import BaseEncryptedField +from .data_encryption_key import DataEncryptionKeyField +from .encrypted_text import EncryptedTextField diff --git a/codeforlife/models/base_encrypted_field.py b/codeforlife/models/fields/base_encrypted.py similarity index 98% rename from codeforlife/models/base_encrypted_field.py rename to codeforlife/models/fields/base_encrypted.py index 2106bb64..3d406eab 100644 --- a/codeforlife/models/base_encrypted_field.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -4,8 +4,8 @@ from django.core.exceptions import ValidationError from django.db import models -from ..types import Args, KwArgs -from .encrypted import EncryptedModel +from ...types import Args, KwArgs +from ..encrypted import EncryptedModel T = t.TypeVar("T") diff --git a/codeforlife/models/base_encrypted_field_test.py b/codeforlife/models/fields/base_encrypted_test.py similarity index 97% rename from codeforlife/models/base_encrypted_field_test.py rename to codeforlife/models/fields/base_encrypted_test.py index 41e97b74..ecda071d 100644 --- a/codeforlife/models/base_encrypted_field_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -3,10 +3,10 @@ from django.db import models -from ..encryption import FakeAead -from ..tests import TestCase -from .base_encrypted_field import BaseEncryptedField -from .encrypted import EncryptedModel +from ...encryption import FakeAead +from ...tests import TestCase +from ..encrypted import EncryptedModel +from .base_encrypted import BaseEncryptedField if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta diff --git a/codeforlife/models/data_encryption_key_field.py b/codeforlife/models/fields/data_encryption_key.py similarity index 95% rename from codeforlife/models/data_encryption_key_field.py rename to codeforlife/models/fields/data_encryption_key.py index d7697e5a..45d9de44 100644 --- a/codeforlife/models/data_encryption_key_field.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -5,9 +5,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from ..encryption import create_dek, get_dek_aead -from ..models import Model -from ..types import KwArgs +from ...encryption import create_dek, get_dek_aead +from ...models import Model +from ...types import KwArgs if t.TYPE_CHECKING: from tink.aead import Aead # type: ignore[import-untyped] diff --git a/codeforlife/models/data_encryption_key_field_test.py b/codeforlife/models/fields/data_encryption_key_test.py similarity index 96% rename from codeforlife/models/data_encryption_key_field_test.py rename to codeforlife/models/fields/data_encryption_key_test.py index e701fc46..0ae2882a 100644 --- a/codeforlife/models/data_encryption_key_field_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -3,9 +3,9 @@ from django.db import models -from ..encryption import create_dek -from ..tests import TestCase -from .data_encryption_key_field import DataEncryptionKeyField +from ...encryption import create_dek +from ...tests import TestCase +from .data_encryption_key import DataEncryptionKeyField if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta diff --git a/codeforlife/models/encrypted_text_field.py b/codeforlife/models/fields/encrypted_text.py similarity index 84% rename from codeforlife/models/encrypted_text_field.py rename to codeforlife/models/fields/encrypted_text.py index 4756459c..8f8f96f8 100644 --- a/codeforlife/models/encrypted_text_field.py +++ b/codeforlife/models/fields/encrypted_text.py @@ -3,7 +3,7 @@ Created on 12/01/2026 at 09:17:46(+00:00). """ -from .base_encrypted_field import BaseEncryptedField +from .base_encrypted import BaseEncryptedField class EncryptedTextField(BaseEncryptedField[str]): diff --git a/codeforlife/models/encrypted_text_field_test.py b/codeforlife/models/fields/encrypted_text_test.py similarity index 87% rename from codeforlife/models/encrypted_text_field_test.py rename to codeforlife/models/fields/encrypted_text_test.py index d6f1fb66..3ea8c6b4 100644 --- a/codeforlife/models/encrypted_text_field_test.py +++ b/codeforlife/models/fields/encrypted_text_test.py @@ -1,5 +1,5 @@ -from ..tests import TestCase -from .encrypted_text_field import EncryptedTextField +from ...tests import TestCase +from .encrypted_text import EncryptedTextField # pylint: disable-next=missing-class-docstring diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index f9203545..e39d4a42 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,6 +1,6 @@ -# Generated by Django 5.1.15 on 2026-01-14 14:11 +# Generated by Django 5.1.15 on 2026-01-19 09:38 -import codeforlife.models.encrypted_text_field +import codeforlife.models.fields.encrypted_text import codeforlife.user.models.user.admin_school_teacher import codeforlife.user.models.user.contactable import codeforlife.user.models.user.google @@ -115,7 +115,7 @@ class Migration(migrations.Migration): ), ( "_token", - codeforlife.models.encrypted_text_field.EncryptedTextField( + codeforlife.models.fields.encrypted_text.EncryptedTextField( associated_data="token", db_column="token", help_text="The encrypted equivalent of the token.", diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 0ec26fbf..a3484a55 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -12,7 +12,8 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ -from ...models import EncryptedModel, EncryptedTextField +from ...models import EncryptedModel +from ...models.fields import EncryptedTextField from ...types import Validators from ...validators import CharSetValidatorBuilder from .user import User @@ -69,7 +70,7 @@ def bulk_create(self, user: User): # type: ignore[override] return super().bulk_create(otp_bypass_tokens) - objects: Manager = Manager() + objects: Manager = Manager() # type: ignore[assignment] user = models.ForeignKey( User, From 4dc7cb9b7e23a7ccec0260bd8616dddb7dd40bd2 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 19 Jan 2026 09:59:28 +0000 Subject: [PATCH 13/45] module docstring --- codeforlife/encryption.py | 7 +++++++ codeforlife/models/encrypted.py | 5 +++++ codeforlife/models/encrypted_test.py | 5 +++++ codeforlife/models/fields/__init__.py | 5 +++++ codeforlife/models/fields/base_encrypted.py | 5 +++++ codeforlife/models/fields/base_encrypted_test.py | 5 +++++ codeforlife/models/fields/data_encryption_key.py | 5 +++++ codeforlife/models/fields/data_encryption_key_test.py | 5 +++++ codeforlife/models/fields/encrypted_text_test.py | 5 +++++ codeforlife/settings/google.py | 7 +++++++ 10 files changed, 54 insertions(+) diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py index b5a5dc5a..3304c811 100644 --- a/codeforlife/encryption.py +++ b/codeforlife/encryption.py @@ -1,3 +1,10 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:55:44(+00:00). + +Various utilities for encrypting/decrypting data. +""" + import typing as t from base64 import b64decode, b64encode from dataclasses import dataclass diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 657aebc9..bf4c2bb8 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:56:25(+00:00). +""" + import typing as t from django.apps import apps diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index b90c8196..4cf25373 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:56:31(+00:00). +""" + import typing as t from ..tests import ModelTestCase diff --git a/codeforlife/models/fields/__init__.py b/codeforlife/models/fields/__init__.py index c22b1252..d55c660e 100644 --- a/codeforlife/models/fields/__init__.py +++ b/codeforlife/models/fields/__init__.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:56:44(+00:00). +""" + from .base_encrypted import BaseEncryptedField from .data_encryption_key import DataEncryptionKeyField from .encrypted_text import EncryptedTextField diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 3d406eab..c416d304 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:57:04(+00:00). +""" + import typing as t from functools import cached_property diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index ecda071d..fa570735 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:56:57(+00:00). +""" + import typing as t from unittest.mock import patch diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index 45d9de44..4d66816d 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:57:19(+00:00). +""" + import typing as t from functools import cached_property diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 0ae2882a..258c3e0b 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:57:10(+00:00). +""" + import typing as t from unittest.mock import MagicMock, patch diff --git a/codeforlife/models/fields/encrypted_text_test.py b/codeforlife/models/fields/encrypted_text_test.py index 3ea8c6b4..eb14f12c 100644 --- a/codeforlife/models/fields/encrypted_text_test.py +++ b/codeforlife/models/fields/encrypted_text_test.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:57:24(+00:00). +""" + from ...tests import TestCase from .encrypted_text import EncryptedTextField diff --git a/codeforlife/settings/google.py b/codeforlife/settings/google.py index b52197fc..37e160e3 100644 --- a/codeforlife/settings/google.py +++ b/codeforlife/settings/google.py @@ -1,3 +1,10 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:54:59(+00:00). + +This file contains all the variables required by Google Cloud Platform (GCP). +""" + import os # Our Google OAuth 2.0 client credentials From 6366b7752b5521b01fb85a70dad378fe8f58025f Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 19 Jan 2026 10:33:34 +0000 Subject: [PATCH 14/45] fix tests --- codeforlife/models/fields/data_encryption_key.py | 1 + codeforlife/models/fields/data_encryption_key_test.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index 4d66816d..b769c539 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -32,6 +32,7 @@ def set_init_kwargs(self, kwargs: KwArgs): """Sets common init kwargs.""" kwargs["editable"] = False kwargs["default"] = create_dek + kwargs["null"] = False kwargs.setdefault("verbose_name", _(self.default_verbose_name)) kwargs.setdefault("help_text", _(self.default_help_text)) diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 258c3e0b..92e0e29f 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -67,11 +67,11 @@ def test_deconstruct(self): assert kwargs["help_text"] == DataEncryptionKeyField.default_help_text @patch( - "codeforlife.models.data_encryption_key_field.create_dek", + "codeforlife.models.fields.data_encryption_key.create_dek", return_value=b"mock_dek_bytes", ) @patch( - "codeforlife.models.data_encryption_key_field.get_dek_aead", + "codeforlife.models.fields.data_encryption_key.get_dek_aead", return_value="mock_dek_aead", ) def test_aead( From 5e31a60bc8e7d38274cac6b123e0f1c13626fb58 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 20 Jan 2026 11:52:41 +0000 Subject: [PATCH 15/45] quick save --- codeforlife/models/fields/base_encrypted.py | 220 +++++++++-- .../models/fields/base_encrypted_test.py | 373 +++++++++++++----- codeforlife/user/migrations/0001_initial.py | 4 +- codeforlife/user/models/otp_bypass_token.py | 2 +- 4 files changed, 460 insertions(+), 139 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index c416d304..26c3465a 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -4,15 +4,105 @@ """ import typing as t +from dataclasses import dataclass from functools import cached_property from django.core.exceptions import ValidationError from django.db import models +from django.db.models.query_utils import DeferredAttribute from ...types import Args, KwArgs from ..encrypted import EncryptedModel T = t.TypeVar("T") +Default: t.TypeAlias = t.Union[T, t.Callable[[], T]] + + +@dataclass(frozen=True) +class _PendingEncryption(t.Generic[T]): + """Helper: Data waiting to be encrypted (User Input).""" + + value: T + instance: EncryptedModel + + +@dataclass(frozen=True) +class _TrustedCiphertext: + """Helper: Trusted ciphertext directly from the DB.""" + + ciphertext: bytes + + +AnyBaseEncryptedField = t.TypeVar( + "AnyBaseEncryptedField", bound="BaseEncryptedField" +) + + +class EncryptedAttribute(DeferredAttribute, t.Generic[AnyBaseEncryptedField]): + """ + Custom descriptor that handles the get/set mechanics for encrypted fields. + """ + + field: AnyBaseEncryptedField + + @property + def _field(self): + return t.cast(AnyBaseEncryptedField, self.field) + + def __get__(self, instance: t.Optional[EncryptedModel], cls=None): + # Return the descriptor itself when accessed on the class. + if instance is None: + return self + + # If we have a cached decrypted value, return it. + cache_name = self._field.cache_name + if hasattr(instance, cache_name): + return getattr(instance, cache_name) + + # Get the raw data from the instance. + value = t.cast( + t.Optional[bytes | _PendingEncryption], + super().__get__(instance, cls), # type: ignore[misc] + ) + + # No data to decrypt. + if value is None: + return None + + # The user just set this value, return it directly. + if isinstance(value, _PendingEncryption): + return value.value + + # Decrypt the value before returning it. + decrypted_value = self._field.decrypt_value(instance, value) + + # Cache the decrypted value on the instance. + setattr(instance, cache_name, decrypted_value) + + return decrypted_value + + def __set__( + self, + instance: EncryptedModel, + value: t.Optional[t.Union[T, _TrustedCiphertext]], + ): + # Clear any cached decrypted value. + cache_name = self._field.cache_name + if hasattr(instance, cache_name): + delattr(instance, cache_name) + + # Store the internal value on the instance. + instance.__dict__[self._field.attname] = ( + None + if value is None + else ( + # If it's trusted ciphertext from the DB, store it directly. + value.ciphertext + if isinstance(value, _TrustedCiphertext) + # If it's a new value from the user, store a pending encryption. + else _PendingEncryption(value, instance) + ) + ) class BaseEncryptedField(models.BinaryField, t.Generic[T]): @@ -20,15 +110,22 @@ class BaseEncryptedField(models.BinaryField, t.Generic[T]): model: t.Type[EncryptedModel] - # Whether to allows decrypting/encrypting the value via the property. - decrypt_value = True - encrypt_value = True + descriptor_class = EncryptedAttribute + + # -------------------------------------------------------------------------- + # Construction & Deconstruction + # -------------------------------------------------------------------------- def set_init_kwargs(self, kwargs: KwArgs): """Sets common init kwargs.""" kwargs.setdefault("db_column", self.associated_data) - def __init__(self, associated_data: str, **kwargs): + def __init__( + self, + associated_data: str, + default: t.Optional[Default[T]] = None, + **kwargs, + ): if not associated_data: raise ValidationError( "Associated data cannot be empty.", @@ -37,7 +134,7 @@ def __init__(self, associated_data: str, **kwargs): self.associated_data = associated_data self.set_init_kwargs(kwargs) - super().__init__(**kwargs) + super().__init__(**kwargs, default=default) def deconstruct(self): name, path, args, kwargs = t.cast( @@ -49,6 +146,10 @@ def deconstruct(self): return name, path, args, kwargs + # -------------------------------------------------------------------------- + # Django Model Field Integration + # -------------------------------------------------------------------------- + def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name, private_only) @@ -83,6 +184,58 @@ def contribute_to_class(self, cls, name, private_only=False): # Register this field as an encrypted field on the model. cls.ENCRYPTED_FIELDS.append(self) + # -------------------------------------------------------------------------- + # Descriptor Methods + # -------------------------------------------------------------------------- + + @t.overload # type: ignore[no-overload-impl,override] + def __get__( + self, instance: None, owner: t.Any + ) -> EncryptedAttribute[t.Self]: ... + + @t.overload + def __get__( + self, instance: EncryptedModel, owner: t.Any + ) -> t.Optional[T]: ... + + @cached_property + def cache_name(self): + """The name used to cache the decrypted value on the instance.""" + return f"_{self.name}_decrypted_value" + + # pylint: disable-next=unused-argument + def from_db_value(self, value: t.Optional[bytes], expression, connection): + """ + Converts a value as returned by the database to a Python object. It is + the reverse of get_prep_value(). + + https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects + """ + if value is None: + return None + + # Wrap it so __set__ knows this is NOT new user input. + return _TrustedCiphertext(value) + + def get_prep_value(self, value: t.Optional[_PendingEncryption[T]]): + """ + 'value' is the current value of the model's attribute, and the method + should return data in a format that has been prepared for use as a + parameter in a query. + + https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-python-objects-to-query-values + """ + # If it's a pending encryption, encrypt it now. + if isinstance(value, _PendingEncryption): + return self.encrypt_value(value.instance, value.value) + + # If it's already bytes (e.g. strict assignment), pass through. + return super().get_prep_value(value) + + # -------------------------------------------------------------------------- + # Crypto Logic + # -------------------------------------------------------------------------- + def bytes_to_value(self, data: bytes) -> T: """Converts decrypted bytes to the field value.""" raise NotImplementedError() @@ -96,46 +249,27 @@ def qual_associated_data(self): """Returns the fully qualified associated data for this field.""" return f"{self.model.associated_data}:{self.associated_data}".encode() - @cached_property - def value(self): - """Returns a property that gets/sets the decrypted/encrypted value.""" - - def decrypt_value(model: EncryptedModel): - """Decrypts a single value using the DEK and associated data.""" - ciphertext: t.Optional[bytes] = getattr(model, self.name) - if ciphertext is None: - return None + def decrypt_value( + self, instance: EncryptedModel, ciphertext: t.Optional[bytes] + ): + """Decrypts a single value using the DEK and associated data.""" + if ciphertext is None: + return None - data = model.dek_aead.decrypt( - ciphertext=ciphertext, - associated_data=self.qual_associated_data, - ) + data = instance.dek_aead.decrypt( + ciphertext=ciphertext, + associated_data=self.qual_associated_data, + ) - return self.bytes_to_value(data) + return self.bytes_to_value(data) - def encrypt_value(model: EncryptedModel, plaintext: t.Optional[T]): - """Encrypts a single value using the DEK and associated data.""" - value = ( - None - if plaintext is None - else model.dek_aead.encrypt( - plaintext=self.value_to_bytes(plaintext), - associated_data=self.qual_associated_data, - ) + def encrypt_value(self, instance: EncryptedModel, plaintext: t.Optional[T]): + """Encrypts a single value using the DEK and associated data.""" + return ( + None + if plaintext is None + else instance.dek_aead.encrypt( + plaintext=self.value_to_bytes(plaintext), + associated_data=self.qual_associated_data, ) - - setattr(model, self.name, value) - - # Create property with getter and/or setter. - value = property( - fget=decrypt_value if self.decrypt_value else None, - fset=encrypt_value if self.encrypt_value else None, ) - - return t.cast(T, value) # Cast to T for mypy. - - @classmethod - def initialize(cls, associated_data: str, **kwargs): - """Helper to create an encrypted field and its value-property.""" - encrypted_field = cls(associated_data=associated_data, **kwargs) - return encrypted_field, encrypted_field.value diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index fa570735..38c8a5ff 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -4,14 +4,19 @@ """ import typing as t -from unittest.mock import patch +from functools import cached_property +from unittest.mock import MagicMock from django.db import models from ...encryption import FakeAead from ...tests import TestCase from ..encrypted import EncryptedModel -from .base_encrypted import BaseEncryptedField +from .base_encrypted import ( + BaseEncryptedField, + _PendingEncryption, + _TrustedCiphertext, +) if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta @@ -20,15 +25,83 @@ # pylint: disable=missing-class-docstring # pylint: disable=too-few-public-methods +# pylint: disable=too-many-instance-attributes + + +class FakeEncryptedModel(EncryptedModel): + """A fake EncryptedModel for testing.""" + + associated_data = "model" + + class Meta(TypedModelMeta): + abstract = True + + @cached_property + def dek_aead(self): + return FakeAead.as_mock() + + +class FakeModelMeta(TypedModelMeta): + """A fake Meta class for testing.""" + + app_label = "codeforlife.user" + + +# pylint: disable-next=abstract-method +class FakeEncryptedField(BaseEncryptedField[str]): + """A fake BaseEncryptedField for testing.""" + + value_to_bytes: MagicMock + bytes_to_value: MagicMock + + @staticmethod + def _value_to_bytes(value: str): + return value.encode() + + @staticmethod + def _bytes_to_value(data: bytes): + return data.decode() + + def __init__(self, associated_data, default=None, **kwargs): + super().__init__(associated_data, default, **kwargs) + + self.value_to_bytes = MagicMock(side_effect=self._value_to_bytes) + self.bytes_to_value = MagicMock(side_effect=self._bytes_to_value) class EncryptedModelTestCase(TestCase): + + @staticmethod + def value_to_bytes(value: str): + """Converts a string value to bytes.""" + return value.encode() + + @staticmethod + def bytes_to_value(data: bytes): + """Converts bytes data to a string value.""" + return data.decode() + def setUp(self): - self.associated_data = "field" - self.decrypted_default = b"default_encrypted_bytes" - self.default = FakeAead.encrypt(self.decrypted_default) - self.field = BaseEncryptedField[str]( - associated_data=self.associated_data, default=self.default + # Set up the first field with a non-callable default for testing. + self.field_associated_data = "field" + self.field_default = "default" + self.field_encrypted_default = FakeAead.encrypt( + self.value_to_bytes(self.field_default) + ) + self.field_decrypted_default = FakeAead.decrypt( + self.field_encrypted_default + ) + self.field = FakeEncryptedField( + associated_data=self.field_associated_data, + default=self.field_default, + ) + + # Set up a second field with a callable default for testing. + self.field2_associated_data = "field2" + self.field2_default = "default2" + self.field2 = FakeEncryptedField( + associated_data=self.field2_associated_data, + default=lambda: self.field2_default, ) def test_init__no_associated_data(self): @@ -38,52 +111,46 @@ def test_init__no_associated_data(self): def test_init(self): """BaseEncryptedField is constructed correctly.""" - assert self.field.associated_data == self.associated_data - assert self.field.db_column == self.associated_data + assert self.field.associated_data == self.field_associated_data + assert self.field.db_column == self.field_associated_data def test_deconstruct(self): """BaseEncryptedField is deconstructed correctly.""" _, _, _, kwargs = self.field.deconstruct() - assert kwargs["associated_data"] == self.associated_data - assert kwargs["db_column"] == self.associated_data + assert kwargs["associated_data"] == self.field_associated_data + assert kwargs["db_column"] == self.field_associated_data def test_contribute_to_class__invalid_model_base_class(self): """Cannot contribute BaseEncryptedField to invalid model base class.""" with self.assert_raises_validation_error( code="invalid_model_base_class" ): - # pylint: disable-next=unused-variable,abstract-method - class InvalidModel(models.Model): + # pylint: disable-next=unused-variable + class Model(models.Model): field = self.field - class Meta(TypedModelMeta): - app_label = "codeforlife.user" + Meta = FakeModelMeta def test_contribute_to_class__already_registered(self): """Cannot contribute BaseEncryptedField that is already registered.""" with self.assert_raises_validation_error(code="already_registered"): - # pylint: disable-next=unused-variable,abstract-method - class InvalidModel(EncryptedModel): + # pylint: disable-next=unused-variable + class Model(FakeEncryptedModel): field = self.field field2 = self.field - class Meta(TypedModelMeta): - app_label = "codeforlife.user" + Meta = FakeModelMeta def test_contribute_to_class(self): """BaseEncryptedField is contributed to class correctly.""" - # pylint: disable-next=unused-variable,abstract-method - class TestModel(EncryptedModel): - associated_data = "model" - + class Model(FakeEncryptedModel): field = self.field - class Meta(TypedModelMeta): - app_label = "codeforlife.user" + Meta = FakeModelMeta - assert self.field in TestModel.ENCRYPTED_FIELDS + assert self.field in Model.ENCRYPTED_FIELDS def test_contribute_to_class__associated_data_already_used(self): """ @@ -92,15 +159,14 @@ def test_contribute_to_class__associated_data_already_used(self): with self.assert_raises_validation_error( code="associated_data_already_used" ): - # pylint: disable-next=unused-variable,abstract-method - class InvalidModel(EncryptedModel): + # pylint: disable-next=unused-variable + class Model(FakeEncryptedModel): field = self.field - field2 = BaseEncryptedField( - associated_data=self.associated_data + field2 = FakeEncryptedField( + associated_data=self.field_associated_data ) - class Meta(TypedModelMeta): - app_label = "codeforlife.user" + Meta = FakeModelMeta def test_bytes_to_value(self): """bytes_to_value raises NotImplementedError.""" @@ -117,75 +183,196 @@ def test_value_to_bytes(self): def test_qual_associated_data(self): """qual_associated_data returns fully qualified associated data.""" - # pylint: disable-next=abstract-method - class ValidModel(EncryptedModel): - associated_data = "model" - + class Model(FakeEncryptedModel): field = self.field - class Meta(TypedModelMeta): - app_label = "codeforlife.user" + Meta = FakeModelMeta assert ( self.field.qual_associated_data - == f"{ValidModel.associated_data}:{self.associated_data}".encode() + == f"{Model.associated_data}:{self.field_associated_data}".encode() ) - def test_value(self): - """value returns a property that encrypts/decrypts the field's value.""" - field = BaseEncryptedField[str]( - associated_data="value", default=self.default + def test_decrypt_value(self): + """decrypt_value decrypts the given ciphertext.""" + + class Model(FakeEncryptedModel): + field = self.field + + Meta = FakeModelMeta + + # Create instance and mock shorthands. + instance = Model() + decrypt_mock: MagicMock = instance.dek_aead.decrypt + bytes_to_value_mock: MagicMock = self.field.bytes_to_value + + # When ciphertext is None, no decryption occurs. + decrypted_value = self.field.decrypt_value(instance, ciphertext=None) + assert decrypted_value is None + decrypt_mock.assert_not_called() + bytes_to_value_mock.assert_not_called() + + # When ciphertext is provided, decryption occurs. + ciphertext = self.field_encrypted_default + decrypted_value = self.field.decrypt_value(instance, ciphertext) + decrypt_kwargs = { + "ciphertext": ciphertext, + "associated_data": self.field.qual_associated_data, + } + decrypt_mock.assert_called_once_with(**decrypt_kwargs) + decrypted_bytes = decrypt_mock.side_effect(**decrypt_kwargs) + bytes_to_value_mock.assert_called_once_with(decrypted_bytes) + assert decrypted_value == bytes_to_value_mock.side_effect( + decrypted_bytes + ) + + def test_encrypt_value(self): + """encrypt_value encrypts the given plaintext.""" + + class Model(FakeEncryptedModel): + field = self.field + + Meta = FakeModelMeta + + # Create instance and mock shorthands. + instance = Model() + encrypt_mock: MagicMock = instance.dek_aead.encrypt + value_to_bytes_mock: MagicMock = self.field.value_to_bytes + + # When plaintext is None, no encryption occurs. + encrypted_bytes = self.field.encrypt_value(instance, plaintext=None) + assert encrypted_bytes is None + value_to_bytes_mock.assert_not_called() + encrypt_mock.assert_not_called() + + # When plaintext is provided, encryption occurs. + plaintext = self.field_default + encrypted_bytes = self.field.encrypt_value(instance, plaintext) + value_to_bytes_mock.assert_called_once_with(plaintext) + decrypted_bytes = value_to_bytes_mock.side_effect(plaintext) + encrypt_kwargs = { + "plaintext": decrypted_bytes, + "associated_data": self.field.qual_associated_data, + } + encrypt_mock.assert_called_once_with(**encrypt_kwargs) + assert encrypted_bytes == encrypt_mock.side_effect(**encrypt_kwargs) + + def test_cache_name(self): + """cache_name returns the correct cache attribute name.""" + + # pylint: disable-next=unused-variable + class Model(FakeEncryptedModel): + field = self.field + + Meta = FakeModelMeta + + assert self.field.cache_name == "_field_decrypted_value" + + def _assert_pending_encryption( + self, + instance: EncryptedModel, + field: BaseEncryptedField, + value: t.Optional[str], + ): + pending_encryption = instance.__dict__[field.attname] + assert isinstance(pending_encryption, _PendingEncryption) + assert pending_encryption.value == value + assert pending_encryption.instance == instance + + def test_set(self): + """__set__ sets the field value correctly.""" + + # Field must have a non-callable default for this test. + assert self.field.default is not None and not callable( + self.field.default + ) + # Field2 must have a callable default for this test. + assert self.field2.default is not None and callable(self.field2.default) + + class Model(FakeEncryptedModel): + field = self.field + field2 = self.field2 + + Meta = FakeModelMeta + + instance = Model() + + # Initial value is the encrypted default. + self._assert_pending_encryption( + instance, self.field, self.field_default + ) + self._assert_pending_encryption( + instance, self.field2, self.field2_default ) - assert isinstance(field.value, property) - assert field.value.fget is not None - assert field.value.fset is not None - assert field.value.fdel is None - - class ValidModel(EncryptedModel): - associated_data = "model" - - _field = field - value: str = field.value - - class Meta(TypedModelMeta): - app_label = "codeforlife.user" - - dek_aead = FakeAead.as_mock() - - with patch.object( - field, "bytes_to_value", return_value="value" - ) as bytes_to_value_mock, patch.object( - field, "value_to_bytes", return_value=b"bytes" - ) as value_to_bytes_mock: - instance = ValidModel() - - # Get the value. - value = instance.value - instance.dek_aead.decrypt.assert_called_once_with( - ciphertext=self.default, - associated_data=field.qual_associated_data, - ) - bytes_to_value_mock.assert_called_once_with(self.decrypted_default) - assert value == bytes_to_value_mock.return_value - - # Set the value. - value = "new_value" - instance.value = value - value_to_bytes_mock.assert_called_once_with(value) - instance.dek_aead.encrypt.assert_called_once_with( - plaintext=value_to_bytes_mock.return_value, - associated_data=field.qual_associated_data, - ) - # pylint: disable-next=protected-access - assert instance._field == FakeAead.encrypt( - value_to_bytes_mock.return_value - ) - - def test_initialize(self): - """BaseEncryptedField.initialize creates field and value property.""" - field, value = BaseEncryptedField.initialize( - associated_data=self.associated_data + value = "initial_value" + instance = Model(field=value) + self._assert_pending_encryption(instance, self.field, value) + + # Set to None. + instance.field = None + assert instance.__dict__[self.field.attname] is None + + # Set to _TrustedCiphertext. + trusted_ciphertext = _TrustedCiphertext(self.encrypted_default) + instance.field = trusted_ciphertext + assert ( + instance.__dict__[self.field.attname] + == trusted_ciphertext.ciphertext ) - assert field.value == value + # Set to new value. + value = "new_value" + instance.field = value + self._assert_pending_encryption(instance, self.field, value) + + # def test_value(self): + # """value returns a property that encrypts/decrypts the field's value.""" + # field = BaseEncryptedField[str]( + # associated_data="value", default=self.default + # ) + + # assert isinstance(field.value, property) + # assert field.value.fget is not None + # assert field.value.fset is not None + # assert field.value.fdel is None + + # class ValidModel(EncryptedModel): + # associated_data = "model" + + # _field = field + # value: str = field.value + + # class Meta(TypedModelMeta): + # app_label = "codeforlife.user" + + # dek_aead = FakeAead.as_mock() + + # with patch.object( + # field, "bytes_to_value", return_value="value" + # ) as bytes_to_value_mock, patch.object( + # field, "value_to_bytes", return_value=b"bytes" + # ) as value_to_bytes_mock: + # instance = ValidModel() + + # # Get the value. + # value = instance.value + # instance.dek_aead.decrypt.assert_called_once_with( + # ciphertext=self.default, + # associated_data=field.qual_associated_data, + # ) + # bytes_to_value_mock.assert_called_once_with(self.decrypted_default) + # assert value == bytes_to_value_mock.return_value + + # # Set the value. + # value = "new_value" + # instance.value = value + # value_to_bytes_mock.assert_called_once_with(value) + # instance.dek_aead.encrypt.assert_called_once_with( + # plaintext=value_to_bytes_mock.return_value, + # associated_data=field.qual_associated_data, + # ) + # # pylint: disable-next=protected-access + # assert instance._field == FakeAead.encrypt( + # value_to_bytes_mock.return_value + # ) diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index e39d4a42..bba29e65 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.15 on 2026-01-19 09:38 +# Generated by Django 5.1.15 on 2026-01-19 14:38 import codeforlife.models.fields.encrypted_text import codeforlife.user.models.user.admin_school_teacher @@ -114,7 +114,7 @@ class Migration(migrations.Migration): ), ), ( - "_token", + "token", codeforlife.models.fields.encrypted_text.EncryptedTextField( associated_data="token", db_column="token", diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index a3484a55..8b53f5ca 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -78,7 +78,7 @@ def bulk_create(self, user: User): # type: ignore[override] on_delete=models.CASCADE, ) - _token, token = EncryptedTextField.initialize( + token = EncryptedTextField( associated_data="token", verbose_name=_("token"), help_text=_("The encrypted equivalent of the token."), From 6d57ab47f6e8a902928ebbc9d0dec6e08760a310 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 20 Jan 2026 15:33:00 +0000 Subject: [PATCH 16/45] fix tests --- codeforlife/models/encrypted.py | 3 +- codeforlife/models/fields/base_encrypted.py | 4 + .../models/fields/base_encrypted_test.py | 222 +++++++----------- 3 files changed, 85 insertions(+), 144 deletions(-) diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index bf4c2bb8..53abae4a 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -22,9 +22,8 @@ class _EncryptedModel(Model): - ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] = [] - associated_data: str + ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] class Meta(TypedModelMeta): abstract = True diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 26c3465a..087fa686 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -165,6 +165,10 @@ def contribute_to_class(self, cls, name, private_only=False): code="invalid_model_base_class", ) + if not hasattr(cls, "ENCRYPTED_FIELDS"): + cls.ENCRYPTED_FIELDS = [self] + return + # Ensure no duplicate encrypted fields. if self in cls.ENCRYPTED_FIELDS: raise ValidationError( diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 38c8a5ff..14a84cab 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -40,6 +40,22 @@ class Meta(TypedModelMeta): def dek_aead(self): return FakeAead.as_mock() + def get_stored_value(self, field: BaseEncryptedField): + """Returns the stored value for the given field.""" + assert field in self.ENCRYPTED_FIELDS + return self.__dict__[field.attname] + + def assert_value_is_pending_encryption( + self, field: BaseEncryptedField, value: str + ): + """ + Asserts the value for the given field is pending encryption. + """ + pending_encryption = self.get_stored_value(field) + assert isinstance(pending_encryption, _PendingEncryption) + assert pending_encryption.value == value + assert pending_encryption.instance == self + class FakeModelMeta(TypedModelMeta): """A fake Meta class for testing.""" @@ -71,26 +87,28 @@ def __init__(self, associated_data, default=None, **kwargs): class EncryptedModelTestCase(TestCase): - @staticmethod - def value_to_bytes(value: str): - """Converts a string value to bytes.""" - return value.encode() + def _get_model_class(self): + """Dynamically creates a FakeEncryptedModel subclass with fields. - @staticmethod - def bytes_to_value(data: bytes): - """Converts bytes data to a string value.""" - return data.decode() + This assigns self.field and self.field2 to the model. + """ + + class Model(FakeEncryptedModel): + field = self.field + field2 = self.field2 + + Meta = FakeModelMeta + + return Model + + def _get_model_instance(self, **kwargs): + """Gets an instance of the dynamically created model class.""" + return self._get_model_class()(**kwargs) def setUp(self): # Set up the first field with a non-callable default for testing. self.field_associated_data = "field" self.field_default = "default" - self.field_encrypted_default = FakeAead.encrypt( - self.value_to_bytes(self.field_default) - ) - self.field_decrypted_default = FakeAead.decrypt( - self.field_encrypted_default - ) self.field = FakeEncryptedField( associated_data=self.field_associated_data, default=self.field_default, @@ -144,13 +162,8 @@ class Model(FakeEncryptedModel): def test_contribute_to_class(self): """BaseEncryptedField is contributed to class correctly.""" - - class Model(FakeEncryptedModel): - field = self.field - - Meta = FakeModelMeta - - assert self.field in Model.ENCRYPTED_FIELDS + Model = self._get_model_class() # Assign fields to model. + self.assertListEqual(Model.ENCRYPTED_FIELDS, [self.field, self.field2]) def test_contribute_to_class__associated_data_already_used(self): """ @@ -182,12 +195,7 @@ def test_value_to_bytes(self): def test_qual_associated_data(self): """qual_associated_data returns fully qualified associated data.""" - - class Model(FakeEncryptedModel): - field = self.field - - Meta = FakeModelMeta - + Model = self._get_model_class() assert ( self.field.qual_associated_data == f"{Model.associated_data}:{self.field_associated_data}".encode() @@ -195,14 +203,8 @@ class Model(FakeEncryptedModel): def test_decrypt_value(self): """decrypt_value decrypts the given ciphertext.""" - - class Model(FakeEncryptedModel): - field = self.field - - Meta = FakeModelMeta - # Create instance and mock shorthands. - instance = Model() + instance = self._get_model_instance() decrypt_mock: MagicMock = instance.dek_aead.decrypt bytes_to_value_mock: MagicMock = self.field.bytes_to_value @@ -213,7 +215,7 @@ class Model(FakeEncryptedModel): bytes_to_value_mock.assert_not_called() # When ciphertext is provided, decryption occurs. - ciphertext = self.field_encrypted_default + ciphertext = FakeAead.encrypt(b"value") decrypted_value = self.field.decrypt_value(instance, ciphertext) decrypt_kwargs = { "ciphertext": ciphertext, @@ -228,14 +230,8 @@ class Model(FakeEncryptedModel): def test_encrypt_value(self): """encrypt_value encrypts the given plaintext.""" - - class Model(FakeEncryptedModel): - field = self.field - - Meta = FakeModelMeta - # Create instance and mock shorthands. - instance = Model() + instance = self._get_model_instance() encrypt_mock: MagicMock = instance.dek_aead.encrypt value_to_bytes_mock: MagicMock = self.field.value_to_bytes @@ -259,29 +255,11 @@ class Model(FakeEncryptedModel): def test_cache_name(self): """cache_name returns the correct cache attribute name.""" - - # pylint: disable-next=unused-variable - class Model(FakeEncryptedModel): - field = self.field - - Meta = FakeModelMeta - + self._get_model_class() # Assign field to model. assert self.field.cache_name == "_field_decrypted_value" - def _assert_pending_encryption( - self, - instance: EncryptedModel, - field: BaseEncryptedField, - value: t.Optional[str], - ): - pending_encryption = instance.__dict__[field.attname] - assert isinstance(pending_encryption, _PendingEncryption) - assert pending_encryption.value == value - assert pending_encryption.instance == instance - - def test_set(self): - """__set__ sets the field value correctly.""" - + def test_set__default(self): + """Setting field to default value stores pending encryption.""" # Field must have a non-callable default for this test. assert self.field.default is not None and not callable( self.field.default @@ -289,90 +267,50 @@ def test_set(self): # Field2 must have a callable default for this test. assert self.field2.default is not None and callable(self.field2.default) - class Model(FakeEncryptedModel): - field = self.field - field2 = self.field2 - - Meta = FakeModelMeta - - instance = Model() - - # Initial value is the encrypted default. - self._assert_pending_encryption( - instance, self.field, self.field_default + instance = self._get_model_instance() + instance.assert_value_is_pending_encryption( + self.field, self.field_default ) - self._assert_pending_encryption( - instance, self.field2, self.field2_default + instance.assert_value_is_pending_encryption( + self.field2, self.field2_default ) + def test_set__init(self): + """Setting field to initial value stores pending encryption.""" value = "initial_value" - instance = Model(field=value) - self._assert_pending_encryption(instance, self.field, value) + instance = self._get_model_instance(field=value) + instance.assert_value_is_pending_encryption(self.field, value) + + def test_set__none(self): + """Setting field to None stores None.""" + assert self.field.default is not None + instance = self._get_model_instance(field=None) + assert instance.get_stored_value(self.field) is None + + def test_set__trusted_ciphertext(self): + """Setting field to _TrustedCiphertext stores ciphertext directly.""" + ciphertext = b"encrypted_value" + trusted_ciphertext = _TrustedCiphertext(ciphertext) + instance = self._get_model_instance(field=trusted_ciphertext) + assert instance.get_stored_value(self.field) == ciphertext + + def test_set__new_value(self): + """ + Setting field to new value stores pending encryption and clears cache. + """ + assert self.field.default is not None - # Set to None. - instance.field = None - assert instance.__dict__[self.field.attname] is None + value = "new_value" + assert self.field.default != value - # Set to _TrustedCiphertext. - trusted_ciphertext = _TrustedCiphertext(self.encrypted_default) - instance.field = trusted_ciphertext - assert ( - instance.__dict__[self.field.attname] - == trusted_ciphertext.ciphertext - ) + instance = self._get_model_instance() - # Set to new value. - value = "new_value" + # Cache the value on the instance. + setattr(instance, self.field.cache_name, value) + + # Clear cache by setting to new value. instance.field = value - self._assert_pending_encryption(instance, self.field, value) - - # def test_value(self): - # """value returns a property that encrypts/decrypts the field's value.""" - # field = BaseEncryptedField[str]( - # associated_data="value", default=self.default - # ) - - # assert isinstance(field.value, property) - # assert field.value.fget is not None - # assert field.value.fset is not None - # assert field.value.fdel is None - - # class ValidModel(EncryptedModel): - # associated_data = "model" - - # _field = field - # value: str = field.value - - # class Meta(TypedModelMeta): - # app_label = "codeforlife.user" - - # dek_aead = FakeAead.as_mock() - - # with patch.object( - # field, "bytes_to_value", return_value="value" - # ) as bytes_to_value_mock, patch.object( - # field, "value_to_bytes", return_value=b"bytes" - # ) as value_to_bytes_mock: - # instance = ValidModel() - - # # Get the value. - # value = instance.value - # instance.dek_aead.decrypt.assert_called_once_with( - # ciphertext=self.default, - # associated_data=field.qual_associated_data, - # ) - # bytes_to_value_mock.assert_called_once_with(self.decrypted_default) - # assert value == bytes_to_value_mock.return_value - - # # Set the value. - # value = "new_value" - # instance.value = value - # value_to_bytes_mock.assert_called_once_with(value) - # instance.dek_aead.encrypt.assert_called_once_with( - # plaintext=value_to_bytes_mock.return_value, - # associated_data=field.qual_associated_data, - # ) - # # pylint: disable-next=protected-access - # assert instance._field == FakeAead.encrypt( - # value_to_bytes_mock.return_value - # ) + instance.assert_value_is_pending_encryption(self.field, value) + + # Ensure cached value is cleared. + assert not hasattr(instance, self.field.cache_name) From c1b32890dcc26641a6d2255e421ce4b5036db1ec Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 20 Jan 2026 16:20:20 +0000 Subject: [PATCH 17/45] get value tests --- .../models/fields/base_encrypted_test.py | 89 ++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 14a84cab..462c5cbd 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -23,13 +23,12 @@ else: TypedModelMeta = object -# pylint: disable=missing-class-docstring # pylint: disable=too-few-public-methods # pylint: disable=too-many-instance-attributes class FakeEncryptedModel(EncryptedModel): - """A fake EncryptedModel for testing.""" + """A fake EncryptedModel without fields for testing.""" associated_data = "model" @@ -41,10 +40,15 @@ def dek_aead(self): return FakeAead.as_mock() def get_stored_value(self, field: BaseEncryptedField): - """Returns the stored value for the given field.""" + """Gets the stored value for the given field.""" assert field in self.ENCRYPTED_FIELDS return self.__dict__[field.attname] + def set_stored_value(self, field: BaseEncryptedField, value): + """Sets the stored value for the given field.""" + assert field in self.ENCRYPTED_FIELDS + self.__dict__[field.attname] = value + def assert_value_is_pending_encryption( self, field: BaseEncryptedField, value: str ): @@ -69,6 +73,7 @@ class FakeEncryptedField(BaseEncryptedField[str]): value_to_bytes: MagicMock bytes_to_value: MagicMock + decrypt_value: MagicMock @staticmethod def _value_to_bytes(value: str): @@ -83,9 +88,15 @@ def __init__(self, associated_data, default=None, **kwargs): self.value_to_bytes = MagicMock(side_effect=self._value_to_bytes) self.bytes_to_value = MagicMock(side_effect=self._bytes_to_value) + self.decrypt_value = MagicMock(side_effect=super().decrypt_value) class EncryptedModelTestCase(TestCase): + """Tests BaseEncryptedField functionality.""" + + # -------------------------------------------------------------------------- + # Test Helper Methods + # -------------------------------------------------------------------------- def _get_model_class(self): """Dynamically creates a FakeEncryptedModel subclass with fields. @@ -94,6 +105,8 @@ def _get_model_class(self): """ class Model(FakeEncryptedModel): + """A fake EncryptedModel with fields for testing.""" + field = self.field field2 = self.field2 @@ -122,6 +135,10 @@ def setUp(self): default=lambda: self.field2_default, ) + # -------------------------------------------------------------------------- + # Construction & Deconstruction Tests + # -------------------------------------------------------------------------- + def test_init__no_associated_data(self): """Cannot create BaseEncryptedField with no associated data.""" with self.assert_raises_validation_error(code="no_associated_data"): @@ -139,6 +156,10 @@ def test_deconstruct(self): assert kwargs["associated_data"] == self.field_associated_data assert kwargs["db_column"] == self.field_associated_data + # -------------------------------------------------------------------------- + # Django Model Field Integration Tests + # -------------------------------------------------------------------------- + def test_contribute_to_class__invalid_model_base_class(self): """Cannot contribute BaseEncryptedField to invalid model base class.""" with self.assert_raises_validation_error( @@ -181,6 +202,10 @@ class Model(FakeEncryptedModel): Meta = FakeModelMeta + # -------------------------------------------------------------------------- + # Encryption & Decryption Tests + # -------------------------------------------------------------------------- + def test_bytes_to_value(self): """bytes_to_value raises NotImplementedError.""" with self.assertRaises(NotImplementedError): @@ -253,6 +278,10 @@ def test_encrypt_value(self): encrypt_mock.assert_called_once_with(**encrypt_kwargs) assert encrypted_bytes == encrypt_mock.side_effect(**encrypt_kwargs) + # -------------------------------------------------------------------------- + # Getting & Setting Values Tests + # -------------------------------------------------------------------------- + def test_cache_name(self): """cache_name returns the correct cache attribute name.""" self._get_model_class() # Assign field to model. @@ -314,3 +343,57 @@ def test_set__new_value(self): # Ensure cached value is cleared. assert not hasattr(instance, self.field.cache_name) + + def test_get__descriptor(self): + """Getting field from class returns the descriptor.""" + Model = self._get_model_class() + assert isinstance(Model.field, BaseEncryptedField.descriptor_class) + assert Model.field.field == self.field + + def test_get__cached(self): + """Getting field when cached returns cached value.""" + value = "decrypted_value" + assert value != self.field.default + + instance = self._get_model_instance() + setattr(instance, self.field.cache_name, value) + assert instance.field == value + + def test_get__none(self): + """Getting field when stored value is None returns None.""" + instance = self._get_model_instance() + instance.set_stored_value(self.field, None) + + assert instance.field is None + self.field.decrypt_value.assert_not_called() + + def test_get__pending_encryption(self): + """ + Getting field when stored value is pending encryption returns value. + """ + instance = self._get_model_instance() + value = "decrypted_value" + pending_encryption = _PendingEncryption(value, instance) + instance.set_stored_value(self.field, pending_encryption) + + assert instance.field == value + self.field.decrypt_value.assert_not_called() + + def test_get__decrypted_value(self): + """Getting field when stored value is ciphertext returns decrypted.""" + plaintext = "decrypted_value" + ciphertext = FakeAead.encrypt(plaintext.encode()) + + # Create instance with stored ciphertext. + instance = self._get_model_instance() + instance.set_stored_value(self.field, ciphertext) + + # Ensure cache is not set initially. + assert not hasattr(instance, self.field.cache_name) + + # Get the field value, which should decrypt the ciphertext. + assert instance.field == plaintext + self.field.decrypt_value.assert_called_once_with(instance, ciphertext) + + # Ensure decrypted value is cached on the instance. + assert getattr(instance, self.field.cache_name) == plaintext From d48e9336fec20cd7cbf2237c57e2664f5822d51c Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 20 Jan 2026 16:28:11 +0000 Subject: [PATCH 18/45] fix --- codeforlife/models/encrypted.py | 33 ++++++++++------------------ codeforlife/models/encrypted_test.py | 11 ++-------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 53abae4a..02788278 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -35,17 +35,6 @@ class Meta(TypedModelMeta): class EncryptedModel(_EncryptedModel): """Base for all models with encrypted fields.""" - def __init__(self, **kwargs): - for name in kwargs: - if any(field.name == name for field in self.ENCRYPTED_FIELDS): - raise ValidationError( - f"Cannot set encrypted field '{name}' via __init__." - " Set the property after initialization instead.", - code="cannot_set_encrypted_field", - ) - - super().__init__(**kwargs) - # pylint: disable-next=too-few-public-methods class Manager( models.Manager[AnyEncryptedModel], t.Generic[AnyEncryptedModel] @@ -54,16 +43,18 @@ class Manager( def update(self, **kwargs): """Ensure encrypted fields are not updated via 'update()'.""" - for name in kwargs: - if any( - field.name == name for field in self.model.ENCRYPTED_FIELDS - ): - raise ValidationError( - f"Cannot update encrypted field '{name}' via" - " 'update()'. Set the property on each instance" - " instead.", - code="cannot_update_encrypted_field", - ) + if hasattr(self.model, "ENCRYPTED_FIELDS"): + for name in kwargs: + if any( + field.name == name + for field in self.model.ENCRYPTED_FIELDS + ): + raise ValidationError( + f"Cannot update encrypted field '{name}' via" + " 'update()'. Set the property on each instance" + " instead.", + code="cannot_update_encrypted_field", + ) return super().update(**kwargs) diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index 4cf25373..866f211d 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -23,26 +23,19 @@ class Person(EncryptedModel): associated_data = "person" - _name, name = EncryptedTextField.initialize(associated_data="name") + name = EncryptedTextField(associated_data="name") class Meta(TypedModelMeta): app_label = "codeforlife.user" class EncryptedModelTestCase(ModelTestCase[EncryptedModel]): - def test_init__cannot_set_encrypted_field(self): - """Cannot set encrypted field via __init__.""" - with self.assert_raises_validation_error( - code="cannot_set_encrypted_field" - ): - Person(_name="Alice") - def test_objects___update__cannot_update_encrypted_field(self): """Cannot update encrypted field via objects.update().""" with self.assert_raises_validation_error( code="cannot_update_encrypted_field" ): - Person.objects.update(_name="Alice") + Person.objects.update(name="Alice") def test_dek_aead(self): """dek_aead raises NotImplementedError.""" From b083cabda41d0f1680a15db83c12bddf92c9c088 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 20 Jan 2026 17:48:37 +0000 Subject: [PATCH 19/45] fix pre_save --- codeforlife/models/encrypted.py | 6 +++- codeforlife/models/encrypted_test.py | 14 +++++--- codeforlife/models/fields/base_encrypted.py | 34 ++++++++++++------- .../models/fields/base_encrypted_test.py | 3 +- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 02788278..0c3fdee2 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -53,11 +53,15 @@ def update(self, **kwargs): f"Cannot update encrypted field '{name}' via" " 'update()'. Set the property on each instance" " instead.", - code="cannot_update_encrypted_field", + code="cannot_update", ) return super().update(**kwargs) + # Disable bulk operations that would bypass field-level encryption. + bulk_update: None = None # type: ignore[assignment] + bulk_create: None = None # type: ignore[assignment] + objects: Manager["EncryptedModel"] = Manager() # type: ignore[assignment] class Meta(TypedModelMeta): diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index 866f211d..f9acf290 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -30,13 +30,19 @@ class Meta(TypedModelMeta): class EncryptedModelTestCase(ModelTestCase[EncryptedModel]): - def test_objects___update__cannot_update_encrypted_field(self): + def test_objects___update__cannot_update(self): """Cannot update encrypted field via objects.update().""" - with self.assert_raises_validation_error( - code="cannot_update_encrypted_field" - ): + with self.assert_raises_validation_error(code="cannot_update"): Person.objects.update(name="Alice") + def test_objects___bulk_update(self): + """Cannot bulk update encrypted field via objects.bulk_update().""" + assert Person.objects.bulk_update is None + + def test_objects___bulk_create(self): + """Cannot bulk create encrypted field via objects.bulk_create().""" + assert Person.objects.bulk_create is None + def test_dek_aead(self): """dek_aead raises NotImplementedError.""" with self.assertRaises(NotImplementedError): diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 087fa686..0c285f3b 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -23,7 +23,6 @@ class _PendingEncryption(t.Generic[T]): """Helper: Data waiting to be encrypted (User Input).""" value: T - instance: EncryptedModel @dataclass(frozen=True) @@ -100,7 +99,7 @@ def __set__( value.ciphertext if isinstance(value, _TrustedCiphertext) # If it's a new value from the user, store a pending encryption. - else _PendingEncryption(value, instance) + else _PendingEncryption(value) ) ) @@ -221,20 +220,31 @@ def from_db_value(self, value: t.Optional[bytes], expression, connection): # Wrap it so __set__ knows this is NOT new user input. return _TrustedCiphertext(value) - def get_prep_value(self, value: t.Optional[_PendingEncryption[T]]): + def pre_save(self, model_instance: EncryptedModel, add): # type: ignore[override] """ - 'value' is the current value of the model's attribute, and the method - should return data in a format that has been prepared for use as a - parameter in a query. - - https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-python-objects-to-query-values + Called before the model is saved. This is where we perform encryption, + because we have access to the instance (needed for the DEK). """ - # If it's a pending encryption, encrypt it now. + value: bytes | _PendingEncryption[T] | None = ( + model_instance.__dict__.get(self.attname) + ) + + # No data to encrypt. + if value is None: + return None + + # Data needs encrypting. if isinstance(value, _PendingEncryption): - return self.encrypt_value(value.instance, value.value) + return self.encrypt_value(model_instance, value.value) + + # Unexpected data type. + if not isinstance(value, bytes): + raise ValidationError( + f"Unexpected value type '{type(value)}' for encryption.", + code="invalid_value_type", + ) - # If it's already bytes (e.g. strict assignment), pass through. - return super().get_prep_value(value) + return value # -------------------------------------------------------------------------- # Crypto Logic diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 462c5cbd..7b7584f5 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -58,7 +58,6 @@ def assert_value_is_pending_encryption( pending_encryption = self.get_stored_value(field) assert isinstance(pending_encryption, _PendingEncryption) assert pending_encryption.value == value - assert pending_encryption.instance == self class FakeModelMeta(TypedModelMeta): @@ -373,7 +372,7 @@ def test_get__pending_encryption(self): """ instance = self._get_model_instance() value = "decrypted_value" - pending_encryption = _PendingEncryption(value, instance) + pending_encryption = _PendingEncryption(value) instance.set_stored_value(self.field, pending_encryption) assert instance.field == value From 35fd41433e9eb67e0c6b0190d7688d5e5980c4b2 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 20 Jan 2026 17:50:26 +0000 Subject: [PATCH 20/45] fix --- codeforlife/models/fields/base_encrypted.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 0c285f3b..40ecd36a 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -220,7 +220,9 @@ def from_db_value(self, value: t.Optional[bytes], expression, connection): # Wrap it so __set__ knows this is NOT new user input. return _TrustedCiphertext(value) - def pre_save(self, model_instance: EncryptedModel, add): # type: ignore[override] + def pre_save( + self, model_instance: EncryptedModel, add # type: ignore[override] + ): """ Called before the model is saved. This is where we perform encryption, because we have access to the instance (needed for the DEK). From 6b53f77128dd8b066de4aad70ef2363dfe4f6f00 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 11:56:32 +0000 Subject: [PATCH 21/45] test pre_save pending encryption --- .../models/fields/base_encrypted_test.py | 37 +++++++++++++- codeforlife/tests/__init__.py | 1 + codeforlife/tests/exceptions.py | 51 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 codeforlife/tests/exceptions.py diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 7b7584f5..020b940c 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -10,7 +10,7 @@ from django.db import models from ...encryption import FakeAead -from ...tests import TestCase +from ...tests import InterruptPipelineError, TestCase from ..encrypted import EncryptedModel from .base_encrypted import ( BaseEncryptedField, @@ -72,6 +72,7 @@ class FakeEncryptedField(BaseEncryptedField[str]): value_to_bytes: MagicMock bytes_to_value: MagicMock + encrypt_value: MagicMock decrypt_value: MagicMock @staticmethod @@ -87,10 +88,12 @@ def __init__(self, associated_data, default=None, **kwargs): self.value_to_bytes = MagicMock(side_effect=self._value_to_bytes) self.bytes_to_value = MagicMock(side_effect=self._bytes_to_value) + self.encrypt_value = MagicMock(side_effect=super().encrypt_value) self.decrypt_value = MagicMock(side_effect=super().decrypt_value) -class EncryptedModelTestCase(TestCase): +# pylint: disable-next=too-many-public-methods +class TestEncryptedModel(TestCase): """Tests BaseEncryptedField functionality.""" # -------------------------------------------------------------------------- @@ -396,3 +399,33 @@ def test_get__decrypted_value(self): # Ensure decrypted value is cached on the instance. assert getattr(instance, self.field.cache_name) == plaintext + + # -------------------------------------------------------------------------- + # pre_save Tests + # -------------------------------------------------------------------------- + + def test_pre_save__pending_encryption(self): + """pre_save encrypts pending encryption before saving.""" + + # Create instance with pending encryption. + instance = self._get_model_instance() + pending_encryption = instance.get_stored_value(self.field) + assert isinstance(pending_encryption, _PendingEncryption) + + # Assert the value is encrypted in pre_save. + def assert_pre_save(result): + self.field.encrypt_value.assert_called_once_with( + instance, pending_encryption.value + ) + assert result == self.field.encrypt_value.side_effect( + instance, pending_encryption.value + ) + + # Run the save pipeline, interrupting at pre_save. + InterruptPipelineError.run( + test_case=self, + step_target=self.field, + step_attribute="pre_save", + assert_step=assert_pre_save, + pipeline=instance.save, + ) diff --git a/codeforlife/tests/__init__.py b/codeforlife/tests/__init__.py index 4a4ba069..185ecf84 100644 --- a/codeforlife/tests/__init__.py +++ b/codeforlife/tests/__init__.py @@ -9,6 +9,7 @@ from .api_client import APIClient, BaseAPIClient from .api_request_factory import APIRequestFactory, BaseAPIRequestFactory from .celery import CeleryTestCase +from .exceptions import InterruptPipelineError from .model import ModelTestCase from .model_list_serializer import ( BaseModelListSerializerTestCase, diff --git a/codeforlife/tests/exceptions.py b/codeforlife/tests/exceptions.py new file mode 100644 index 00000000..54a24bfa --- /dev/null +++ b/codeforlife/tests/exceptions.py @@ -0,0 +1,51 @@ +""" +© Ocado Group +Created on 22/01/2026 at 11:19:31(+00:00). +""" + +import typing as t +from unittest.mock import patch + +if t.TYPE_CHECKING: + from ..types import Args, KwArgs + from .test import TestCase + + +class InterruptPipelineError(Exception): + """Custom exception to support the Interruption Pattern in tests. + + The Interruption Pattern is a testing technique used to verify that a + specific step in a complex pipeline is reached and executed correctly, + without allowing the pipeline to finish. It is useful when the final steps + have side effects you want to avoid (like writing to a database, sending an + email, or charging a credit card). + """ + + @classmethod + # pylint: disable-next=too-many-arguments + def run( + cls, + test_case: "TestCase", + step_target, + step_attribute: str, + assert_step: t.Callable[[t.Any], None], + pipeline: t.Callable[..., t.Any], + pipeline_args: t.Optional["Args"] = None, + pipeline_kwargs: t.Optional["KwArgs"] = None, + ): + """Run a pipeline, interrupting at a specified step.""" + + # Get the original step method. + step = getattr(step_target, step_attribute) + assert callable(step) + + def side_effect(*step_args, **step_kwargs): + result = step(*step_args, **step_kwargs) # Call the original step. + assert_step(result) # Assert the step was reached correctly. + raise cls() # Interrupt the pipeline. + + # Patch the step method to include the side effect. + with patch.object(step_target, step_attribute, side_effect=side_effect): + # Run the pipeline and expect the interruption. + with test_case.assertRaises(cls): + pipeline(*(pipeline_args or ()), **(pipeline_kwargs or {})) From 88e069863df3dc65ced4ec08ad0d313419334e84 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 12:08:08 +0000 Subject: [PATCH 22/45] more pre_save tests --- .../models/fields/base_encrypted_test.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 020b940c..23318a89 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -429,3 +429,57 @@ def assert_pre_save(result): assert_step=assert_pre_save, pipeline=instance.save, ) + + def test_pre_save__none(self): + """pre_save with no value does nothing.""" + + # Create instance with no stored value. + instance = self._get_model_instance() + instance.set_stored_value(self.field, None) + + # Assert pre_save does nothing. + def assert_pre_save(result): + assert result is None + self.field.encrypt_value.assert_not_called() + + # Run the save pipeline, interrupting at pre_save. + InterruptPipelineError.run( + test_case=self, + step_target=self.field, + step_attribute="pre_save", + assert_step=assert_pre_save, + pipeline=instance.save, + ) + + def test_pre_save__trusted_ciphertext(self): + """pre_save with trusted ciphertext does nothing.""" + + # Create instance with trusted ciphertext. + ciphertext = b"encrypted_value" + trusted_ciphertext = _TrustedCiphertext(ciphertext) + instance = self._get_model_instance(field=trusted_ciphertext) + + # Assert pre_save returns the ciphertext directly. + def assert_pre_save(result): + assert result == ciphertext + self.field.encrypt_value.assert_not_called() + + # Run the save pipeline, interrupting at pre_save. + InterruptPipelineError.run( + test_case=self, + step_target=self.field, + step_attribute="pre_save", + assert_step=assert_pre_save, + pipeline=instance.save, + ) + + def test_pre_save__invalid_value_type(self): + """pre_save with invalid value type raises ValidationError.""" + + # Create instance with invalid stored value. + instance = self._get_model_instance() + instance.set_stored_value(self.field, 12345) # Invalid type. + + # Run the save pipeline, interrupting at pre_save. + with self.assert_raises_validation_error(code="invalid_value_type"): + instance.save() From b1a38d29a29c78475bbece4dad4a8e09b9e54d35 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 12:08:43 +0000 Subject: [PATCH 23/45] house keeping --- codeforlife/models/fields/base_encrypted_test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 23318a89..b2249c23 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -406,7 +406,6 @@ def test_get__decrypted_value(self): def test_pre_save__pending_encryption(self): """pre_save encrypts pending encryption before saving.""" - # Create instance with pending encryption. instance = self._get_model_instance() pending_encryption = instance.get_stored_value(self.field) @@ -432,7 +431,6 @@ def assert_pre_save(result): def test_pre_save__none(self): """pre_save with no value does nothing.""" - # Create instance with no stored value. instance = self._get_model_instance() instance.set_stored_value(self.field, None) @@ -453,7 +451,6 @@ def assert_pre_save(result): def test_pre_save__trusted_ciphertext(self): """pre_save with trusted ciphertext does nothing.""" - # Create instance with trusted ciphertext. ciphertext = b"encrypted_value" trusted_ciphertext = _TrustedCiphertext(ciphertext) @@ -475,7 +472,6 @@ def assert_pre_save(result): def test_pre_save__invalid_value_type(self): """pre_save with invalid value type raises ValidationError.""" - # Create instance with invalid stored value. instance = self._get_model_instance() instance.set_stored_value(self.field, 12345) # Invalid type. From 2dc485e822f5de26a3419b602ec46e09493fbd40 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 13:57:13 +0000 Subject: [PATCH 24/45] fixes --- codeforlife/models/fields/base_encrypted.py | 30 +++++++++++++++------ codeforlife/user/models/otp_bypass_token.py | 7 +---- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 40ecd36a..d9a42988 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -42,11 +42,16 @@ class EncryptedAttribute(DeferredAttribute, t.Generic[AnyBaseEncryptedField]): Custom descriptor that handles the get/set mechanics for encrypted fields. """ - field: AnyBaseEncryptedField + _field: AnyBaseEncryptedField @property - def _field(self): - return t.cast(AnyBaseEncryptedField, self.field) + def field(self): + """Helper to get the field with the correct type.""" + return t.cast(AnyBaseEncryptedField, self._field) + + @field.setter + def field(self, value: AnyBaseEncryptedField): + self._field = value def __get__(self, instance: t.Optional[EncryptedModel], cls=None): # Return the descriptor itself when accessed on the class. @@ -54,7 +59,7 @@ def __get__(self, instance: t.Optional[EncryptedModel], cls=None): return self # If we have a cached decrypted value, return it. - cache_name = self._field.cache_name + cache_name = self.field.cache_name if hasattr(instance, cache_name): return getattr(instance, cache_name) @@ -73,7 +78,7 @@ def __get__(self, instance: t.Optional[EncryptedModel], cls=None): return value.value # Decrypt the value before returning it. - decrypted_value = self._field.decrypt_value(instance, value) + decrypted_value = self.field.decrypt_value(instance, value) # Cache the decrypted value on the instance. setattr(instance, cache_name, decrypted_value) @@ -86,12 +91,12 @@ def __set__( value: t.Optional[t.Union[T, _TrustedCiphertext]], ): # Clear any cached decrypted value. - cache_name = self._field.cache_name + cache_name = self.field.cache_name if hasattr(instance, cache_name): delattr(instance, cache_name) # Store the internal value on the instance. - instance.__dict__[self._field.attname] = ( + instance.__dict__[self.field.attname] = ( None if value is None else ( @@ -191,7 +196,7 @@ def contribute_to_class(self, cls, name, private_only=False): # Descriptor Methods # -------------------------------------------------------------------------- - @t.overload # type: ignore[no-overload-impl,override] + @t.overload # type: ignore[override] def __get__( self, instance: None, owner: t.Any ) -> EncryptedAttribute[t.Self]: ... @@ -201,6 +206,15 @@ def __get__( self, instance: EncryptedModel, owner: t.Any ) -> t.Optional[T]: ... + def __get__( + self, instance: t.Optional[EncryptedModel], owner: t.Any + ) -> t.Union[EncryptedAttribute[t.Self], t.Optional[T]]: + return t.cast( + t.Union[EncryptedAttribute[t.Self], t.Optional[T]], + # pylint: disable-next=no-member + super().__get__(instance, owner), + ) + @cached_property def cache_name(self): """The name used to cache the decrypted value on the instance.""" diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 8b53f5ca..ecc5ffc2 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -62,13 +62,8 @@ def bulk_create(self, user: User): # type: ignore[override] user.otp_bypass_tokens.all().delete() - otp_bypass_tokens: t.List[OtpBypassToken] = [] for token in tokens: - otp_bypass_token = OtpBypassToken(user=user) - otp_bypass_token.token = token - otp_bypass_tokens.append(otp_bypass_token) - - return super().bulk_create(otp_bypass_tokens) + OtpBypassToken(user=user, token=token).save() objects: Manager = Manager() # type: ignore[assignment] From 4e2b482b6a251dc2a41ff1d6363d56d8b6d72564 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 18:56:19 +0000 Subject: [PATCH 25/45] support fixtures --- codeforlife/models/fields/base_encrypted.py | 57 ++++++++++++------- .../models/fields/base_encrypted_test.py | 16 ++++++ codeforlife/user/fixtures/school_2.json | 22 +++---- 3 files changed, 63 insertions(+), 32 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index d9a42988..514d7f33 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -42,6 +42,8 @@ class EncryptedAttribute(DeferredAttribute, t.Generic[AnyBaseEncryptedField]): Custom descriptor that handles the get/set mechanics for encrypted fields. """ + InternalValue: t.TypeAlias = t.Optional[t.Union[bytes, _PendingEncryption]] + _field: AnyBaseEncryptedField @property @@ -63,22 +65,21 @@ def __get__(self, instance: t.Optional[EncryptedModel], cls=None): if hasattr(instance, cache_name): return getattr(instance, cache_name) - # Get the raw data from the instance. - value = t.cast( - t.Optional[bytes | _PendingEncryption], - super().__get__(instance, cls), # type: ignore[misc] + # Get the internal value from the instance. + internal_value: EncryptedAttribute.InternalValue = ( + instance.__dict__.get(self.field.attname) ) # No data to decrypt. - if value is None: + if internal_value is None: return None # The user just set this value, return it directly. - if isinstance(value, _PendingEncryption): - return value.value + if isinstance(internal_value, _PendingEncryption): + return internal_value.value # Decrypt the value before returning it. - decrypted_value = self.field.decrypt_value(instance, value) + decrypted_value = self.field.decrypt_value(instance, internal_value) # Cache the decrypted value on the instance. setattr(instance, cache_name, decrypted_value) @@ -88,25 +89,33 @@ def __get__(self, instance: t.Optional[EncryptedModel], cls=None): def __set__( self, instance: EncryptedModel, - value: t.Optional[t.Union[T, _TrustedCiphertext]], + value: t.Optional[ + t.Union["memoryview[bytes]", _TrustedCiphertext, t.Any] + ], ): # Clear any cached decrypted value. cache_name = self.field.cache_name if hasattr(instance, cache_name): delattr(instance, cache_name) - # Store the internal value on the instance. - instance.__dict__[self.field.attname] = ( - None - if value is None - else ( - # If it's trusted ciphertext from the DB, store it directly. - value.ciphertext - if isinstance(value, _TrustedCiphertext) - # If it's a new value from the user, store a pending encryption. - else _PendingEncryption(value) - ) - ) + # Determine the internal value to set. + internal_value: EncryptedAttribute.InternalValue + if value is None: + internal_value = None + elif isinstance(value, memoryview): # From fixture load. + if not isinstance(value.obj, bytes): + raise ValidationError( + "Expected bytes in memoryview for encrypted field.", + code="invalid_memoryview_type", + ) + internal_value = value.obj + elif isinstance(value, _TrustedCiphertext): # From DB. + internal_value = value.ciphertext + else: # From user input. + internal_value = _PendingEncryption(value) + + # Set the internal value on the instance. + instance.__dict__[self.field.attname] = internal_value class BaseEncryptedField(models.BinaryField, t.Generic[T]): @@ -196,16 +205,19 @@ def contribute_to_class(self, cls, name, private_only=False): # Descriptor Methods # -------------------------------------------------------------------------- + # Get the descriptor with the correct types. @t.overload # type: ignore[override] def __get__( self, instance: None, owner: t.Any ) -> EncryptedAttribute[t.Self]: ... + # Get the internal value when accessed on an instance. @t.overload def __get__( self, instance: EncryptedModel, owner: t.Any ) -> t.Optional[T]: ... + # Actual implementation of __get__. def __get__( self, instance: t.Optional[EncryptedModel], owner: t.Any ) -> t.Union[EncryptedAttribute[t.Self], t.Optional[T]]: @@ -215,6 +227,9 @@ def __get__( super().__get__(instance, owner), ) + # Set the internal value when assigned on an instance. + def __set__(self, instance: EncryptedModel, value: t.Optional[T]): ... + @cached_property def cache_name(self): """The name used to cache the decrypted value on the instance.""" diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index b2249c23..5eb9c304 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -325,6 +325,22 @@ def test_set__trusted_ciphertext(self): instance = self._get_model_instance(field=trusted_ciphertext) assert instance.get_stored_value(self.field) == ciphertext + def test_set__memoryview(self): + """Setting field to memoryview stores bytes directly.""" + value = b"byte_value" + memoryview_value = memoryview(value) + instance = self._get_model_instance(field=memoryview_value) + assert instance.get_stored_value(self.field) == value + + def test_set__memoryview__invalid_memoryview_type(self): + """Setting field to invalid memoryview type raises ValidationError.""" + value = bytearray(b"Hello") + memoryview_value = memoryview(value) + with self.assert_raises_validation_error( + code="invalid_memoryview_type" + ): + self._get_model_instance(field=memoryview_value) + def test_set__new_value(self): """ Setting field to new value stores pending encryption and clears cache. diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 2fd9b02a..86f3cfb1 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -41,7 +41,7 @@ "pk": 1, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0DfBz0jtynO_NyzpsG_IClRWETapgBtPnZKR9GJnL7Yj68pG5mpfJwy9yn-fzfDYYL3Rxyv5Uzp6PPA-OoQyPN99A==" + "token": "ZmFrZV9lbmM6WVdGaFlXRmhZV0U9" } }, { @@ -49,7 +49,7 @@ "pk": 2, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0Dv2ZTvAbtOdiAvJg8c-Y7QgikQmZFuzKXPbjfIw-Zc1aDF3Xy8_25vgZpQCpvO8m3X55zy7fTCaI7gLMpkK2Vnig==" + "token": "ZmFrZV9lbmM6WW1KaVltSmlZbUk9" } }, { @@ -57,7 +57,7 @@ "pk": 3, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0D9eN0PiXVfqX820jjOo8NCjlQM4jjEwgOxkfGHNEGkTDoPN-MulsM7n70JdZ3Sh2vvbHGBZj3_DmFFeXxhmyOOtg==" + "token": "ZmFrZV9lbmM6WTJOalkyTmpZMk09" } }, { @@ -65,7 +65,7 @@ "pk": 4, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0EITA69-SWr69FPOg6KMpE_6_lERFr7cIEi7SoECzUVETiFmVEhCAe5yurBqB2wTUuAgZ_H0LnwSH8QoeM3pJ8eKw==" + "token": "ZmFrZV9lbmM6WkdSa1pHUmtaR1E9" } }, { @@ -73,7 +73,7 @@ "pk": 5, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0EThR1vJGD6uJ4hrtef0KY1RyfTB8x1dMvbxAOhW9L0Zm3LvhQfKmy-BnrOTddU9sYIFWTWj3ra65V8gKEG3lGHsA==" + "token": "ZmFrZV9lbmM6WldWbFpXVmxaV1U9" } }, { @@ -81,7 +81,7 @@ "pk": 6, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0EfaRvyIDSy_ZLDx5ap-SHG2udtvhqOF2VEfpXac4rKrYI5-8zvPlMOLFXdMbbcJrQlI1NBAf_1WLbg2wD4cS8Lpg==" + "token": "ZmFrZV9lbmM6Wm1abVptWm1abVk9" } }, { @@ -89,7 +89,7 @@ "pk": 7, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0EoS5Oz5XlpjpIlNyCtgA3QnclUu7fTH09fTP8HN7LtmRiPg6Ee7jxJIOPzO4pYuuZtYt7KBFjIfNwPcwCMbZS1VQ==" + "token": "ZmFrZV9lbmM6WjJkbloyZG5aMmM9" } }, { @@ -97,7 +97,7 @@ "pk": 8, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0EwNEvH1nIoW934uo0mJkwSLe2OjLKLAiJwt1dMsnqkoVwUSbX9Rju2HZ_xNJ-gfVspZOvVqh1BXxSLjowmPLlt5A==" + "token": "ZmFrZV9lbmM6YUdob2FHaG9hR2c9" } }, { @@ -105,7 +105,7 @@ "pk": 9, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0E8iBOFay2uIm5mV6MNmTYXSWHZuhUqx8cAeNtlhnoX9dQitrbZ-NiR2n8p-ZoTNzxOwAmrGr6l_9x1IAxl4KUv7g==" + "token": "ZmFrZV9lbmM6YVdscGFXbHBhV2s9" } }, { @@ -113,7 +113,7 @@ "pk": 10, "fields": { "user": 25, - "token": "ENC:gAAAAABnt0FI41vva7R8HDqec7XAYE5wP2o7tpnz7Qu_ZKpOtiMC2HUqOJNeUFtgJGLxF-6Iu_YZaAMxYzZ66xmlMNjgZ-HCzQ==" + "token": "ZmFrZV9lbmM6YW1wcWFtcHFhbW89" } }, { @@ -181,4 +181,4 @@ "teacher": 9 } } -] \ No newline at end of file +] From c0138f96ad7d4010465aaeb5073fd4c8008aef55 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 19:21:10 +0000 Subject: [PATCH 26/45] DeferredAttribute --- codeforlife/models/fields/base_encrypted.py | 17 +++--------- .../models/fields/deferred_attribute.py | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 codeforlife/models/fields/deferred_attribute.py diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 514d7f33..67f2c2bf 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -9,10 +9,10 @@ from django.core.exceptions import ValidationError from django.db import models -from django.db.models.query_utils import DeferredAttribute from ...types import Args, KwArgs from ..encrypted import EncryptedModel +from .deferred_attribute import DeferredAttribute T = t.TypeVar("T") Default: t.TypeAlias = t.Union[T, t.Callable[[], T]] @@ -37,24 +37,15 @@ class _TrustedCiphertext: ) -class EncryptedAttribute(DeferredAttribute, t.Generic[AnyBaseEncryptedField]): +class EncryptedAttribute( + DeferredAttribute[AnyBaseEncryptedField], t.Generic[AnyBaseEncryptedField] +): """ Custom descriptor that handles the get/set mechanics for encrypted fields. """ InternalValue: t.TypeAlias = t.Optional[t.Union[bytes, _PendingEncryption]] - _field: AnyBaseEncryptedField - - @property - def field(self): - """Helper to get the field with the correct type.""" - return t.cast(AnyBaseEncryptedField, self._field) - - @field.setter - def field(self, value: AnyBaseEncryptedField): - self._field = value - def __get__(self, instance: t.Optional[EncryptedModel], cls=None): # Return the descriptor itself when accessed on the class. if instance is None: diff --git a/codeforlife/models/fields/deferred_attribute.py b/codeforlife/models/fields/deferred_attribute.py new file mode 100644 index 00000000..ae61a74c --- /dev/null +++ b/codeforlife/models/fields/deferred_attribute.py @@ -0,0 +1,27 @@ +""" +© Ocado Group +Created on 22/01/2026 at 13:43:46(+00:00). +""" + +import typing as t + +from django.db.models import Field +from django.db.models.query_utils import DeferredAttribute as _DeferredAttribute + +AnyField = t.TypeVar("AnyField", bound=Field) + + +# pylint: disable-next=too-few-public-methods +class DeferredAttribute(_DeferredAttribute, t.Generic[AnyField]): + """Custom DeferredAttribute with type hints ref to the field.""" + + _field: AnyField + + @property + def field(self): + """Helper to get the field with the correct type.""" + return t.cast(AnyField, self._field) + + @field.setter + def field(self, value: AnyField): + self._field = value From 02b7942a6c60882027d19ea3b940e7124a40c443 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 22 Jan 2026 19:59:48 +0000 Subject: [PATCH 27/45] default get and set with type args --- codeforlife/models/fields/base_encrypted.py | 13 +++++++------ codeforlife/models/fields/deferred_attribute.py | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 67f2c2bf..42922aa6 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -38,7 +38,8 @@ class _TrustedCiphertext: class EncryptedAttribute( - DeferredAttribute[AnyBaseEncryptedField], t.Generic[AnyBaseEncryptedField] + DeferredAttribute[AnyBaseEncryptedField, EncryptedModel], + t.Generic[AnyBaseEncryptedField], ): """ Custom descriptor that handles the get/set mechanics for encrypted fields. @@ -46,7 +47,7 @@ class EncryptedAttribute( InternalValue: t.TypeAlias = t.Optional[t.Union[bytes, _PendingEncryption]] - def __get__(self, instance: t.Optional[EncryptedModel], cls=None): + def __get__(self, instance, cls=None): # Return the descriptor itself when accessed on the class. if instance is None: return self @@ -57,8 +58,8 @@ def __get__(self, instance: t.Optional[EncryptedModel], cls=None): return getattr(instance, cache_name) # Get the internal value from the instance. - internal_value: EncryptedAttribute.InternalValue = ( - instance.__dict__.get(self.field.attname) + internal_value: EncryptedAttribute.InternalValue = super().__get__( + instance, cls ) # No data to decrypt. @@ -79,7 +80,7 @@ def __get__(self, instance: t.Optional[EncryptedModel], cls=None): def __set__( self, - instance: EncryptedModel, + instance, value: t.Optional[ t.Union["memoryview[bytes]", _TrustedCiphertext, t.Any] ], @@ -106,7 +107,7 @@ def __set__( internal_value = _PendingEncryption(value) # Set the internal value on the instance. - instance.__dict__[self.field.attname] = internal_value + super().__set__(instance, internal_value) class BaseEncryptedField(models.BinaryField, t.Generic[T]): diff --git a/codeforlife/models/fields/deferred_attribute.py b/codeforlife/models/fields/deferred_attribute.py index ae61a74c..4b17af60 100644 --- a/codeforlife/models/fields/deferred_attribute.py +++ b/codeforlife/models/fields/deferred_attribute.py @@ -5,14 +5,15 @@ import typing as t -from django.db.models import Field +from django.db.models import Field, Model from django.db.models.query_utils import DeferredAttribute as _DeferredAttribute +AnyModel = t.TypeVar("AnyModel", bound=Model) AnyField = t.TypeVar("AnyField", bound=Field) # pylint: disable-next=too-few-public-methods -class DeferredAttribute(_DeferredAttribute, t.Generic[AnyField]): +class DeferredAttribute(_DeferredAttribute, t.Generic[AnyField, AnyModel]): """Custom DeferredAttribute with type hints ref to the field.""" _field: AnyField @@ -25,3 +26,15 @@ def field(self): @field.setter def field(self, value: AnyField): self._field = value + + def __get__(self, instance: t.Optional[AnyModel], cls=None): + # Return the descriptor itself when accessed on the class. + if instance is None: + return self + + # Get the internal value from the instance. + return instance.__dict__.get(self.field.attname) + + def __set__(self, instance: AnyModel, value): + # Set the internal value on the instance. + instance.__dict__[self.field.attname] = value From b95c06a5dfbf8679cb479c06e2fe43b6449c1fa7 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 26 Jan 2026 14:39:46 +0000 Subject: [PATCH 28/45] dek model and field --- codeforlife/models/__init__.py | 1 + codeforlife/models/data_encryption_key.py | 31 ++++++ .../models/data_encryption_key_test.py | 49 +++++++++ codeforlife/models/encrypted.py | 23 ++-- codeforlife/models/encrypted_test.py | 22 +++- codeforlife/models/fields/base_encrypted.py | 8 +- .../models/fields/data_encryption_key.py | 93 +++++++++++----- .../models/fields/data_encryption_key_test.py | 100 ++++++++++-------- codeforlife/tests/model.py | 8 ++ codeforlife/user/models/otp_bypass_token.py | 9 +- .../user/models/otp_bypass_token_test.py | 1 + 11 files changed, 258 insertions(+), 87 deletions(-) create mode 100644 codeforlife/models/data_encryption_key.py create mode 100644 codeforlife/models/data_encryption_key_test.py diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 278be46b..adb3f0cb 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -7,4 +7,5 @@ from .abstract_base_user import AbstractBaseUser from .base import * from .base_session_store import BaseSessionStore +from .data_encryption_key import DataEncryptionKeyModel from .encrypted import EncryptedModel diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py new file mode 100644 index 00000000..efc2e12b --- /dev/null +++ b/codeforlife/models/data_encryption_key.py @@ -0,0 +1,31 @@ +""" +© Ocado Group +Created on 26/01/2026 at 10:32:18(+00:00). +""" + +import typing as t + +from ..encryption import get_dek_aead +from .base import Model +from .fields import DataEncryptionKeyField + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + + +class DataEncryptionKeyModel(Model): + """Model to store data encryption keys.""" + + # Create a DataEncryptionKeyField to store the encrypted DEK. + dek: DataEncryptionKeyField = DataEncryptionKeyField() + + class Meta(TypedModelMeta): + abstract = True + + @property + def dek_aead(self): + """Return the AEAD primitive for the data encryption key.""" + # TODO: Cache this value. + return get_dek_aead(self.dek) if self.dek else None diff --git a/codeforlife/models/data_encryption_key_test.py b/codeforlife/models/data_encryption_key_test.py new file mode 100644 index 00000000..76f97a6a --- /dev/null +++ b/codeforlife/models/data_encryption_key_test.py @@ -0,0 +1,49 @@ +""" +© Ocado Group +Created on 26/01/2026 at 13:44:31(+00:00). +""" + +import typing as t +from unittest.mock import MagicMock, patch + +from ..encryption import FakeAead +from ..tests import ModelTestCase +from .data_encryption_key import DataEncryptionKeyModel + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + +# pylint: disable=missing-class-docstring +# pylint: disable=too-few-public-methods + + +class TestDataEncryptionKeyModel(ModelTestCase[DataEncryptionKeyModel]): + @classmethod + def get_model_class(cls): + """ + Dynamically create a subclass of DataEncryptionKeyModel for testing. + """ + + class TestModel(super().get_model_class()): + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + return TestModel + + @patch("codeforlife.models.data_encryption_key.get_dek_aead") + def test_dek_aead(self, get_dek_aead_mock: MagicMock): + """dek_aead property returns None when dek is not set.""" + instance = self.get_model_instance() + + with self.subTest("When dek is set"): + dek_aead_mock = FakeAead.as_mock() + get_dek_aead_mock.return_value = dek_aead_mock + assert instance.dek_aead is dek_aead_mock + get_dek_aead_mock.assert_called_once_with(instance.dek) + + with self.subTest("When dek is None"): + get_dek_aead_mock.reset_mock() + instance.dek = None + assert instance.dek_aead is None diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 0c3fdee2..372f2692 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -21,19 +21,15 @@ TypedModelMeta = object -class _EncryptedModel(Model): - associated_data: str - ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] - - class Meta(TypedModelMeta): - abstract = True +AnyEncryptedModel = t.TypeVar("AnyEncryptedModel", bound="EncryptedModel") -AnyEncryptedModel = t.TypeVar("AnyEncryptedModel", bound=_EncryptedModel) +class EncryptedModel(Model): + """Base for all models with encrypted fields.""" + associated_data: str -class EncryptedModel(_EncryptedModel): - """Base for all models with encrypted fields.""" + ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] # pylint: disable-next=too-few-public-methods class Manager( @@ -59,8 +55,13 @@ def update(self, **kwargs): return super().update(**kwargs) # Disable bulk operations that would bypass field-level encryption. - bulk_update: None = None # type: ignore[assignment] - bulk_create: None = None # type: ignore[assignment] + aupdate: t.Never = None # type: ignore[assignment] + bulk_update: t.Never = None # type: ignore[assignment] + abulk_update: t.Never = None # type: ignore[assignment] + bulk_create: t.Never = None # type: ignore[assignment] + abulk_create: t.Never = None # type: ignore[assignment] + in_bulk: t.Never = None # type: ignore[assignment] + ain_bulk: t.Never = None # type: ignore[assignment] objects: Manager["EncryptedModel"] = Manager() # type: ignore[assignment] diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index f9acf290..759841f1 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -29,20 +29,40 @@ class Meta(TypedModelMeta): app_label = "codeforlife.user" -class EncryptedModelTestCase(ModelTestCase[EncryptedModel]): +class TestEncryptedModel(ModelTestCase[EncryptedModel]): def test_objects___update__cannot_update(self): """Cannot update encrypted field via objects.update().""" with self.assert_raises_validation_error(code="cannot_update"): Person.objects.update(name="Alice") + def test_objects___aupdate(self): + """Cannot aupdate encrypted field via objects.aupdate().""" + assert Person.objects.aupdate is None + def test_objects___bulk_update(self): """Cannot bulk update encrypted field via objects.bulk_update().""" assert Person.objects.bulk_update is None + def test_objects___abulk_update(self): + """Cannot abulk_update encrypted field via objects.abulk_update().""" + assert Person.objects.abulk_update is None + def test_objects___bulk_create(self): """Cannot bulk create encrypted field via objects.bulk_create().""" assert Person.objects.bulk_create is None + def test_objects___abulk_create(self): + """Cannot abulk_create encrypted field via objects.abulk_create().""" + assert Person.objects.abulk_create is None + + def test_objects__in_bulk(self): + """Cannot in_bulk encrypted field via objects.in_bulk().""" + assert Person.objects.in_bulk is None + + def test_objects__ain_bulk(self): + """Cannot ain_bulk encrypted field via objects.ain_bulk().""" + assert Person.objects.ain_bulk is None + def test_dek_aead(self): """dek_aead raises NotImplementedError.""" with self.assertRaises(NotImplementedError): diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 42922aa6..6e35eb16 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -8,7 +8,7 @@ from functools import cached_property from django.core.exceptions import ValidationError -from django.db import models +from django.db.models import BinaryField from ...types import Args, KwArgs from ..encrypted import EncryptedModel @@ -81,9 +81,7 @@ def __get__(self, instance, cls=None): def __set__( self, instance, - value: t.Optional[ - t.Union["memoryview[bytes]", _TrustedCiphertext, t.Any] - ], + value: t.Optional[t.Union[memoryview, _TrustedCiphertext, t.Any]], ): # Clear any cached decrypted value. cache_name = self.field.cache_name @@ -110,7 +108,7 @@ def __set__( super().__set__(instance, internal_value) -class BaseEncryptedField(models.BinaryField, t.Generic[T]): +class BaseEncryptedField(BinaryField, t.Generic[T]): """Encrypted field base class.""" model: t.Type[EncryptedModel] diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index b769c539..bd9a3a1d 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -4,25 +4,58 @@ """ import typing as t -from functools import cached_property +from dataclasses import dataclass, field from django.core.exceptions import ValidationError -from django.db import models +from django.db.models import BinaryField from django.utils.translation import gettext_lazy as _ -from ...encryption import create_dek, get_dek_aead -from ...models import Model +from ...encryption import create_dek from ...types import KwArgs +from .deferred_attribute import DeferredAttribute if t.TYPE_CHECKING: - from tink.aead import Aead # type: ignore[import-untyped] + from ...models import DataEncryptionKeyModel +AnyDataEncryptionKeyField = t.TypeVar( + "AnyDataEncryptionKeyField", bound="DataEncryptionKeyField" +) -class DataEncryptionKeyField(models.BinaryField): + +@dataclass(frozen=True) +class _Default: + """A default value holder for DataEncryptionKeyField.""" + + dek: bytes = field(default_factory=create_dek) + + +class DataEncryptionKeyAttribute( + DeferredAttribute[AnyDataEncryptionKeyField, "DataEncryptionKeyModel"], + t.Generic[AnyDataEncryptionKeyField], +): + """Descriptor for DataEncryptionKeyField.""" + + def __set__(self, instance, value): + if isinstance(value, _Default): + value = value.dek + elif value is not None: + raise ValidationError( + "DataEncryptionKeyField can only be set to None.", + code="cannot_set_value", + ) + + super().__set__(instance, value) + + +class DataEncryptionKeyField(BinaryField): """ A custom BinaryField to store a encrypted data encryption key (DEK). """ + model: t.Type["DataEncryptionKeyModel"] + + descriptor_class = DataEncryptionKeyAttribute + default_verbose_name = "data encryption key" default_help_text = ( "The encrypted data encryption key (DEK) for this model." @@ -31,7 +64,7 @@ class DataEncryptionKeyField(models.BinaryField): def set_init_kwargs(self, kwargs: KwArgs): """Sets common init kwargs.""" kwargs["editable"] = False - kwargs["default"] = create_dek + kwargs["default"] = _Default kwargs["null"] = False kwargs.setdefault("verbose_name", _(self.default_verbose_name)) kwargs.setdefault("help_text", _(self.default_help_text)) @@ -61,21 +94,31 @@ def deconstruct(self): self.set_init_kwargs(kwargs) return name, path, args, kwargs - @cached_property - def aead(self): - """Return the AEAD primitive for this data encryption key.""" - - def get_aead(model: Model): - dek: bytes = getattr(model, self.name) - - # TODO: Cache this value. - return get_dek_aead(dek) - - # Create a property with getter. Cast to Aead for mypy. - return t.cast("Aead", property(fget=get_aead)) - - @classmethod - def initialize(cls, **kwargs): - """Helpers to create a new DEK and return its AEAD primitive.""" - dek = cls(**kwargs) - return dek, dek.aead + # -------------------------------------------------------------------------- + # Descriptor Methods + # -------------------------------------------------------------------------- + + # Get the descriptor. + @t.overload # type: ignore[override] + def __get__( + self, instance: None, owner: t.Any + ) -> DataEncryptionKeyAttribute[t.Self]: ... + + # Get the value. + @t.overload + def __get__( + self, instance: "DataEncryptionKeyModel", owner: t.Any + ) -> t.Optional[bytes]: ... + + # Actual implementation of __get__. + def __get__( + self, instance: t.Optional["DataEncryptionKeyModel"], owner: t.Any + ): + return t.cast( + t.Union[DataEncryptionKeyAttribute[t.Self], t.Optional[bytes]], + # pylint: disable-next=no-member + super().__get__(instance, owner), + ) + + # Can only be set to None to allow data shredding. + def __set__(self, instance: "DataEncryptionKeyModel", value: None): ... diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 92e0e29f..1860eac5 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -4,13 +4,11 @@ """ import typing as t -from unittest.mock import MagicMock, patch from django.db import models -from ...encryption import create_dek from ...tests import TestCase -from .data_encryption_key import DataEncryptionKeyField +from .data_encryption_key import DataEncryptionKeyField, _Default if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta @@ -22,8 +20,29 @@ class DataEncryptionKeyFieldTestCase(TestCase): + def _get_model_class(self): + """Dynamically creates a Model subclass with a DEK field. + + This assigns self.field to the 'dek' attribute of the model. + """ + + class Model(models.Model): + """A fake Model with a DEK field for testing.""" + + dek = self.field + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + return Model + + def _get_model_instance(self, **kwargs): + """Gets an instance of the dynamically created model class.""" + return self._get_model_class()(**kwargs) + def setUp(self): - self.field: DataEncryptionKeyField = DataEncryptionKeyField() + # Casting as the field is not deferred in a model. + self.field = t.cast(DataEncryptionKeyField, DataEncryptionKeyField()) def test_init__editable_not_allowed(self): """Cannot create DataEncryptionKeyField with editable=True.""" @@ -43,8 +62,7 @@ def test_init__null_not_allowed(self): def test_init(self): """DataEncryptionKeyField is constructed correctly.""" assert self.field.editable is False - # pylint: disable-next=comparison-with-callable - assert self.field.default == create_dek + assert self.field.default == _Default assert self.field.null is False assert ( self.field.verbose_name @@ -57,8 +75,7 @@ def test_deconstruct(self): _, _, _, kwargs = self.field.deconstruct() assert kwargs["editable"] is False - # pylint: disable-next=comparison-with-callable - assert kwargs["default"] == create_dek + assert kwargs["default"] == _Default assert kwargs["null"] is False assert ( kwargs["verbose_name"] @@ -66,38 +83,35 @@ def test_deconstruct(self): ) assert kwargs["help_text"] == DataEncryptionKeyField.default_help_text - @patch( - "codeforlife.models.fields.data_encryption_key.create_dek", - return_value=b"mock_dek_bytes", - ) - @patch( - "codeforlife.models.fields.data_encryption_key.get_dek_aead", - return_value="mock_dek_aead", - ) - def test_aead( - self, mock_get_dek_aead: MagicMock, mock_create_dek: MagicMock - ): - """AEAD returns a property that gets the AEAD for the DEK.""" - - class ValidModel(models.Model): - associated_data = "model" - - _dek: DataEncryptionKeyField = DataEncryptionKeyField() - dek_aead = _dek.aead - - class Meta(TypedModelMeta): - app_label = "codeforlife.user" - - instance = ValidModel() - mock_create_dek.assert_called_once_with() - - mock_get_dek_aead.assert_not_called() - dek_aead = instance.dek_aead - mock_get_dek_aead.assert_called_once_with(mock_create_dek.return_value) - assert dek_aead == mock_get_dek_aead.return_value - - def test_initialize(self): - """DataEncryptionKeyField.initialize creates field and aead property.""" - field, aead = DataEncryptionKeyField.initialize() - - assert field.aead == aead + def test_get__descriptor(self): + """Getting field from class returns the descriptor.""" + Model = self._get_model_class() + assert isinstance(Model.dek, DataEncryptionKeyField.descriptor_class) + assert Model.dek.field == self.field + + def test_get__value(self): + """Getting field from instance returns the DEK bytes.""" + instance = self._get_model_instance() + dek_value = instance.dek + assert isinstance(dek_value, bytes) + assert dek_value == instance.__dict__["dek"] + + def test_set__default(self): + """Setting field to _Default sets to default DEK bytes.""" + instance = self._get_model_instance() + default = _Default() + instance.dek = default + assert default.dek == instance.__dict__["dek"] + + def test_set__none(self): + """Setting field to None sets to None.""" + instance = self._get_model_instance() + assert instance.dek is not None + instance.dek = None + assert instance.__dict__["dek"] is None + + def test_set__cannot_set_value(self): + """Setting field to any value other than None or _Default raises.""" + instance = self._get_model_instance() + with self.assert_raises_validation_error(code="cannot_set_value"): + instance.dek = b"some_value" diff --git a/codeforlife/tests/model.py b/codeforlife/tests/model.py index 74285b18..e79e93e8 100644 --- a/codeforlife/tests/model.py +++ b/codeforlife/tests/model.py @@ -28,6 +28,14 @@ def get_model_class(cls) -> t.Type[AnyModel]: """ return get_arg(cls, 0) + def get_model_instance(self, *args, **kwargs) -> AnyModel: + """Get an instance of the model. + + Returns: + An instance of the model. + """ + return self.get_model_class()(*args, **kwargs) + def assert_raises_integrity_error(self, *args, **kwargs): """Assert the code block raises an integrity error. diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index ecc5ffc2..504024f3 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -62,8 +62,13 @@ def bulk_create(self, user: User): # type: ignore[override] user.otp_bypass_tokens.all().delete() - for token in tokens: - OtpBypassToken(user=user, token=token).save() + otp_bypass_tokens = [ + OtpBypassToken(user=user, token=token) for token in tokens + ] + for otp_bypass_token in otp_bypass_tokens: + otp_bypass_token.save() + + return otp_bypass_tokens objects: Manager = Manager() # type: ignore[assignment] diff --git a/codeforlife/user/models/otp_bypass_token_test.py b/codeforlife/user/models/otp_bypass_token_test.py index 75d006d4..a2d2d969 100644 --- a/codeforlife/user/models/otp_bypass_token_test.py +++ b/codeforlife/user/models/otp_bypass_token_test.py @@ -35,6 +35,7 @@ def test_objects__bulk_create(self): assert len(otp_bypass_tokens) == self.user.otp_bypass_tokens.count() for otp_bypass_token in otp_bypass_tokens: + assert otp_bypass_token.token is not None assert len(otp_bypass_token.token) == OtpBypassToken.length assert all( char in OtpBypassToken.allowed_chars From f5dc4aa694bea6655db1a0b39d477edaea5250f1 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 26 Jan 2026 15:05:23 +0000 Subject: [PATCH 29/45] fixes --- codeforlife/models/data_encryption_key_test.py | 2 +- codeforlife/models/fields/base_encrypted_test.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/codeforlife/models/data_encryption_key_test.py b/codeforlife/models/data_encryption_key_test.py index 76f97a6a..75c2670f 100644 --- a/codeforlife/models/data_encryption_key_test.py +++ b/codeforlife/models/data_encryption_key_test.py @@ -26,7 +26,7 @@ def get_model_class(cls): Dynamically create a subclass of DataEncryptionKeyModel for testing. """ - class TestModel(super().get_model_class()): + class TestModel(DataEncryptionKeyModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 5eb9c304..e28eb060 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -23,6 +23,7 @@ else: TypedModelMeta = object +# pylint: disable=missing-class-docstring # pylint: disable=too-few-public-methods # pylint: disable=too-many-instance-attributes @@ -93,9 +94,7 @@ def __init__(self, associated_data, default=None, **kwargs): # pylint: disable-next=too-many-public-methods -class TestEncryptedModel(TestCase): - """Tests BaseEncryptedField functionality.""" - +class TestBaseEncryptedField(TestCase): # -------------------------------------------------------------------------- # Test Helper Methods # -------------------------------------------------------------------------- @@ -212,13 +211,17 @@ def test_bytes_to_value(self): """bytes_to_value raises NotImplementedError.""" with self.assertRaises(NotImplementedError): # pylint: disable-next=expression-not-assigned - self.field.bytes_to_value(b"data") + BaseEncryptedField[str](associated_data="test").bytes_to_value( + b"data" + ) def test_value_to_bytes(self): """value_to_bytes raises NotImplementedError.""" with self.assertRaises(NotImplementedError): # pylint: disable-next=expression-not-assigned - self.field.value_to_bytes("value") + BaseEncryptedField[str](associated_data="test").value_to_bytes( + "value" + ) def test_qual_associated_data(self): """qual_associated_data returns fully qualified associated data.""" From 4175fc944791001ed44b2e3b773ad588df7eb0a4 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 26 Jan 2026 15:31:31 +0000 Subject: [PATCH 30/45] local support --- codeforlife/encryption.py | 9 +++++++++ codeforlife/models/fields/data_encryption_key_test.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py index 3304c811..20b593cf 100644 --- a/codeforlife/encryption.py +++ b/codeforlife/encryption.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, create_autospec from django.conf import settings +from django.utils.crypto import get_random_string from tink import ( # type: ignore[import-untyped] BinaryKeysetReader, BinaryKeysetWriter, @@ -93,6 +94,10 @@ def create_dek(): Creates a new random AES-256-GCM data encryption key (DEK), wraps it with Cloud KMS, and returns the binary blob for storage. """ + # In local environment, return a fake encrypted DEK. + if settings.ENV == "local": + return FakeAead.encrypt(get_random_string(32).encode()) + stream = BytesIO() new_keyset_handle(key_template=aead_key_templates.AES256_GCM).write( keyset_writer=BinaryKeysetWriter(stream), @@ -107,6 +112,10 @@ def get_dek_aead(dek: bytes) -> Aead: if not dek: raise ValueError("The data encryption key (DEK) is missing.") + # In local environment, return the fake AEAD primitive. + if settings.ENV == "local": + return FakeAead() + return read_keyset_handle( keyset_reader=BinaryKeysetReader(dek), master_key_aead=_get_kek_aead(), diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 1860eac5..5e38e294 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -19,7 +19,7 @@ # pylint: disable=too-few-public-methods -class DataEncryptionKeyFieldTestCase(TestCase): +class TestDataEncryptionKeyField(TestCase): def _get_model_class(self): """Dynamically creates a Model subclass with a DEK field. From 0ed8221e81d8f2087e52ebbd75c9bd44b682cf1d Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 28 Jan 2026 16:13:41 +0000 Subject: [PATCH 31/45] final tweaks --- codeforlife/encryption.py | 16 +++++- .../models/data_encryption_key_test.py | 1 + codeforlife/models/encrypted.py | 19 ++++++- codeforlife/models/encrypted_test.py | 36 +++++++------ codeforlife/models/fields/__init__.py | 1 + codeforlife/models/fields/base_encrypted.py | 53 +++++++++---------- .../models/fields/base_encrypted_test.py | 6 +-- .../models/fields/data_encryption_key.py | 25 ++++++--- .../models/fields/data_encryption_key_test.py | 8 +-- .../models/fields/deferred_attribute.py | 9 ++-- codeforlife/tests/exceptions.py | 12 ++++- 11 files changed, 120 insertions(+), 66 deletions(-) diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py index 20b593cf..4803697e 100644 --- a/codeforlife/encryption.py +++ b/codeforlife/encryption.py @@ -29,7 +29,13 @@ @dataclass class FakeAead: - """A fake AEAD primitive for local testing.""" + """A fake AEAD primitive for local testing. + + We cannot call a real KMS service in a local/test environment as that would: + 1. require network access. + 2. require valid credentials. + 3. incur costs. + """ @staticmethod # pylint: disable-next=unused-argument @@ -58,7 +64,13 @@ def as_mock(cls): @dataclass class FakeGcpKmsClient: - """A fake GcpKmsClient for local testing.""" + """A fake GcpKmsClient for local testing. + + We cannot call a real KMS service in a local/test environment as that would: + 1. require network access. + 2. require valid credentials. + 3. incur costs. + """ key_uri: str diff --git a/codeforlife/models/data_encryption_key_test.py b/codeforlife/models/data_encryption_key_test.py index 75c2670f..42acdfd7 100644 --- a/codeforlife/models/data_encryption_key_test.py +++ b/codeforlife/models/data_encryption_key_test.py @@ -38,6 +38,7 @@ def test_dek_aead(self, get_dek_aead_mock: MagicMock): instance = self.get_model_instance() with self.subTest("When dek is set"): + assert instance.dek is not None dek_aead_mock = FakeAead.as_mock() get_dek_aead_mock.return_value = dek_aead_mock assert instance.dek_aead is dek_aead_mock diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 372f2692..302f8db1 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -113,9 +113,12 @@ def _check_associated_data(cls, **kwargs): else: for model in apps.get_models(): if ( + # pylint: disable-next=too-many-boolean-expressions not model is cls and not model._meta.abstract and issubclass(model, EncryptedModel) + and hasattr(model, "associated_data") + and isinstance(model.associated_data, str) and model.associated_data == cls.associated_data ): errors.append( @@ -124,7 +127,7 @@ def _check_associated_data(cls, **kwargs): f" '{cls.associated_data}'", hint=( f"{cls.__module__}.{cls.__name__}" - " shares this ID with" + " shares this associated data with" f" {model.__module__}.{model.__name__}." ), obj=cls, @@ -132,6 +135,20 @@ def _check_associated_data(cls, **kwargs): ) ) + if not issubclass(cls.objects.__class__, EncryptedModel.Manager): + errors.append( + checks.Error( + "EncryptedModel subclasses must use the" + " EncryptedModel.Manager.", + hint=( + f"Set 'objects = EncryptedModel.Manager()' on" + f" {cls.__module__}.{cls.__name__}." + ), + obj=cls, + id="codeforlife.user.E005", + ) + ) + return errors @classmethod diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index 759841f1..5eb2f136 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -5,6 +5,8 @@ import typing as t +from django.db import models + from ..tests import ModelTestCase from ..user.models import OtpBypassToken from .encrypted import EncryptedModel @@ -77,10 +79,7 @@ class E001(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check( - error_id="codeforlife.user.E001", - model_class=E001, - ) + self.assert_check(error_id="codeforlife.user.E001", model_class=E001) def test_check__e002(self): """Check for string associated_data.""" @@ -92,10 +91,7 @@ class E002(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check( - error_id="codeforlife.user.E002", - model_class=E002, - ) + self.assert_check(error_id="codeforlife.user.E002", model_class=E002) def test_check__e003(self): """Check for non-empty associated_data.""" @@ -107,10 +103,7 @@ class E003(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check( - error_id="codeforlife.user.E003", - model_class=E003, - ) + self.assert_check(error_id="codeforlife.user.E003", model_class=E003) def test_check__e004(self): """Check for unique associated_data.""" @@ -122,7 +115,18 @@ class E004(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check( - error_id="codeforlife.user.E004", - model_class=E004, - ) + self.assert_check(error_id="codeforlife.user.E004", model_class=E004) + + def test_check__e005(self): + """Check manager subclasses EncryptedModel.Manager.""" + + # pylint: disable-next=abstract-method + class E005(EncryptedModel): + associated_data = "example" + + objects = models.Manager() # type: ignore[assignment] + + class Meta(TypedModelMeta): + app_label = "codeforlife.user" + + self.assert_check(error_id="codeforlife.user.E005", model_class=E005) diff --git a/codeforlife/models/fields/__init__.py b/codeforlife/models/fields/__init__.py index d55c660e..19cfac94 100644 --- a/codeforlife/models/fields/__init__.py +++ b/codeforlife/models/fields/__init__.py @@ -5,4 +5,5 @@ from .base_encrypted import BaseEncryptedField from .data_encryption_key import DataEncryptionKeyField +from .deferred_attribute import DeferredAttribute from .encrypted_text import EncryptedTextField diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 6e35eb16..013e2508 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -15,7 +15,6 @@ from .deferred_attribute import DeferredAttribute T = t.TypeVar("T") -Default: t.TypeAlias = t.Union[T, t.Callable[[], T]] @dataclass(frozen=True) @@ -35,33 +34,25 @@ class _TrustedCiphertext: AnyBaseEncryptedField = t.TypeVar( "AnyBaseEncryptedField", bound="BaseEncryptedField" ) +Value: t.TypeAlias = t.Union[bytes, _PendingEncryption[T]] class EncryptedAttribute( - DeferredAttribute[AnyBaseEncryptedField, EncryptedModel], - t.Generic[AnyBaseEncryptedField], + DeferredAttribute[AnyBaseEncryptedField, EncryptedModel, Value[T]], + t.Generic[AnyBaseEncryptedField, T], ): """ Custom descriptor that handles the get/set mechanics for encrypted fields. """ - InternalValue: t.TypeAlias = t.Optional[t.Union[bytes, _PendingEncryption]] - def __get__(self, instance, cls=None): + # Get the internal value from the instance. + internal_value = super().__get__(instance, cls) + # Return the descriptor itself when accessed on the class. - if instance is None: + if internal_value is self: return self - # If we have a cached decrypted value, return it. - cache_name = self.field.cache_name - if hasattr(instance, cache_name): - return getattr(instance, cache_name) - - # Get the internal value from the instance. - internal_value: EncryptedAttribute.InternalValue = super().__get__( - instance, cls - ) - # No data to decrypt. if internal_value is None: return None @@ -70,8 +61,15 @@ def __get__(self, instance, cls=None): if isinstance(internal_value, _PendingEncryption): return internal_value.value + # If we have a cached decrypted value, return it. + cache_name = self.field.cache_name + if hasattr(instance, cache_name): + return t.cast(T, getattr(instance, cache_name)) + # Decrypt the value before returning it. - decrypted_value = self.field.decrypt_value(instance, internal_value) + decrypted_value = t.cast( + T, self.field.decrypt_value(instance, internal_value) + ) # Cache the decrypted value on the instance. setattr(instance, cache_name, decrypted_value) @@ -81,7 +79,9 @@ def __get__(self, instance, cls=None): def __set__( self, instance, - value: t.Optional[t.Union[memoryview, _TrustedCiphertext, t.Any]], + value: t.Optional[ # type: ignore[override] + t.Union[memoryview, _TrustedCiphertext, T] + ], ): # Clear any cached decrypted value. cache_name = self.field.cache_name @@ -89,7 +89,7 @@ def __set__( delattr(instance, cache_name) # Determine the internal value to set. - internal_value: EncryptedAttribute.InternalValue + internal_value: t.Optional[Value[T]] if value is None: internal_value = None elif isinstance(value, memoryview): # From fixture load. @@ -126,7 +126,8 @@ def set_init_kwargs(self, kwargs: KwArgs): def __init__( self, associated_data: str, - default: t.Optional[Default[T]] = None, + # Set type for default to match T. + default: t.Optional[t.Union[T, t.Callable[[], T]]] = None, **kwargs, ): if not associated_data: @@ -199,7 +200,7 @@ def contribute_to_class(self, cls, name, private_only=False): @t.overload # type: ignore[override] def __get__( self, instance: None, owner: t.Any - ) -> EncryptedAttribute[t.Self]: ... + ) -> EncryptedAttribute[t.Self, T]: ... # Get the internal value when accessed on an instance. @t.overload @@ -208,11 +209,9 @@ def __get__( ) -> t.Optional[T]: ... # Actual implementation of __get__. - def __get__( - self, instance: t.Optional[EncryptedModel], owner: t.Any - ) -> t.Union[EncryptedAttribute[t.Self], t.Optional[T]]: + def __get__(self, instance: t.Optional[EncryptedModel], owner: t.Any): return t.cast( - t.Union[EncryptedAttribute[t.Self], t.Optional[T]], + t.Union[EncryptedAttribute[t.Self, T], t.Optional[T]], # pylint: disable-next=no-member super().__get__(instance, owner), ) @@ -246,9 +245,7 @@ def pre_save( Called before the model is saved. This is where we perform encryption, because we have access to the instance (needed for the DEK). """ - value: bytes | _PendingEncryption[T] | None = ( - model_instance.__dict__.get(self.attname) - ) + value: t.Optional[Value[T]] = model_instance.__dict__.get(self.attname) # No data to encrypt. if value is None: diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index e28eb060..85524826 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -373,10 +373,10 @@ def test_get__descriptor(self): def test_get__cached(self): """Getting field when cached returns cached value.""" - value = "decrypted_value" - assert value != self.field.default - instance = self._get_model_instance() + instance.set_stored_value(self.field, b"irrelevant") + + value = "decrypted_value" setattr(instance, self.field.cache_name, value) assert instance.field == value diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index bd9a3a1d..a2c0e855 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -30,21 +30,29 @@ class _Default: class DataEncryptionKeyAttribute( - DeferredAttribute[AnyDataEncryptionKeyField, "DataEncryptionKeyModel"], + DeferredAttribute[ + AnyDataEncryptionKeyField, "DataEncryptionKeyModel", bytes + ], t.Generic[AnyDataEncryptionKeyField], ): """Descriptor for DataEncryptionKeyField.""" - def __set__(self, instance, value): + def __set__( + self, + instance, + value: t.Optional[_Default], # type: ignore[override] + ): if isinstance(value, _Default): - value = value.dek - elif value is not None: + internal_value = value.dek + elif value is None: + internal_value = None + else: raise ValidationError( "DataEncryptionKeyField can only be set to None.", code="cannot_set_value", ) - super().__set__(instance, value) + super().__set__(instance, internal_value) class DataEncryptionKeyField(BinaryField): @@ -65,7 +73,7 @@ def set_init_kwargs(self, kwargs: KwArgs): """Sets common init kwargs.""" kwargs["editable"] = False kwargs["default"] = _Default - kwargs["null"] = False + kwargs["null"] = True kwargs.setdefault("verbose_name", _(self.default_verbose_name)) kwargs.setdefault("help_text", _(self.default_help_text)) @@ -80,9 +88,10 @@ def __init__(self, **kwargs): "DataEncryptionKeyField cannot have a default value.", code="default_not_allowed", ) - if kwargs.get("null", False): + if not kwargs.get("null", True): raise ValidationError( - "DataEncryptionKeyField cannot be null.", + "DataEncryptionKeyField must allow null to support data" + " shredding.", code="null_not_allowed", ) diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 5e38e294..e21721b4 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -54,16 +54,16 @@ def test_init__default_not_allowed(self): with self.assert_raises_validation_error(code="default_not_allowed"): DataEncryptionKeyField(default=b"default_value") - def test_init__null_not_allowed(self): + def test_init__null_allowed(self): """Cannot create DataEncryptionKeyField with null=True.""" with self.assert_raises_validation_error(code="null_not_allowed"): - DataEncryptionKeyField(null=True) + DataEncryptionKeyField(null=False) def test_init(self): """DataEncryptionKeyField is constructed correctly.""" assert self.field.editable is False assert self.field.default == _Default - assert self.field.null is False + assert self.field.null is True assert ( self.field.verbose_name == DataEncryptionKeyField.default_verbose_name @@ -76,7 +76,7 @@ def test_deconstruct(self): assert kwargs["editable"] is False assert kwargs["default"] == _Default - assert kwargs["null"] is False + assert kwargs["null"] is True assert ( kwargs["verbose_name"] == DataEncryptionKeyField.default_verbose_name diff --git a/codeforlife/models/fields/deferred_attribute.py b/codeforlife/models/fields/deferred_attribute.py index 4b17af60..0ae9ea89 100644 --- a/codeforlife/models/fields/deferred_attribute.py +++ b/codeforlife/models/fields/deferred_attribute.py @@ -10,10 +10,11 @@ AnyModel = t.TypeVar("AnyModel", bound=Model) AnyField = t.TypeVar("AnyField", bound=Field) +T = t.TypeVar("T") # pylint: disable-next=too-few-public-methods -class DeferredAttribute(_DeferredAttribute, t.Generic[AnyField, AnyModel]): +class DeferredAttribute(_DeferredAttribute, t.Generic[AnyField, AnyModel, T]): """Custom DeferredAttribute with type hints ref to the field.""" _field: AnyField @@ -21,6 +22,8 @@ class DeferredAttribute(_DeferredAttribute, t.Generic[AnyField, AnyModel]): @property def field(self): """Helper to get the field with the correct type.""" + # Mypy tries to be helpful here but fails to infer the correct type. + # Hence the cast. return t.cast(AnyField, self._field) @field.setter @@ -33,8 +36,8 @@ def __get__(self, instance: t.Optional[AnyModel], cls=None): return self # Get the internal value from the instance. - return instance.__dict__.get(self.field.attname) + return t.cast(t.Optional[T], instance.__dict__.get(self.field.attname)) - def __set__(self, instance: AnyModel, value): + def __set__(self, instance: AnyModel, value: t.Optional[T]): # Set the internal value on the instance. instance.__dict__[self.field.attname] = value diff --git a/codeforlife/tests/exceptions.py b/codeforlife/tests/exceptions.py index 54a24bfa..a15d488b 100644 --- a/codeforlife/tests/exceptions.py +++ b/codeforlife/tests/exceptions.py @@ -33,7 +33,17 @@ def run( pipeline_args: t.Optional["Args"] = None, pipeline_kwargs: t.Optional["KwArgs"] = None, ): - """Run a pipeline, interrupting at a specified step.""" + """Run a pipeline, interrupting at a specified step. + + Args: + test_case: The test case instance to use for assertions. + step_target: The object containing the step method to patch. + step_attribute: The name of the step method to patch. + assert_step: A callable that asserts the step was reached correctly. + pipeline: The pipeline function to run. + pipeline_args: Positional arguments to pass to the pipeline. + pipeline_kwargs: Keyword arguments to pass to the pipeline. + """ # Get the original step method. step = getattr(step_target, step_attribute) From 7643e931f401168b682805c76288b59fd5d515e7 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 29 Jan 2026 11:50:42 +0000 Subject: [PATCH 32/45] cache dek aead --- Pipfile | 4 +- Pipfile.lock | 300 +++++++++--------- codeforlife/caches.py | 124 -------- codeforlife/caches/__init__.py | 8 + codeforlife/caches/base.py | 67 ++++ codeforlife/caches/base_dynamic_key.py | 59 ++++ codeforlife/caches/base_fixed_key.py | 56 ++++ codeforlife/models/data_encryption_key.py | 44 ++- codeforlife/models/encrypted.py | 26 +- codeforlife/models/fields/base_encrypted.py | 4 - .../models/fields/data_encryption_key_test.py | 4 +- .../models/fields/deferred_attribute.py | 10 +- .../user/caches/google_oauth2_token.py | 6 +- 13 files changed, 409 insertions(+), 303 deletions(-) delete mode 100644 codeforlife/caches.py create mode 100644 codeforlife/caches/__init__.py create mode 100644 codeforlife/caches/base.py create mode 100644 codeforlife/caches/base_dynamic_key.py create mode 100644 codeforlife/caches/base_fixed_key.py diff --git a/Pipfile b/Pipfile index 99487ed1..0a905661 100644 --- a/Pipfile +++ b/Pipfile @@ -34,9 +34,10 @@ cfl-common = "==8.9.11" # TODO: remove codeforlife-portal = "==8.9.11" # TODO: remove rapid-router = "==7.6.12" # TODO: remove phonenumbers = "==8.12.12" # TODO: remove -google-auth = "==2.40.3" +google-auth = "==2.48.0" google-cloud-bigquery = "==3.38.0" tink = {version = "==1.13.0", extras = ["gcpkms"]} +cachetools = "==6.2.6" [dev-packages] celery-types = "==0.23.0" @@ -59,6 +60,7 @@ django-stubs = {version = "==4.2.6", extras = ["compatible-mypy"]} djangorestframework-stubs = {version = "==3.14.4", extras = ["compatible-mypy"]} types-regex = "==2024.11.6.*" types-psutil = "==7.0.0.20250601" +types-cachetools = "==6.2.*" [requires] python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock index bfa7e0f2..5f32aa09 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0d7acda6ffa0e971e77aea484d8b08e17d64048bfc45cd38b6e56986b788bea8" + "sha256": "b667f1f286b6bc2715c8b390d90f68aebdfe313278f40fcca11008b10b9fa1de" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "absl-py": { "hashes": [ - "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", - "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d" + "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", + "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4" ], - "markers": "python_version >= '3.8'", - "version": "==2.3.1" + "markers": "python_version >= '3.10'", + "version": "==2.4.0" }, "amqp": { "hashes": [ @@ -50,10 +50,10 @@ }, "bazel-runfiles": { "hashes": [ - "sha256:558b5f8f90285ba9a7cedbf289fa3c895a2a7abd238ad0245d84586bed224459" + "sha256:57a2cc04e0b924606e8dd70fc8b31d157db43680a300f546dc60207b5ce7ca82" ], "markers": "python_version >= '3.7'", - "version": "==1.7.0" + "version": "==1.8.3" }, "billiard": { "hashes": [ @@ -82,11 +82,12 @@ }, "cachetools": { "hashes": [ - "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", - "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a" + "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", + "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda" ], - "markers": "python_version >= '3.7'", - "version": "==5.5.2" + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==6.2.6" }, "celery": { "extras": [ @@ -606,12 +607,12 @@ }, "google-auth": { "hashes": [ - "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", - "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77" + "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", + "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.40.3" + "markers": "python_version >= '3.8'", + "version": "==2.48.0" }, "google-cloud-bigquery": { "hashes": [ @@ -632,11 +633,11 @@ }, "google-cloud-kms": { "hashes": [ - "sha256:389ed5cf085e212b6e4a55af1cffe06e6a47aa1827782ad8549591285cc2d620", - "sha256:e9a4b2dca4c50a8c74f7ed6ac8b5ef5abc18c416b419a04080908b3270170c22" + "sha256:2060d56cebe856ecdff04b6ea6b477a498b620ed57577804e0aea2950a2edab1", + "sha256:adc9924ec7bafe1b01236f8f5aba706902b39d64abceef0b4f739725dc66d08b" ], "markers": "python_version >= '3.7'", - "version": "==3.7.0" + "version": "==3.10.0" }, "google-crc32c": { "hashes": [ @@ -1291,19 +1292,19 @@ }, "protobuf": { "hashes": [ - "sha256:08a6ca12f60ba99097dd3625ef4275280f99c9037990e47ce9368826b159b890", - "sha256:1fd18f030ae9df97712fbbb0849b6e54c63e3edd9b88d8c3bb4771f84d8db7a4", - "sha256:2756963dcfd414eba46bcbb341f0e2c652036e5d700f112b3bb90fa1a031893a", - "sha256:642fce7187526c98683c79a3ad68e5d646a5ef5eb004582fe123fc9a33a9456b", - "sha256:648b7b0144222eb06cf529a3d7b01333c5f30b4196773b682d388f04db373759", - "sha256:6fa9b5f4baa12257542273e5e6f3c3d3867b30bc2770c14ad9ac8315264bf986", - "sha256:b4046f9f2ede57ad5b1d9917baafcbcad42f8151a73c755a1e2ec9557b0a764f", - "sha256:c2bf221076b0d463551efa2e1319f08d4cffcc5f0d864614ccd3d0e77a637794", - "sha256:c46dcc47b243b299f4f7eabeed21929c07f0d36fffe2ea8431793b53c308ab80", - "sha256:c8794debeb402963fddff41a595e1f649bcd76616ba56c835645cab4539e810e" + "sha256:0f12ddbf96912690c3582f9dffb55530ef32015ad8e678cd494312bd78314c4f", + "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", + "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", + "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", + "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", + "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", + "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", + "sha256:955478a89559fa4568f5a81dce77260eabc5c686f9e8366219ebd30debf06aa6", + "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", + "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91" ], "markers": "python_version >= '3.9'", - "version": "==6.33.3" + "version": "==6.33.4" }, "psutil": { "hashes": [ @@ -1417,11 +1418,11 @@ }, "pyasn1": { "hashes": [ - "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", - "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034" + "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", + "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b" ], "markers": "python_version >= '3.8'", - "version": "==0.6.1" + "version": "==0.6.2" }, "pyasn1-modules": { "hashes": [ @@ -1999,11 +2000,11 @@ }, "botocore-stubs": { "hashes": [ - "sha256:49d15529002bd1099a9a099a77d70b7b52859153783440e96eb55791e8147d1b", - "sha256:70a8a53ba2684ff462c44d5996acd85fc5c7eb969e2cf3c25274441269524298" + "sha256:5a9b2a4062f7cc19e0648508f67d3f1a1fd8d3e0d6f5a0d3244cc9656e54cc67", + "sha256:7357d1876ae198757dbe0a73f887449ffdda18eb075d7d3cc2e22d3580dcb17c" ], "markers": "python_version >= '3.9'", - "version": "==1.42.25" + "version": "==1.42.37" }, "celery-types": { "hashes": [ @@ -2154,109 +2155,109 @@ "toml" ], "hashes": [ - "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", - "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", - "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", - "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", - "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", - "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", - "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", - "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", - "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", - "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", - "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", - "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", - "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", - "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", - "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", - "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", - "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", - "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", - "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", - "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", - "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", - "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", - "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", - "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", - "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", - "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", - "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", - "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", - "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", - "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", - "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", - "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", - "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", - "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", - "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", - "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", - "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", - "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", - "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", - "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", - "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", - "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", - "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", - "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", - "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", - "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", - "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", - "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", - "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", - "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", - "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", - "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", - "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", - "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", - "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", - "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", - "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", - "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", - "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", - "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", - "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", - "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", - "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", - "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", - "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", - "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", - "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", - "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", - "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", - "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", - "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", - "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", - "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", - "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", - "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", - "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", - "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", - "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", - "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", - "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", - "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", - "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", - "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", - "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", - "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", - "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", - "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", - "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", - "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", - "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", - "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", - "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766" + "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", + "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", + "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", + "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", + "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", + "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", + "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", + "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c", + "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", + "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", + "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", + "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", + "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", + "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", + "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", + "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", + "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2", + "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", + "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", + "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", + "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", + "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", + "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f", + "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", + "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", + "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", + "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", + "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5", + "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", + "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", + "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", + "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", + "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", + "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", + "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", + "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", + "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad", + "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", + "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", + "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", + "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31", + "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", + "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", + "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", + "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", + "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", + "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", + "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", + "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", + "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", + "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", + "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", + "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", + "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", + "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc", + "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896", + "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", + "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99", + "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", + "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", + "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", + "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", + "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", + "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", + "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", + "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", + "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", + "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", + "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", + "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", + "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", + "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c", + "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", + "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", + "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", + "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", + "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", + "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", + "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", + "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8", + "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", + "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", + "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", + "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", + "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", + "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", + "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b", + "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", + "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", + "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", + "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", + "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99" ], "markers": "python_version >= '3.10'", - "version": "==7.13.1" + "version": "==7.13.2" }, "dill": { "hashes": [ - "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", - "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" + "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", + "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa" ], - "markers": "python_version >= '3.8'", - "version": "==0.4.0" + "markers": "python_version >= '3.9'", + "version": "==0.4.1" }, "django": { "hashes": [ @@ -2289,11 +2290,11 @@ }, "django-stubs-ext": { "hashes": [ - "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", - "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b" + "sha256:230c51575551b0165be40177f0f6805f1e3ebf799b835c85f5d64c371ca6cf71", + "sha256:6db4054d1580657b979b7d391474719f1a978773e66c7070a5e246cd445a25a9" ], "markers": "python_version >= '3.10'", - "version": "==5.2.8" + "version": "==5.2.9" }, "django-test-migrations": { "hashes": [ @@ -2464,11 +2465,11 @@ }, "pathspec": { "hashes": [ - "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", - "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c" + "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", + "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" ], "markers": "python_version >= '3.9'", - "version": "==1.0.3" + "version": "==1.0.4" }, "platformdirs": { "hashes": [ @@ -2613,19 +2614,28 @@ }, "tomlkit": { "hashes": [ - "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", - "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0" + "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", + "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064" ], - "markers": "python_version >= '3.8'", - "version": "==0.13.3" + "markers": "python_version >= '3.9'", + "version": "==0.14.0" }, "types-awscrt": { "hashes": [ - "sha256:009cfe5b9af8c75e8304243490e20a5229e7a56203f1d41481f5522233453f51", - "sha256:aa8b42148af0847be14e2b8ea3637a3518ffab038f8d3be7083950f3ce87d3ff" + "sha256:08b13494f93f45c1a92eb264755fce50ed0d1dc75059abb5e31670feb9a09724", + "sha256:7e4364ac635f72bd57f52b093883640b1448a6eded0ecbac6e900bf4b1e4777b" ], "markers": "python_version >= '3.8'", - "version": "==0.31.0" + "version": "==0.31.1" + }, + "types-cachetools": { + "hashes": [ + "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", + "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==6.2.0.20251022" }, "types-psutil": { "hashes": [ diff --git a/codeforlife/caches.py b/codeforlife/caches.py deleted file mode 100644 index fba5c50c..00000000 --- a/codeforlife/caches.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -© Ocado Group -Created on 11/08/2025 at 11:07:45(+01:00). -""" - -import typing as t - -from django.core.cache import cache - -K = t.TypeVar("K") -V = t.TypeVar("V") - - -class BaseValueCache(t.Generic[V]): - """Base class which helps to get and set cache values.""" - - @classmethod - def get( - cls, - key: str, - default: t.Optional[V] = None, - version: t.Optional[int] = None, - ) -> t.Optional[V]: - """ - Fetch a given key from the cache. If the key does not exist, return - default, which itself defaults to None. - """ - return cache.get( - key=key, - default=default, - version=version, - ) - - @classmethod - def set( - cls, - key: str, - value: V, - timeout: t.Optional[int] = None, - version: t.Optional[int] = None, - ): - """ - Set a value in the cache. If timeout is given, use that timeout for the - key; otherwise use the default cache timeout. - """ - cache.set( - key=key, - value=value, - timeout=timeout, - version=version, - ) - - -class BaseFixedKeyValueCache(BaseValueCache[V], t.Generic[V]): - """Base class which helps to get and set cache values with a fixed key.""" - - key: str - - # pylint: disable=arguments-differ - - @classmethod - def get( # type: ignore[override] - cls, - default: t.Optional[V] = None, - version: t.Optional[int] = None, - ) -> t.Optional[V]: - return super().get( - key=cls.key, - default=default, - version=version, - ) - - @classmethod - def set( # type: ignore[override] - cls, - value: V, - timeout: t.Optional[int] = None, - version: t.Optional[int] = None, - ): - super().set( - key=cls.key, - value=value, - timeout=timeout, - version=version, - ) - - # pylint: enable=arguments-differ - - -class BaseDynamicKeyValueCache(BaseValueCache[V], t.Generic[K, V]): - """Base class which helps to get and set cache values with a dynamic key.""" - - @staticmethod - def make_key(key: K) -> str: - """Make the cache key from the key's data.""" - raise NotImplementedError() - - @classmethod - def get( # type: ignore[override] - cls, - key: K, - default: t.Optional[V] = None, - version: t.Optional[int] = None, - ) -> t.Optional[V]: - return super().get( - key=cls.make_key(key), - default=default, - version=version, - ) - - @classmethod - def set( # type: ignore[override] - cls, - key: K, - value: V, - timeout: t.Optional[int] = None, - version: t.Optional[int] = None, - ): - super().set( - key=cls.make_key(key), - value=value, - timeout=timeout, - version=version, - ) diff --git a/codeforlife/caches/__init__.py b/codeforlife/caches/__init__.py new file mode 100644 index 00000000..96bb76f3 --- /dev/null +++ b/codeforlife/caches/__init__.py @@ -0,0 +1,8 @@ +""" +© Ocado Group +Created on 28/01/2026 at 16:52:19(+00:00). +""" + +from .base import BaseCache +from .base_dynamic_key import BaseDynamicKeyCache +from .base_fixed_key import BaseFixedKeyCache diff --git a/codeforlife/caches/base.py b/codeforlife/caches/base.py new file mode 100644 index 00000000..9438fa9c --- /dev/null +++ b/codeforlife/caches/base.py @@ -0,0 +1,67 @@ +""" +© Ocado Group +Created on 28/01/2026 at 16:52:19(+00:00). +""" + +import typing as t + +from django.core.cache import cache + +V = t.TypeVar("V") + + +class BaseCache(t.Generic[V]): + """Base class which helps to get and set cache values.""" + + timeout: int | None = None + + @classmethod + def get( + cls, + key: str, + default: t.Optional[V] = None, + version: t.Optional[int] = None, + ) -> t.Optional[V]: + """ + Fetch a given key from the cache. If the key does not exist, return + default, which itself defaults to None. + """ + return cache.get( + key=key, + default=default, + version=version, + ) + + @classmethod + def set( + cls, + key: str, + value: V, + timeout: t.Optional[int] = None, + version: t.Optional[int] = None, + ): + """ + Set a value in the cache. If timeout is given, use that timeout for the + key; otherwise use the default cache timeout. + """ + cache.set( + key=key, + value=value, + timeout=timeout or cls.timeout, + version=version, + ) + + @classmethod + def delete( + cls, + key: str, + version: t.Optional[int] = None, + ): + """ + Delete a key from the cache and return whether it succeeded, failing + silently. + """ + cache.delete( + key=key, + version=version, + ) diff --git a/codeforlife/caches/base_dynamic_key.py b/codeforlife/caches/base_dynamic_key.py new file mode 100644 index 00000000..e3b48628 --- /dev/null +++ b/codeforlife/caches/base_dynamic_key.py @@ -0,0 +1,59 @@ +""" +© Ocado Group +Created on 28/01/2026 at 16:52:19(+00:00). +""" + +import typing as t + +from .base import BaseCache + +V = t.TypeVar("V") +K = t.TypeVar("K") + + +class BaseDynamicKeyCache(BaseCache[V], t.Generic[K, V]): + """Base class which helps to get and set cache values with a dynamic key.""" + + @staticmethod + def make_key(key: K) -> str: + """Make the cache key from the key's data.""" + raise NotImplementedError() + + @classmethod + def get( # type: ignore[override] + cls, + key: K, + default: t.Optional[V] = None, + version: t.Optional[int] = None, + ) -> t.Optional[V]: + return super().get( + key=cls.make_key(key), + default=default, + version=version, + ) + + @classmethod + def set( # type: ignore[override] + cls, + key: K, + value: V, + timeout: t.Optional[int] = None, + version: t.Optional[int] = None, + ): + super().set( + key=cls.make_key(key), + value=value, + timeout=timeout, + version=version, + ) + + @classmethod + def delete( # type: ignore[override] + cls, + key: K, + version: t.Optional[int] = None, + ): + super().delete( + key=cls.make_key(key), + version=version, + ) diff --git a/codeforlife/caches/base_fixed_key.py b/codeforlife/caches/base_fixed_key.py new file mode 100644 index 00000000..4fd1dda5 --- /dev/null +++ b/codeforlife/caches/base_fixed_key.py @@ -0,0 +1,56 @@ +""" +© Ocado Group +Created on 28/01/2026 at 16:52:19(+00:00). +""" + +import typing as t + +from .base import BaseCache + +V = t.TypeVar("V") + + +class BaseFixedKeyCache(BaseCache[V], t.Generic[V]): + """Base class which helps to get and set cache values with a fixed key.""" + + key: str + + # pylint: disable=arguments-differ + + @classmethod + def get( # type: ignore[override] + cls, + default: t.Optional[V] = None, + version: t.Optional[int] = None, + ) -> t.Optional[V]: + return super().get( + key=cls.key, + default=default, + version=version, + ) + + @classmethod + def set( # type: ignore[override] + cls, + value: V, + timeout: t.Optional[int] = None, + version: t.Optional[int] = None, + ): + super().set( + key=cls.key, + value=value, + timeout=timeout, + version=version, + ) + + @classmethod + def delete( # type: ignore[override] + cls, + version: t.Optional[int] = None, + ): + super().delete( + key=cls.key, + version=version, + ) + + # pylint: enable=arguments-differ diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py index efc2e12b..14cf8ca1 100644 --- a/codeforlife/models/data_encryption_key.py +++ b/codeforlife/models/data_encryption_key.py @@ -5,8 +5,11 @@ import typing as t +from cachetools import TTLCache +from django.core.exceptions import ValidationError + from ..encryption import get_dek_aead -from .base import Model +from .encrypted import EncryptedModel from .fields import DataEncryptionKeyField if t.TYPE_CHECKING: @@ -15,9 +18,21 @@ TypedModelMeta = object -class DataEncryptionKeyModel(Model): +class DataEncryptionKeyModel(EncryptedModel): """Model to store data encryption keys.""" + # Cache configuration for data encryption keys. + dek_cache_maxsize: int = 1024 + dek_cache_ttl: int = 900 # 15 minutes + + DEK_CACHE: TTLCache + + def __init_subclass__(cls): + super().__init_subclass__() + cls.DEK_CACHE = TTLCache( + maxsize=cls.dek_cache_maxsize, ttl=cls.dek_cache_ttl + ) + # Create a DataEncryptionKeyField to store the encrypted DEK. dek: DataEncryptionKeyField = DataEncryptionKeyField() @@ -26,6 +41,25 @@ class Meta(TypedModelMeta): @property def dek_aead(self): - """Return the AEAD primitive for the data encryption key.""" - # TODO: Cache this value. - return get_dek_aead(self.dek) if self.dek else None + # Return None if there is no DEK. + if self.dek is None: + return None + + # Ensure the instance is saved before accessing the DEK AEAD. + if self.pk is None: + raise ValidationError( + "Instance must be saved before accessing dek_aead.", + code="unsaved_instance", + ) + + # Check the cache for the DEK AEAD. + if self.pk in self.DEK_CACHE: + return self.DEK_CACHE[self.pk] + + # Get the AEAD primitive for the data encryption key. + dek_aead = get_dek_aead(self.dek) + + # Cache the DEK AEAD for future access. + self.DEK_CACHE[self.pk] = dek_aead + + return dek_aead diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 302f8db1..80ebb8aa 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -31,6 +31,10 @@ class EncryptedModel(Model): ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] + def __init_subclass__(cls): + super().__init_subclass__() + cls.ENCRYPTED_FIELDS = [] + # pylint: disable-next=too-few-public-methods class Manager( models.Manager[AnyEncryptedModel], t.Generic[AnyEncryptedModel] @@ -39,18 +43,16 @@ class Manager( def update(self, **kwargs): """Ensure encrypted fields are not updated via 'update()'.""" - if hasattr(self.model, "ENCRYPTED_FIELDS"): - for name in kwargs: - if any( - field.name == name - for field in self.model.ENCRYPTED_FIELDS - ): - raise ValidationError( - f"Cannot update encrypted field '{name}' via" - " 'update()'. Set the property on each instance" - " instead.", - code="cannot_update", - ) + for name in kwargs: + if any( + field.name == name for field in self.model.ENCRYPTED_FIELDS + ): + raise ValidationError( + f"Cannot update encrypted field '{name}' via" + " 'update()'. Set the property on each instance" + " instead.", + code="cannot_update", + ) return super().update(**kwargs) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 013e2508..eea596c8 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -169,10 +169,6 @@ def contribute_to_class(self, cls, name, private_only=False): code="invalid_model_base_class", ) - if not hasattr(cls, "ENCRYPTED_FIELDS"): - cls.ENCRYPTED_FIELDS = [self] - return - # Ensure no duplicate encrypted fields. if self in cls.ENCRYPTED_FIELDS: raise ValidationError( diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index e21721b4..73420002 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -26,7 +26,7 @@ def _get_model_class(self): This assigns self.field to the 'dek' attribute of the model. """ - class Model(models.Model): + class DekModel(models.Model): """A fake Model with a DEK field for testing.""" dek = self.field @@ -34,7 +34,7 @@ class Model(models.Model): class Meta(TypedModelMeta): app_label = "codeforlife.user" - return Model + return DekModel def _get_model_instance(self, **kwargs): """Gets an instance of the dynamically created model class.""" diff --git a/codeforlife/models/fields/deferred_attribute.py b/codeforlife/models/fields/deferred_attribute.py index 0ae9ea89..cc98f779 100644 --- a/codeforlife/models/fields/deferred_attribute.py +++ b/codeforlife/models/fields/deferred_attribute.py @@ -31,12 +31,10 @@ def field(self, value: AnyField): self._field = value def __get__(self, instance: t.Optional[AnyModel], cls=None): - # Return the descriptor itself when accessed on the class. - if instance is None: - return self - - # Get the internal value from the instance. - return t.cast(t.Optional[T], instance.__dict__.get(self.field.attname)) + return t.cast( + t.Optional[T], + super().__get__(instance, cls), # type: ignore[misc] + ) def __set__(self, instance: AnyModel, value: t.Optional[T]): # Set the internal value on the instance. diff --git a/codeforlife/user/caches/google_oauth2_token.py b/codeforlife/user/caches/google_oauth2_token.py index aaa19d99..969ca2a6 100644 --- a/codeforlife/user/caches/google_oauth2_token.py +++ b/codeforlife/user/caches/google_oauth2_token.py @@ -8,7 +8,7 @@ import requests from django.conf import settings -from ...caches import BaseDynamicKeyValueCache +from ...caches import BaseDynamicKeyCache from ...types import OAuth2TokenFromRefreshDict from ..models import GoogleUser @@ -24,9 +24,7 @@ class GoogleOAuth2TokenCacheValue(t.TypedDict): class GoogleOAuth2TokenCache( - BaseDynamicKeyValueCache[ - GoogleOAuth2TokenCacheKey, GoogleOAuth2TokenCacheValue - ] + BaseDynamicKeyCache[GoogleOAuth2TokenCacheKey, GoogleOAuth2TokenCacheValue] ): """ Authorization to a user's Google account. The key is the user's ID. The From 72d4959a20f839fbb87db40d12c20221b9df23d9 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 29 Jan 2026 12:08:05 +0000 Subject: [PATCH 33/45] fix tests --- codeforlife/models/data_encryption_key.py | 4 +- .../models/data_encryption_key_test.py | 59 ++++++++++++++----- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py index 14cf8ca1..4a9b9fdb 100644 --- a/codeforlife/models/data_encryption_key.py +++ b/codeforlife/models/data_encryption_key.py @@ -22,8 +22,8 @@ class DataEncryptionKeyModel(EncryptedModel): """Model to store data encryption keys.""" # Cache configuration for data encryption keys. - dek_cache_maxsize: int = 1024 - dek_cache_ttl: int = 900 # 15 minutes + dek_cache_maxsize: float = 1024 + dek_cache_ttl: float = 900 # 15 minutes DEK_CACHE: TTLCache diff --git a/codeforlife/models/data_encryption_key_test.py b/codeforlife/models/data_encryption_key_test.py index 42acdfd7..0b52ec26 100644 --- a/codeforlife/models/data_encryption_key_test.py +++ b/codeforlife/models/data_encryption_key_test.py @@ -32,19 +32,50 @@ class Meta(TypedModelMeta): return TestModel - @patch("codeforlife.models.data_encryption_key.get_dek_aead") - def test_dek_aead(self, get_dek_aead_mock: MagicMock): - """dek_aead property returns None when dek is not set.""" + def test_dek_aead__none(self): + """Returns None when dek is None.""" + instance = self.get_model_instance(dek=None) + assert instance.dek_aead is None + + def test_dek_aead__unsaved_instance(self): + """Cannot get dek before saving the instance.""" instance = self.get_model_instance() + with self.assert_raises_validation_error(code="unsaved_instance"): + _ = instance.dek_aead + + @patch("codeforlife.models.data_encryption_key.get_dek_aead") + def test_dek_aead__not_cached(self, get_dek_aead_mock: MagicMock): + """Returns dek_aead and caches it when not cached.""" + # Create an instance with a primary key to mimic a saved instance. + instance = self.get_model_instance(pk=1) + assert instance.dek is not None + + # Setup the mock to return a FakeAead instance. + dek_aead_mock = FakeAead.as_mock() + get_dek_aead_mock.return_value = dek_aead_mock + + # Initially, the cache should not have the dek_aead. After accessing + # dek_aead, it should be cached. + assert instance.pk not in instance.DEK_CACHE + assert instance.dek_aead is dek_aead_mock + assert instance.pk in instance.DEK_CACHE + + # Ensure the get_dek_aead function was called with the correct dek. + get_dek_aead_mock.assert_called_once_with(instance.dek) + + @patch("codeforlife.models.data_encryption_key.get_dek_aead") + def test_dek_aead__cached(self, get_dek_aead_mock: MagicMock): + """Returns the cached dek_aead.""" + # Create an instance with a primary key to mimic a saved instance. + instance = self.get_model_instance(pk=1) + assert instance.dek is not None + + # Pre-populate the cache with a FakeAead instance. + dek_aead_mock = FakeAead.as_mock() + instance.DEK_CACHE[instance.pk] = dek_aead_mock + + # Accessing dek_aead should return the cached value. + assert instance.dek_aead is dek_aead_mock - with self.subTest("When dek is set"): - assert instance.dek is not None - dek_aead_mock = FakeAead.as_mock() - get_dek_aead_mock.return_value = dek_aead_mock - assert instance.dek_aead is dek_aead_mock - get_dek_aead_mock.assert_called_once_with(instance.dek) - - with self.subTest("When dek is None"): - get_dek_aead_mock.reset_mock() - instance.dek = None - assert instance.dek_aead is None + # Ensure the get_dek_aead function was not called as its cached. + get_dek_aead_mock.assert_not_called() From 89fcd9dddc5f06d54279e9b8ba47298be8fc6f0e Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 29 Jan 2026 13:40:30 +0000 Subject: [PATCH 34/45] rename --- codeforlife/models/data_encryption_key.py | 10 +++++----- codeforlife/models/data_encryption_key_test.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py index 4a9b9fdb..f0617731 100644 --- a/codeforlife/models/data_encryption_key.py +++ b/codeforlife/models/data_encryption_key.py @@ -25,11 +25,11 @@ class DataEncryptionKeyModel(EncryptedModel): dek_cache_maxsize: float = 1024 dek_cache_ttl: float = 900 # 15 minutes - DEK_CACHE: TTLCache + DEK_AEAD_CACHE: TTLCache def __init_subclass__(cls): super().__init_subclass__() - cls.DEK_CACHE = TTLCache( + cls.DEK_AEAD_CACHE = TTLCache( maxsize=cls.dek_cache_maxsize, ttl=cls.dek_cache_ttl ) @@ -53,13 +53,13 @@ def dek_aead(self): ) # Check the cache for the DEK AEAD. - if self.pk in self.DEK_CACHE: - return self.DEK_CACHE[self.pk] + if self.pk in self.DEK_AEAD_CACHE: + return self.DEK_AEAD_CACHE[self.pk] # Get the AEAD primitive for the data encryption key. dek_aead = get_dek_aead(self.dek) # Cache the DEK AEAD for future access. - self.DEK_CACHE[self.pk] = dek_aead + self.DEK_AEAD_CACHE[self.pk] = dek_aead return dek_aead diff --git a/codeforlife/models/data_encryption_key_test.py b/codeforlife/models/data_encryption_key_test.py index 0b52ec26..a1c30acf 100644 --- a/codeforlife/models/data_encryption_key_test.py +++ b/codeforlife/models/data_encryption_key_test.py @@ -56,9 +56,9 @@ def test_dek_aead__not_cached(self, get_dek_aead_mock: MagicMock): # Initially, the cache should not have the dek_aead. After accessing # dek_aead, it should be cached. - assert instance.pk not in instance.DEK_CACHE + assert instance.pk not in instance.DEK_AEAD_CACHE assert instance.dek_aead is dek_aead_mock - assert instance.pk in instance.DEK_CACHE + assert instance.pk in instance.DEK_AEAD_CACHE # Ensure the get_dek_aead function was called with the correct dek. get_dek_aead_mock.assert_called_once_with(instance.dek) @@ -72,7 +72,7 @@ def test_dek_aead__cached(self, get_dek_aead_mock: MagicMock): # Pre-populate the cache with a FakeAead instance. dek_aead_mock = FakeAead.as_mock() - instance.DEK_CACHE[instance.pk] = dek_aead_mock + instance.DEK_AEAD_CACHE[instance.pk] = dek_aead_mock # Accessing dek_aead should return the cached value. assert instance.dek_aead is dek_aead_mock From e8e8509999ee4e7f60d8c9bf7b84c57637c92e40 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 29 Jan 2026 17:07:16 +0000 Subject: [PATCH 35/45] enforce one dek per model --- codeforlife/models/__init__.py | 1 + .../models/base_data_encryption_key.py | 66 ++++++++++++++++++ ...st.py => base_data_encryption_key_test.py} | 18 +++-- codeforlife/models/data_encryption_key.py | 49 ++----------- .../models/fields/base_encrypted_test.py | 2 +- .../models/fields/data_encryption_key.py | 51 +++++++++++--- .../models/fields/data_encryption_key_test.py | 68 ++++++++++++++++++- 7 files changed, 192 insertions(+), 63 deletions(-) create mode 100644 codeforlife/models/base_data_encryption_key.py rename codeforlife/models/{data_encryption_key_test.py => base_data_encryption_key_test.py} (80%) diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index adb3f0cb..21934e1f 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -6,6 +6,7 @@ from .abstract_base_session import AbstractBaseSession from .abstract_base_user import AbstractBaseUser from .base import * +from .base_data_encryption_key import BaseDataEncryptionKeyModel from .base_session_store import BaseSessionStore from .data_encryption_key import DataEncryptionKeyModel from .encrypted import EncryptedModel diff --git a/codeforlife/models/base_data_encryption_key.py b/codeforlife/models/base_data_encryption_key.py new file mode 100644 index 00000000..760c9049 --- /dev/null +++ b/codeforlife/models/base_data_encryption_key.py @@ -0,0 +1,66 @@ +""" +© Ocado Group +Created on 26/01/2026 at 10:32:18(+00:00). +""" + +import typing as t + +from cachetools import TTLCache +from django.core.exceptions import ValidationError + +from ..encryption import get_dek_aead +from .encrypted import EncryptedModel + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta + + from .fields import DataEncryptionKeyField +else: + TypedModelMeta = object + + +class BaseDataEncryptionKeyModel(EncryptedModel): + """Model to store data encryption keys.""" + + # Cache configuration for data encryption keys. + dek_aead_cache_maxsize: float = 1024 + dek_aead_cache_ttl: float = 900 # 15 minutes + + DEK_AEAD_CACHE: TTLCache + + def __init_subclass__(cls): + super().__init_subclass__() + cls.DEK_AEAD_CACHE = TTLCache( + maxsize=cls.dek_aead_cache_maxsize, ttl=cls.dek_aead_cache_ttl + ) + + # Reference to the DataEncryptionKeyField. + _dek: t.Optional["DataEncryptionKeyField"] = None + + class Meta(TypedModelMeta): + abstract = True + + @property + def dek_aead(self): + # Return None if there is no DEK. + if self._dek is None: + return None + + # Ensure the instance is saved before accessing the DEK AEAD. + if self.pk is None: + raise ValidationError( + "Instance must be saved before accessing dek_aead.", + code="unsaved_instance", + ) + + # Check the cache for the DEK AEAD. + if self.pk in self.DEK_AEAD_CACHE: + return self.DEK_AEAD_CACHE[self.pk] + + # Get the AEAD primitive for the data encryption key. + dek_aead = get_dek_aead(self._dek) + + # Cache the DEK AEAD for future access. + self.DEK_AEAD_CACHE[self.pk] = dek_aead + + return dek_aead diff --git a/codeforlife/models/data_encryption_key_test.py b/codeforlife/models/base_data_encryption_key_test.py similarity index 80% rename from codeforlife/models/data_encryption_key_test.py rename to codeforlife/models/base_data_encryption_key_test.py index a1c30acf..7ee28065 100644 --- a/codeforlife/models/data_encryption_key_test.py +++ b/codeforlife/models/base_data_encryption_key_test.py @@ -8,7 +8,8 @@ from ..encryption import FakeAead from ..tests import ModelTestCase -from .data_encryption_key import DataEncryptionKeyModel +from .base_data_encryption_key import BaseDataEncryptionKeyModel +from .fields import DataEncryptionKeyField if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta @@ -19,19 +20,24 @@ # pylint: disable=too-few-public-methods -class TestDataEncryptionKeyModel(ModelTestCase[DataEncryptionKeyModel]): +class TestDataEncryptionKeyModel(ModelTestCase[BaseDataEncryptionKeyModel]): @classmethod def get_model_class(cls): """ - Dynamically create a subclass of DataEncryptionKeyModel for testing. + Dynamically create a subclass of BaseDataEncryptionKeyModel for testing. """ - class TestModel(DataEncryptionKeyModel): + class TestModel(BaseDataEncryptionKeyModel): + dek: DataEncryptionKeyField = DataEncryptionKeyField() + class Meta(TypedModelMeta): app_label = "codeforlife.user" return TestModel + def get_model_instance(self, *args, **kwargs): + return self.get_model_class()(*args, **kwargs) + def test_dek_aead__none(self): """Returns None when dek is None.""" instance = self.get_model_instance(dek=None) @@ -43,7 +49,7 @@ def test_dek_aead__unsaved_instance(self): with self.assert_raises_validation_error(code="unsaved_instance"): _ = instance.dek_aead - @patch("codeforlife.models.data_encryption_key.get_dek_aead") + @patch("codeforlife.models.base_data_encryption_key.get_dek_aead") def test_dek_aead__not_cached(self, get_dek_aead_mock: MagicMock): """Returns dek_aead and caches it when not cached.""" # Create an instance with a primary key to mimic a saved instance. @@ -63,7 +69,7 @@ def test_dek_aead__not_cached(self, get_dek_aead_mock: MagicMock): # Ensure the get_dek_aead function was called with the correct dek. get_dek_aead_mock.assert_called_once_with(instance.dek) - @patch("codeforlife.models.data_encryption_key.get_dek_aead") + @patch("codeforlife.models.base_data_encryption_key.get_dek_aead") def test_dek_aead__cached(self, get_dek_aead_mock: MagicMock): """Returns the cached dek_aead.""" # Create an instance with a primary key to mimic a saved instance. diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py index f0617731..b396d7ae 100644 --- a/codeforlife/models/data_encryption_key.py +++ b/codeforlife/models/data_encryption_key.py @@ -1,15 +1,11 @@ """ © Ocado Group -Created on 26/01/2026 at 10:32:18(+00:00). +Created on 29/01/2026 at 14:03:09(+00:00). """ import typing as t -from cachetools import TTLCache -from django.core.exceptions import ValidationError - -from ..encryption import get_dek_aead -from .encrypted import EncryptedModel +from .base_data_encryption_key import BaseDataEncryptionKeyModel from .fields import DataEncryptionKeyField if t.TYPE_CHECKING: @@ -18,48 +14,11 @@ TypedModelMeta = object -class DataEncryptionKeyModel(EncryptedModel): - """Model to store data encryption keys.""" - - # Cache configuration for data encryption keys. - dek_cache_maxsize: float = 1024 - dek_cache_ttl: float = 900 # 15 minutes - - DEK_AEAD_CACHE: TTLCache - - def __init_subclass__(cls): - super().__init_subclass__() - cls.DEK_AEAD_CACHE = TTLCache( - maxsize=cls.dek_cache_maxsize, ttl=cls.dek_cache_ttl - ) +class DataEncryptionKeyModel(BaseDataEncryptionKeyModel): + """A model that includes a data encryption key field.""" # Create a DataEncryptionKeyField to store the encrypted DEK. dek: DataEncryptionKeyField = DataEncryptionKeyField() class Meta(TypedModelMeta): abstract = True - - @property - def dek_aead(self): - # Return None if there is no DEK. - if self.dek is None: - return None - - # Ensure the instance is saved before accessing the DEK AEAD. - if self.pk is None: - raise ValidationError( - "Instance must be saved before accessing dek_aead.", - code="unsaved_instance", - ) - - # Check the cache for the DEK AEAD. - if self.pk in self.DEK_AEAD_CACHE: - return self.DEK_AEAD_CACHE[self.pk] - - # Get the AEAD primitive for the data encryption key. - dek_aead = get_dek_aead(self.dek) - - # Cache the DEK AEAD for future access. - self.DEK_AEAD_CACHE[self.pk] = dek_aead - - return dek_aead diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 85524826..5af8b9e5 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -284,7 +284,7 @@ def test_encrypt_value(self): assert encrypted_bytes == encrypt_mock.side_effect(**encrypt_kwargs) # -------------------------------------------------------------------------- - # Getting & Setting Values Tests + # Descriptor Methods Tests # -------------------------------------------------------------------------- def test_cache_name(self): diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index a2c0e855..aec15366 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -12,11 +12,9 @@ from ...encryption import create_dek from ...types import KwArgs +from ..base_data_encryption_key import BaseDataEncryptionKeyModel from .deferred_attribute import DeferredAttribute -if t.TYPE_CHECKING: - from ...models import DataEncryptionKeyModel - AnyDataEncryptionKeyField = t.TypeVar( "AnyDataEncryptionKeyField", bound="DataEncryptionKeyField" ) @@ -31,7 +29,7 @@ class _Default: class DataEncryptionKeyAttribute( DeferredAttribute[ - AnyDataEncryptionKeyField, "DataEncryptionKeyModel", bytes + AnyDataEncryptionKeyField, BaseDataEncryptionKeyModel, bytes ], t.Generic[AnyDataEncryptionKeyField], ): @@ -60,10 +58,14 @@ class DataEncryptionKeyField(BinaryField): A custom BinaryField to store a encrypted data encryption key (DEK). """ - model: t.Type["DataEncryptionKeyModel"] + model: t.Type[BaseDataEncryptionKeyModel] descriptor_class = DataEncryptionKeyAttribute + # -------------------------------------------------------------------------- + # Construction & Deconstruction + # -------------------------------------------------------------------------- + default_verbose_name = "data encryption key" default_help_text = ( "The encrypted data encryption key (DEK) for this model." @@ -103,6 +105,39 @@ def deconstruct(self): self.set_init_kwargs(kwargs) return name, path, args, kwargs + # -------------------------------------------------------------------------- + # Django Model Field Integration + # -------------------------------------------------------------------------- + + def contribute_to_class(self, cls, name, private_only=False): + super().contribute_to_class(cls, name, private_only) + + # Skip fake models used for migrations. + if cls.__module__ == "__fake__": + return + + # Ensure the model subclasses BaseDataEncryptionKeyModel. + if not issubclass(cls, BaseDataEncryptionKeyModel): + raise ValidationError( + f"'{cls.__module__}.{cls.__name__}' must subclass" + f" '{BaseDataEncryptionKeyModel.__module__}." + f"{BaseDataEncryptionKeyModel.__name__}'.", + code="invalid_model_base_class", + ) + + # Ensure only one DEK field per model. + # pylint: disable-next=protected-access + if cls._dek is not None: + raise ValidationError( + f"'{cls.__module__}.{cls.__name__}' already has a" + " DataEncryptionKeyField defined.", + code="multiple_dek_fields_not_allowed", + ) + + # Set the class DEK field reference. + # pylint: disable-next=protected-access + cls._dek = getattr(cls, self.name) + # -------------------------------------------------------------------------- # Descriptor Methods # -------------------------------------------------------------------------- @@ -116,12 +151,12 @@ def __get__( # Get the value. @t.overload def __get__( - self, instance: "DataEncryptionKeyModel", owner: t.Any + self, instance: BaseDataEncryptionKeyModel, owner: t.Any ) -> t.Optional[bytes]: ... # Actual implementation of __get__. def __get__( - self, instance: t.Optional["DataEncryptionKeyModel"], owner: t.Any + self, instance: t.Optional[BaseDataEncryptionKeyModel], owner: t.Any ): return t.cast( t.Union[DataEncryptionKeyAttribute[t.Self], t.Optional[bytes]], @@ -130,4 +165,4 @@ def __get__( ) # Can only be set to None to allow data shredding. - def __set__(self, instance: "DataEncryptionKeyModel", value: None): ... + def __set__(self, instance: BaseDataEncryptionKeyModel, value: None): ... diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 73420002..ee50f30e 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -8,6 +8,7 @@ from django.db import models from ...tests import TestCase +from ..base_data_encryption_key import BaseDataEncryptionKeyModel from .data_encryption_key import DataEncryptionKeyField, _Default if t.TYPE_CHECKING: @@ -19,20 +20,29 @@ # pylint: disable=too-few-public-methods +class FakeModelMeta(TypedModelMeta): + """A fake Meta class for testing.""" + + app_label = "codeforlife.user" + + class TestDataEncryptionKeyField(TestCase): + # -------------------------------------------------------------------------- + # Test Helper Methods + # -------------------------------------------------------------------------- + def _get_model_class(self): """Dynamically creates a Model subclass with a DEK field. This assigns self.field to the 'dek' attribute of the model. """ - class DekModel(models.Model): + class DekModel(BaseDataEncryptionKeyModel): """A fake Model with a DEK field for testing.""" dek = self.field - class Meta(TypedModelMeta): - app_label = "codeforlife.user" + Meta = FakeModelMeta return DekModel @@ -43,6 +53,11 @@ def _get_model_instance(self, **kwargs): def setUp(self): # Casting as the field is not deferred in a model. self.field = t.cast(DataEncryptionKeyField, DataEncryptionKeyField()) + self.field2 = t.cast(DataEncryptionKeyField, DataEncryptionKeyField()) + + # -------------------------------------------------------------------------- + # Construction & Deconstruction Tests + # -------------------------------------------------------------------------- def test_init__editable_not_allowed(self): """Cannot create DataEncryptionKeyField with editable=True.""" @@ -83,6 +98,53 @@ def test_deconstruct(self): ) assert kwargs["help_text"] == DataEncryptionKeyField.default_help_text + # -------------------------------------------------------------------------- + # Django Model Field Integration Tests + # -------------------------------------------------------------------------- + + def test_contribute_to_class__invalid_model_base_class(self): + """ + Cannot contribute DataEncryptionKeyField to invalid model base class. + """ + with self.assert_raises_validation_error( + code="invalid_model_base_class" + ): + # pylint: disable-next=unused-variable + class Model(models.Model): + field = self.field + + Meta = FakeModelMeta + + def test_contribute_to_class__multiple_dek_fields_not_allowed(self): + """ + Cannot contribute multiple DataEncryptionKeyFields to a model. + """ + with self.assert_raises_validation_error( + code="multiple_dek_fields_not_allowed" + ): + # pylint: disable-next=unused-variable + class Model(BaseDataEncryptionKeyModel): + dek1 = self.field + dek2 = self.field2 + + Meta = FakeModelMeta + + def test_contribute_to_class(self): + """DataEncryptionKeyField is contributed to model correctly.""" + with self.subTest("Class attribute set correctly"): + Model = self._get_model_class() + # pylint: disable-next=protected-access + assert Model._dek == Model.dek + + with self.subTest("Instance attribute set correctly"): + instance = Model() + # pylint: disable-next=protected-access + assert instance._dek == instance.dek + + # -------------------------------------------------------------------------- + # Descriptor Methods Tests + # -------------------------------------------------------------------------- + def test_get__descriptor(self): """Getting field from class returns the descriptor.""" Model = self._get_model_class() From cb49a6f2c0a6b3216862451299939443af6979dd Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 3 Feb 2026 15:51:24 +0000 Subject: [PATCH 36/45] docs and fixes --- codeforlife/encryption.py | 62 ++++-- .../models/base_data_encryption_key.py | 13 +- codeforlife/models/data_encryption_key.py | 4 + codeforlife/models/encrypted.py | 59 +++-- codeforlife/models/encrypted_test.py | 10 +- codeforlife/models/fields/base_encrypted.py | 155 +++++++++----- .../models/fields/base_encrypted_test.py | 56 ++--- .../models/fields/data_encryption_key.py | 58 +++-- docs/client-side-encryption.md | 201 ++++++++++++++++++ 9 files changed, 488 insertions(+), 130 deletions(-) create mode 100644 docs/client-side-encryption.md diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py index 4803697e..b0a2ea4c 100644 --- a/codeforlife/encryption.py +++ b/codeforlife/encryption.py @@ -2,7 +2,28 @@ © Ocado Group Created on 19/01/2026 at 09:55:44(+00:00). -Various utilities for encrypting/decrypting data. +Here we provide a few utility functions that interact with Google Cloud KMS and +the `tink` cryptography library. + +To avoid a dependency on Google Cloud KMS during local development and in CI/CD +pipelines, we use fake (mock) implementations of the KMS client and its AEAD +primitive. A simple check for the environment (e.g., `settings.ENV == "local"`) +determines whether to use the real `GcpKmsClient` or a `FakeGcpKmsClient`. + +The fake client mimics the behavior of the real one. Instead of performing real +encryption, it simulates it by encoding the plaintext in base64 and adding a +prefix. This allows the application to run without needing cloud credentials +while still being able to distinguish between "encrypted" and plaintext data. + +While the `FakeAead` and `FakeGcpKmsClient` are sufficient for running a local +development server, they are not `unittest.mock.MagicMock` instances by default. +This is intentional, as we don't want the overhead of tracking function calls +during local development. + +For unit tests, where you might need to assert that encryption or decryption +methods were called, both fake classes provide an `as_mock()` class method. This +method returns a `MagicMock` instance of the class, allowing you to use mock +assertions like `assert_called_once()`. """ import typing as t @@ -29,13 +50,7 @@ @dataclass class FakeAead: - """A fake AEAD primitive for local testing. - - We cannot call a real KMS service in a local/test environment as that would: - 1. require network access. - 2. require valid credentials. - 3. incur costs. - """ + """A fake AEAD primitive for local testing.""" @staticmethod # pylint: disable-next=unused-argument @@ -54,7 +69,12 @@ def decrypt(ciphertext: bytes, associated_data: bytes = b""): @classmethod def as_mock(cls): - """Factory method to build the mock AEAD.""" + """ + Returns the class as a functional MagicMock for testing. The mock tracks + calls while still performing the fake encryption and decryption by using + the original methods as side effects. We set `instance=True` to ensure + the mock behaves as an instance of the class, not the class itself. + """ mock: MagicMock = create_autospec(Aead, instance=True) mock.encrypt.side_effect = cls.encrypt mock.decrypt.side_effect = cls.decrypt @@ -64,13 +84,7 @@ def as_mock(cls): @dataclass class FakeGcpKmsClient: - """A fake GcpKmsClient for local testing. - - We cannot call a real KMS service in a local/test environment as that would: - 1. require network access. - 2. require valid credentials. - 3. incur costs. - """ + """A fake GcpKmsClient for local testing.""" key_uri: str @@ -87,7 +101,12 @@ def get_aead(self, key_uri: str) -> Aead: @classmethod def as_mock(cls): - """Factory method to build the mock GcpKmsClient.""" + """ + Returns the class as a functional MagicMock for testing. It returns a + mocked FakeAead instance that is also functional. We set `instance=True` + to ensure the mock behaves as an instance of the class, not the class + itself. + """ mock: MagicMock = create_autospec(_GcpKmsClient, instance=True) mock.get_aead.return_value = FakeAead.as_mock() @@ -120,7 +139,10 @@ def create_dek(): def get_dek_aead(dek: bytes) -> Aead: - """Get the AEAD primitive for the given data encryption key (DEK).""" + """ + Takes an encrypted DEK, decrypts it with the KEK, and returns the AEAD + primitive for performing cryptographic operations. + """ if not dek: raise ValueError("The data encryption key (DEK) is missing.") @@ -137,10 +159,10 @@ def get_dek_aead(dek: bytes) -> Aead: # Ensure Tink AEAD is registered. aead_register() -# Get the GcpKmsClient class depending on the environment. +# Use the fake client in 'local' environment, otherwise use the real one. GcpKmsClient = FakeGcpKmsClient if settings.ENV == "local" else _GcpKmsClient -# Register the GCP KMS client. +# Register the GCP KMS client with Tink. GcpKmsClient.register_client( key_uri=settings.GCP_KMS_KEY_URI, credentials_path=None ) diff --git a/codeforlife/models/base_data_encryption_key.py b/codeforlife/models/base_data_encryption_key.py index 760c9049..20e045b4 100644 --- a/codeforlife/models/base_data_encryption_key.py +++ b/codeforlife/models/base_data_encryption_key.py @@ -1,6 +1,10 @@ """ © Ocado Group Created on 26/01/2026 at 10:32:18(+00:00). + +This abstract model brings the `EncryptedModel` and `DataEncryptionKeyField` +together. It also implements the `dek_aead` property, which retrieves and caches +the decrypted DEK's AEAD primitive for use in encryption/decryption operations. """ import typing as t @@ -20,12 +24,13 @@ class BaseDataEncryptionKeyModel(EncryptedModel): - """Model to store data encryption keys.""" + """Model to store and manage a data encryption key.""" # Cache configuration for data encryption keys. dek_aead_cache_maxsize: float = 1024 dek_aead_cache_ttl: float = 900 # 15 minutes + # In-memory cache for the decrypted DEK AEAD primitive. DEK_AEAD_CACHE: TTLCache def __init_subclass__(cls): @@ -34,7 +39,8 @@ def __init_subclass__(cls): maxsize=cls.dek_aead_cache_maxsize, ttl=cls.dek_aead_cache_ttl ) - # Reference to the DataEncryptionKeyField. + # A class-level reference to the DataEncryptionKeyField instance. + # This is set by the `contribute_to_class` method of the field. _dek: t.Optional["DataEncryptionKeyField"] = None class Meta(TypedModelMeta): @@ -42,6 +48,9 @@ class Meta(TypedModelMeta): @property def dek_aead(self): + """ + Provides the AEAD primitive for the DEK, caching it for performance. + """ # Return None if there is no DEK. if self._dek is None: return None diff --git a/codeforlife/models/data_encryption_key.py b/codeforlife/models/data_encryption_key.py index b396d7ae..301f6f16 100644 --- a/codeforlife/models/data_encryption_key.py +++ b/codeforlife/models/data_encryption_key.py @@ -1,6 +1,10 @@ """ © Ocado Group Created on 29/01/2026 at 14:03:09(+00:00). + +This model inherits from `BaseDataEncryptionKeyModel` and conveniently includes +the `dek` field by default. It serves as a ready-to-use base for any model that +needs to manage its own Data Encryption Key. """ import typing as t diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 80ebb8aa..69c9f0b4 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -1,6 +1,29 @@ """ © Ocado Group Created on 19/01/2026 at 09:56:25(+00:00). + +This is the base class for any model that will contain encrypted fields. Its +primary role is to override the default manager to disable bulk operations that +could otherwise bypass the field-level encryption logic, potentially leading to +data corruption or leaks. It also uses Django's `check` framework to validate +the model's configuration. + +A critical security measure in this architecture is the custom `Manager` within +`EncryptedModel`. Standard Django bulk operations like `bulk_create()` and +`update()` bypass the individual model's `save()` method and, by extension, our +custom field's `pre_save` logic. If these methods were allowed, it would be +possible to insert or update data without it being properly encrypted, or to +corrupt existing encrypted data. + +To prevent this, the `Manager` explicitly disables these operations by setting +them to `None`. The `update()` method is given a special implementation that +actively checks if any of the fields being updated are encrypted fields and +raises a `ValidationError` if they are. This forces developers to fetch model +instances and update their properties individually, ensuring the encryption +logic is always triggered. Furthermore, the model's `check` framework includes a +validation step to ensure that any subclass of `EncryptedModel` is using a +manager that inherits from `EncryptedModel.Manager`, guaranteeing these security +measures are always enforced. """ import typing as t @@ -31,15 +54,24 @@ class EncryptedModel(Model): ENCRYPTED_FIELDS: t.List["BaseEncryptedField"] + def __init__(self, *args, **kwargs): + # Each instance gets its own dict of decrypted values. + self.__decrypted_values__: t.Dict[str, t.Any] = {} + super().__init__(*args, **kwargs) + def __init_subclass__(cls): super().__init_subclass__() + # Each subclass gets its own list of encrypted fields. cls.ENCRYPTED_FIELDS = [] # pylint: disable-next=too-few-public-methods class Manager( models.Manager[AnyEncryptedModel], t.Generic[AnyEncryptedModel] ): - """Base manager for models with encrypted fields.""" + """ + Base manager for models with encrypted fields. Disables bulk operations + that would bypass field-level encryption. + """ def update(self, **kwargs): """Ensure encrypted fields are not updated via 'update()'.""" @@ -88,7 +120,7 @@ def _check_associated_data(cls, **kwargs): "Must define an associated_data attribute.", hint=f"{cls.__module__}.{cls.__name__}", obj=cls, - id="codeforlife.user.E001", + id="encrypted.E001", ) ) # Ensure associated_data is a string. @@ -98,7 +130,7 @@ def _check_associated_data(cls, **kwargs): "associated_data must be a string.", hint=f"{cls.__module__}.{cls.__name__}", obj=cls, - id="codeforlife.user.E002", + id="encrypted.E002", ) ) # Ensure associated_data is not empty. @@ -108,7 +140,7 @@ def _check_associated_data(cls, **kwargs): "associated_data cannot be empty.", hint=f"{cls.__module__}.{cls.__name__}", obj=cls, - id="codeforlife.user.E003", + id="encrypted.E003", ) ) # Ensure associated_data is unique. @@ -133,10 +165,18 @@ def _check_associated_data(cls, **kwargs): f" {model.__module__}.{model.__name__}." ), obj=cls, - id="codeforlife.user.E004", + id="encrypted.E004", ) ) + return errors + + @classmethod + def check(cls, **kwargs): + """Run model checks, including custom checks for encrypted models.""" + errors = super().check(**kwargs) + errors.extend(cls._check_associated_data(**kwargs)) + if not issubclass(cls.objects.__class__, EncryptedModel.Manager): errors.append( checks.Error( @@ -147,19 +187,12 @@ def _check_associated_data(cls, **kwargs): f" {cls.__module__}.{cls.__name__}." ), obj=cls, - id="codeforlife.user.E005", + id="encrypted.E005", ) ) return errors - @classmethod - def check(cls, **kwargs): - """Run model checks, including custom checks for encrypted models.""" - errors = super().check(**kwargs) - errors.extend(cls._check_associated_data(**kwargs)) - return errors - @property def dek_aead(self) -> "Aead": """Gets the AEAD primitive for this model's DEK.""" diff --git a/codeforlife/models/encrypted_test.py b/codeforlife/models/encrypted_test.py index 5eb2f136..35e346f6 100644 --- a/codeforlife/models/encrypted_test.py +++ b/codeforlife/models/encrypted_test.py @@ -79,7 +79,7 @@ class E001(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check(error_id="codeforlife.user.E001", model_class=E001) + self.assert_check(error_id="encrypted.E001", model_class=E001) def test_check__e002(self): """Check for string associated_data.""" @@ -91,7 +91,7 @@ class E002(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check(error_id="codeforlife.user.E002", model_class=E002) + self.assert_check(error_id="encrypted.E002", model_class=E002) def test_check__e003(self): """Check for non-empty associated_data.""" @@ -103,7 +103,7 @@ class E003(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check(error_id="codeforlife.user.E003", model_class=E003) + self.assert_check(error_id="encrypted.E003", model_class=E003) def test_check__e004(self): """Check for unique associated_data.""" @@ -115,7 +115,7 @@ class E004(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check(error_id="codeforlife.user.E004", model_class=E004) + self.assert_check(error_id="encrypted.E004", model_class=E004) def test_check__e005(self): """Check manager subclasses EncryptedModel.Manager.""" @@ -129,4 +129,4 @@ class E005(EncryptedModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" - self.assert_check(error_id="codeforlife.user.E005", model_class=E005) + self.assert_check(error_id="encrypted.E005", model_class=E005) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index eea596c8..621ddf36 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -1,11 +1,56 @@ """ © Ocado Group Created on 19/01/2026 at 09:57:04(+00:00). + +This is where the core logic of transparent encryption and decryption happens. +`BaseEncryptedField` is a generic field that stores encrypted data as bytes. The +magic is in its descriptor, `EncryptedAttribute`. + +The descriptor intercepts get and set operations: + +- On `set`: The value is not immediately encrypted. It's wrapped in a + `_PendingEncryption` object and stored in the model instance's `__dict__`. + This is a performance optimization to avoid encrypting a value multiple + times if it's changed repeatedly before saving. Any previously cached + decrypted value is cleared. +- On `get`: The descriptor first checks if a decrypted value is already cached + on the model instance. If so, it returns it immediately. Otherwise, it checks + the value in `__dict__`. If it's ciphertext (bytes), it's decrypted + on-the-fly, cached on the instance, and then returned. If it's a pending + plaintext value, it's returned directly. +- On `save`: The field's `pre_save` method is called. It checks for a + `_PendingEncryption` object. If found, it encrypts the plaintext value using + the `dek_aead` and replaces it with the resulting ciphertext bytes, which are + then written to the database. + +A key challenge is differentiating between a value that has just been loaded +from the database (and is therefore encrypted ciphertext) and a new plaintext +value that a developer is setting. + +- When a developer sets `user.email = "new@example.com"`, this is **new + plaintext** that needs to be encrypted on save. +- When Django loads a `User` from the database, the `email` field contains + **existing ciphertext** (raw bytes). + +We solve this with two wrapper classes: + +1. `_PendingEncryption(value)`: When a developer sets a value on the field, the + `EncryptedAttribute` descriptor wraps it in this class. This marks the data + as "dirty" or "pending encryption." The `pre_save` method looks for this + wrapper to know what needs to be encrypted. +2. `_TrustedCiphertext(value)`: When Django loads data from the database, the + `from_db_value` method on the field is called. We wrap the raw bytes from the + database in this class. The `EncryptedAttribute` descriptor's `__set__` + method sees this wrapper and knows the value is already-encrypted ciphertext, + preventing it from being re-wrapped as `_PendingEncryption`. This avoids + unnecessary re-encryption of data that hasn't changed. + +This distinction allows the field to correctly handle both new data and existing +data without ambiguity. """ import typing as t from dataclasses import dataclass -from functools import cached_property from django.core.exceptions import ValidationError from django.db.models import BinaryField @@ -19,22 +64,23 @@ @dataclass(frozen=True) class _PendingEncryption(t.Generic[T]): - """Helper: Data waiting to be encrypted (User Input).""" + """A wrapper for plaintext that is pending encryption.""" value: T @dataclass(frozen=True) class _TrustedCiphertext: - """Helper: Trusted ciphertext directly from the DB.""" + """A wrapper for ciphertext that comes directly from the database.""" ciphertext: bytes +Value: t.TypeAlias = t.Union[_TrustedCiphertext, _PendingEncryption[T]] + AnyBaseEncryptedField = t.TypeVar( "AnyBaseEncryptedField", bound="BaseEncryptedField" ) -Value: t.TypeAlias = t.Union[bytes, _PendingEncryption[T]] class EncryptedAttribute( @@ -42,7 +88,7 @@ class EncryptedAttribute( t.Generic[AnyBaseEncryptedField, T], ): """ - Custom descriptor that handles the get/set mechanics for encrypted fields. + Descriptor that handles the get/set mechanics for encrypted fields. """ def __get__(self, instance, cls=None): @@ -61,20 +107,27 @@ def __get__(self, instance, cls=None): if isinstance(internal_value, _PendingEncryption): return internal_value.value - # If we have a cached decrypted value, return it. - cache_name = self.field.cache_name - if hasattr(instance, cache_name): - return t.cast(T, getattr(instance, cache_name)) + if isinstance(internal_value, _TrustedCiphertext): + # If we have a cached decrypted value, return it. + if self.field.attname in instance.__decrypted_values__: + return t.cast( + T, instance.__decrypted_values__[self.field.attname] + ) - # Decrypt the value before returning it. - decrypted_value = t.cast( - T, self.field.decrypt_value(instance, internal_value) - ) + # Decrypt the value before returning it. + decrypted_value = t.cast( + T, self.field.decrypt_value(instance, internal_value.ciphertext) + ) - # Cache the decrypted value on the instance. - setattr(instance, cache_name, decrypted_value) + # Cache the decrypted value on the instance. + instance.__decrypted_values__[self.field.attname] = decrypted_value - return decrypted_value + return decrypted_value + + raise ValidationError( + "Unexpected internal value type for encrypted field.", + code="invalid_internal_value_type", + ) def __set__( self, @@ -84,23 +137,24 @@ def __set__( ], ): # Clear any cached decrypted value. - cache_name = self.field.cache_name - if hasattr(instance, cache_name): - delattr(instance, cache_name) + instance.__decrypted_values__.pop(self.field.attname, None) # Determine the internal value to set. internal_value: t.Optional[Value[T]] if value is None: internal_value = None - elif isinstance(value, memoryview): # From fixture load. + # When Django loads data from a fixture (e.g., a JSON file), it + # provides binary data as a `memoryview` object. Our descriptor + # handles this by extracting the raw bytes from the `memoryview`. + elif isinstance(value, memoryview): if not isinstance(value.obj, bytes): raise ValidationError( "Expected bytes in memoryview for encrypted field.", code="invalid_memoryview_type", ) - internal_value = value.obj + internal_value = _TrustedCiphertext(value.obj) elif isinstance(value, _TrustedCiphertext): # From DB. - internal_value = value.ciphertext + internal_value = value else: # From user input. internal_value = _PendingEncryption(value) @@ -155,6 +209,10 @@ def deconstruct(self): # -------------------------------------------------------------------------- def contribute_to_class(self, cls, name, private_only=False): + """ + Called by Django when the field is added to a model. This method + performs critical validations and registers the field with the model. + """ super().contribute_to_class(cls, name, private_only) # Skip fake models used for migrations. @@ -192,15 +250,13 @@ def contribute_to_class(self, cls, name, private_only=False): # Descriptor Methods # -------------------------------------------------------------------------- - # Get the descriptor with the correct types. @t.overload # type: ignore[override] - def __get__( + def __get__( # Get the descriptor with the correct types. self, instance: None, owner: t.Any ) -> EncryptedAttribute[t.Self, T]: ... - # Get the internal value when accessed on an instance. @t.overload - def __get__( + def __get__( # Get the internal value when accessed on an instance. self, instance: EncryptedModel, owner: t.Any ) -> t.Optional[T]: ... @@ -215,18 +271,12 @@ def __get__(self, instance: t.Optional[EncryptedModel], owner: t.Any): # Set the internal value when assigned on an instance. def __set__(self, instance: EncryptedModel, value: t.Optional[T]): ... - @cached_property - def cache_name(self): - """The name used to cache the decrypted value on the instance.""" - return f"_{self.name}_decrypted_value" - # pylint: disable-next=unused-argument def from_db_value(self, value: t.Optional[bytes], expression, connection): """ - Converts a value as returned by the database to a Python object. It is - the reverse of get_prep_value(). - - https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects + Converts a value as returned by the database to a Python object. + We wrap the raw bytes in _TrustedCiphertext to signal that this is + existing ciphertext from the database, not new plaintext. """ if value is None: return None @@ -237,10 +287,7 @@ def from_db_value(self, value: t.Optional[bytes], expression, connection): def pre_save( self, model_instance: EncryptedModel, add # type: ignore[override] ): - """ - Called before the model is saved. This is where we perform encryption, - because we have access to the instance (needed for the DEK). - """ + """Before saving, encrypt any pending values.""" value: t.Optional[Value[T]] = model_instance.__dict__.get(self.attname) # No data to encrypt. @@ -251,29 +298,35 @@ def pre_save( if isinstance(value, _PendingEncryption): return self.encrypt_value(model_instance, value.value) - # Unexpected data type. - if not isinstance(value, bytes): - raise ValidationError( - f"Unexpected value type '{type(value)}' for encryption.", - code="invalid_value_type", - ) + # Already encrypted data from DB, store as-is. + if isinstance(value, _TrustedCiphertext): + return value.ciphertext - return value + raise ValidationError( + f"Unexpected value type '{type(value)}' for encryption.", + code="invalid_value_type", + ) # -------------------------------------------------------------------------- # Crypto Logic # -------------------------------------------------------------------------- def bytes_to_value(self, data: bytes) -> T: - """Converts decrypted bytes to the field value.""" + """ + Subclasses must implement this method to convert decrypted bytes back to + the field's value type. + """ raise NotImplementedError() def value_to_bytes(self, value: T) -> bytes: - """Converts the field value to bytes for encryption.""" + """ + Subclasses must implement this method to convert the field's value to + bytes before encryption. + """ raise NotImplementedError() @property - def qual_associated_data(self): + def full_associated_data(self): """Returns the fully qualified associated data for this field.""" return f"{self.model.associated_data}:{self.associated_data}".encode() @@ -286,7 +339,7 @@ def decrypt_value( data = instance.dek_aead.decrypt( ciphertext=ciphertext, - associated_data=self.qual_associated_data, + associated_data=self.full_associated_data, ) return self.bytes_to_value(data) @@ -298,6 +351,6 @@ def encrypt_value(self, instance: EncryptedModel, plaintext: t.Optional[T]): if plaintext is None else instance.dek_aead.encrypt( plaintext=self.value_to_bytes(plaintext), - associated_data=self.qual_associated_data, + associated_data=self.full_associated_data, ) ) diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 5af8b9e5..4c206d1e 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -223,11 +223,11 @@ def test_value_to_bytes(self): "value" ) - def test_qual_associated_data(self): - """qual_associated_data returns fully qualified associated data.""" + def test_full_associated_data(self): + """Returns fully qualified associated data.""" Model = self._get_model_class() assert ( - self.field.qual_associated_data + self.field.full_associated_data == f"{Model.associated_data}:{self.field_associated_data}".encode() ) @@ -249,7 +249,7 @@ def test_decrypt_value(self): decrypted_value = self.field.decrypt_value(instance, ciphertext) decrypt_kwargs = { "ciphertext": ciphertext, - "associated_data": self.field.qual_associated_data, + "associated_data": self.field.full_associated_data, } decrypt_mock.assert_called_once_with(**decrypt_kwargs) decrypted_bytes = decrypt_mock.side_effect(**decrypt_kwargs) @@ -278,7 +278,7 @@ def test_encrypt_value(self): decrypted_bytes = value_to_bytes_mock.side_effect(plaintext) encrypt_kwargs = { "plaintext": decrypted_bytes, - "associated_data": self.field.qual_associated_data, + "associated_data": self.field.full_associated_data, } encrypt_mock.assert_called_once_with(**encrypt_kwargs) assert encrypted_bytes == encrypt_mock.side_effect(**encrypt_kwargs) @@ -287,11 +287,6 @@ def test_encrypt_value(self): # Descriptor Methods Tests # -------------------------------------------------------------------------- - def test_cache_name(self): - """cache_name returns the correct cache attribute name.""" - self._get_model_class() # Assign field to model. - assert self.field.cache_name == "_field_decrypted_value" - def test_set__default(self): """Setting field to default value stores pending encryption.""" # Field must have a non-callable default for this test. @@ -323,17 +318,17 @@ def test_set__none(self): def test_set__trusted_ciphertext(self): """Setting field to _TrustedCiphertext stores ciphertext directly.""" - ciphertext = b"encrypted_value" - trusted_ciphertext = _TrustedCiphertext(ciphertext) + trusted_ciphertext = _TrustedCiphertext(b"encrypted_value") instance = self._get_model_instance(field=trusted_ciphertext) - assert instance.get_stored_value(self.field) == ciphertext + assert instance.get_stored_value(self.field) is trusted_ciphertext def test_set__memoryview(self): """Setting field to memoryview stores bytes directly.""" - value = b"byte_value" - memoryview_value = memoryview(value) + memoryview_value = memoryview(b"byte_value") instance = self._get_model_instance(field=memoryview_value) - assert instance.get_stored_value(self.field) == value + trusted_ciphertext = instance.get_stored_value(self.field) + assert isinstance(trusted_ciphertext, _TrustedCiphertext) + assert trusted_ciphertext.ciphertext == memoryview_value.obj def test_set__memoryview__invalid_memoryview_type(self): """Setting field to invalid memoryview type raises ValidationError.""" @@ -356,14 +351,26 @@ def test_set__new_value(self): instance = self._get_model_instance() # Cache the value on the instance. - setattr(instance, self.field.cache_name, value) + instance.__decrypted_values__[self.field.attname] = value # Clear cache by setting to new value. instance.field = value instance.assert_value_is_pending_encryption(self.field, value) # Ensure cached value is cleared. - assert not hasattr(instance, self.field.cache_name) + assert self.field.attname not in instance.__decrypted_values__ + + def test_get__invalid_internal_value_type(self): + """ + Getting field with invalid internal value type raises ValidationError. + """ + instance = self._get_model_instance() + instance.set_stored_value(self.field, b"data") # Invalid type. + + with self.assert_raises_validation_error( + code="invalid_internal_value_type" + ): + _ = instance.field def test_get__descriptor(self): """Getting field from class returns the descriptor.""" @@ -374,10 +381,10 @@ def test_get__descriptor(self): def test_get__cached(self): """Getting field when cached returns cached value.""" instance = self._get_model_instance() - instance.set_stored_value(self.field, b"irrelevant") + instance.set_stored_value(self.field, _TrustedCiphertext(b"irrelevant")) value = "decrypted_value" - setattr(instance, self.field.cache_name, value) + instance.__decrypted_values__[self.field.attname] = value assert instance.field == value def test_get__none(self): @@ -407,17 +414,16 @@ def test_get__decrypted_value(self): # Create instance with stored ciphertext. instance = self._get_model_instance() - instance.set_stored_value(self.field, ciphertext) - + instance.set_stored_value(self.field, _TrustedCiphertext(ciphertext)) # Ensure cache is not set initially. - assert not hasattr(instance, self.field.cache_name) + assert self.field.attname not in instance.__decrypted_values__ # Get the field value, which should decrypt the ciphertext. assert instance.field == plaintext self.field.decrypt_value.assert_called_once_with(instance, ciphertext) # Ensure decrypted value is cached on the instance. - assert getattr(instance, self.field.cache_name) == plaintext + assert instance.__decrypted_values__[self.field.attname] == plaintext # -------------------------------------------------------------------------- # pre_save Tests @@ -493,7 +499,7 @@ def test_pre_save__invalid_value_type(self): """pre_save with invalid value type raises ValidationError.""" # Create instance with invalid stored value. instance = self._get_model_instance() - instance.set_stored_value(self.field, 12345) # Invalid type. + instance.set_stored_value(self.field, b"data") # Invalid type. # Run the save pipeline, interrupting at pre_save. with self.assert_raises_validation_error(code="invalid_value_type"): diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index aec15366..c1f82171 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -1,6 +1,36 @@ """ © Ocado Group Created on 19/01/2026 at 09:57:19(+00:00). + +This field is responsible for managing the lifecycle of a DEK for a model +instance. When a new model instance is created, this field automatically +generates a new DEK, encrypts it with the KEK, and prepares it to be stored in +the database. + +This is achieved using a `_Default` dataclass as a sentinel. In the +`DataEncryptionKeyField`'s `__init__` method, the `default` is set to the +`_Default` class. When a new model instance is created, Django sets the field's +value to an instance of `_Default()`. + +The `_Default` dataclass has a field `dek` with a `default_factory` that points +to the `create_dek` function. This means that when `_Default` is instantiated, a +new DEK is automatically created. + +The field's custom descriptor, `DataEncryptionKeyAttribute`, then intercepts the +`_Default` instance in its `__set__` method, extracts the newly created `dek` +from it, and sets it as the field's value on the model instance. This elegant +pattern ensures that a new DEK is generated only when a new model instance is +created. + +The `contribute_to_class` method on `DataEncryptionKeyField` performs crucial +validations when the field is added to a model. It ensures that the model +inherits from the correct base class (`BaseDataEncryptionKeyModel`) and, most +importantly, it guarantees that a model can only have one +`DataEncryptionKeyField`. It does this by checking a class-level `_dek` +attribute; if this attribute is already set, it means another DEK field has +already been processed, and a `ValidationError` is raised. This prevents +developers from accidentally creating multiple encryption keys for a single +model instance, which would lead to ambiguity and potential data loss. """ import typing as t @@ -22,7 +52,7 @@ @dataclass(frozen=True) class _Default: - """A default value holder for DataEncryptionKeyField.""" + """A default value holder that creates a new DEK on instantiation.""" dek: bytes = field(default_factory=create_dek) @@ -33,16 +63,19 @@ class DataEncryptionKeyAttribute( ], t.Generic[AnyDataEncryptionKeyField], ): - """Descriptor for DataEncryptionKeyField.""" + """ + Descriptor for DataEncryptionKeyField that handles the automatic creation of + a new DEK and data shredding. + """ def __set__( self, instance, value: t.Optional[_Default], # type: ignore[override] ): - if isinstance(value, _Default): + if isinstance(value, _Default): # new instance is being created internal_value = value.dek - elif value is None: + elif value is None: # data is being shredded internal_value = None else: raise ValidationError( @@ -55,7 +88,7 @@ def __set__( class DataEncryptionKeyField(BinaryField): """ - A custom BinaryField to store a encrypted data encryption key (DEK). + A custom BinaryField to store an encrypted data encryption key (DEK). """ model: t.Type[BaseDataEncryptionKeyModel] @@ -73,9 +106,9 @@ class DataEncryptionKeyField(BinaryField): def set_init_kwargs(self, kwargs: KwArgs): """Sets common init kwargs.""" - kwargs["editable"] = False - kwargs["default"] = _Default - kwargs["null"] = True + kwargs["editable"] = False # DEK should not be editable in admin forms + kwargs["default"] = _Default # Default to a new DEK generator + kwargs["null"] = True # Allow null for data shredding kwargs.setdefault("verbose_name", _(self.default_verbose_name)) kwargs.setdefault("help_text", _(self.default_help_text)) @@ -142,20 +175,17 @@ def contribute_to_class(self, cls, name, private_only=False): # Descriptor Methods # -------------------------------------------------------------------------- - # Get the descriptor. @t.overload # type: ignore[override] - def __get__( + def __get__( # Get the descriptor. self, instance: None, owner: t.Any ) -> DataEncryptionKeyAttribute[t.Self]: ... - # Get the value. @t.overload - def __get__( + def __get__( # Get the value. self, instance: BaseDataEncryptionKeyModel, owner: t.Any ) -> t.Optional[bytes]: ... - # Actual implementation of __get__. - def __get__( + def __get__( # Actual implementation of __get__. self, instance: t.Optional[BaseDataEncryptionKeyModel], owner: t.Any ): return t.cast( diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md new file mode 100644 index 00000000..cd891783 --- /dev/null +++ b/docs/client-side-encryption.md @@ -0,0 +1,201 @@ +# Client-Side Encryption v3 + +Client-Side Encryption with Per-User Keys and Django ORM Integration + +--- + +## 1. Executive Summary + +This architecture implements **Application-Layer Encryption** (often called **Client-Side Encryption** relative to the database) using a **Per-User Key** strategy. + +Instead of relying on a single global key (which is a single point of failure), every user in the system is assigned a unique **Data Encryption Key (DEK)**. This key wraps their specific data. These DEKs are themselves encrypted by a master **Key Encryption Key (KEK)** managed by **Google Cloud KMS**. + +**Security Guarantee:** The database (PostgreSQL) never sees plaintext data or the plaintext keys required to decrypt it. A database leak results in zero data compromise without also compromising the running application server and Google Cloud credentials. + +This document outlines an approach that seamlessly integrates this encryption strategy into the **Django ORM**, making the encryption and decryption of data transparent to the developer. + +--- + +## 2. Architecture Overview + +### The "Two-Key" Envelope System + +We utilize a hierarchy of keys to balance security and performance. + +1. **KEK (Key Encryption Key):** + * **Location:** Google Cloud KMS (Hardware Security Module). + * **Role:** The "Master Lock." It never leaves Google. It is used only to encrypt/decrypt the User Keys (DEKs). +2. **DEK (Data Encryption Key):** + * **Location:** Encrypted in the database (e.g., in the `users` table); Decrypted only in Application Memory (RAM). + * **Role:** The "Worker Bee." Unique to every user. Used to encrypt/decrypt the actual database fields (e.g., SSNs, names). + +--- + +## 4. Encryption/Decryption Utilities + +At the core of this system are a few utility functions that interact with Google Cloud KMS and the `tink` cryptography library. In addition, to avoid a dependency on Google Cloud KMS during local development and in CI/CD pipelines, we use fake (mock) implementations of the KMS client and its AEAD primitive. + +**[codeforlife/encryption.py](../codeforlife/encryption.py)** + +--- + +## 5. Django ORM Integration + +To make working with encrypted data seamless, we've integrated the encryption logic directly into Django's ORM. This is achieved through a combination of a base model class, custom model fields, and descriptors. + +### Associated Data for Integrity + +A core principle of this architecture is the use of **Associated Data** to ensure the integrity of encrypted values. Authenticated Encryption with Associated Data (AEAD) algorithms, like the AES-GCM we use, bind a piece of ciphertext to a specific context. This means that ciphertext encrypted in one context cannot be decrypted in another, which prevents certain attacks like swapping encrypted values between different database columns or rows. + +To achieve this, we enforce the use of an `associated_data` string at two levels: + +1. **Model-level:** Every `EncryptedModel` subclass must define a unique `associated_data` string. This scopes all encrypted fields within that model. +2. **Field-level:** Every `BaseEncryptedField` instance must be initialized with its own `associated_data` string, which must be unique within that model. + +These two strings are combined to create a fully qualified identifier that is passed to the encryption and decryption functions. This provides two critical layers of integrity: + +1. **Field-Level Integrity:** Consider a `Balance` model with two encrypted fields, `debit` and `credit`. The field-level AD ensures their values cannot be swapped. The AD for each would be `"balance:debit"` and `"balance:credit"`. An encrypted debit value cannot be moved to the credit column, as the decryption would fail due to the AD mismatch. + +2. **Model-Level Integrity:** Now consider two different models, `Debit` and `Credit`, each with an encrypted `balance` field. The model-level AD prevents swapping values between them. The AD would be `"debit:balance"` and `"credit:balance"`. An encrypted balance from a `Debit` instance cannot be moved to a `Credit` instance, again because the AD would not match during decryption. + +### Implementation + +The implementation details can be found in the docstring of these files. It's recommended you read them in the following order. + +1. **[codeforlife/models/encrypted.py](../codeforlife/models/encrypted.py):** This is the base class for any model that will contain encrypted fields. +1. **[codeforlife/models/fields/base_encrypted.py](../codeforlife/models/fields/base_encrypted.py):** This is where the core logic of transparent encryption and decryption happens. +1. **[codeforlife/models/fields/encrypted_text.py](../codeforlife/models/fields/encrypted_text.py):** A concrete encrypted text field which subclasses `BaseEncryptedField`. +1. **[codeforlife/models/base_data_encryption_key.py](../codeforlife/models/base_data_encryption_key.py):** This abstract model brings the `EncryptedModel` and `DataEncryptionKeyField` together. +1. **[codeforlife/models/data_encryption_key.py](../codeforlife/models/data_encryption_key.py):** This model inherits from `BaseDataEncryptionKeyModel` and conveniently includes the `dek` field by default. +1. **[codeforlife/models/fields/data_encryption_key.py](../codeforlife/models/fields/data_encryption_key.py):** This field is responsible for managing the lifecycle of a DEK for a model instance. + +--- + +## 6. Usage Patterns + +Here are two common patterns for using the encryption framework. + +### Pattern 1: Self-Contained Encrypted Model + +This is the simplest pattern. The model inherits from `DataEncryptionKeyModel`, which means it manages its own DEK and can have one or more encrypted fields. This is ideal for models that represent a primary entity, like a `User`. + +```python +class User(DataEncryptionKeyModel): + """ + A user model with an encrypted email. Because it inherits from + DataEncryptionKeyModel, it automatically gets a 'dek' field to manage + its own encryption key. + """ + associated_data = "user" # Required for EncryptedModel + + username = models.CharField(max_length=150, unique=True) + email = EncryptedTextField(associated_data="email") + + class Meta: + app_label = "auth" + +# --- Usage --- + +# Create a new user. A new DEK is automatically generated and stored in the +# 'dek' field. The 'email' field is encrypted using this key. +user = User.objects.create( + username="johndoe", + email="john.doe@example.com" +) + +# The 'dek' and 'email' fields are stored as encrypted bytes in the database. +# But when we access the 'email' attribute, it's decrypted automatically. +print(f"User's email: {user.email}") +# >>> User's email: john.doe@example.com + +# You can update the email as you would with a normal field. +user.email = "john.doe.new@example.com" +user.save() +``` + +### Pattern 2: Delegated Encryption Key + +Sometimes, you have a model whose data should be encrypted under another object's key. For example, a `Secret` that belongs to a `User`. The `Secret` itself doesn't need its own DEK; it should be encrypted with the `User`'s DEK. + +In this case, the model inherits directly from `EncryptedModel` and must implement the `dek_aead` property to point to the key provider (the `User` model in this case). + +```python +class Secret(EncryptedModel): + """ + A model that stores a secret value. It does not have its own DEK. + Instead, it relies on the related User's DEK for encryption. + """ + associated_data = "secret" # Required for EncryptedModel + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="secrets") + secret_value = EncryptedTextField(associated_data="secret-value") + + class Meta: + app_label = "app" + + @property + def dek_aead(self) -> Aead: + """ + This model delegates encryption to the associated user's DEK. + The `dek_aead` property is implemented to return the user's AEAD primitive. + """ + return self.user.dek_aead + +# --- Usage --- + +# Assume 'user' is the User instance created in the previous example. +secret = Secret.objects.create( + user=user, + secret_value="my-super-secret-password" +) + +# The 'secret_value' is encrypted using the DEK from the 'user' object. +# When accessed, it's decrypted using the same key. +print(f"The secret is: {secret.secret_value}") +# >>> The secret is: my-super-secret-password +``` + +--- + +## 7. Sequence Diagrams + +### A. User Signup (Key Generation & Encryption) + +```mermaid +sequenceDiagram + participant App as Application + participant KMS as Google Cloud KMS + participant DB as Database + + App->>App: User.objects.create(username='johndoe', email='john.doe@example.com') + App->>App: DataEncryptionKeyField.pre_save() + App->>App: create_dek() + App->>KMS: Encrypt(new_dek_keyset) with KEK + KMS-->>App: encrypted_dek + App->>App: EncryptedTextField.pre_save() + App->>App: user.dek_aead (gets/decrypts/caches DEK) + App->>KMS: Decrypt(encrypted_dek) with KEK + KMS-->>App: dek_aead + App->>App: dek_aead.encrypt('john.doe@example.com') + App-->>App: encrypted_email + App->>DB: INSERT INTO auth_user (username, email, dek) VALUES ('johndoe', encrypted_email, encrypted_dek) +``` + +### B. Data Access (Decryption) + +```mermaid +sequenceDiagram + participant App as Application + participant KMS as Google Cloud KMS + participant DB as Database + + App->>DB: SELECT id, username, email, dek FROM auth_user WHERE username='johndoe' + DB-->>App: user_instance (with encrypted email and dek) + App->>App: print(user.email) + App->>App: EncryptedAttribute.__get__() + App->>App: user.dek_aead (gets/decrypts/caches DEK) + App->>KMS: Decrypt(user.dek) with KEK + KMS-->>App: dek_aead + App->>App: dek_aead.decrypt(user.email_encrypted) + App-->>App: 'john.doe@example.com' +``` From 2ec4ae96487d4b45e16c180b89c35890a1b00f3d Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 3 Feb 2026 16:20:33 +0000 Subject: [PATCH 37/45] fix types --- codeforlife/models/fields/deferred_attribute.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codeforlife/models/fields/deferred_attribute.py b/codeforlife/models/fields/deferred_attribute.py index cc98f779..f4d13286 100644 --- a/codeforlife/models/fields/deferred_attribute.py +++ b/codeforlife/models/fields/deferred_attribute.py @@ -30,7 +30,9 @@ def field(self): def field(self, value: AnyField): self._field = value - def __get__(self, instance: t.Optional[AnyModel], cls=None): + def __get__( + self, instance: t.Optional[AnyModel], cls=None # type: ignore[override] + ): return t.cast( t.Optional[T], super().__get__(instance, cls), # type: ignore[misc] From 6d78a87b376e05546560e926bbb6c5fa3505f2d4 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 4 Feb 2026 13:25:50 +0000 Subject: [PATCH 38/45] lazy create DEKs --- .../models/base_data_encryption_key.py | 33 ++++- .../models/base_data_encryption_key_test.py | 39 ++++- .../models/fields/data_encryption_key.py | 59 ++++---- .../models/fields/data_encryption_key_test.py | 22 +-- docs/client-side-encryption.md | 138 ++++++++++++++---- 5 files changed, 211 insertions(+), 80 deletions(-) diff --git a/codeforlife/models/base_data_encryption_key.py b/codeforlife/models/base_data_encryption_key.py index 20e045b4..9a61ec16 100644 --- a/codeforlife/models/base_data_encryption_key.py +++ b/codeforlife/models/base_data_encryption_key.py @@ -12,7 +12,7 @@ from cachetools import TTLCache from django.core.exceptions import ValidationError -from ..encryption import get_dek_aead +from ..encryption import create_dek, get_dek_aead from .encrypted import EncryptedModel if t.TYPE_CHECKING: @@ -51,10 +51,6 @@ def dek_aead(self): """ Provides the AEAD primitive for the DEK, caching it for performance. """ - # Return None if there is no DEK. - if self._dek is None: - return None - # Ensure the instance is saved before accessing the DEK AEAD. if self.pk is None: raise ValidationError( @@ -62,6 +58,10 @@ def dek_aead(self): code="unsaved_instance", ) + # Return None if there is no DEK. + if self._dek is None: + return None + # Check the cache for the DEK AEAD. if self.pk in self.DEK_AEAD_CACHE: return self.DEK_AEAD_CACHE[self.pk] @@ -73,3 +73,26 @@ def dek_aead(self): self.DEK_AEAD_CACHE[self.pk] = dek_aead return dek_aead + + def save( + self, + *args, + force_insert=False, + force_update=False, + using=None, + update_fields=None, + ): + # Lazily create a new DEK for new instances. + if self.pk is None: + # pylint: disable-next=protected-access + dek_class_attr = self.__class__._dek + if dek_class_attr is not None: + self.__dict__[dek_class_attr.field.attname] = create_dek() + + return super().save( # type: ignore[misc] + *args, + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) diff --git a/codeforlife/models/base_data_encryption_key_test.py b/codeforlife/models/base_data_encryption_key_test.py index 7ee28065..565a1e3b 100644 --- a/codeforlife/models/base_data_encryption_key_test.py +++ b/codeforlife/models/base_data_encryption_key_test.py @@ -6,7 +6,7 @@ import typing as t from unittest.mock import MagicMock, patch -from ..encryption import FakeAead +from ..encryption import FakeAead, create_dek from ..tests import ModelTestCase from .base_data_encryption_key import BaseDataEncryptionKeyModel from .fields import DataEncryptionKeyField @@ -33,14 +33,18 @@ class TestModel(BaseDataEncryptionKeyModel): class Meta(TypedModelMeta): app_label = "codeforlife.user" + def set_dek_for_test(self): + """Sets the dek field for testing purposes.""" + self.__dict__[self.__class__.dek.field.attname] = create_dek() + return TestModel def get_model_instance(self, *args, **kwargs): return self.get_model_class()(*args, **kwargs) def test_dek_aead__none(self): - """Returns None when dek is None.""" - instance = self.get_model_instance(dek=None) + """Returns None when dek is None on a saved instance.""" + instance = self.get_model_instance(pk=1, dek=None) assert instance.dek_aead is None def test_dek_aead__unsaved_instance(self): @@ -54,7 +58,7 @@ def test_dek_aead__not_cached(self, get_dek_aead_mock: MagicMock): """Returns dek_aead and caches it when not cached.""" # Create an instance with a primary key to mimic a saved instance. instance = self.get_model_instance(pk=1) - assert instance.dek is not None + instance.set_dek_for_test() # Setup the mock to return a FakeAead instance. dek_aead_mock = FakeAead.as_mock() @@ -74,7 +78,7 @@ def test_dek_aead__cached(self, get_dek_aead_mock: MagicMock): """Returns the cached dek_aead.""" # Create an instance with a primary key to mimic a saved instance. instance = self.get_model_instance(pk=1) - assert instance.dek is not None + instance.set_dek_for_test() # Pre-populate the cache with a FakeAead instance. dek_aead_mock = FakeAead.as_mock() @@ -85,3 +89,28 @@ def test_dek_aead__cached(self, get_dek_aead_mock: MagicMock): # Ensure the get_dek_aead function was not called as its cached. get_dek_aead_mock.assert_not_called() + + @patch("django.db.models.base.Model.save", autospec=True) + @patch( + "codeforlife.models.base_data_encryption_key.create_dek", + autospec=True, + ) + def test_save__creates_dek( + self, create_dek_mock: MagicMock, save_mock: MagicMock + ): + """Saves a new DEK when saving a new instance.""" + instance = self.get_model_instance() + assert instance.dek is None + instance.save() + + # Ensure create_dek was called and save was called correctly. + create_dek_mock.assert_called_once_with() + save_mock.assert_called_once_with( + instance, + force_insert=False, + force_update=False, + using=None, + update_fields=None, + ) + + assert instance.dek is not None diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index c1f82171..369ce649 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -2,25 +2,10 @@ © Ocado Group Created on 19/01/2026 at 09:57:19(+00:00). -This field is responsible for managing the lifecycle of a DEK for a model -instance. When a new model instance is created, this field automatically -generates a new DEK, encrypts it with the KEK, and prepares it to be stored in -the database. - -This is achieved using a `_Default` dataclass as a sentinel. In the -`DataEncryptionKeyField`'s `__init__` method, the `default` is set to the -`_Default` class. When a new model instance is created, Django sets the field's -value to an instance of `_Default()`. - -The `_Default` dataclass has a field `dek` with a `default_factory` that points -to the `create_dek` function. This means that when `_Default` is instantiated, a -new DEK is automatically created. - -The field's custom descriptor, `DataEncryptionKeyAttribute`, then intercepts the -`_Default` instance in its `__set__` method, extracts the newly created `dek` -from it, and sets it as the field's value on the model instance. This elegant -pattern ensures that a new DEK is generated only when a new model instance is -created. +This field is responsible for storing the encrypted Data Encryption Key (DEK) +for a model instance. The actual lifecycle of the DEK, including its lazy +creation for new model instances, is managed by the `save()` method on the +`BaseDataEncryptionKeyModel`. The `contribute_to_class` method on `DataEncryptionKeyField` performs crucial validations when the field is added to a model. It ensures that the model @@ -34,13 +19,12 @@ """ import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass from django.core.exceptions import ValidationError from django.db.models import BinaryField from django.utils.translation import gettext_lazy as _ -from ...encryption import create_dek from ...types import KwArgs from ..base_data_encryption_key import BaseDataEncryptionKeyModel from .deferred_attribute import DeferredAttribute @@ -51,10 +35,10 @@ @dataclass(frozen=True) -class _Default: - """A default value holder that creates a new DEK on instantiation.""" +class _TrustedDek: + """A wrapper for a DEK that comes directly from the database.""" - dek: bytes = field(default_factory=create_dek) + dek: bytes class DataEncryptionKeyAttribute( @@ -64,18 +48,21 @@ class DataEncryptionKeyAttribute( t.Generic[AnyDataEncryptionKeyField], ): """ - Descriptor for DataEncryptionKeyField that handles the automatic creation of - a new DEK and data shredding. + Descriptor for DataEncryptionKeyField that handles data shredding. """ def __set__( self, instance, - value: t.Optional[_Default], # type: ignore[override] + value: t.Optional[_TrustedDek], # type: ignore[override] ): - if isinstance(value, _Default): # new instance is being created + # Clear any cached DEK AEAD. + if instance.pk is not None and instance.pk in instance.DEK_AEAD_CACHE: + instance.DEK_AEAD_CACHE.pop(instance.pk, None) + + if isinstance(value, _TrustedDek): # From DB. internal_value = value.dek - elif value is None: # data is being shredded + elif value is None: # Data is being shredded. internal_value = None else: raise ValidationError( @@ -107,7 +94,6 @@ class DataEncryptionKeyField(BinaryField): def set_init_kwargs(self, kwargs: KwArgs): """Sets common init kwargs.""" kwargs["editable"] = False # DEK should not be editable in admin forms - kwargs["default"] = _Default # Default to a new DEK generator kwargs["null"] = True # Allow null for data shredding kwargs.setdefault("verbose_name", _(self.default_verbose_name)) kwargs.setdefault("help_text", _(self.default_help_text)) @@ -196,3 +182,16 @@ def __get__( # Actual implementation of __get__. # Can only be set to None to allow data shredding. def __set__(self, instance: BaseDataEncryptionKeyModel, value: None): ... + + # pylint: disable-next=unused-argument + def from_db_value(self, value: t.Optional[bytes], expression, connection): + """ + Converts a value as returned by the database to a Python object. + We wrap the raw bytes in _TrustedDek to signal that this is an + existing DEK from the database, not new plaintext. + """ + if value is None: + return None + + # Wrap it so __set__ knows this is NOT new user input. + return _TrustedDek(value) diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index ee50f30e..fc57fad9 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -7,9 +7,10 @@ from django.db import models +from ...encryption import create_dek from ...tests import TestCase from ..base_data_encryption_key import BaseDataEncryptionKeyModel -from .data_encryption_key import DataEncryptionKeyField, _Default +from .data_encryption_key import DataEncryptionKeyField, _TrustedDek if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta @@ -44,6 +45,10 @@ class DekModel(BaseDataEncryptionKeyModel): Meta = FakeModelMeta + def set_dek_for_test(self): + """Sets the dek field for testing purposes.""" + self.__dict__[self.__class__.dek.field.attname] = create_dek() + return DekModel def _get_model_instance(self, **kwargs): @@ -77,7 +82,6 @@ def test_init__null_allowed(self): def test_init(self): """DataEncryptionKeyField is constructed correctly.""" assert self.field.editable is False - assert self.field.default == _Default assert self.field.null is True assert ( self.field.verbose_name @@ -90,7 +94,6 @@ def test_deconstruct(self): _, _, _, kwargs = self.field.deconstruct() assert kwargs["editable"] is False - assert kwargs["default"] == _Default assert kwargs["null"] is True assert ( kwargs["verbose_name"] @@ -154,26 +157,27 @@ def test_get__descriptor(self): def test_get__value(self): """Getting field from instance returns the DEK bytes.""" instance = self._get_model_instance() + instance.set_dek_for_test() dek_value = instance.dek assert isinstance(dek_value, bytes) assert dek_value == instance.__dict__["dek"] def test_set__default(self): - """Setting field to _Default sets to default DEK bytes.""" + """Setting field to _TrustedDek sets to DEK bytes.""" instance = self._get_model_instance() - default = _Default() - instance.dek = default - assert default.dek == instance.__dict__["dek"] + trusted_dek = _TrustedDek(b"dek") + instance.dek = trusted_dek + assert trusted_dek.dek == instance.__dict__["dek"] def test_set__none(self): """Setting field to None sets to None.""" instance = self._get_model_instance() - assert instance.dek is not None + instance.set_dek_for_test() instance.dek = None assert instance.__dict__["dek"] is None def test_set__cannot_set_value(self): - """Setting field to any value other than None or _Default raises.""" + """Setting field to any value other than None or _TrustedDek raises.""" instance = self._get_model_instance() with self.assert_raises_validation_error(code="cannot_set_value"): instance.dek = b"some_value" diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index cd891783..0ac1af83 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -159,43 +159,119 @@ print(f"The secret is: {secret.secret_value}") ## 7. Sequence Diagrams -### A. User Signup (Key Generation & Encryption) +This section contains diagrams that explain what the Django ORM is doing. + +### 1. DEK Generation and Initial Save + +This diagram shows the process that occurs when a new `EncryptedModel` instance (e.g., a `User`) is created and saved for the first time. The `EncryptedModel.save()` method is overridden to lazily manage the creation of the user-specific DEK. ```mermaid sequenceDiagram - participant App as Application - participant KMS as Google Cloud KMS - participant DB as Database - - App->>App: User.objects.create(username='johndoe', email='john.doe@example.com') - App->>App: DataEncryptionKeyField.pre_save() - App->>App: create_dek() - App->>KMS: Encrypt(new_dek_keyset) with KEK - KMS-->>App: encrypted_dek - App->>App: EncryptedTextField.pre_save() - App->>App: user.dek_aead (gets/decrypts/caches DEK) - App->>KMS: Decrypt(encrypted_dek) with KEK - KMS-->>App: dek_aead - App->>App: dek_aead.encrypt('john.doe@example.com') - App-->>App: encrypted_email - App->>DB: INSERT INTO auth_user (username, email, dek) VALUES ('johndoe', encrypted_email, encrypted_dek) + actor Developer + participant User as User (EncryptedModel) + participant EncryptedModel as EncryptedModel (Base Class) + participant GcpKmsClient as Tink/KMS Client + participant PostgreSQL + + Developer->>User: user = User(name="test") + Developer->>User: user.save() + User->>EncryptedModel: save() + activate EncryptedModel + + Note over EncryptedModel, GcpKmsClient: If self.pk is None (new user) + EncryptedModel->>GcpKmsClient: create_dek() + activate GcpKmsClient + GcpKmsClient-->>EncryptedModel: Returns new encrypted DEK + deactivate GcpKmsClient + + EncryptedModel->>User: self.dek = encrypted_dek + Note over User, PostgreSQL: The model's save() method is called via super() + User->>PostgreSQL: INSERT INTO users (name, dek) + PostgreSQL-->>User: Returns + deactivate EncryptedModel ``` -### B. Data Access (Decryption) +### 2. Data Encryption + +This diagram illustrates what happens when a developer sets a value on an encrypted field. The `EncryptedAttribute` descriptor intercepts the assignment, wrapping the plaintext value in a `_PendingEncryption` object. The actual encryption happens later, just before the model is saved to the database. + +```mermaid +sequenceDiagram + actor Developer + participant User as User (Model Instance) + participant EncryptedAttribute as EncryptedAttribute (Descriptor) + participant _PendingEncryption as _PendingEncryption (Wrapper) + participant BaseEncryptedField as BaseEncryptedField + participant GcpKmsClient as Tink/KMS Client + participant PostgreSQL + + Developer->>User: user.ssn = "123-456-7890" + User->>EncryptedAttribute: __set__(user, "123-456-7890") + activate EncryptedAttribute + EncryptedAttribute->>_PendingEncryption: Create(value="123-456-7890") + _PendingEncryption-->>EncryptedAttribute: Returns _PendingEncryption instance + Note left of EncryptedAttribute: Caches are cleared + EncryptedAttribute->>User: instance.__dict__["ssn"] = _PendingEncryption(...) + deactivate EncryptedAttribute + + Developer->>User: user.save() + User->>BaseEncryptedField: get_prep_value(_PendingEncryption(...)) + activate BaseEncryptedField + BaseEncryptedField->>GcpKmsClient: Decrypt user's DEK + GcpKmsClient-->>BaseEncryptedField: Returns plaintext DEK + BaseEncryptedField->>GcpKmsClient: encrypt(value, associated_data) + GcpKmsClient-->>BaseEncryptedField: Returns ciphertext + BaseEncryptedField-->>User: Returns ciphertext + deactivate BaseEncryptedField + User->>PostgreSQL: UPDATE users SET ssn=ciphertext + PostgreSQL-->>User: Returns +``` + +### 3. Data Decryption + +This diagram shows the process of reading an encrypted value from a model instance. The `EncryptedAttribute` descriptor checks an in-memory cache for the decrypted value first. If it's not cached, it decrypts the ciphertext from the database and populates the cache. ```mermaid sequenceDiagram - participant App as Application - participant KMS as Google Cloud KMS - participant DB as Database - - App->>DB: SELECT id, username, email, dek FROM auth_user WHERE username='johndoe' - DB-->>App: user_instance (with encrypted email and dek) - App->>App: print(user.email) - App->>App: EncryptedAttribute.__get__() - App->>App: user.dek_aead (gets/decrypts/caches DEK) - App->>KMS: Decrypt(user.dek) with KEK - KMS-->>App: dek_aead - App->>App: dek_aead.decrypt(user.email_encrypted) - App-->>App: 'john.doe@example.com' + actor Developer + participant User as User (Model Instance) + participant EncryptedAttribute as EncryptedAttribute (Descriptor) + participant GcpKmsClient as Tink/KMS Client + + Developer->>User: print(user.ssn) + User->>EncryptedAttribute: __get__(user) + activate EncryptedAttribute + + Note over User, EncryptedAttribute: Check instance cache for decrypted value + alt Value is cached + EncryptedAttribute-->>Developer: return cached_value + else Value is not cached + EncryptedAttribute->>User: Get ciphertext from instance.__dict__ + User-->>EncryptedAttribute: Returns ciphertext + EncryptedAttribute->>GcpKmsClient: Decrypt user's DEK + GcpKmsClient-->>EncryptedAttribute: Returns plaintext DEK + EncryptedAttribute->>GcpKmsClient: decrypt(ciphertext, associated_data) + GcpKmsClient-->>EncryptedAttribute: Returns plaintext value + Note left of EncryptedAttribute: setattr(instance, cache_name, plaintext_value) + EncryptedAttribute-->>Developer: return plaintext_value + end + deactivate EncryptedAttribute +``` + +### 4. Data Shredding + +Data shredding is achieved by nullifying the user's encrypted DEK. Once the key is gone, the data associated with it is rendered permanently unrecoverable. + +```mermaid +sequenceDiagram + actor Developer + participant User as User (Model Instance) + participant PostgreSQL + + Developer->>User: user.encrypted_dek = None + Developer->>User: user.save() + User->>PostgreSQL: UPDATE users SET encrypted_dek=NULL WHERE id=... + PostgreSQL-->>User: Returns + + Note over Developer, PostgreSQL: The user's data (e.g., SSN) is now permanently unrecoverable. ``` From 442d2dc992501b1bd12a38f5d5638fdbb2c7c932 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 4 Feb 2026 13:48:45 +0000 Subject: [PATCH 39/45] Encrypted Model and Field Initialization --- docs/client-side-encryption.md | 62 +++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index 0ac1af83..a6edc3e8 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -215,8 +215,9 @@ sequenceDiagram deactivate EncryptedAttribute Developer->>User: user.save() - User->>BaseEncryptedField: get_prep_value(_PendingEncryption(...)) + User->>BaseEncryptedField: pre_save(user, True) activate BaseEncryptedField + Note right of BaseEncryptedField: Checks for _PendingEncryption BaseEncryptedField->>GcpKmsClient: Decrypt user's DEK GcpKmsClient-->>BaseEncryptedField: Returns plaintext DEK BaseEncryptedField->>GcpKmsClient: encrypt(value, associated_data) @@ -242,9 +243,9 @@ sequenceDiagram User->>EncryptedAttribute: __get__(user) activate EncryptedAttribute - Note over User, EncryptedAttribute: Check instance cache for decrypted value + Note over User, EncryptedAttribute: Check instance.__decrypted_values__ cache alt Value is cached - EncryptedAttribute-->>Developer: return cached_value + EncryptedAttribute-->>Developer: return instance.__decrypted_values__[field_name] else Value is not cached EncryptedAttribute->>User: Get ciphertext from instance.__dict__ User-->>EncryptedAttribute: Returns ciphertext @@ -252,7 +253,7 @@ sequenceDiagram GcpKmsClient-->>EncryptedAttribute: Returns plaintext DEK EncryptedAttribute->>GcpKmsClient: decrypt(ciphertext, associated_data) GcpKmsClient-->>EncryptedAttribute: Returns plaintext value - Note left of EncryptedAttribute: setattr(instance, cache_name, plaintext_value) + EncryptedAttribute->>User: instance.__decrypted_values__[field_name] = plaintext_value EncryptedAttribute-->>Developer: return plaintext_value end deactivate EncryptedAttribute @@ -275,3 +276,56 @@ sequenceDiagram Note over Developer, PostgreSQL: The user's data (e.g., SSN) is now permanently unrecoverable. ``` + +### 5. Encrypted Model and Field Initialization + +This diagram shows how an encrypted model and its fields are initialized and validated when Django starts up. + +```mermaid +sequenceDiagram + participant Django as Django Startup + participant EncryptedModel as EncryptedModel (Class) + participant BaseEncryptedField as BaseEncryptedField + + Django->>EncryptedModel: check() + activate EncryptedModel + + Note over EncryptedModel: 1. Validate `associated_data` + alt `associated_data` is not defined, not a string, or empty + EncryptedModel-->>Django: raise Error + end + + Note over EncryptedModel: 2. Check `associated_data` uniqueness + alt `associated_data` is used by another model + EncryptedModel-->>Django: raise Error + end + + Note over EncryptedModel: 3. Validate Manager + alt `objects` is not a subclass of `EncryptedModel.Manager` + EncryptedModel-->>Django: raise Error + end + deactivate EncryptedModel + + Django->>BaseEncryptedField: contribute_to_class(EncryptedModel, "ssn") + activate BaseEncryptedField + + Note over BaseEncryptedField: 4. Validate Model Subclass + alt issubclass(Model, EncryptedModel) is False + BaseEncryptedField-->>Django: raise ValidationError + end + + Note over BaseEncryptedField: 5. Check for Duplicate Fields + alt "ssn" is already in Model.ENCRYPTED_FIELDS + BaseEncryptedField-->>Django: raise ValidationError + end + + Note over BaseEncryptedField: 6. Check for Duplicate Associated Data + alt "model:ssn" is already used by another field + BaseEncryptedField-->>Django: raise ValidationError + end + + Note over BaseEncryptedField: 7. Register Field + BaseEncryptedField->>EncryptedModel: Model.ENCRYPTED_FIELDS.append(self) + + deactivate BaseEncryptedField +``` From c052b29134ad7480c7b03586302b4bba625b8757 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 4 Feb 2026 13:52:03 +0000 Subject: [PATCH 40/45] DEK Field Initialization --- docs/client-side-encryption.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index a6edc3e8..f2afd6db 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -329,3 +329,32 @@ sequenceDiagram deactivate BaseEncryptedField ``` + +### 6. DEK Field Initialization + +This diagram details the validation that occurs when a `DataEncryptionKeyField` is added to a model. The field's `contribute_to_class` method ensures that the model is a valid `BaseDataEncryptionKeyModel` and that it contains only one DEK field. + +```mermaid +sequenceDiagram + participant Django as Django Startup + participant DataEncryptionKeyField as DataEncryptionKeyField + participant BaseDataEncryptionKeyModel as BaseDataEncryptionKeyModel (Class) + + Django->>DataEncryptionKeyField: contribute_to_class(Model, "dek") + activate DataEncryptionKeyField + + Note over DataEncryptionKeyField: 1. Validate Model Subclass + alt issubclass(Model, BaseDataEncryptionKeyModel) is False + DataEncryptionKeyField-->>Django: raise ValidationError + end + + Note over DataEncryptionKeyField: 2. Check for multiple DEK fields + alt Model._dek is not None + DataEncryptionKeyField-->>Django: raise ValidationError + end + + Note over DataEncryptionKeyField: 3. Register Field on Model + DataEncryptionKeyField->>BaseDataEncryptionKeyModel: Model._dek = self + + deactivate DataEncryptionKeyField +``` From 285194290b6c7ad8ec5ab220c34a6657f6de6507 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 4 Feb 2026 14:03:47 +0000 Subject: [PATCH 41/45] DEK AEAD Caching --- docs/client-side-encryption.md | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index f2afd6db..67ae9b27 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -358,3 +358,56 @@ sequenceDiagram deactivate DataEncryptionKeyField ``` + +### 7. DEK AEAD Caching + +To minimize latency and cost associated with decrypting the Data Encryption Key (DEK) via GCP KMS, the system employs an in-memory, time-to-live (TTL) cache for the AEAD primitive (`DEK_AEAD_CACHE`). + +The following diagrams illustrate the two main caching flows: retrieval (cache hit/miss) and invalidation. + +#### 7.1. Cache Retrieval (Hit & Miss) + +This diagram shows how the `dek_aead` property on a `BaseDataEncryptionKeyModel` instance leverages the cache. A cache hit returns the stored AEAD primitive immediately, while a cache miss triggers a call to GCP KMS to decrypt the key, which is then cached for subsequent requests. + +```mermaid +sequenceDiagram + participant User + participant EncryptedField as EncryptedField (__get__) + participant DEKEnabledModel as DEK-Enabled Model + participant DEK_AEAD_CACHE as TTLCache (in-memory) + participant KMS as Google Cloud KMS + + User->>+EncryptedField: Accesses encrypted attribute + EncryptedField->>+DEKEnabledModel: Accesses `dek_aead` property + alt Cache Miss + DEKEnabledModel->>DEK_AEAD_CACHE: Check for cached AEAD primitive (not found) + DEKEnabledModel->>+KMS: Decrypt DEK using KEK + KMS-->>-DEKEnabledModel: Returns AEAD primitive + DEKEnabledModel->>DEK_AEAD_CACHE: Store AEAD primitive with instance PK + else Cache Hit + DEKEnabledModel->>DEK_AEAD_CACHE: Check for cached AEAD primitive (found) + DEK_AEAD_CACHE-->>DEKEnabledModel: Return AEAD primitive + end + DEKEnabledModel-->>-EncryptedField: Return AEAD primitive + EncryptedField->>EncryptedField: Decrypts data using AEAD primitive + EncryptedField-->>-User: Returns decrypted value +``` + +#### 7.2. Cache Invalidation + +The cache must be invalidated whenever the underlying DEK changes to prevent the use of stale keys. This happens automatically when the `DataEncryptionKeyField` is set, for example, during a data shredding operation where the key is set to `None`. + +```mermaid +sequenceDiagram + participant User + participant DEKEnabledModel as DEK-Enabled Model + participant DataEncryptionKeyAttribute as DataEncryptionKeyAttribute (__set__) + participant DEK_AEAD_CACHE as TTLCache (in-memory) + + User->>+DEKEnabledModel: Shred data (e.g., `instance.dek = None`) + DEKEnabledModel->>+DataEncryptionKeyAttribute: Sets `dek` field to new value + DataEncryptionKeyAttribute->>+DEK_AEAD_CACHE: Delete cached AEAD for instance PK + DEK_AEAD_CACHE-->>-DataEncryptionKeyAttribute: + DataEncryptionKeyAttribute->>DEKEnabledModel: Update `dek` value in `__dict__` + DEKEnabledModel-->>-User: +``` From 42ae7c75392c8c7ab0e9113994f835483c546687 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 4 Feb 2026 14:17:17 +0000 Subject: [PATCH 42/45] house keeping --- codeforlife/models/base_data_encryption_key.py | 13 +++++-------- codeforlife/models/fields/data_encryption_key.py | 6 ++---- .../models/fields/data_encryption_key_test.py | 6 ++---- docs/client-side-encryption.md | 4 ++-- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/codeforlife/models/base_data_encryption_key.py b/codeforlife/models/base_data_encryption_key.py index 9a61ec16..600d5a2f 100644 --- a/codeforlife/models/base_data_encryption_key.py +++ b/codeforlife/models/base_data_encryption_key.py @@ -41,7 +41,7 @@ def __init_subclass__(cls): # A class-level reference to the DataEncryptionKeyField instance. # This is set by the `contribute_to_class` method of the field. - _dek: t.Optional["DataEncryptionKeyField"] = None + DEK_FIELD: t.Optional["DataEncryptionKeyField"] = None class Meta(TypedModelMeta): abstract = True @@ -59,7 +59,7 @@ def dek_aead(self): ) # Return None if there is no DEK. - if self._dek is None: + if self.DEK_FIELD is None: return None # Check the cache for the DEK AEAD. @@ -67,7 +67,7 @@ def dek_aead(self): return self.DEK_AEAD_CACHE[self.pk] # Get the AEAD primitive for the data encryption key. - dek_aead = get_dek_aead(self._dek) + dek_aead = get_dek_aead(self.DEK_FIELD) # Cache the DEK AEAD for future access. self.DEK_AEAD_CACHE[self.pk] = dek_aead @@ -83,11 +83,8 @@ def save( update_fields=None, ): # Lazily create a new DEK for new instances. - if self.pk is None: - # pylint: disable-next=protected-access - dek_class_attr = self.__class__._dek - if dek_class_attr is not None: - self.__dict__[dek_class_attr.field.attname] = create_dek() + if self.pk is None and self.__class__.DEK_FIELD is not None: + self.__dict__[self.__class__.DEK_FIELD.field.attname] = create_dek() return super().save( # type: ignore[misc] *args, diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index 369ce649..f4e71610 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -145,8 +145,7 @@ def contribute_to_class(self, cls, name, private_only=False): ) # Ensure only one DEK field per model. - # pylint: disable-next=protected-access - if cls._dek is not None: + if cls.DEK_FIELD is not None: raise ValidationError( f"'{cls.__module__}.{cls.__name__}' already has a" " DataEncryptionKeyField defined.", @@ -154,8 +153,7 @@ def contribute_to_class(self, cls, name, private_only=False): ) # Set the class DEK field reference. - # pylint: disable-next=protected-access - cls._dek = getattr(cls, self.name) + cls.DEK_FIELD = getattr(cls, self.name) # -------------------------------------------------------------------------- # Descriptor Methods diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index fc57fad9..42d8c3b8 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -136,13 +136,11 @@ def test_contribute_to_class(self): """DataEncryptionKeyField is contributed to model correctly.""" with self.subTest("Class attribute set correctly"): Model = self._get_model_class() - # pylint: disable-next=protected-access - assert Model._dek == Model.dek + assert Model.DEK_FIELD == Model.dek with self.subTest("Instance attribute set correctly"): instance = Model() - # pylint: disable-next=protected-access - assert instance._dek == instance.dek + assert instance.DEK_FIELD == instance.dek # -------------------------------------------------------------------------- # Descriptor Methods Tests diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index 67ae9b27..e33ac8fe 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -349,12 +349,12 @@ sequenceDiagram end Note over DataEncryptionKeyField: 2. Check for multiple DEK fields - alt Model._dek is not None + alt Model.DEK_FIELD is not None DataEncryptionKeyField-->>Django: raise ValidationError end Note over DataEncryptionKeyField: 3. Register Field on Model - DataEncryptionKeyField->>BaseDataEncryptionKeyModel: Model._dek = self + DataEncryptionKeyField->>BaseDataEncryptionKeyModel: Model.DEK_FIELD = self deactivate DataEncryptionKeyField ``` From 5df9a286b4b1939cad1ca5e31a94802bb61d1e09 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 9 Feb 2026 09:31:23 +0000 Subject: [PATCH 43/45] feedback --- docs/client-side-encryption.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index e33ac8fe..ca8a76b5 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -31,7 +31,7 @@ We utilize a hierarchy of keys to balance security and performance. --- -## 4. Encryption/Decryption Utilities +## 3. Encryption/Decryption Utilities At the core of this system are a few utility functions that interact with Google Cloud KMS and the `tink` cryptography library. In addition, to avoid a dependency on Google Cloud KMS during local development and in CI/CD pipelines, we use fake (mock) implementations of the KMS client and its AEAD primitive. @@ -39,7 +39,7 @@ At the core of this system are a few utility functions that interact with Google --- -## 5. Django ORM Integration +## 4. Django ORM Integration To make working with encrypted data seamless, we've integrated the encryption logic directly into Django's ORM. This is achieved through a combination of a base model class, custom model fields, and descriptors. @@ -71,7 +71,7 @@ The implementation details can be found in the docstring of these files. It's re --- -## 6. Usage Patterns +## 5. Usage Patterns Here are two common patterns for using the encryption framework. @@ -157,7 +157,7 @@ print(f"The secret is: {secret.secret_value}") --- -## 7. Sequence Diagrams +## 6. Sequence Diagrams This section contains diagrams that explain what the Django ORM is doing. From 671a8ec994587af2b036e7a83904847e4cd3cdb1 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 9 Feb 2026 09:36:09 +0000 Subject: [PATCH 44/45] fix --- docs/client-side-encryption.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index ca8a76b5..d767390f 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -1,4 +1,4 @@ -# Client-Side Encryption v3 +# Client-Side Encryption Client-Side Encryption with Per-User Keys and Django ORM Integration From 9fe135ed4b928866cfbc205a6c94e1aba26858fd Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 9 Feb 2026 09:45:16 +0000 Subject: [PATCH 45/45] house keeping --- docs/client-side-encryption.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index d767390f..144f2e8b 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -330,7 +330,7 @@ sequenceDiagram deactivate BaseEncryptedField ``` -### 6. DEK Field Initialization +### 6. DEK Model and Field Initialization This diagram details the validation that occurs when a `DataEncryptionKeyField` is added to a model. The field's `contribute_to_class` method ensures that the model is a valid `BaseDataEncryptionKeyModel` and that it contains only one DEK field.