Анонимизация базы данных или как быть уверенным, что ты не нарушаешь закон “О персональных данных”

от автора

В настоящее время практически все ИТ-продукты работают с персональной информацией пользователя: ФИО, телефон, e-mail, паспортные и другие идентифицирующие данные. Для  обеспечения защиты прав и свобод, человека и гражданина при обработке его персональных данных в Российской Федерации существует Федеральный закон от 27.07.2006 N 152-ФЗ “О персональных данных”.

Согласно пункту 2 статьи 5 обработка персональных данных должна ограничиваться достижением конкретных, заранее определенных и законных целей, а в статье 6 установлено, что обработка персональных данных осуществляется с согласия субъекта персональных данных. Все это накладывает определенные ограничения на разработку программных продуктов и заставляет разработчиков думать о возможных последствиях несоблюдения норм законодательства.

Хочется заметить, что во многих случаях для непосредственной разработки личные данные пользователя не важны, необходима сама структура данных, их полнота и количество. По этой причине, а также в рамках соблюдения закона, персональные данные пользователя можно анонимизировать, чем и пришлось заниматься в рамках своей профессиональной деятельности. 

Под анонимизацией в рамках статьи стоит понимать процесс изменения данных введенных пользователем и сохраненных в БД на программно сгенерированные данные, которые по виду и типу совпадают с реальными, но не имеют отношения к конкретному пользователю.  О том, как была организована работа по этому вопросу и какой в итоге получился результат и будет эта статья.

Начало законопослушного программиста

Прежде чем приступить к описанию процесса анонимизации базы данных, опишу задачу, которая была мне поставлена:

  1. Подключить и использовать библиотеку django-gdpr-assist.

  2. Реализовать локальный плагин для Flake8, который проверял бы корректность анонимизации данных.

  3. Написать manage.py команду для анонимизации базы данных.

В своей работе я использую Django Rest Framework, по этой причине ниже представленный код будет реализован на языке программирования Python. Структура статьи будет соответствовать задаче, описанной выше, а в конце поделюсь мыслями, к которым пришел при ее выполнении и ссылкой на код плагина. Также приведу код модели, с которой мы будем работать.

from django.db import models from django.utils.translation import gettext_lazy as _ from django_nova_users.models import User from rules.contrib.models import RulesModelBase, RulesModelMixin   class Account(RulesModelMixin, models.Model, metaclass=RulesModelBase):   """Аккаунт."""      user = models.OneToOneField(         User,         on_delete=models.CASCADE,         related_name='account',     )     photo = models.ImageField(         _('аватар'),         upload_to='media',         blank=True,         null=True,     )     birth_date = models.DateField(         _('дата рождения'),           blank=True,           null=True,     )     passport_series = models.CharField(         _('серия паспорта'),         max_length=4,         blank=True,     )     passport_number = models.CharField(         _('номер паспорта'),         max_length=4,         blank=True,     )      class Meta(object):         verbose_name = _('аккаунт')         verbose_name_plural = ('аккаунты')      def str(self):         return self.user.full_name

Использование библиотеки django-gdpr-assist для анонимизации данных

Общий регламент защиты персональных данных (General Data Protection Regulation, GDPR) — постановление Европейского Союза, направленное на возможность дать гражданам контроль над собственными персональными данными.

Не смотря на то, что Россия не входит в Европейский союз, Федеральный закон № 152 “О персональных данных” содержит в себе ключевые принципы данного положения, а рассматриваемая библиотека позволяет из соблюсти: анонимизировать личные данные пользователя.

Данная библиотека работает следующим образом:

  1. Создается база данных gdpr_log, которая состоит из двух таблиц: таблица, где содержится информация о миграциях и таблица-журнал, где фиксируется действие, приложение, модель и pk объекта надо которым осуществлено действие. По умолчанию записи в журнале создаются при анонимизации экземпляра или при использовании команды anonymise_db данной библиотеки.

  2. В базе данных, которая являются стандартной (default) в проекте, создается таблица gdpr_assist_privacyanonymised, где также фиксируются объекты, которые подверглись изменению.

  3. Процесс анонимизации представляет собой изменение определенных данных, которые хранятся в стандартной (default) базе данных на программно-сгенерированные данные. 

  4. Данные, которые были изменены в ходе процесса анонимизации, нельзя привести к первоначальному виду.

