# Спецификация: go-live инварианты безопасности публичных точек входа Лидерры

## Цель

Зафиксировать проверяемые инварианты безопасности портала перед публикацией в
интернет и дать критерий приёмки для отчёта доменного аудита (threat-model +
pdn-152fz + billing). Предмет — авторизация чувствительных API-маршрутов, защита
исходящих webhook от SSRF, полнота удаления персональных данных по 152-ФЗ и
денежная корректность списаний. Доказательная база каждого инварианта — реальные
файлы кода и схемы, перечисленные в `verified-context-json` ниже.

## Авторизация чувствительных маршрутов {#D1}

Контракт: каждый маршрут в `app/routes/web.php`, отдающий данные тенанта или
выполняющий привилегированное действие, несёт middleware `auth:sanctum` + `tenant`;
зона `/api/admin/*` обязана иметь application-layer гейт авторизации, а не полагаться
только на сетевой периметр (reverse-proxy). Привилегированное действие привязано к
личности исполнителя в audit-trail (идентификатор из аутентифицированной сессии, не
из параметра запроса).

Артефакты: `app/routes/web.php` (группы `Route::middleware(['auth:sanctum','tenant'])`
строки 46–272; админ-группа `Route::middleware('saas-admin')` строки 86–177; публичные
auth-flow строки 20–42; capability-token `signed` строки 77–80) и
`app/app/Http/Middleware/EnsureSaasAdmin.php` (тело `handle()` строки 32–35).

Edge-case: auth-flow (login/register/2fa/forgot/reset) публичны намеренно; `executeErasure`,
`updateBalance`, `refund`, импersonation принимают `admin_user_id` параметром / стаб-трейтом
`ResolvesAdminUserId`. Критерий приёмки: нет чувствительного маршрута без авторизации;
админ-действие невозможно выполнить, минуя application-гейт; исполнитель достоверно
зафиксирован.

## SSRF-поверхность исходящих webhook {#D2}

Контракт: целевой URL исходящего webhook задаёт администратор тенанта; перед каждым
исходящим запросом адрес проходит guard, блокирующий приватные/зарезервированные сети
и cloud-metadata; HTTP-клиент не следует за редиректами во внутреннюю сеть.

Артефакты: `app/app/Support/WebhookUrlGuard.php` (метод `blockReason()` строки 24–39:
резолюция всех A/AAAA, `FILTER_FLAG_NO_PRIV_RANGE|NO_RES_RANGE`, IPv6 ::1/fe80/fc00/
IPv4-mapped) и `app/app/Http/Controllers/Api/WebhookSettingsController.php` (метод
`test()` строки 120–168, вызов `Http::timeout(10)->post(...)` строки 150–152;
`update()` SSRF-гард строки 64–67).

Edge-case (явно фиксируется): (1) **DNS-rebinding** — guard резолвит хост в момент
проверки, но фактический `Http::post` резолвит хост заново на этапе соединения; между
двумя резолюциями публичный хост может переразрешиться в приватный IP (TOCTOU). В коде
комментарий строки 59–63 признаёт это только для будущей доставки, не для `test()`.
(2) **3xx-редирект** — Guzzle по умолчанию следует за редиректами (до 5), pre-flight
guard проверяет лишь исходный URL. Критерий приёмки: ни pre-flight guard, ни фактический
запрос не дают обратиться к внутреннему адресу ни через редирект, ни через DNS-rebinding;
неустранимый остаток помечается accepted-risk с владельцем.

## Полнота удаления ПДн по 152-ФЗ {#D3}

Контракт: операция удаления субъекта анонимизирует ВСЕ копии телефона/почты во всех
таблицах текущей версии схемы — скалярные колонки, JSON-массивы доп. телефонов, журналы
и производные региональные данные; сервис синхронизирован с актуальной версией схемы.

Артефакты: `app/app/Services/Pd/PdErasureService.php` (метод `eraseSubject()` строки
39–197: чистит `users`, `supplier_leads.phone`+`raw_payload`, `deals.phone`+`contact_name`;
docstring «колонки схемы v8.19» строка 17), `app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php`
(метод `executeErasure()` строки 145–192) и `db/schema.sql` (`deals.phones JSONB` строка
1632; заголовок схемы v8.40; таблица `lead_region_resolution_log` + 4 региональные колонки
живут в дельта-миграции `2026_05_31_100000`, в schema.sql отсутствуют).

