Справочник DBA: базы данных изнутри читается по порядку

Статья 11.6 · раздел 11 «Безопасность и соответствие» · 85 из 109

Персональные данные в PostgreSQL: маскирование копии, сохранение связей и удаление

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2), PostgreSQL Anonymizer 3.3.2, образ registry.gitlab.com/dalibo/postgresql_anonymizer:3.3.2, --memory 2g --cpus 4; 45 000 синтетических строк, таблицы с индексами 5 300 224 байта; опыт выполнен 29 октября 2026, времена — один прогон
содержание решать задачи SQL на собеседовании

Разработчику нужна копия базы, но не имена и адреса клиентов. Если заменить каждый email случайной строкой независимо, соединение клиентов с подписками сломается. Статическое маскирование меняет сохранённые значения, а одинаковая функция с одной солью сохраняет равенство ключей. Проверять нужно и скрытие личных полей, и связи, ради которых копию создавали.

Роли с минимальными правами ограничивают доступ к оригиналу. Здесь проверяется следующий шаг: подготовить отдельную копию, из которой исходные значения не попадут в выгрузку. Все данные ниже синтетические; реальный прод для опыта не нужен.

DBA / 01

В разработку уходит проверенная логическая копия

Маскирование и проверка связей перед выгрузкойИсточник с 10 тысячами клиентов и 30 тысячами заказов копируется в закрытую зону. Имена заменяются, email клиентов и подписок хешируются с одной солью. Проверка сохраняет 5000 связей по email и сумму заказов 179850.00. В разработку передаётся новый логический дамп без правил маскирования и исходных значений; исходные копии и соль остаются в закрытой зоне.01 / копия в закрытой зоне подготовки10 000 клиентов · 30 000 заказов · 5000 подписок02 / замена имени, хеширование emailодна соль для клиентов и подписок; ключи заказов сохранены03 / проверка и новый логический дампJOIN = 5000 · SUM(amount) = 179850.00 · исходных имён = 0Разработка получает только проверенный результатбез исходного дампа, соли и правил; проверка после восстановленияСохранённые id и суммы всё ещё требуют оценки риска идентификации.
Маскируется копия внутри закрытой зоны. Проверенный логический дамп отделяет её от исходных файлов и WAL; сохранённые связи не доказывают юридическое обезличивание.

Стенд и данные

В одном одноразовом контейнере будут три базы: source_demo с исходными синтетическими данными, masking для обработки и dev_demo для проверки выгрузки. Это удобство опыта; реальную исходную копию держи в отдельном защищённом контуре, недоступном разработчику.

docker run -d --name dba-mask-review --memory 2g --cpus 4 \
  -e POSTGRES_PASSWORD=pg \
  registry.gitlab.com/dalibo/postgresql_anonymizer:3.3.2
# Дождись «accepting connections»:
docker exec dba-mask-review pg_isready -U postgres
docker exec dba-mask-review psql -U postgres -c 'CREATE DATABASE source_demo'
docker exec dba-mask-review psql -U postgres -c 'CREATE DATABASE masking'

В SQL-сеансе docker exec -it dba-mask-review psql -X -U postgres -d source_demo -v ON_ERROR_STOP=1:

CREATE SCHEMA crm;
CREATE TABLE crm.customers (
  id integer PRIMARY KEY, full_name text NOT NULL,
  email text NOT NULL UNIQUE
);
CREATE TABLE crm.subscribers (email text PRIMARY KEY);
CREATE TABLE crm.orders (
  id integer PRIMARY KEY,
  customer_id integer NOT NULL REFERENCES crm.customers(id),
  amount numeric(10,2) NOT NULL
);
INSERT INTO crm.customers
SELECT i, 'Клиент ' || i, 'person-' || i || '@example.test'
FROM generate_series(1,10000) i;
INSERT INTO crm.subscribers SELECT email FROM crm.customers WHERE id % 2 = 0;
INSERT INTO crm.orders
SELECT i, 1 + (i - 1) % 10000, (i % 1000 + 100)::numeric / 100
FROM generate_series(1,30000) i;
COMMENT ON COLUMN crm.customers.full_name IS 'pd:name';
COMMENT ON COLUMN crm.customers.email IS 'pd:email';
COMMENT ON COLUMN crm.subscribers.email IS 'pd:email';
ANALYZE;

Логическая копия в базу подготовки; pipefail позволяет заметить ошибку источника, а ON_ERROR_STOP — ошибку восстановления:

