Skip to content

ТЗ — Согласия/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_textsid, type, version, body, published_atверсии текстов согласий по типу
cms_consent_recordsid, subject_type, subject_id, consent_text_id, ip, source, given_at, revoked_at, minor_flag, external_idжурнал фактов согласия, полиморфная привязка
cms_data_removal_requestsid, 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 /revoketype (опционально — конкретный тип согласия)принадлежность субъекта текущему аутентифицированному пользователю; rate-limit
POST /admin/removal-requestsubject_type, subject_id, reasonFormRequest + Policy consents.manage
GET /admin/export/{subjectId}subjectIdPolicy 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_typesarray["pdn"]нетОбязательные типы согласий на формах
consents.removal_grace_daysint14нетОтсрочка перед фактическим обезличиванием
consents.record_retention_yearsint3нетСрок хранения журнала согласий после отзыва/утраты актуальности
consents.minor_age_thresholdint18нетВозраст, ниже которого согласие помечается как согласие несовершеннолетнего (если форма собирает возраст)
consents.revoke_cascades_notificationsbooltrueнетОтзыв согласия типа «рассылка» автоматически останавливает активные подписки через cms/notifications-bus
consents.rate_limit_per_hourint10нетЛимит запросов revoke/removal-request/export на субъекта в час; достижение — 429 с понятным сообщением, не тихая блокировка
consents.auto_removal_enabledbooltrueнетKill-switch: false аварийно приостанавливает джобу process-removals (автообезличивание по расписанию) без выключения модуля целиком — на случай бага в логике обезличивания; заявки остаются pending, ручной POST /admin/removal-request продолжает создавать записи

API

МетодПутьДоступНазначение
GET/api/v1/consents/textspublicАктуальные тексты согласий по типу
POST/api/v1/consents/revokeauthОтзыв согласия текущим пользователем (требует активной сессии)
GET/POST/api/v1/consents/revoke-link/{signature}public, подписанный URL (persistent)Отзыв согласия по одной ссылке из письма без активной сессии (One-Click Unsubscribe, RFC 8058-подобный) — см. «Безопасность»
POST/api/v1/admin/consents/removal-requestadmin (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событие CookieConsentGiveninвыбор авторизованного пользователя дублируется в журнал согласий модуля
Ядро — пользователисобытие UserDeletedinавтоматический каскад «забыть» — создание/обработка cms_data_removal_requests без ручного запроса
Ядро — пользователисобытие UserMerged(primary, secondary)inперенос cms_consent_records со вторичного субъекта на первичного без дублей активных согласий
cms/notifications-bus (suggests)сервис-вызов + событие ConsentRevokedoutотзыв согласия типа «рассылка» останавливает активные подписки, если revoke_cascades_notifications=true
cms/cabinet-b2c (suggests)REST /api/v1/consents/*in/outкабинет — внешний потребитель, отзыв/просмотр согласий доступны пользователю из личного кабинета
Модули-держатели ПДн субъекта (любой, косвенно)событие DataRemovalRequestedoutвладелец данных сам решает, как обезличить свои таблицы — модуль 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 запрещены.

Внутренняя база знаний студии