Установка и настройка данной библиотеки не займет много времени и хорошо описана в официальной документации, перейдем сразу к вопросам ее использования. GDPR-assist позволяет анонимизировать определенные поля модели двумя способами:

  1. Автоматическая регистрация через определение параметра конфиденциальности в PrivacyMeta классе модели.

  2. Ручная регистрация через использование функции gdpr_assist.register(<ModelClass>, [<PrivacyMetaClass>]).

После изучения документации я решил воспользоваться первым способом для анонимизации данных, но в ходе его реализация я столкнулся с проблемой: в модели не был доступен атрибут _privacy_meta. В ходе некоторых манипуляций мне так и не удалось получить доступ к данному атрибуту, поэтому я воспользовался вторым способом: использовал функцию gdpr_assist.register().

Анонимизация полей, указанных в переменной fields внутри class PrivacyMeta может происходить по умолчанию, а может быть переопределена пользовательским анонимайзером через метод класса PrivacyMeta anonymise<field_name> (для генерирования данных я использую библиотеку Faker).

Реализация локального плагина для Flake8 по контролю анонимизации данных

Изначально, я хотел написать статью только о том, как я реализовывал испытывал мучения и страдал плагин для Flake8, но после, не найдя чего-то похожего, решил рассказать все, что удалось узнать в ходе выполнения задачи. 

Кто-то из вас может задаться вопрос причем тут анонимизация БД и плагин? При разработке мы часто меняем модели данных, удаляем и добавляем поля. Плагин контролирует разработку, позволяет программисту не держать в голове тонну информации, а сконцентрироваться на поставленной задаче. Разрабатываемый плагин будет учитывать изменения, вносимые в модели данных и позволит не забыть анонимизировать данные, идентифицирующие пользователя, а также подскажет как правильно это делать.

Написание плагина для flake8 у меня отняло много времени, сил и нервов, но по итогу я сделал для себя некоторые выводы, о которых поделюсь в самом конце. Теперь от лирики перейдем к делу! Мой путь начался с поиска информации в Интернете и ее изучении. Самое полезное что мне удалось найти, и что стало моей отправной точкой:

  1. Видео о написании плагина на flake8 и официальная документация.

  2. Первоначальная информация об абстрактном синтаксическом дереве и официальная документация модуля ast.

  3. Статья How to write Flake8 plugins ? и How to create a Flake 8 Plugin.

Процесс написания плагина, я также разобью на этапы, которые у меня были в разнобой останавливаясь подробнее на тех моментах, которые у меня вызвали трудности.

Этап №1. Знакомство с модулем ast

Согласно документации модуль ast помогает приложениям Python обрабатывать деревья грамматики абстрактного синтаксиса Python. Сам абстрактный синтаксис может меняться с каждым выпуском Python; этот модуль помогает узнать программно, как выглядит текущая грамматика.

Ниже приведу пример того, как выглядит абстрактное синтаксическое дерево нашей модели и код для вывода дерева в консоль.

