# Маскирование ПДн (pg_anonymizer) — правила SECURITY LABEL для боевого liderra.ru

## Цель

На боевом `liderra.ru` расширение `anon` 3.0.13 установлено (22.05.2026), но не задано
**ни одного** правила маскирования (`anon.pg_masking_rules` → 0 строк). Без правил
обычный `pg_dump` выгружает персональные данные в открытом виде. Нужно декларативно
навесить `SECURITY LABEL` на все колонки-носители ПДн так, чтобы маскированный дамп
отдавал замаскированные значения, **не затрагивая работу портала, RLS и FK-целостность**.
На dev-среде (native Windows PostgreSQL) расширение `anon` физически не устанавливается
— цель ориентирована на прод; применение на прод оформляется планом, без самостоятельного
запуска.

## ПДн-колонки и функции маскирования {#D1}

Контракт «колонка → функция маскирования» (источник перечня — фактические определения
таблиц в `db/schema.sql`):

| Таблица | Колонка | Тип | Маска | Почему так |
|---|---|---|---|---|
| `users` | `email` | VARCHAR(255) | `anon.partial_email(email)` | детерминированная — сохраняет `UNIQUE(tenant_id,email)` при restore |
| `users` | `phone` | VARCHAR(20) | `anon.partial(phone,2,'******',2)` | форма `+7******67`, детерминир. |
| `users` | `first_name` / `last_name` | VARCHAR | `anon.fake_first_name()` / `anon.fake_last_name()` | поля `fio` в схеме нет — ФИО разнесено |
| `users` | `totp_secret` | TEXT | `VALUE NULL` | уже шифрован, в дампе не нужен |
| `users` | `avatar_path` | VARCHAR(500) | `VALUE NULL` | путь к файлу пользователя |
| `deals` | `phone` | VARCHAR(20) | `anon.partial(phone,2,'******',2)` | метка на родителе наследуется партициями |
| `deals` | `phones` | JSONB | `VALUE '[]'::jsonb` | доп. телефоны |
| `deals` | `contact_name` | VARCHAR(255) | `anon.fake_last_name()` | |
| `deals` | `comment` | TEXT | `VALUE NULL` | свободный текст, может содержать ПДн |
| `supplier_leads` | `phone` | VARCHAR(20) | `anon.partial(phone,2,'******',2)` | |
| `supplier_leads` | `raw_payload` | JSONB (NOT NULL) | `VALUE '{}'::jsonb` | сырой лид целиком — крупный носитель ПДн; не NULL из-за NOT NULL |
| `pd_subject_requests` | `subject_email` | VARCHAR(255) | `anon.partial_email(subject_email)` | |
| `pd_subject_requests` | `subject_phone` | VARCHAR(20) | `anon.partial(subject_phone,2,'******',2)` | |
| `pd_subject_requests` | `subject_full_name` | VARCHAR(255) | `anon.fake_last_name()` | |
| `pd_subject_requests` | `description` / `response_text` | TEXT | `VALUE NULL` | |
| `pd_processing_log` | `ip_address` | INET | `VALUE '0.0.0.0'::inet` | единственный прямой ПДн в таблице (остальное — ссылки) |
| `auth_log` | `email` | VARCHAR(255) | `anon.partial_email(email)` | |
| `tenants` | `contact_email` | VARCHAR(255) | `anon.partial_email(contact_email)` | |

Edge-cases: `anon.partial` на телефоне короче 4 символов возвращает значение
безопасно; JSONB с `NOT NULL` (raw_payload) нельзя маскировать в NULL — только редакция
`'{}'::jsonb`. Колонки-ключи (`id`, `tenant_id`, ключи партиций `received_at`/`created_at`,
любые FK) **не маскируются**.

## Безопасность применения на прод {#D2}

Конвенция, гарантирующая отсутствие влияния на боевой портал:

- `SECURITY LABEL ON COLUMN` активирует маскирование **только** для ролей, помеченных
  `MASKED`. Роли приложения (`crm_app_user`, `crm_app_admin`, `crm_supplier_worker`,
  `crm_readonly`, `crm_migrator`) видят реальные данные без изменений.
- Реальные данные в таблицах **не переписываются**: это динамическое маскирование, а не
  статическое `anon.anonymize_database()` — последнее на проде НЕ запускается.
- RLS и маскирование — **независимые** механизмы: tenant-изоляция работает по `tenant_id`
  и ролям, маскирование — по метке роли; они не пересекаются. Колонки, участвующие в RLS
  (`tenant_id`) и FK, не маскируются → ни изоляция, ни целостность не нарушаются.
- Применение идемпотентно: повторный `SECURITY LABEL` на ту же колонку перезаписывает
  прежний; файл можно прогонять повторно.

Критерий: после применения `SELECT count(*) FROM anon.pg_masking_rules` > 0 и каждая
колонка из {#D1} присутствует; обычное чтение под `crm_app_user` возвращает реальные
данные (проверка невлияния).

## Маскированный дамп {#D3}

Рецепт получения дампа с замаскированными ПДн (anon 3.x, динамическое маскирование):

1. Выделенная роль для дампа с `BYPASSRLS` (чтобы выгрузить все тенанты) и меткой `MASKED`:
   `CREATE ROLE anon_dumper LOGIN PASSWORD '<secret>' BYPASSRLS;`
   `GRANT pg_read_all_data TO anon_dumper;`
   `SECURITY LABEL FOR anon ON ROLE anon_dumper IS 'MASKED';`
2. Дамп под этой ролью с включённым прозрачным маскированием:
   `PGOPTIONS="-c anon.transparent_dynamic_masking=on" pg_dump -U anon_dumper -d liderra -Fc > liderra-masked.dump`
   (либо обёртка `pg_dump_anon`, если присутствует в сборке).

Критерий приёмки: в полученном дампе значения `deals.phone` имеют вид `+7******XX`,
`users.email` — частично замаскированы, `raw_payload` = `{}`; реальных телефонов/почт нет.

## dev vs прод — фактическое состояние {#D4}

- **dev (native Windows PostgreSQL 16):** `anon` и `pgaudit` отсутствуют (`pg_extension`
  → 0 строк). Расширение `anon` 3.x — Rust/pgrx, готовой Windows-сборки нет; Docker/WSL на
  этой машине невозможны (нет вложенной виртуализации). dev держит только демо-данные —
  реальных ПДн там нет, поэтому отсутствие маскирования на dev не является утечкой.
- **прод (Ubuntu, PostgreSQL 16.14):** `anon` 3.0.13 + `pgaudit` 16.0 установлены; правил
  маскирования 0 — это закрываемая дыра. Прежний аудит, отметивший «anon не установлен»,
  опрашивал локальную копию, а не прод — расхождение фиксируется в setup-доке.

```verified-context-json
[
  {"id":"vc1","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"CREATE TABLE supplier_leads ("}
]
```