Edge-case: вторичные телефоны в `deals.phones[]`; таблицы/колонки, добавленные после
написания сервиса (lead-region). Критерий приёмки: после удаления ни одна таблица не
содержит исходный телефон субъекта.

## Денежная корректность списаний {#D4}

Контракт: денежные величины считаются в копейках через `bcmath`, без PHP-float; списание
за лид идемпотентно (один доставленный лид → одно списание даже при повторе джобы);
`charge_source` фиксируется в журнале.

Артефакты: `app/app/Services/Billing/LedgerService.php` (метод `chargeForDelivery()` строки
47–128: `bcmul/bccomp/bcsub/bcdiv`, INSERT `lead_charges` строки 89–98) и `db/schema.sql`
(`CREATE TABLE lead_charges` строки 1069–1082 — индекс на `deal_id` НЕ unique; `CREATE TABLE
supplier_lead_deliveries` строки 2043–2049 — PRIMARY KEY `(supplier_lead_id, tenant_id)`).

Edge-case: повтор фоновой задачи доставки; гонка двух доставок одного лида одному тенанту
(сериализуется `lockForUpdate(tenant)` + PK `supplier_lead_deliveries`). Критерий приёмки:
на деньгах нет float-арифметики; двойное списание невозможно на уровне приложения, желательно
подкреплено ограничением БД.

## Критерий приёмки отчёта {#D5}

Отчёт доменного аудита оформляется по шаблону ниже: одна строка на инвариант, с файлом и
строкой, классом приоритета (P1/P2/P3), статусом (закрыт / остаточный риск / открыт) и
рекомендацией; в конце — вердикт готовности к публикации GO / NO-GO. Открытые вопросы
проекта (Б-1/SEC/CTO/Биз/Диз/DO) не закрываются без явного решения владельца.

Шаблон отчёта (заполняемый аудитом):

```
| ID | Инвариант (раздел) | Файл:строка | Приоритет | Статус | Рекомендация |
|----|--------------------|-------------|-----------|--------|--------------|
| F-T2 | D1 авторизация админ-зоны | app/app/Http/Middleware/EnsureSaasAdmin.php:32 | P1 | остаточный риск | вернуть app-гейт |
| F-T1 | D2 SSRF redirect/rebinding | …Api/WebhookSettingsController.php:150 | P2 | остаточный риск | withoutRedirecting |
| F-P1 | D3 неполнота 152-ФЗ erasure | …Services/Pd/PdErasureService.php:104 | P1 | открыт | расширить сервис |
| F-B1 | D4 idempotency-гард БД | db/schema.sql:1069 | P3 | остаточный риск | UNIQUE(deal_id) |

Вердикт go-live: GO / NO-GO + перечень блокеров.
```

Критерий приёмки: каждая строка шаблона заполнена для каждого инварианта D1–D4; вердикт
вынесен; ни один открытый вопрос проекта не помечен «закрыт».

```verified-context-json
[
  {"id":"D1a","kind":"EXTRACTED","ref":"app/routes/web.php","anchor":"api/webhook/supplier/{secret}"},
  {"id":"D1b","kind":"EXTRACTED","ref":"app/app/Http/Middleware/EnsureSaasAdmin.php","anchor":"class EnsureSaasAdmin"},
  {"id":"D2a","kind":"EXTRACTED","ref":"app/app/Support/WebhookUrlGuard.php","anchor":"final class WebhookUrlGuard"},
  {"id":"D2b","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/WebhookSettingsController.php","anchor":"public function test(Request $request)"},
  {"id":"D3a","kind":"EXTRACTED","ref":"app/app/Services/Pd/PdErasureService.php","anchor":"class PdErasureService"},
  {"id":"D3b","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/AdminPdSubjectRequestsController.php","anchor":"function executeErasure"},
  {"id":"D3c","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"phones          JSONB"},
  {"id":"D4a","kind":"EXTRACTED","ref":"app/app/Services/Billing/LedgerService.php","anchor":"final class LedgerService"},
  {"id":"D4b","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"CREATE TABLE supplier_lead_deliveries"},
  {"id":"D4c","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"CREATE TABLE lead_charges"}
]
```
