Django でモデルフィールドの値を暗号化してくれる EncryptedField を作成する方法

##はじめに
業務でDjangoで作ったシステムのデータを暗号化してDBで保管するという
タスクに携わったので、その方法を記事にしたいと思います。

###この記事で触れること
* Modelの値を暗号化してDBに保存してくれるEncryptedFieldの作り方
* 既存のデータの置き換えについて
* マイグレーションの挙動について

###この記事で触れないこと
* Pythonで暗号化をする方法
* saveメソッドを継承して暗号化をする方法

###環境
Django3.0.1
python3.9
暗号化ライブラリ: PyCryptodome


##結論と説明
こちらがメインのコードです。
encryption decryptionは暗号化・複合化の関数となります。

```python:myfield.py
class EncryptedFieldMixin:
    """
    Djangoのモデルフィールドを、DB登録時に暗号化するように
    変更するMixin
    戻り値が str のため、models.BinaryFieldなどには使えない
    """

    def pre_save(self, model_instance, add):
        """
        model_instance がもつフィールドの値を暗号化する
        """
        value = getattr(model_instance, self.attname)
        if value is None:
            return value
        return encryption(plain_text=value, password=PASSWORD)

    def from_db_value(self, cipher_text, expression, connection):
        """
        DBから取り出したフィールドの値を復号化する
        暗号化されていないデータの場合はそのまま返す
        """
        if cipher_text is None:
            return cipher_text
        try:
            return decryption(encrypted=cipher_text, password=PASSWORD)
        except ValueError:
            return cipher_text


class EncryptedTextField(EncryptedFieldMixin, models.TextField):
    """
    データをDB登録時に自動で暗号化するフィールド
    TextFieldを継承しているため、ModelAdminやModelFormでも
    TextFieldと同じように使える。
    マイグレーションファイルは生成されるが DBのデータ型は同じため、
    既存のTextFieldとの置き換えが可能。
    """
    pass

```

使い方

````python:models.py
from myfield import EncryptedTextField

class Hoge(models.Model):
    text = EncryptedTextField(max_length=500)

````

###説明
Djangoの各フィールドモデルには、[pre_save](https://github.com/django/django/blob/main/django/db/models/fields/__init__.py#L816)とfrom_db_valueというメソッドがあります。
pre_saveはDBに書き込む前に呼び出され、それぞれのフィールドにあった適切な処理がされます。ちなみに、TextFieldとCharFieldでは何も行われません。

一方from_db_valueはDBからインスタンスを作成する際に[get_db_converters](https://github.com/django/django/blob/4be58fcc369a024bd7ac72bb09af7d230499cccb/django/db/models/fields/__init__.py#L746)で呼び出されます。

```python:__init__.py
# 一部抜粋
class Field:

    def get_db_converters(self, connection):
        if hasattr(self, 'from_db_value'):
            return [self.from_db_value]
        return []

```
from_db_valueに書くことで、復号化のメソッドをdb_converterの一つとしてDBの処理に送ることができます。

注意点
from_db_valueは特殊なフィールド、例えばJSONFieldなどでのみ定義されています。そういったフィールドを暗号化する際には、
superで親のメソッドにアクセスするのを忘れないようにしてください。


###既存のデータの置き換えについて
EncryptedMixinはDBの値が暗号とそのままのデータが混在していることを前提にしています。
使う暗号ロジックにもよると思いますが、pythonのPyCryptodomeは暗号化していないデータを複合化しようとするとValueErrorを返します。
このValueErrorを暗号化されているかどうかの判定に使っています。つまり、一度DBから取り出した値を復号化してみて、もしエラーが出ればそのままインスタンスに渡します。
そうすることで、

```python:encryption.com
from django.core.management.base import BaseCommand
from example.models import Hoge

class Command(command):
    def handle(self, *args, **kwargs):
        from obj in Hoge.objects.all():
            obj.save()

```
こんな感じの簡単なコマンドを実行するだけで置き換えが可能となります。


###マイグレーションの挙動について

EncryptedCharFieldを定義、またはCharFieldと置き換えてmakemigrationコマンドを叩くとマイグレーションファイルがつくられます。
このファイルをマイグレーションするとCharFieldと同じDBのデータ型となります。
これは各フィールドが[get_internal_type](https://github.com/django/django/blob/4be58fcc369a024bd7ac72bb09af7d230499cccb/django/db/models/fields/__init__.py#L1060)というメソッドで定義しています。

```python:__init__.py

class CharField(Field):

    def get_internal_type(self):
        return "CharField"

```

ここで渡した値をDjangoが参照してデータベースの型を決めます。例えば[こちら](https://github.com/django/django/blob/1061f5243646b4c9b8a758f8a36c9e2ccdded1cf/django/db/backends/mysql/base.py#L104)はMySQLの場合です。
EncryptedCharFieldはCharFieldを継承しているためCharFieldと同じデータ型が定義されることになります。

既存のフィールドを置き換える際でも、マイグレーションをせずに置き換えすることができます。

#最後に
今回はDjangoのフィールドを暗号化する方法を記事にしました。
何か間違いなどがありましたらコメントしていただけると嬉しいです。