Module(    body=[        ImportFrom(            module='django.db',            names=[alias(name='models')],            level=0        ),        ImportFrom(            module='django.utils.translation',            names=[                alias(                    name='gettext_lazy',                    asname=''                )            ],            level=0        ),        ImportFrom(            module='django_nova_users.models',            names=[alias(name='User')],            level=0        ),        ImportFrom(            module='rules.contrib.models',            names=[                alias(name='RulesModelBase'),                alias(name='RulesModelMixin')            ],            level=0        ),        ClassDef(            name='Account',            bases=[                Name(                    id='RulesModelMixin',                    ctx=Load()                ),                Attribute(                    value=Name(                        id='models',                        ctx=Load()                    ),                    attr='Model',                    ctx=Load())            ],            keywords=[                keyword(                    arg='metaclass',                    value=Name(                        id='RulesModelBase',                        ctx=Load()                    )                )            ],            body=[                Assign(                    targets=[                        Name(                            id='user',                            ctx=Store()                        )                    ],                    value=Call(                        func=Attribute(                            value=Name(                                id='models',                                ctx=Load()                            ),                            attr='OneToOneField',                            ctx=Load()                        ),                        args=[                            Name(                                id='User',                                ctx=Load()                            )                        ],                        keywords=[                            keyword(                                arg='on_delete',                                value=Attribute(                                    value=Name(                                        id='models',                                        ctx=Load()                                    ),                                    attr='CASCADE',                                    ctx=Load()                                )                            ),                            keyword(                                arg='related_name',                                value=Constant(                                    value='account'                                )                            )                        ]                    )                ),                ...
import ast from pprint import pprint   tree = ast.parse(""" class Account(RulesModelMixin, models.Model, metaclass=RulesModelBase):    user = models.OneToOneField(        User,        on_delete=models.CASCADE,        related_name='account',        ) … """)  pprint(ast.dump(tree))

Как видно из дерева, каждому элементу нашего кода (в модуле ast называется node или узел) соответствует определенный класс из модуля ast: classClassDef, from/importImportFrom и так далее. При этом узлы имеют свои атрибуты, и могут быть вложены друг в друга.

Этап №2. Создание класса плагина

Прежде чем создавать класс плагина, мы должны решить какого вида у нас плагин:

  1. Плагин, проверяющий исходный код — extension.

  2. Плагин, сообщающий об ошибках — report.

В нашем случае плагин проверяет исходный код на соответствие правилам анонимизации, поэтому название класса AdbExtension. Создавая класс плагина необходимо указать название (name) и версию плагина (version) а также создать два метода: 

  1. def init() — получает и устанавливает синтаксическое дерево.

  2. def run() — передает полученное дерево классу с логикой плагина и выводит найденные ошибки.

import ast from typing import Any, Generator, Tuple, Type   class AdbExtension(object):    """Плагин для проверки корректности анонимизации базы данных."""    name = 'flake8-anonymise'    version = '0.0.1'     def init(self, tree: ast.AST, *args) -> None:        """Получаем древовидное представление исходного кода."""        self.tree = tree     def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:        """Выводим найденные ошибки, исходя из логики плагина."""        parser = AdbVision()  # класс с логикой плагина        parser.visit(self.tree)  #начало посещения узлов дерева        for line, col, problem in sorted(parser.problems):  # вывод найденных проблем            yield line, col, problem, type(self)  

Этап №3. Локальная конфигурация плагина

Для того чтобы плагин заработал, необходимо создать файл конфигурации. В нашем проекте это файл setup.cfg. В данном файле необходимо прописать следующее:

[flake8:local-plugins] extension =   ADB = plugin:AdbExtension paths = ./flake8_anonymise/

extension — вид плагина. Как говорилось выше, мы реализуем плагин, проверяющий код.

ADB = plugin:AdbExtension — код ошибки и название класса плагина (plugin — название файла, где находится класс плагина, AdbExtension — название класса плагина.).

ADB — код ошибки, с которым будет работать ваш плагин (в большинстве своем состоит из трех букв).

paths =./flake8_anonymise/ — путь до файла с классом вашего плагина.

Этап №4. Реализация логики плагина

Основная логика плагина заключается в следующем:

  1. Поиск классов, которые описывают модель

  2. Поиск внутри модели класса PrivacyMeta (который создается в соответствии с библиотекой django-gdpr-assist).

  3. Внутри класса PrivacyMeta должно быть 2 переменные: fields — список полей модели для анонимизации; non_sensitive — список всех остальных полей модели.

  4. Для каждого элемента списка fields должна быть прописана пользовательская функция анонимизации.

  5. Должна быть указана функция gdpr_register().

Ниже будут приведены лишь основные части кода. В конце статьи будет находится ссылка, где можно будет ознакомится с полным кодом.

import ast   class AdbVision(ast.NodeVisitor):    """Проверка файла на наличие класса, удовлетворяющего условиям."""     def init(self, *args, **kwargs) -> None:        """Установка праметров и переменных для хранения данных."""       self.problems: List[Tuple[int, int, str]] = []       self.parent_class = ['models.Model']       # название внутреннего класса необходимого для анонимизации       self.param_part_name_class_anonymise = 'PrivacyMeta'       # поля обязательные во внутреннем классе       self.param_fields_sub_class = ['fields', 'non_sensitive']       # функция регистрации модели и класса для анонимизации       self.param_func_anonymise = 'gdpr_assist.register'       # часть названия функции для анонимизации       self.param_part_name_function = 'anonymise'       self.main_class = ''  # название модели       self.anonymise_class = ''  # название класса анонимизации       self.errors = {         'ADB001': 'ADB001 В моделе {main_class} отсутсвует класс ' +         'PrivacyMeta, его необходимо создать.',       }

На 2 этапе в методе def run() мы установили parser = AdbVision() и parser.visit(self.tree).

Теперь видно, что AdbVision это класс, в котором будет реализована основная логика плагина. Он наследуется от класса ast.NodeVisitor, который является базовым классом посетителя узла, который проходит по абстрактному синтаксическому дереву и вызывает функцию посетителя для каждого найденного узла. parser.visit(self.tree) — запускает проход по узлам дерева.

ВАЖНО! Хочется сделать акцент на словаре self.errors, где ключом выступает строка ADB001. Очень важно, чтобы коды ошибок совпадали с настройками плагина (extension = ADB = plugin:AdbExtension). Если не соблюсти данное правило, то плагин не будет отображать найденные ошибки. Более подробно о кодах ошибки./

Исходя из логики плагина в первую очередь мы должны найти классы, которые описывают модель данных. Для того чтобы найти такой класс нам необходимо переопределить метод visit_ClassDef(), где ClassDef это класс необходимого узла. Далее мы будем искать те классы, которые наследуются от models.Model или пользовательских классов, например, AbstractBaseModel (переменная self.parent_class: list). Список классов, от которых наследуется рассматриваемый класс, содержится в атрибуте ‘bases’.

def visit_ClassDef(self, node):    """Поиск необходимых классов."""     if hasattr(node, 'bases'):  # ищем классы, у которых есть родитель        self.is_django_model = True        is_model_attr = False        is_model_name = False         for base in node.bases:  # проверяем отчего наследуется класс            if isinstance(base, Attribute):                is_model_attr = self.visit_Attribute(base)             if isinstance(base, Name):                is_model_name = self.visit_Name(base)         # Анализируем тело родительского класса        if is_model_attr or is_model_name:            self.main_class = node.name            self.analysis_body(node)     # анализируем класс PrivacyMeta    if node.name == self.anonymise_class:        self.analysis_body(node)     return False

При этом если класс наследуется от models.Model, то нам надо проанализировать два узла: class Attribute (отвечает за Model) и class Name (отвечает за models).

Если бы мы искали AbstractBaseModel, то пришлось бы проанализировать только узел class Name.

def visit_Name(self, node):    """Проверяем узлы, которые имеют класс Name."""     if self.is_django_model:        # проверка при поиске родительского класса и полей модели        if node.id in self.convert_list(self.parent_class):            return node.id         # проверка при поиске атрибутов PrivacyMeta        if node.id in self.param_fields_sub_class:            return node.id     if self.is_search_gdpr:       # проверка при поиске функции gdpr_assist.register        if node.id in self.param_func_anonymise.split('.'):            return node.id         # проверка при поиске аргументов функции gdpr_assist.register        if node.id == self.main_class:            return node.id     ast.NodeVisitor.generic_visit(self, node)    return False  def visit_Attribute(self, node):    """Проверяем узлы, которые имеют класс Attribute."""     if isinstance(node.value, Name):        name = self.visit_Name(node.value)         if self.is_django_model:            # ищем сопадения с models.Model или типами полей            if node.attr in [*self.convert_list(self.parent_class), *self.type_field]:                return '{}.{}'.format(name, node.attr)         if self.is_search_gdpr:            if node.attr in self.param_func_anonymise.split('.'):                return '{}.{}'.format(name, node.attr)             if node.attr == self.anonymise_class:                return '{}.{}'.format(name, node.attr)     ast.NodeVisitor.generic_visit(self, node)    return False

Я привел полный код своих функций, чтобы показать что многие узлы имеет один и тот-же, и необходимо учитывать это при поиске нужных элементов. Важной частью в коде является наличие функции ast.NodeVisitor.generic_visit(self, node), которая вызовет функцию visit() для всех дочерних элементов узла. В случае, если мы не укажем данную функцию, то, если у пользовательских методов есть дочерние узлы, они не будут посещены.

Дальнейшая разработка заключалась в переопределении методов def visit_NodeClasse() для поиска и извлечения необходимых данных в синтаксическом дереве и выводе ошибок при отсутствии элементов логики по анонимизации.

Автоматизация процесса анонимизации базы данных с помощью manage.py команды

Самой быстрой частью задачи являлось написание manage.py команды для автоматизации процесса анонимизации данных. Команда рассчитана на то, что у нас уже есть бекап базы данных, которая используется в релизе, но бекап не применен к БД, участвующей в разработке.

Для собственной команды необходимо создать каталог  management,  с вложенным каталогом command в каталоге приложения.

my_project/                     <-- каталог проекта  |-- myapp/                     <-- каталог приложения  |    |-- management/  |    |    +-- commands/  |    |         +-- adb.py      <-- модуль с кодом команды  |    |-- migrations/  |    |    +-- init.py  |    |-- init.py  |    |-- admin.py  |    |-- apps.py  |    |-- models.py  |    |-- tests.py  |    +-- views.py  |-- myapp/  |    |-- init.py  |    |-- settings.py  |    |-- urls.py  |    |-- wsgi.py  +-- manage.py  

Код команды:

from django.core.management.base import BaseCommand from django.core.management import call_command   class Command(BaseCommand):   """Команда для анонимизации БД."""     help = 'Анонимизация базы данных.'      def add_arguments(self, parser):         """Аргументы для работы команды."""         parser.add_argument(           'input_filename',           type=str,            help=u'Название файла для загрузки бекапа.',         )         parser.add_argument(           'output_filename',           type=str,            help=u'Название файла для выгрузки анонимизированной БД.',         )                  def handle(self, *args, **kwargs):           """Логика команды по анонимизации данных."""           call_command(             'dbrestore',             '--database=default',             f"--input-filename={kwargs['input_filename']}",           )           call_command('migrate', '--database=gdpr_log')           call_command('anonymise_db')           if kwargs['output_filename']             call_command(               'dbbackup',                '--database=default',               f"--output-filename={kwargs['output_filename']}",             )           else:             call_command('dbbackup', '--database=default')

call_comand() — позволяет вызвать функцию управления.

команды dbbackup и dbrestore относятся к пакету django-dbbackup. Данный пакет предоставляет команды управления для резервного копирования и восстановления базы данных с помощью различных хранилищ, в том числе и локального. 

Краткие итоги

В ходе написания статьи, а также во время переписывания плагина я выявил следующие недостатки в своей работе:

  1. Плагин получился очень большим. Пока мне не удалось разбить его на более мелкие работающие сущности.

  2. Плагин получился не красивым. Я переписывал плагин несколько раз, и хоть за эти попытки я его немного улучшил, все-же он сложен для понимания и выглядит мягко говоря ужасно.

  3. Функционал плагина узок. Данный плагин покрывает только половину логики анонимизации БД, а именно анализ класса модели и наличия в нем класса PrivacyMeta. Необходимо до конца разобраться с автоматическим созданием атрибута _privacy_meta и возможностью выносить логику анонимизации в отдельный файл.

  4. Отсутствует возможность передавать параметры в плагин. В дальнейшем я планирую разобраться как реализовать данный функционал, чтобы можно было более гибко использовать локальный плагин и настраивать его.

Кроме минусов плагина, хотелось бы описать минусы использования django-gdpr-assist:

  1. Анонимизация БД применяется только к default базе данных, нельзя передавать в качестве параметров иные БД.

  2. В момент анонимизации БД, пользователю задается вопрос об уверенности в процессе анонимизации. На него необходимо ответить yes или no. Первоначально я не понимал почему отправка y или n не приносили результатов.

  3. Отсутствует автоматическая регистрация атрибута _privacy_meta. Из-за своего небольшого опыта мне не удалось разобраться с данной проблемой, возможно кто-то из читающих сможет помочь решить ее.

Полученный опыт:

  1. Необходимо более детально читать документацию, в ней написаны многие вещи, над которыми я думал много времени, или о которых спрашивал у своих коллег по работе.

  2. Не надо стараться реализовать большой функционал сразу, а также не надо сразу внедрять дополнительные функции, если не реализованы основные. Когда я начал писать плагин, то хотелось сделать что-то универсальное, но в конечном итоге только потратил на это время.

  3. Отдых помогает. Несколько раз я продвигался с мертвой точки только после того, как отдыхал, хотя до этого мог сидеть несколько часов и даже не приблизиться к решению.

Ссылка на github


ссылка на оригинал статьи https://habr.com/ru/post/654719/