set -euo pipefail
docker exec dba-mask-review pg_dump -U postgres -d source_demo \
  -n crm --no-owner --no-acl | \
  docker exec -i dba-mask-review psql -X -U postgres -d masking -v ON_ERROR_STOP=1

Дальнейшие SQL-блоки, до раздела об удалении, выполняй в docker exec -it dba-mask-review psql -X -U postgres -d masking -v ON_ERROR_STOP=1.

CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.init();
SELECT extversion FROM pg_extension WHERE extname='anon';
SELECT sum(pg_total_relation_size(c.oid)) AS table_bytes
FROM pg_class c WHERE c.relnamespace='crm'::regnamespace AND c.relkind='r';

В образе расширение уже есть; IF NOT EXISTS оставляет его на месте. В опыте версия — 3.3.2, сумма размеров таблиц и индексов — 5300224 байта. Это 45 тысяч строк, а не многогигабайтная копия; время ниже нельзя линейно переносить на прод.

Инвентаризация: каталог не знает смысл столбца

Комментарии pd:* — выбранная здесь договорённость. Каталог покажет помеченные поля, но сам не решит, что свободный текст, IP или идентификатор позволяют узнать человека:

SELECT a.attrelid::regclass AS tbl, a.attname AS col,
       col_description(a.attrelid,a.attnum) AS classification
FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid
WHERE c.relnamespace='crm'::regnamespace AND c.relkind='r'
  AND a.attnum > 0 AND NOT a.attisdropped
  AND col_description(a.attrelid,a.attnum) LIKE 'pd:%'
ORDER BY a.attrelid::regclass::text, a.attname;
вывод
       tbl       |    col    | classification
-----------------+-----------+----------------
 crm.customers   | email     | pd:email
 crm.customers   | full_name | pd:name
 crm.subscribers | email     | pd:email
(3 rows)

Новый столбец без комментария не попадёт в эту выборку. Поэтому проверяй изменения схемы и состав данных: JSON, адрес доставки, историю обращений, вложения и журналы. Для каждого поля зафиксируй назначение, правило замены и проверку результата. В этом опыте id и суммы оставлены ради связей; в реальной копии они тоже могут раскрывать человека в сочетании с другими сведениями.

Динамическая маска: оригинал остаётся

Покажи читателю константы вместо имени и email. Роль получает SELECT только на нужную таблицу:

SECURITY LABELFOR anon ON COLUMN crm.customers.full_name
  IS $$MASKED WITH VALUE 'имя скрыто'$$;
SECURITY LABEL FOR anon ON COLUMN crm.customers.email
  IS $$MASKED WITH VALUE 'hidden@example.test'$$;
CREATE ROLE masked_reader LOGIN;
GRANT USAGE ON SCHEMA crm TO masked_reader;
GRANT SELECT ON crm.customers TO masked_reader;
SECURITY LABEL FOR anon ON ROLE masked_reader IS 'MASKED';
ALTER DATABASE masking SET anon.transparent_dynamic_masking = true;

Настройка базы действует в новых соединениях. Из оболочки выполни два запроса:

docker exec dba-mask-review psql -X -U masked_reader -d masking \
  -c 'SELECT * FROM crm.customers WHERE id=1'
docker exec dba-mask-review psql -X -U postgres -d masking \
  -c 'SELECT * FROM crm.customers WHERE id=1'
вывод (masked_reader, затем postgres)
 id | full_name  |        email
----+------------+---------------------
  1 | имя скрыто | hidden@example.test
(1 row)

 id | full_name |         email
----+-----------+-----------------------
  1 | Клиент 1  | person-1@example.test
(1 row)

Динамическое маскирование действует при чтении отмеченной ролью. У администратора остаются оригиналы, а подстановка одной строки вместо каждого email не сохраняет уникальность результатов. Перед передачей самостоятельной копии нужны физически изменённые значения и новая выгрузка. Для следующего шага отзови доступ временной роли: REVOKE SELECT ON crm.customers FROM masked_reader;.

Статическая маска и одинаковые ключи

Измени правила: имя заменяется искусственной фамилией, email — полным хешем. В обеих таблицах функция и соль одинаковые. Хеши здесь хранятся как text; приложение, требующее формат email, потребует отдельного правила и проверки формата.

SECURITY LABEL FOR anon ON COLUMN crm.customers.full_name
  IS 'MASKED WITH FUNCTION anon.dummy_last_name()';
SECURITY LABEL FOR anon ON COLUMN crm.customers.email
  IS 'MASKED WITH FUNCTION anon.hash(email)';
SECURITY LABEL FOR anon ON COLUMN crm.subscribers.email
  IS 'MASKED WITH FUNCTION anon.hash(email)';
