Разработчику нужна копия базы, но не имена и адреса клиентов. Если заменить каждый email случайной строкой независимо, соединение клиентов с подписками сломается. Статическое маскирование меняет сохранённые значения, а одинаковая функция с одной солью сохраняет равенство ключей. Проверять нужно и скрытие личных полей, и связи, ради которых копию создавали.
Роли с минимальными правами ограничивают доступ к оригиналу. Здесь проверяется следующий шаг: подготовить отдельную копию, из которой исходные значения не попадут в выгрузку. Все данные ниже синтетические; реальный прод для опыта не нужен.
В разработку уходит проверенная логическая копия
Стенд и данные
В одном одноразовом контейнере будут три базы: 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 не выводит копию из-под требований автоматически и не разрешает переносить её в любой регион. Проверенный здесь механизм — замена значений с контролем связей.
Чек-лист
- Найди личные поля и связанные копии; каталог и поиск по имени столбца дают кандидатов, полноту подтверждает владелец данных.
- Маскируй в закрытой зоне; динамическая маска не убирает оригинал из файлов базы.
- Проверь JOIN, уникальность, FK и суммы, затем отсутствие исходных значений; оцени сохранённые косвенные идентификаторы.
- Передай только новый логический дамп без исходной копии и соли; повтори проверки после восстановления.
- Раздели удаление человека, очистку по сроку и обработку бэкапов;
DETACHиdeleted_atне удаляют данные.
Удалить учебный стенд вместе с томом: docker rm -f -v dba-mask-review.