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