CREATE ROLE masker NOLOGIN;
ALTER TABLE crm.customers OWNER TO masker;
ALTER TABLE crm.subscribers OWNER TO masker;
ALTER TABLE crm.orders OWNER TO masker;
GRANT USAGE ON SCHEMA crm, anon TO masker;
SET anon.salt = 'demo-only-not-a-production-secret';
SET anon.algorithm = 'sha256';
SET ROLE masker;

В образе 3.3.2 anon.nosuperuser запрещает запуск маскирования суперпользователем. Поэтому выполняет его отдельный владелец таблиц masker; защиту не отключаем. SET anon.salt и переход роли выполняются в одном SQL-сеансе. Соль в примере публичная и годится только для синтетического набора; настоящую соль храни в закрытом контуре и не выдавай с копией. Правила с хешированием здесь используются для статической замены; это не проверка их работы под динамически маскируемой ролью.

В sql/hash.sql, тег 3.3.2 функция anon.hash передаёт значение, соль и алгоритм в anon.digest:

  SELECT anon.digest(
    seed,
    pg_catalog.current_setting('anon.salt'),
    pg_catalog.current_setting('anon.algorithm')
  );

Одинаковый email получает одинаковый псевдоним. Полный хеш уменьшает риск коллизий по сравнению с усечённым, но не делает идентификацию невозможной: короткое пространство исходных значений и доступ к соли меняют риск. Это псевдонимизация, а не автоматическое доказательство обезличивания. При маскировании столбцов с внешним ключом нужно отдельно спланировать согласованное обновление обеих сторон; здесь FK заказов использует неизменённый числовой id.

Сначала сними контрольные числа, затем запусти замену на копии:

SELECT (SELECT count(*) FROM crm.customers) AS customers,
       (SELECT count(*) FROM crm.orders) AS orders,
       (SELECT sum(amount) FROM crm.orders) AS total,
       (SELECT count(*) FROM crm.customers JOIN crm.subscribers USING(email)) AS email_join;
\timing on
SELECT anon.anonymize_table('crm.customers');
SELECT anon.anonymize_table('crm.subscribers');
\timing off
вывод
 customers | orders |   total   | email_join
-----------+--------+-----------+------------
     10000 |  30000 | 179850.00 |       5000
(1 row)

Timing is on.
 anonymize_table
-----------------
 t
(1 row)

Time: 142.193 ms
 anonymize_table
-----------------
 t
(1 row)

Time: 57.775 ms
Timing is off.

Это по одному прогону каждой таблицы, не сравнительный тест производительности. В src/masking/static_masking.rs, тег 3.3.2 собирается SQL с обычным UPDATE:

            "
            SET CONSTRAINTS ALL DEFERRED;
            SET row_security TO 'off';
            UPDATE {tablename}
            SET {masking_assignments}
            {where_clause};
            RESET row_security;
            ANALYZE {tablename};
        "
        )

Обновление затрагивает строки и индексы, создаёт WAL и старые версии строк. Передача каталога данных или физической копии после такого UPDATE может сохранить исходные значения. Для разработки ниже используется новый логический дамп. Повторный запуск хеширования изменит уже изменённые значения; при ошибке начни со свежей закрытой копии, не выдавай частично обработанную базу.

Проверка до выгрузки и после восстановления

Повтори запрос четырёх контрольных чисел: результат остался 10000 | 30000 | 179850.00 | 5000. Проверка личных полей:

SELECT count(*) FILTER (WHERE full_name LIKE 'Клиент %') AS original_names,
       count(*) FILTER (WHERE email LIKE 'person-%@example.test') AS original_emails,
       count(DISTINCT email) AS unique_emails
FROM crm.customers;
вывод
 original_names | original_emails | unique_emails
----------------+-----------------+---------------
              0 |               0 |         10000
(1 row)

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

После RESET ROLE; экспортируй только прикладную схему, без исходных правил маскирования, комментариев и владельцев:

set -euo pipefail
docker exec dba-mask-review psql -U postgres -c 'CREATE DATABASE dev_demo'
docker exec dba-mask-review pg_dump -U postgres -d masking -n crm \
  --no-owner --no-privileges --no-security-labels --no-comments | \
  docker exec -i dba-mask-review psql -X -U postgres -d dev_demo -v ON_ERROR_STOP=1

Правила не нужны получателю уже преобразованных данных; расширение и его служебные схемы не входят в -n crm. Параметры базы с солью тоже не переносятся этим дампом. В восстановленной dev_demo повторная проверка дала 10 000 клиентов, 0 исходных имён/email, 5000 связей по email и сумму 179 850,00. Проверки, прерывающие выдачу копии при несоответствии:

