Тема
ТЗ — Согласия/152-ФЗ (cms/consents)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Расширение базового согласия ядра (consent_at на лиде): реестр версионируемых текстов согласий, полный журнал фактов согласия, экспорт данных субъекта и обработка запроса на удаление — для соответствия 152-ФЗ.
- Реестр текстов согласий с версиями (обработка ПДн, рассылка, cookie — раздельно);
- журнал факта согласия: кто, когда, какая версия текста, откуда (форма/страница/IP);
- доказуемость согласия: журнал хранит не только факт, но и точную версию текста, показанную субъекту в момент согласия (не текущую редакцию текста);
- отзыв согласия пользователем из кабинета/по ссылке в письме, с каскадом на зависимые рассылки/обработку;
- согласие несовершеннолетнего — отдельный флаг возрастного подтверждения на форме, где применимо (законный представитель — вне зоны ответственности модуля, фиксируется как ограничение);
- экспорт всех данных субъекта по запросу (машиночитаемый файл, привязанные сущности);
- запрос на удаление — обезличивание (не hard delete, если данные нужны для истории заказов);
- отчёт для проверок: список согласий за период, версии текстов, статистика отзывов.
Зависимости и выключение
requires: ядро (лиды, consent_at) · suggests: cms/cabinet-b2c, cms/notifications-bus (уведомление об отзыве/удалении)
Поведение при выключении: остаётся базовое согласие ядра (чекбокс + consent_at на лиде без версионирования и журнала); расширенные функции (экспорт, обезличивание, отчёт) недоступны до включения модуля. Персистентный роут revoke-link (см. «API») переживает выключение зависимого cms/notifications-bus, но не выключение самого модуля consents — при uninstall деградирует так же, как остальной модуль.
Стоимость внешних API — не применимо: модуль не вызывает платных внешних сервисов.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_consent_texts | id, type, version, body, published_at | версии текстов согласий по типу |
cms_consent_records | id, subject_type, subject_id, consent_text_id, ip, source, given_at, revoked_at, minor_flag, external_id | журнал фактов согласия, полиморфная привязка |
cms_data_removal_requests | id, subject_type, subject_id, status, processed_at | запросы на удаление/обезличивание |
cms_consent_records — append-only (факт согласия не редактируется, отзыв — отдельная запись revoked_at), BRIN-индекс по given_at; FK consent_text_idconstrained()->index(); индекс на (subject_type, subject_id); external_id nullable + unique — ключ идемпотентности легаси-импорта (см. «Донорский код»). type в cms_consent_texts и status в cms_data_removal_requests — PHP Enum.
published_at в cms_consent_texts — дата вступления версии текста в силу, не дата создания черновика: версия может быть создана заранее и опубликована позже (отложенная публикация), запись факта согласия всегда ссылается на версию, у которой published_at уже наступил на момент согласия — черновик без published_at не может быть выдан пользователю и не участвует в резолве «актуальный текст».
ПДн-паспорт (§4 стандарта). Модуль хранит ПДн в cms_consent_records (ip, косвенно — факт согласия конкретного субъекта) и регистрирует свои обработчики «выгрузить всё по субъекту» / «забыть по запросу» в контракте PrivacyRegistry (cms/core-contracts, ревизия 14.07.2026, п.15) — только для своих таблиц (cms_consent_records, cms_data_removal_requests); также предоставляет админ-UI приёма и обработки запросов субъекта. Агрегация обработчиков всех модулей с ПДн-паспортом — команды ядра cms:privacy:export/forget --user= --dry-run, не сам модуль consents:
- «выгрузить всё по субъекту» —
GET /admin/export/{subjectId}(см. «API»); - «забыть по запросу» —
cms_data_removal_requests+ джобаprocess-removals(см. «Фоновая работа», «События и обмен» —UserDeleted); - срок хранения журнала регулируется
consents.record_retention_years(152-ФЗ не требует вечного хранения — только на срок, необходимый для цели обработки); по истечении срока запись обезличивается той же процедурой, что и ручной запрос на удаление, без отдельного пути кода.
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Форма ядра с чекбоксом согласия | consent_type[], accepted (bool), minor_flag (опц.) | FormRequest ядра: обязательные типы из required_types должны быть отмечены; accepted=false для обязательного типа → 422 |
POST /revoke | type (опционально — конкретный тип согласия) | принадлежность субъекта текущему аутентифицированному пользователю; rate-limit |
POST /admin/removal-request | subject_type, subject_id, reason | FormRequest + Policy consents.manage |
GET /admin/export/{subjectId} | subjectId | Policy consents.manage; выборка строго по subject_id, без побочных связей |
CookieConsentGiven (событие cms/cookie-consent) | categories[], subject_id (nullable) | принимается только при наличии subject_id (авторизованный пользователь) — анонимные согласия по cookie в журнал не пишутся |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
GET /consents/texts | актуальные тексты по типу | JSON, публичный |
| Filament (журнал, реестр текстов, очередь удаления) | таблицы | список |
cms:consents:process-removals --json | результат обезличивания | JSON |
события ConsentGiven/ConsentRevoked/DataRemovalRequested | см. «События и обмен» | payload события |
| Экспорт субъекта | данные субъекта + привязанные сущности | архив (JSON/CSV, машиночитаемый) |
Настройки (группа consents)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
consents.required_types | array | ["pdn"] | нет | Обязательные типы согласий на формах |
consents.removal_grace_days | int | 14 | нет | Отсрочка перед фактическим обезличиванием |
consents.record_retention_years | int | 3 | нет | Срок хранения журнала согласий после отзыва/утраты актуальности |
consents.minor_age_threshold | int | 18 | нет | Возраст, ниже которого согласие помечается как согласие несовершеннолетнего (если форма собирает возраст) |
consents.revoke_cascades_notifications | bool | true | нет | Отзыв согласия типа «рассылка» автоматически останавливает активные подписки через cms/notifications-bus |
consents.rate_limit_per_hour | int | 10 | нет | Лимит запросов revoke/removal-request/export на субъекта в час; достижение — 429 с понятным сообщением, не тихая блокировка |
consents.auto_removal_enabled | bool | true | нет | Kill-switch: false аварийно приостанавливает джобу process-removals (автообезличивание по расписанию) без выключения модуля целиком — на случай бага в логике обезличивания; заявки остаются pending, ручной POST /admin/removal-request продолжает создавать записи |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/consents/texts | public | Актуальные тексты согласий по типу |
| POST | /api/v1/consents/revoke | auth | Отзыв согласия текущим пользователем (требует активной сессии) |
| GET/POST | /api/v1/consents/revoke-link/{signature} | public, подписанный URL (persistent) | Отзыв согласия по одной ссылке из письма без активной сессии (One-Click Unsubscribe, RFC 8058-подобный) — см. «Безопасность» |
| POST | /api/v1/admin/consents/removal-request | admin (consents.manage) | Обработка запроса на удаление |
| GET | /api/v1/admin/consents/export/{subjectId} | admin (consents.manage) | Экспорт данных субъекта («выгрузить всё по субъекту», §4 стандарта) |
| GET | /api/v1/admin/consents/report?from=&to= | admin (consents.manage) | Отчёт для проверки РКН за период (см. «Компоненты») |
revoke-link помечен persistent в манифесте (ревизия ядра 14.07.2026, п.14): роут обязан отвечать даже при выключенном cms/notifications-bus — новое письмо со ссылкой прислать нельзя, но уже разосланная ссылка продолжает работать. Отличие от POST /revoke: не требует активной сессии, субъект резолвится из подписи URL, не из RequestContext.
Компоненты
Filament: реестр текстов согласий с версиями (с датой вступления в силу published_at), журнал согласий с фильтром по субъекту, очередь запросов на удаление, отчёт для проверки РКН за период. Команды: cms:consents:process-removals --json (отложенное обезличивание по истечении grace-периода), cms:consents:import-legacy --source=<профиль> (см. «Донорский код»).
Отчёт для проверки РКН (GET /api/v1/admin/consents/report, массовое действие «экспорт согласий за период» в Filament) — фиксированный формат по периоду:
| Поле | Смысл |
|---|---|
consent_type | тип согласия (обработка ПДн / рассылка / cookie) |
text_version | версия текста, действовавшая на записи |
effective_from | дата вступления версии текста в силу (published_at) |
active_count | количество действующих согласий на конец периода |
revoked_count | количество отзывов за период |
Формат — CSV/JSON, скачиваемый из Filament и доступный тем же контрактом через API (единая точка правды для ручной выгрузки и программного запроса аудитора).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ConsentGiven | зафиксировано согласие | subject_type, subject_id, consent_text_id |
ConsentRevoked | согласие отозвано | subject_type, subject_id, type |
DataRemovalRequested | подан запрос на удаление | subject_type, subject_id |
Слушает (ревизия ядра 14.07.2026):
CookieConsentGiven(cms/cookie-consent) — пишется вcms_consent_recordsпри включённом модуле, только при наличииsubject_id;UserDeleted(ядро, п.3) — канонический триггер каскада «забыть»: модуль сам создаёт и сразу обрабатываетcms_data_removal_requestsпоuser_id, без ручного обращения черезPOST /admin/removal-request— это основной путь обезличивания, ручной эндпоинт остаётся для удаления ПДн без удаления аккаунта;UserMerged(primary, secondary)(ядро, п.13) — переноситcms_consent_recordsсsubject_id=secondaryнаprimary, сохраняя все факты; при активном согласии одного типа у обоих субъектов дубликат не создаётся — остаётся запись с более раннимgiven_at(см. «Крайние случаи»).
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро — лиды (consent_at) | сервис-вызов (requires) | in | базовое согласие ядра расширяется версионированным текстом при включённом модуле |
cms/cookie-consent | событие CookieConsentGiven | in | выбор авторизованного пользователя дублируется в журнал согласий модуля |
| Ядро — пользователи | событие UserDeleted | in | автоматический каскад «забыть» — создание/обработка cms_data_removal_requests без ручного запроса |
| Ядро — пользователи | событие UserMerged(primary, secondary) | in | перенос cms_consent_records со вторичного субъекта на первичного без дублей активных согласий |
cms/notifications-bus (suggests) | сервис-вызов + событие ConsentRevoked | out | отзыв согласия типа «рассылка» останавливает активные подписки, если revoke_cascades_notifications=true |
cms/cabinet-b2c (suggests) | REST /api/v1/consents/* | in/out | кабинет — внешний потребитель, отзыв/просмотр согласий доступны пользователю из личного кабинета |
| Модули-держатели ПДн субъекта (любой, косвенно) | событие DataRemovalRequested | out | владелец данных сам решает, как обезличить свои таблицы — модуль consents не пишет в чужие таблицы напрямую |
Фоновая работа
Именованная очередь consents для cms:consents:process-removals (отложенное обезличивание после removal_grace_days и по каскаду UserDeleted); джоба идемпотентна — повторный прогон не повторяет обезличивание уже обработанных субъектов. Джоба не запускается, если consents.auto_removal_enabled=false (kill-switch, см. «Настройки») — заявки остаются pending, ручная обработка через Filament/POST /admin/removal-request продолжает работать.
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда/действие |
|---|---|---|
Запросы на удаление зависли в pending | отставание очереди consents, флаг auto_removal_enabled | проверить очередь в Pulse/health; если kill-switch выключен — включить; вручную прогнать cms:consents:process-removals --json |
| Форма отклоняет все отправки с ошибкой согласия | текст согласия не опубликован для нужного типа (и site_id/locale, если включены измерения) | создать/опубликовать версию текста в реестре (Filament → «тексты согласий»), проверить published_at не в будущем |
| Жалоба РКН / плановый аудит | период, за который нужен отчёт | GET /api/v1/admin/consents/report?from=&to= или массовое действие «экспорт за период» в Filament |
Производительность и кеш
- Ожидаемый объём: журнал согласий растёт линейно с числом лидов/регистраций — append-only, тысячи–десятки тысяч записей в год на средний сайт;
- горячий путь — отправка формы с чекбоксом согласия: бюджет ≤2 запроса (чтение актуального текста согласия из кеша группы
consents:texts, вставка записи журнала); - индекс
(subject_type, subject_id)критичен для экспорта/отзыва/обезличивания конкретного субъекта; BRIN поgiven_at— под отчёты за период (проверки); - кешируется:
consents:texts— актуальные версии текстов, инвалидируется при публикации новой версии (afterSave()в Filament); - экспорт и обезличивание — не горячий путь (редкие операции по запросу субъекта), бюджет запросов не нормируется так же строго, но выполняются в очереди, не в запросе, если объём связанных данных велик.
Безопасность
Вход в revoke/removal-request/export — через FormRequest-whitelist и проверку принадлежности субъекта текущему пользователю (или права consents.manage для админ-эндпоинтов). Экспорт не должен утекать чужие данные через связи — выборка строго по subject_id. Rate-limit consents.rate_limit_per_hour на revoke/removal-request/export — защита от злоупотребления функцией, 429 при достижении. Векторы, специфичные для модуля:
- доказуемость согласия при споре/проверке — журнал хранит
consent_text_id(конкретную версию), не ссылку на «текущий текст»; удаление/правка текста версии запрещена (append-only реестр версий, новая редакция — новая версия); - подмена субъекта в экспорте/отзыве —
subject_idберётся изRequestContextаутентифицированного пользователя, не из тела запроса, для self-service эндпоинтов; админ-эндпоинты — подconsents.manageс аудитом действия; - утечка через связи при экспорте — выборка данных субъекта не эксплуатирует
->with()на сущности других пользователей; тест на «экспорт не содержит чужих данных» обязателен; - согласие несовершеннолетнего —
minor_flagфиксируется, но модуль не проверяет возраст криптографически (нет верифицированного ID) — это отражается вdocs/module.mdкак ограничение, а не выдаётся за полную проверку; revoke-linkбез сессии — подпись URL проверяется на срок действия и однократное/многократное использование по политике модуля; подделка подписи не должна давать доступ к чужому согласию — субъект резолвится только из валидной подписи, не из query-параметра.
Матрица ролей:
| Действие | Субъект (self-service) | Редактор | Менеджер (consents.manage) | Админ | studio |
|---|---|---|---|---|---|
| Просмотр своих согласий, отзыв своих | ✅ | — | — | — | — |
Отзыв по revoke-link (без сессии, по подписи) | ✅ | — | — | — | — |
| Просмотр журнала согласий (чужих субъектов) | — | — | ✅ | ✅ | ✅ |
| Реестр текстов согласий: создание версии | — | — | ✅ | ✅ | ✅ |
Обработка запроса на удаление (removal-request) | — | — | ✅ | ✅ | ✅ |
| Экспорт данных субъекта | — | — | ✅ | ✅ | ✅ |
| Отчёт для проверки РКН | — | — | ✅ | ✅ | ✅ |
| Ручной запуск обезличивания (минуя расписание) | — | — | — | ✅ | ✅ |
Kill-switch auto_removal_enabled | — | — | — | — | ✅ |
Запуск import-legacy | — | — | — | — | ✅ |
Права: consents.view, consents.manage. У self-service действий (просмотр/отзыв своих согласий, revoke-link) отдельного permission нет — доступ определяется принадлежностью субъекта аутентифицированному пользователю (или валидной подписью ссылки), не ролью.
UX-требования
Админ:
- пустой журнал согласий — «согласий пока не зафиксировано»;
- массовое действие — экспорт согласий за период (для отчёта проверяющему органу);
- ошибка «текст согласия не опубликован для этого типа» — понятная подсказка «создайте версию текста в реестре», а не молчаливый пропуск проверки;
- подтверждение необратимого — обезличивание субъекта по истечении grace-периода требует явного подтверждения при ручном запуске (не только по расписанию);
- очередь запросов на удаление показывает
removal_grace_daysдо фактической обработки — админ видит, сколько времени осталось.
Посетитель/пользователь:
- отзыв согласия доступен самостоятельно из кабинета/по ссылке в письме, без обращения в поддержку;
- форма с несколькими типами согласий — сохраняет уже отмеченные чекбоксы при ошибке валидации остальных полей (не сброс формы целиком);
- запрос на экспорт/удаление — подтверждение «запрос принят, обработка займёт до N дней», не мгновенная иллюзия исполнения.
Крайние случаи и типовые баги
- доказуемость согласия: текст изменился после согласия → журнал хранит
consent_text_idконкретной версии, показ «какой текст видел пользователь» всегда берёт эту версию, не текущую редакцию — контрактный тест на неизменность связки; - отзыв согласия → каскад →
revoke_cascades_notifications=trueостанавливает активные рассылки черезcms/notifications-bus; приfalse— отзыв фиксируется в журнале, но каскад не выполняется автоматически (осознанная настройка, не баг); - согласие несовершеннолетнего →
minor_flagфиксируется в записи журнала при наличии данных о возрасте на форме; модуль не блокирует форму сам — блокировка/ особый режим для несовершеннолетних вне зоны ответственности модуля (документируется как ограничение); - гонка: параллельные запросы согласия и отзыва от одного субъекта → append-only журнал не рвётся конкурентным доступом — каждый факт (
given_at/revoked_at) своя запись; актуальное состояние читается как «последняя запись безrevoked_at», не через мутацию одной строки; - двойной сабмит запроса на удаление → идемпотентность по
(subject_type, subject_id, status=pending): повторный запрос не создаёт вторую запись обработки, обновляет существующую или отклоняется как дубликат; - выключение модуля посреди отложенного обезличивания → джоба
process-removalsне запускается (расписание модуля выключено вместе с ним), запросы остаютсяpendingдо включения обратно — не теряются и не обрабатываются наполовину; - отсутствие
cms/notifications-bus(suggests не установлен) → отзыв согласия фиксируется в журнале, каскад на рассылки просто не выполняется — деградация, не ошибка; - пустой субъект / огромный объём связанных данных при экспорте → пустой субъект (нет данных) — экспорт возвращает пустой, но валидный файл, не 404/500; огромный объём — экспорт уходит в очередь с уведомлением по готовности, не синхронный ответ API с таймаутом;
- обезличивание ломает историю заказов → ⚠️ Противоречие: полное удаление строк нарушало бы ссылочную целостность и финансовую историю (append-only §4 стандарта). Разрешение: обезличивание заменяет ПДн-поля субъекта (email, имя, телефон) на плейсхолдеры, сохраняя сами строки и связи — реализовано как отдельная процедура на уровне модуля-держателя данных,
consentsтолько инициирует запрос событием; - измерение site/locale отсутствует → тексты согласий версионируются по типу, не обязательно по сайту/локали в минимальной конфигурации — при мультисайте/мультиязычии
cms_consent_textsтребуетsite_id/localeкак nullable-измерения (контрактный тест в обоих режимах, как и остальные контентные сущности ядра); - удаление аккаунта без ручного запроса →
UserDeletedинициирует каскад «забыть» автоматически (создание/обработкаcms_data_removal_requestsпоuser_id) — субъект не обязан вызыватьremoval-request; ручной эндпоинт остаётся для случая «удалить ПДн, но не удалять аккаунт»; - слияние аккаунтов: конфликт двух активных согласий одного типа →
UserMergedпереносит записиsecondary → primary; если активное согласие одного типа было у обоих, дубликат не создаётся — остаётся запись с более раннимgiven_at, вторая помечается поглощённой (не отозванной — отзыва не было); контрактный тест на отсутствие дублей после merge; revoke-linkпри выключенномcms/notifications-bus→ роутpersistent(ревизия ядра 14.07.2026, п.14) обязан отвечать: новую ссылку прислать нельзя, но разосланная ранее продолжает отзывать согласие — деградация только отправки, не действия ссылки;- легаси-импорт: донор не хранит версию текста → создаётся синтетическая версия в
cms_consent_textsс пометкой «legacy-импорт, точный текст неизвестен» — реальная историческая версия недоступна и не выдаётся за реконструированную (ограничение источника, не баг обработки).
Донорский код
Донор: — (новая разработка). Для клиентов, переезжающих со старых сайтов, — легаси-импорт: cms:consents:import-legacy --source=<профиль> [--dry-run] — переносит согласия с донорских сайтов (обычно нетипизированный чекбокс формы без версионирования текста) в cms_consent_records.
- Маппинг: донор хранит, как правило, только факт согласия и дату (иногда IP), без версии текста. Импортёр создаёт на профиль-источник одну синтетическую версию в
cms_consent_texts(body= плейсхолдер «legacy-импорт, точный текст на момент согласия неизвестен»,published_at= дата самой ранней перенесённой записи) и привязывает к ней все факты источника — документированное ограничение, а не попытка восстановить исходный текст; - Идемпотентность: ключ
external_id(уникален в рамках источника) — повторный прогон обновляет запись, не дублирует; --dry-run: отчёт расхождений без записи в БД — создано/обновлено/пропущено и почему (пропуск без причины запрещён);- прогон на копии данных донора — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: форма без отметки обязательного типа согласия не проходит валидацию;
- [ ] Журнал согласия фиксирует версию текста, действовавшую на момент согласия (не текущую);
- [ ] Обезличивание по запросу не ломает целостность истории заказов (замена ПДн-полей, не удаление строк);
- [ ] Экспорт данных субъекта не содержит чужих данных (утечка через связи);
- [ ] Отзыв согласия доступен без обращения в поддержку (самостоятельно из кабинета);
- [ ] Rate-limit на запросы удаления/экспорта (защита от злоупотребления функцией);
- [ ] Отзыв согласия типа «рассылка» останавливает активные подписки при
revoke_cascades_notifications=true; - [ ] Повторный запрос на удаление не создаёт дубликат pending-заявки;
- [ ] Экспорт пустого субъекта возвращает валидный пустой файл, не ошибку;
- [ ] Выключение модуля не запускает и не бросает на середине отложенное обезличивание;
- [ ]
UserDeletedинициирует каскад обезличивания автоматически, без ручного запроса черезremoval-request; - [ ]
UserMerged(primary, secondary)переносит согласия с secondary на primary без дублей активных согласий одного типа; - [ ]
revoke-link(persistent-роут) отзывает согласие по подписанной ссылке без активной сессии, в том числе при выключенномcms/notifications-bus; - [ ]
import-legacyидемпотентен (повтор поexternal_idне дублирует), создаёт синтетическую версию текста с явной меткой «legacy-импорт»,--dry-runотдаёт отчёт расхождений без записи в БД; - [ ] Kill-switch
consents.auto_removal_enabled=falseне запускает автообезличивание по расписанию, ручная обработка заявок остаётся доступной; - [ ] Отчёт для проверки РКН за период отдаёт все поля (тип, версия текста, дата вступления в силу, действующие/отозванные) и совпадает между Filament-выгрузкой и API;
- [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
consents_test;migrate:fresh/refresh/reset,db:wipeзапрещены.