django

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

  • среда, 9 марта 2022 г. в 00:35:47
https://habr.com/ru/post/654719/
  • Разработка веб-сайтов
  • Python
  • Программирование
  • Django
  • Хранение данных


В настоящее время практически все ИТ-продукты работают с персональной информацией пользователя: ФИО, телефон, 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: class - ClassDef, from/import - ImportFrom и так далее. При этом узлы имеют свои атрибуты, и могут быть вложены друг в друга.

Этап №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