【Python・Django】中間テーブルのプライマリキーを後からID→UUIDに変更したい

【Python・Django】中間テーブルのプライマリキーを後からID→UUIDに変更したい


# django # python

環境

Python:3.6 Django:2.1

やりたいこと

Djangoを使って開発を行っていましたが、最初は中間テーブルのプライマリキーをデフォルトのIDで使っていたものの、セキュリティ上の懸念に後から気がつき、変更ID→UUIDに変更したくなってしまった。

開発環境ではDB作り直せばよいのですが、せっかくなので消さずに修正を行ってみました。

※UUIDは確率論的に重複することのないランダムな値です。Webサービス等でIDをそのまま使っていた場合は、IDをベースにDBサイズやテーブル内容の推測、それに伴う攻撃が考えられるらしいです。

※今回は中間テーブルのIDのため、外部キーの被参照先となっていません。外部キーの被参照先となっている場合の検証はしていませんが、恐らくうまく動作しません

作業手順

前提と考え

以下のようにthroughを定義して中間テーブルを独自に定義しているモデルがあるとします。 (もししていなかった場合は定義する必要があります。)

// 公式ヘルプのサンプルを改変
import uuid
from django.db import models

class Person(models.Model):
    uuid = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    name = models.CharField(max_length=50)

class Group(models.Model):
    uuid = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    name = models.CharField(max_length=128)
    members = models.ManyToManyField(
        Person,
        through='Membership',
        through_fields=('group', 'person'),
    )

class Membership(models.Model):
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
    person = models.ForeignKey(Person, on_delete=models.CASCADE)
    inviter = models.ForeignKey(
        Person,
        on_delete=models.CASCADE,
        related_name="membership_invites",
    )
    invite_reason = models.CharField(max_length=64)

以下のようにidをUUIDに書き換えるてマイグレーションを行うのですが、そのままだとユニーク制約にひっかかってエラーが出てしまいます。

class Membership(models.Model):
    uuid = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
    person = models.ForeignKey(Person, on_delete=models.CASCADE)
    inviter = models.ForeignKey(
        Person,
        on_delete=models.CASCADE,
        related_name="membership_invites",
    )
    invite_reason = models.CharField(max_length=64)

Djangoのマイグレーションではカラム追加時に各行にユニークな値を入れるということが出来ません。 そのため、面倒ですが以下の手順を踏む必要があります。

  1. uuid列を初期かつ編集可能としてマイグレーションを実行
  2. uuid列に本来の値を設定
  3. プライマリキーが2つ出来ないように、もともとのプライマリキーを削除
  4. uuid列をプライマリキーに設定し、編集不可とする。

こうやってデフォルトのIDをUUIDに置き換える

普通に「python manage.py makemigration」を実行する

以下のようなマイグレーションファイルが出来上がる(このままだとNG)。

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

    dependencies = [
        ('myapp', '0003_auto_20150129_1705'),
    ]

    operations = [
        migrations.RemoveField(
            model_name='mymodel',
            name='id',
        ),
        migrations.AddField(
            model_name='mymodel',
            name='uuid',
            field=models.UUIDField(editable=False, primary_key=True, serialize=False),
        ),
    ]

まずはuuidにnullとして登録するように変更。この時点で以前のプライマリキーの削除するとuuid作成時にエラーが出るのでまだ削除はしない。

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

    dependencies = [
        ('myapp', '0003_auto_20150129_1705'),
    ]

    operations = [
        # migrations.RemoveField(
        #    model_name='mymodel',
        #     name='id',
        # ),
        migrations.AddField(
            model_name='mymodel',
            name='uuid',
            field=models.UUIDField(default=None, editable=True, null=True, serialize=False),
            preserve_default=False,
        ),
    ]

uuid列に本来の値を設定しつつ、旧プライマリキーの削除と本来の制約をかけるマイグレーションファイルを作成

空のマイグレーションファイルを作成。

「python manage.py makemigrations myapp —empty」を実行すればok。 手動でファイルを作っても問題ないですが、前のマイグレーションとの依存関係もファイル内に記載されるため、コマンドで作成するのが無難。

正しいプライマリキーの設定になるようマイグレーションファイルを設定

各レコードにユニークなuuidを設定する関数を作成してそれを呼び出す形を取ります。 当然ですが、ここの値の設定次第uuidに限らず好きな値の設定が可能です。

なお、「正しい値の設定→旧プライマリキー列削除→UUID列をプライマリキーに設定」という順番で処理を書かないとエラーで実行出来ないので要注意。

from django.db import migrations, models
import uuid

def gen_uuid(apps, schema_editor):
    MyModel = apps.get_model('myapp', 'MyModel')
    for row in MyModel.objects.all():
        row.uuid = uuid.uuid4()
        row.save(update_fields=['uuid'])

class Migration(migrations.Migration):

    dependencies = [
        ('myapp', '0004_add_uuid_field'),
    ]

    operations = [
        # omit reverse_code=... if you don't want the migration to be reversible.
        migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
        migrations.RemoveField(
            model_name='MyModel',
            name='id',
        ),
        migrations.AlterField(
            model_name='MyModel',
            name='uuid',
            field=models.UUIDField(editable=False, primary_key=True, serialize=False),
        ),
    ]

マイグレーションを実行

以上で必要なDBの操作はすべてマイグレーションファイルに記載が出来ましたので、通常通り 「python manage.py migrate」を実行すればきちんとuuidに一意な値が格納されています。

参考

https://docs.djangoproject.com/en/2.0/howto/writing-migrations/#migrations-that-add-unique-fields

http://www.denzow.me/entry/2017/12/23/150501

なかなか骨が折れますな。。きちんと設計はしようということですね…