В настоящее время практически все ИТ-продукты работают с персональной информацией пользователя: ФИО, телефон, e-mail, паспортные и другие идентифицирующие данные. Для обеспечения защиты прав и свобод, человека и гражданина при обработке его персональных данных в Российской Федерации существует Федеральный закон от 27.07.2006 N 152-ФЗ “О персональных данных”.
Согласно пункту 2 статьи 5 обработка персональных данных должна ограничиваться достижением конкретных, заранее определенных и законных целей, а в статье 6 установлено, что обработка персональных данных осуществляется с согласия субъекта персональных данных. Все это накладывает определенные ограничения на разработку программных продуктов и заставляет разработчиков думать о возможных последствиях несоблюдения норм законодательства.
Хочется заметить, что во многих случаях для непосредственной разработки личные данные пользователя не важны, необходима сама структура данных, их полнота и количество. По этой причине, а также в рамках соблюдения закона, персональные данные пользователя можно анонимизировать, чем и пришлось заниматься в рамках своей профессиональной деятельности.
Под анонимизацией в рамках статьи стоит понимать процесс изменения данных введенных пользователем и сохраненных в БД на программно сгенерированные данные, которые по виду и типу совпадают с реальными, но не имеют отношения к конкретному пользователю. О том, как была организована работа по этому вопросу и какой в итоге получился результат и будет эта статья.
Начало законопослушного программиста
Прежде чем приступить к описанию процесса анонимизации базы данных, опишу задачу, которая была мне поставлена:
-
Подключить и использовать библиотеку django-gdpr-assist.
-
Реализовать локальный плагин для Flake8, который проверял бы корректность анонимизации данных.
-
Написать
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 “О персональных данных” содержит в себе ключевые принципы данного положения, а рассматриваемая библиотека позволяет из соблюсти: анонимизировать личные данные пользователя.
Данная библиотека работает следующим образом:
-
Создается база данных
gdpr_log, которая состоит из двух таблиц: таблица, где содержится информация о миграциях и таблица-журнал, где фиксируется действие, приложение, модель иpkобъекта надо которым осуществлено действие. По умолчанию записи в журнале создаются при анонимизации экземпляра или при использовании командыanonymise_dbданной библиотеки. -
В базе данных, которая являются стандартной (default) в проекте, создается таблица
gdpr_assist_privacyanonymised, где также фиксируются объекты, которые подверглись изменению. -
Процесс анонимизации представляет собой изменение определенных данных, которые хранятся в стандартной (default) базе данных на программно-сгенерированные данные.
-
Данные, которые были изменены в ходе процесса анонимизации, нельзя привести к первоначальному виду.
Установка и настройка данной библиотеки не займет много времени и хорошо описана в официальной документации, перейдем сразу к вопросам ее использования. GDPR-assist позволяет анонимизировать определенные поля модели двумя способами:
-
Автоматическая регистрация через определение параметра конфиденциальности в
PrivacyMetaклассе модели. -
Ручная регистрация через использование функции
gdpr_assist.register(<ModelClass>, [<PrivacyMetaClass>]).
После изучения документации я решил воспользоваться первым способом для анонимизации данных, но в ходе его реализация я столкнулся с проблемой: в модели не был доступен атрибут _privacy_meta. В ходе некоторых манипуляций мне так и не удалось получить доступ к данному атрибуту, поэтому я воспользовался вторым способом: использовал функцию gdpr_assist.register().
Анонимизация полей, указанных в переменной fields внутри class PrivacyMeta может происходить по умолчанию, а может быть переопределена пользовательским анонимайзером через метод класса PrivacyMeta anonymise<field_name> (для генерирования данных я использую библиотеку Faker).
Реализация локального плагина для Flake8 по контролю анонимизации данных
Изначально, я хотел написать статью только о том, как я реализовывал испытывал мучения и страдал плагин для Flake8, но после, не найдя чего-то похожего, решил рассказать все, что удалось узнать в ходе выполнения задачи.
Кто-то из вас может задаться вопрос причем тут анонимизация БД и плагин? При разработке мы часто меняем модели данных, удаляем и добавляем поля. Плагин контролирует разработку, позволяет программисту не держать в голове тонну информации, а сконцентрироваться на поставленной задаче. Разрабатываемый плагин будет учитывать изменения, вносимые в модели данных и позволит не забыть анонимизировать данные, идентифицирующие пользователя, а также подскажет как правильно это делать.
Написание плагина для flake8 у меня отняло много времени, сил и нервов, но по итогу я сделал для себя некоторые выводы, о которых поделюсь в самом конце. Теперь от лирики перейдем к делу! Мой путь начался с поиска информации в Интернете и ее изучении. Самое полезное что мне удалось найти, и что стало моей отправной точкой:
-
Видео о написании плагина на flake8 и официальная документация.
-
Первоначальная информация об абстрактном синтаксическом дереве и официальная документация модуля ast.
-
Статья 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. Создание класса плагина
Прежде чем создавать класс плагина, мы должны решить какого вида у нас плагин:
-
Плагин, проверяющий исходный код — extension.
-
Плагин, сообщающий об ошибках — report.
В нашем случае плагин проверяет исходный код на соответствие правилам анонимизации, поэтому название класса AdbExtension. Создавая класс плагина необходимо указать название (name) и версию плагина (version) а также создать два метода:
-
def init()— получает и устанавливает синтаксическое дерево. -
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. Реализация логики плагина
Основная логика плагина заключается в следующем:
-
Поиск классов, которые описывают модель
-
Поиск внутри модели класса
PrivacyMeta(который создается в соответствии с библиотекойdjango-gdpr-assist). -
Внутри класса
PrivacyMetaдолжно быть 2 переменные:fields— список полей модели для анонимизации;non_sensitive— список всех остальных полей модели. -
Для каждого элемента списка
fieldsдолжна быть прописана пользовательская функция анонимизации. -
Должна быть указана функция
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. Данный пакет предоставляет команды управления для резервного копирования и восстановления базы данных с помощью различных хранилищ, в том числе и локального.
Краткие итоги
В ходе написания статьи, а также во время переписывания плагина я выявил следующие недостатки в своей работе:
-
Плагин получился очень большим. Пока мне не удалось разбить его на более мелкие работающие сущности.
-
Плагин получился не красивым. Я переписывал плагин несколько раз, и хоть за эти попытки я его немного улучшил, все-же он сложен для понимания и выглядит мягко говоря ужасно.
-
Функционал плагина узок. Данный плагин покрывает только половину логики анонимизации БД, а именно анализ класса модели и наличия в нем класса PrivacyMeta. Необходимо до конца разобраться с автоматическим созданием атрибута _privacy_meta и возможностью выносить логику анонимизации в отдельный файл.
-
Отсутствует возможность передавать параметры в плагин. В дальнейшем я планирую разобраться как реализовать данный функционал, чтобы можно было более гибко использовать локальный плагин и настраивать его.
Кроме минусов плагина, хотелось бы описать минусы использования django-gdpr-assist:
-
Анонимизация БД применяется только к default базе данных, нельзя передавать в качестве параметров иные БД.
-
В момент анонимизации БД, пользователю задается вопрос об уверенности в процессе анонимизации. На него необходимо ответить yes или no. Первоначально я не понимал почему отправка y или n не приносили результатов.
-
Отсутствует автоматическая регистрация атрибута
_privacy_meta. Из-за своего небольшого опыта мне не удалось разобраться с данной проблемой, возможно кто-то из читающих сможет помочь решить ее.
Полученный опыт:
-
Необходимо более детально читать документацию, в ней написаны многие вещи, над которыми я думал много времени, или о которых спрашивал у своих коллег по работе.
-
Не надо стараться реализовать большой функционал сразу, а также не надо сразу внедрять дополнительные функции, если не реализованы основные. Когда я начал писать плагин, то хотелось сделать что-то универсальное, но в конечном итоге только потратил на это время.
-
Отдых помогает. Несколько раз я продвигался с мертвой точки только после того, как отдыхал, хотя до этого мог сидеть несколько часов и даже не приблизиться к решению.
ссылка на оригинал статьи https://habr.com/ru/post/654719/
Добавить комментарий