DO $$ BEGIN
  ASSERT (SELECT count(*) FROM crm.customers) = 10000;
  ASSERT (SELECT count(*) FROM crm.customers JOIN crm.subscribers USING(email)) = 5000;
  ASSERT (SELECT count(*) FROM crm.orders JOIN crm.customers ON customer_id = crm.customers.id) = 30000;
  ASSERT (SELECT sum(amount) FROM crm.orders) = 179850.00;
  ASSERT NOT EXISTS (SELECT FROM crm.customers
    WHERE full_name LIKE 'Клиент %' OR email LIKE 'person-%@example.test');
END $$;

Запусти этот блок через psql -v ON_ERROR_STOP=1 именно в dev_demo; plpgsql.check_asserts должен оставаться включённым (значение по умолчанию). Выгрузка неполного набора схем требует проверки зависимостей: здесь все прикладные таблицы находятся в crm.

Удаление по человеку и по сроку — разные операции

Секционирование позволяет удалять целый временной диапазон. Для запроса конкретного человека такой диапазон не подходит: его строки могут быть в нескольких секциях. А deleted_at только помечает запись, сохраняя её поля.

В dev_demo создай маленький журнал событий:

CREATE TABLE crm.events (customer_id integer, happened_at date NOT NULL)
PARTITION BY RANGE (happened_at);
CREATE TABLE crm.events_sep PARTITION OF crm.events
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE crm.events_oct PARTITION OF crm.events
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
INSERT INTO crm.events VALUES (1,'2026-09-15'), (1,'2026-10-15'), (2,'2026-10-16');
ALTER TABLE crm.events DETACH PARTITION crm.events_sep;
SELECT count(*) AS parent_rows FROM crm.events;
SELECT count(*) AS detached_rows FROM crm.events_sep;
вывод
 parent_rows
-------------
           2
(1 row)

 detached_rows
---------------
             1
(1 row)

После DETACH старая секция существует как отдельная таблица. Если согласованный срок хранения истёк, DROP TABLE crm.events_sep; удаляет её как объект. Для удаления клиента 1 требуется другой набор действий:

BEGIN;
DELETE FROM crm.events WHERE customer_id=1;
DELETE FROM crm.orders WHERE customer_id=1;
DELETE FROM crm.subscribers s USING crm.customers c
  WHERE c.id=1 AND s.email=c.email;
DELETE FROM crm.customers WHERE id=1;
COMMIT;
SELECT count(*) AS subject_events FROM crm.events WHERE customer_id=1;

В опыте удалены 1 событие, 3 заказа, 0 подписок (клиент 1 не подписан) и 1 клиент; осталось 0 его событий в родительской таблице. Это проверка SQL, а не подтверждение уничтожения всех копий. Отдельно учитывай бэкапы, WAL, реплики, выгрузки, журналы и правила повторного применения удаления после восстановления. Сохраняемые по другому основанию документы требуют отдельного решения; универсальный DELETE всей истории не заменяет политику хранения.

Что сверить с 152-ФЗ

Проверено 29.10.2026 по тексту на сайте Минтруда, редакция от 24.06.2025: ст. 18 ч. 5 ограничивает использование зарубежных баз при сборе данных граждан РФ, с указанными в законе исключениями; ст. 5 ч. 7 связывает хранение с целями обработки; ст. 21 различает основания прекращения обработки и уничтожения. У них разные условия и сроки — единого таймера «удалить всех через N дней» нет.

Применимость редакции, основание, сроки и достаточность обезличивания подтверждают юрист и ответственный за ПДн. Замена email не выводит копию из-под требований автоматически и не разрешает переносить её в любой регион. Проверенный здесь механизм — замена значений с контролем связей.

Чек-лист

  1. Найди личные поля и связанные копии; каталог и поиск по имени столбца дают кандидатов, полноту подтверждает владелец данных.
  2. Маскируй в закрытой зоне; динамическая маска не убирает оригинал из файлов базы.
  3. Проверь JOIN, уникальность, FK и суммы, затем отсутствие исходных значений; оцени сохранённые косвенные идентификаторы.
  4. Передай только новый логический дамп без исходной копии и соли; повтори проверки после восстановления.
  5. Раздели удаление человека, очистку по сроку и обработку бэкапов; DETACH и deleted_at не удаляют данные.

Удалить учебный стенд вместе с томом: docker rm -f -v dba-mask-review.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru