Compare commits

..

14 Commits

Author SHA1 Message Date
Дмитрий 6e36c2455d docs(plan): регистрация — подтверждение email кодом + обязательный телефон
12 задач TDD: PhoneNormalizer, Mailable+шаблон, register/start|verify|resend,
удаление старого register, фронт (утилита телефона, API, store, двухшаговый
RegisterView, тесты), полная регрессия + живая проверка SMTP (blocked на пароль).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:24:47 +03:00
Дмитрий 4c2f4da664 docs(spec): регистрация — подтверждение email кодом + обязательный телефон
Дизайн: 6-значный код на email до создания аккаунта (pending в сессии,
паттерн 2FA), обязательный телефон по маске +7 (XXX) XXX-XX-XX (только
сбор, без SMS), реальная доставка через Яндекс SMTP. Без правок схемы БД.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:10:36 +03:00
Дмитрий 1df353ae51 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:02:40 +03:00
Дмитрий 47cf202226 chore(gitleaks): ignore Nuclei docs curl-auth-user false-positive (05437ba)
Cross-branch FP: gitleaks-full-history сканит все refs; чужой коммит a8-infosec
Nuclei docs -u http://... ловится curl-auth-user (не аутентификация).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:34:08 +03:00
Дмитрий 888ead3264 docs(etalon): тест-сервер YC + 3 канала поставщика настроены
§5 факт о тест-сервере (доступ, демо-логины, info@lkomega.ru), §6 нить
каналов миграции, §1 git push feat/test-deploy. +2 слова cspell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:32:31 +03:00
Дмитрий dcc1040f73 docs(deploy): test-server runbook — supplier migration channels section
3 канала проверены вживую на тест-сервере (webhook/CSV reconcile/export),
+предпосылки (Node20+Playwright+Chromium под /var/www/.cache, PlaywrightBridge 180s),
secret/allowlist/supplier-portal/HTTPS TODO. +2 слова в cspell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:28:09 +03:00
Дмитрий b873c53aad fix(supplier): PlaywrightBridge timeout 75->180 for weak-VM Chromium cold-start
На 2 vCPU/2GB YC VM холодный старт Chromium в refresh-session ~65s wall-clock,
не укладывался в прежние 75s (60s Node + 15s buffer). Поднято до 180s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:27:11 +03:00
Дмитрий bf4ed65d0e docs(deploy): runbook — crm_app_user + auth-friendly RLS, isolation verified, 4 test clients
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:05:08 +03:00
Дмитрий 3b2096b4cb docs(deploy): test-server runbook (access, services, update, HTTPS, teardown)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:42:25 +03:00
Дмитрий 2f4cf433cd docs(etalon): bump после supplier dead-donor/UI-бейдж fix + баннер 18:00 (83613b4+68f42ad)
§1 git: HEAD origin/main 68f42ad, ветка дрейфнула на feat/test-deploy, push через cherry-pick worktree. §6: +нить supplier-синк fix. cspell +3 слова.
2026-05-21 11:28:06 +03:00
Дмитрий 5fef4647c1 feat(projects): информационный баннер о сроке изменений до 18:00 МСК
Закрывается крестиком, закрытие запоминается в localStorage. Чисто фронтенд (информация, без блокировок, без бэкенда). +3 Vitest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:17:04 +03:00
Дмитрий 815f0a2dcd docs(deploy): test-deploy Yandex Cloud spec + plan (single VM, nginx/php/pg/redis, real RLS roles)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:02:17 +03:00
Дмитрий e6752b5e4c feat(deploy): temporary SAAS_ADMIN_TEST_BYPASS flag for test server (off by default)
Allows SaaS-admin area in non-local/testing envs only when SAAS_ADMIN_TEST_BYPASS=true.
Default false -> production unaffected. Remove after Yandex 360 SSO (Б-1 + DO-4).
TDD: tests/Feature/Middleware/EnsureSaasAdminTest.php (2 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:00:16 +03:00
Дмитрий 1220bddf3e fix(supplier): recreate deleted donor + fill legacy FK in online sync
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 10:59:37 +03:00
132 changed files with 3228 additions and 12257 deletions
+1 -3
View File
@@ -38,7 +38,5 @@ See `references/aggregation-template.md`.
## Behavioral rule reminders
- **«Не использован ≠ проблема» (условное, Pravila §16.4 v1.36)** — when reporting node usage counts, distinguish two cases:
1. **Unused + no profile task in episodes** → capability-readiness, do NOT flag.
2. **Unused + profile task present (missed activation)** → mandatory section in the report. Cite `tools/observer-classification-map.json` for the classification→node mapping and `tools/.node-dormancy.json` for DEFERRED exclusions. NEVER mark unused-by-design nodes as «zombie» / «removal candidate».
- **«Не использован ≠ проблема»** — when reporting node usage counts, NEVER mark unused nodes as «zombie» / «removal candidate». Cite `memory/feedback_brain_unused_tools_not_problem.md`.
- **No auto-edit** — every regulatory suggestion is a candidate, not an action.
@@ -55,32 +55,6 @@ For each factor below, render a table: factor value × outcome counts
(one table each — same columns)
## Missed Activations (Pravila §16.4 v1.36)
Surface candidates where a profile-classified task ran with `node_chosen === 'direct'` and at least one non-dormant recommended node was available. The analyzer returns `missedActivations: { totalMissed, byNode, byClassification }` — render the two breakdowns below.
**Source:** `analyze(episodes, { classificationMap, dormancy }).missedActivations`.
### By node
| Node | Episodes missed | Classifications hit |
|---|---|---|
| #NN | N | refactor (a), bugfix (b) |
### By classification
| Classification | Missed episodes | Top recommended nodes (non-dormant) |
|---|---|---|
| refactor | N | #11, #12, #43 |
**Interpretation guide:**
- High count on one node → router-miss pattern. Suggest updating `tools/observer-classification-map.json` or a workflow nudge.
- Spread across many nodes with classification leaning to `other` → the classification dictionary may need refinement (separate concern, not a missed activation).
- All zero → either no profile work this period, or the router is operating cleanly.
**NOT to be auto-applied:** these are candidates for human review in retro, not commits or hook blocks.
## Episodes → tasks (from analyzer `tasks`)
| task_ref | episodes | turns that are rework |
@@ -139,4 +113,4 @@ problem** per `memory/feedback_brain_unused_tools_not_problem`.
## Informational metrics (NOT alerts)
- Nodes used at least once this period: K / 60+
- Nodes never used since beginning of observer logs: L / 67**not a problem if there was no profile task** per Pravila §16.4 v1.36 and [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md). See `## Missed Activations` above for profile-task-present cases.
- Nodes never used since beginning of observer logs: L / 60+**not a problem** per [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md)
-66
View File
@@ -1,66 +0,0 @@
---
name: pdn-152fz-audit
description: Аудит защиты персональных данных Лидерры и соответствие 152-ФЗ. Режим 1 — техника (где лежат ПДн в схеме/коде, RLS, маскирование pg_anonymizer, утечки в логах/Sentry/CSV-экспортах, шифрование). Режим 2 — закон (хранение в РФ, согласия, сроки/удаление, реестр обработки, уведомление РКН, права субъекта pd_subject_request). Используй при «проверь ПДн», «утекают ли персональные данные», «соответствие 152-ФЗ», «где хранятся телефоны лидов», «маскируются ли данные в дампах». НЕ для денежной корректности (billing-audit), security-аудита кода (D3/Semgrep), юридического оформления договоров/политик (D2 право), generic-угроз (threat-model #72).
---
# ПДн 152-ФЗ Аудит — защита персональных данных Лидерры
Проектный скил раздела A8 карты «Информационная безопасность». Проверяет
**защиту персональных данных** и соответствие Федеральному закону №152-ФЗ
«О персональных данных» для SaaS-портала, обрабатывающего телефоны лидов
и данные клиентов-компаний перед выходом в продакшен.
## Когда использовать
- Вопрос «не утекают ли ПДн в логи / Sentry / CSV-экспорты?»
- Проверка технической защиты ПДн перед запуском (RLS, маскирование, шифрование).
- Оценка соответствия 152-ФЗ: хранение в РФ, согласия, права субъекта, реестр.
- Ревью кода, затрагивающего `deals`, `users`, `pd_subject_requests`,
`pd_processing_log`, `supplier_leads` или CSV-импорт/экспорт лидов.
## Два режима
### Режим 1 — Технический аудит ПДн
Проверяет, что персональные данные физически защищены в коде и схеме БД.
Вопросы:
- Какие таблицы/колонки содержат ПДн? Под RLS ли они?
- Маскируются ли ПДн в дампах (pg_anonymizer)?
- Не утекают ли phone/email/ФИО в Laravel-логи, Sentry, `activity_log.context`,
`auth_log`, `supplier_leads.raw_payload`?
- Зашифрованы ли чувствительные поля в покое (totp_secret)?
- Защищены ли CSV-экспорты лидов (signed URL + аудит в `pd_processing_log`)?
**Запустить:** пройти по чек-листу `references/checklist.md` → Раздел 1.
### Режим 2 — Соответствие 152-ФЗ
Проверяет правовую и процессную сторону обработки ПДн.
Вопросы:
- Хранятся ли ПДн на территории РФ?
- Зафиксированы ли согласия субъектов ПДн (`tenant_consents`)?
- Есть ли механизм обращений субъектов (`pd_subject_requests` + дедлайн 30 дней)?
- Ведётся ли журнал обработки ПДн (`pd_processing_log`)?
- Уведомлен ли РКН? Есть ли реестр обработки?
- Реализовано ли право на ограничение обработки (`processing_restricted`)?
**Запустить:** пройти по чек-листу `references/checklist.md` → Раздел 2.
## Границы
-`billing-audit` #62 — тот про *денежную корректность начислений*; pdn-152fz-audit про *персональные данные*.
- ≠ D3 «audit-security» (#39/#40 Trail of Bits / Semgrep) — те про *security-уязвимости кода*; pdn-152fz-audit про *данные субъектов ПДн*.
- ≠ D2 «Право / договоры» — там юридическое оформление (политика обработки, договор с оператором); pdn-152fz-audit про *технику и процедуры*.
-`threat-model` #72 — тот про *моделирование угроз*; pdn-152fz-audit про *конкретные ПДн в конкретных таблицах*.
## Связано
- Reuse: Boost #10 (SQL-запросы к схеме), Semgrep #25 (статанализ кода на утечки),
Sentry MCP #34 (проверка runtime-маскирования), pg_anonymizer #29 (дампы).
- ADR-013 (infosec-tooling A8).
- Нормативная основа: ФЗ-152 ст.18 (уведомление РКН), ст.21 ч.5 (ограничение
обработки), ст.22 (реестр операторов), ст.14 (права субъекта).
@@ -1,10 +0,0 @@
{
"skill": "pdn-152fz-audit",
"cases": [
{"prompt": "проверь, не утекают ли телефоны лидов в логи", "should_trigger": true},
{"prompt": "соответствует ли портал 152-ФЗ перед запуском", "should_trigger": true},
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": false, "expected": "threat-model"},
{"prompt": "составь договор обработки персональных данных", "should_trigger": false, "expected": "D2 право"}
]
}
@@ -1,202 +0,0 @@
# ПДн 152-ФЗ — чек-лист аудита Лидерры
Основан на реальных артефактах проекта (db/schema.sql v8.26, 21.05.2026).
## Таблицы-носители ПДн (инвентарь)
| Таблица | ПДн-колонки | Тип субъекта |
|---|---|---|
| `deals` | `phone`, `phones` (JSONB), `contact_name`, `city` | лид (физлицо) |
| `supplier_leads` | `phone`, `raw_payload` (JSONB — весь payload поставщика) | лид (физлицо) |
| `users` | `email`, `first_name`, `last_name`, `phone`, `totp_secret` | пользователь-клиент |
| `tenants` | `contact_email`, `organization_name` | организация-клиент |
| `auth_log` | `email` (при login_failed для неизвестного пользователя) | пользователь |
| `pd_subject_requests` | `subject_email`, `subject_phone`, `subject_full_name` | субъект ПДн |
| `impersonation_tokens` | косвенно (связь user — admin) | пользователь |
| `import_log` | `filename`, `file_path` (может содержать имя файла с ПДн) | лид (косвенно) |
---
## Раздел 1 — Технический аудит ПДн
### Т1. RLS на таблицах-носителях ПДн
- [ ] `deals``ENABLE ROW LEVEL SECURITY` ✅ (подтверждено schema.sql:2780).
Проверить: `FORCE ROW LEVEL SECURITY` не выставлен (только у `lead_charges`
— там сильнее). Убедиться, что `crm_app_user` не BYPASSRLS.
- [ ] `users` — RLS включён (schema.sql:2778). Политика `tenant_isolation` по
`tenant_id`. Проверить: нет прямого SELECT * без `SET LOCAL app.current_tenant_id`.
- [ ] `supplier_leads`**RLS не включён** (таблица SaaS-уровня, schema.sql:1948).
Это осознанное решение. Проверить: доступ только из воркера
(`crm_supplier_worker` BYPASSRLS) с явным `WHERE tenant_id`.
- [ ] `pd_subject_requests`**RLS не включён** намеренно (saas-уровневая,
schema.sql:2483). Доступ только через `crm_admin_user` BYPASSRLS.
Проверить: tenant-приложение к таблице не обращается.
- [ ] `auth_log` — RLS включён (schema.sql:2810). Политика `tenant_isolation`.
Проверить: поле `email` в строке `login_failed` — не утекает ли email
несуществующего пользователя в посторонний тенант.
- [ ] `import_log` — RLS включён (schema.sql:2790).
### Т2. Маскирование ПДн в дампах (pg_anonymizer #29)
- [ ] **Проверить вручную:** OPEN-И-24 (schema.sql:113) — «pg_anonymizer процедура,
документация в Прил. И, без изменений схемы». Расширение ставится в фазе 3
(db/CHANGELOG_schema.md:625). На момент аудита — **расширение может быть не
установлено**. Выполнить: `psql -c "SELECT extname FROM pg_extension WHERE extname='anon';"`.
- [ ] Если pg_anonymizer установлен: проверить наличие `SECURITY LABEL` /
`anon.mask_column` на колонках `deals.phone`, `deals.contact_name`,
`users.email`, `users.first_name`, `users.last_name`.
- [ ] Если pg_anonymizer **не установлен**: дампы (`pg_dump`) содержат ПДн в открытом
виде — критический риск перед продакшеном. Требуется: либо установить
расширение и настроить маски, либо запретить дампы с ПДн вне зашифрованного
хранилища.
### Т3. Утечки ПДн в логи и Sentry
- [ ] **Sentry PII-scrubbing** (OPEN-И-16, schema.sql:68): конфигурация в
`app/config/sentry.php` (narrative §22 «Sentry PII-scrubbing»).
Проверить: whitelist событий задан; regex-маска `phone`/`email`/`password`/
`secret`/`token`/`api_key` включена. Тест: намеренно вызвать ошибку с
телефоном в payload и проверить Sentry-событие.
- [ ] **Laravel-логи (`storage/logs/`)**: нет ли `Log::info`/`Log::debug` с
`$deal->phone`, `$lead->phone`, `request()->all()` в необработанном виде.
Grep: `Log::` + `phone\|email\|contact_name` в `app/app/`.
- [ ] **`activity_log.context`** (JSONB, schema.sql:1775): поле `context` журнала
действий по сделкам. Проверить: не пишется ли туда `phone`/`contact_name`
полностью (должны быть только ID и маскированные значения).
- [ ] **`supplier_leads.raw_payload`** (JSONB, schema.sql:1966): хранит весь
webhook-payload от поставщика, включая телефон. Это осознанное хранение
(нужно для дебага/реконсайла). Проверить: доступ ограничен только
`crm_supplier_worker` + `crm_admin_user`; не отдаётся в tenant API.
- [ ] **`auth_log.email`** (schema.sql:1458): email попадает в лог при `login_failed`
для неизвестного адреса. Проверить: колонка не индексируется publicly,
доступна только под RLS tenant-политикой.
### Т4. Шифрование чувствительных полей в покое
- [ ] **`users.totp_secret`** (schema.sql:723): комментарий «ШИФРУЕТСЯ `Crypt::encrypt`».
Проверить: в коде Laravel используется `Crypt::encrypt`/`decrypt`, не plain TEXT.
Grep: `totp_secret` в моделях/сервисах — нет ли прямого assignment без encrypt.
- [ ] **`tenants.webhook_token`** (schema.sql:628): хранится в открытом виде как
уникальный токен. Допустимо (по дизайну — это API-ключ, не пароль), но
проверить: не логируется ли при ротации (`webhook_token_rotated_at`).
- [ ] **Encryption at rest (диск/облако)**: Yandex Cloud `ru-central1` — проверить,
включено ли шифрование диска/объектного хранилища на уровне YC-консоли.
Это вне кода, но обязательно для 152-ФЗ.
### Т5. CSV-экспорт лидов и signed URL
- [ ] **`report_jobs`** (schema.sql:2313): `file_path` = `s3://bucket/path/file.xlsx`.
Триггер `trg_report_jobs_export_log` (schema.sql:3096) автоматически пишет
запись в `pd_processing_log` при INSERT. Проверить: триггер активен в prod.
SQL: `SELECT tgname, tgenabled FROM pg_trigger WHERE tgname = 'trg_report_jobs_export_log';`
- [ ] **Signed URL TTL**: schema.sql:3182 — «доступ через signed URL TTL 1 ч».
Проверить в коде: `Storage::temporaryUrl(...)` с `now()->addHour()`.
Файлы экспорта не доступны без аутентификации.
- [ ] **`report_jobs.expires_at`**: автоудаление файла. Проверить: есть ли
scheduled command / cleanup job, удаляющий S3-файл и обнуляющий `file_path`
после `expires_at`.
### Т6. CSV-импорт исторических лидов
- [ ] **`import_log.file_path`** (schema.sql:1544): путь к загруженному CSV-файлу с
ПДн. Проверить: файл хранится во временном/приватном location, не в
публично доступном URL; удаляется после обработки.
- [ ] **Проверить вручную:** содержит ли исторический CSV телефоны лидов в открытом
виде в `storage/`? Если да — нужен cleanup после импорта.
---
## Раздел 2 — Соответствие 152-ФЗ
### З1. Хранение ПДн на территории РФ (ст.18.1 152-ФЗ)
- [ ] Облако: Yandex Cloud, регион `ru-central1` (Москва) — **✅ РФ**.
Подтверждено в CLAUDE.md §2.
- [ ] S3-хранилище файлов экспорта (`report_jobs.file_path`): убедиться, что
Yandex Object Storage используется (не AWS S3 / GCS). Проверить
`app/config/filesystems.php`.
- [ ] Self-hosted Sentry: Yandex Cloud `ru-central1` — ✅ РФ (CLAUDE.md §2).
Проверить: Sentry не проксирует события в eu.sentry.io / sentry.io (US).
- [ ] Unisender Go (email): **Проверить вручную** — уточнить у Unisender
расположение серверов; письма с ПДн (email адреса) передаются провайдеру.
### З2. Согласия субъектов ПДн (ст.6, ст.9 152-ФЗ)
- [ ] **`tenant_consents`** (schema.sql:2430): таблица согласий. Проверить:
при регистрации тенанта записывается `consent_type='pd_processing'` с
`document_version`, `ip_address`, `user_agent`, `given_at`.
- [ ] Проверить: согласие на обработку ПДн лидов (телефоны физлиц) — не пользователя-
клиента, а лидов. Лиды приходят от поставщика (crm.bp-gr.ru) — проверить
договор с поставщиком (правовое основание обработки ст.6 ч.1 п.5 или п.4).
**Проверить вручную** — вне schema (юридический документ).
- [ ] `consent_type` значения: `pd_processing`, `marketing`, `oferta_v1` — убедиться,
что consent_type='pd_processing' обязателен при регистрации (нет bypass).
### З3. Сроки хранения и удаление (ст.21 152-ФЗ)
- [ ] **Soft-delete в `deals`** (schema.sql:1648 `deleted_at`): после soft-delete
данные остаются. Проверить: есть ли политика retention (hard-delete или
анонимизация `phone`/`contact_name` через N дней после `deleted_at`).
**Проверить вручную:** scheduled command для hard-delete сделок.
- [ ] **`users.deleted_at`** (schema.sql:751): комментарий «soft delete + анонимизация».
Проверить в коде: при soft-delete пользователя анонимизируются ли
`email`/`first_name`/`last_name`/`phone`? Grep: `UserObserver` / `UserService`
метод delete/anonymize.
- [ ] **Право на удаление** (ст.21): обращение типа `request_type='deletion'` в
`pd_subject_requests`. Проверить: есть ли процедура исполнения (скрипт/ручной
процесс) удаления ПДн конкретного субъекта по `subject_phone`/`subject_email`
из `deals`, `supplier_leads`, `activity_log`.
### З4. Журнал обработки ПДн (ст.18.1 152-ФЗ)
- [ ] **`pd_processing_log`** (schema.sql:2449): таблица журнала. RLS включён
(schema.sql:2806), политика `tenant_isolation` (schema.sql:2846).
Проверить: `subject_type`, `action`, `purpose` заполняются при
ключевых операциях (просмотр сделки, экспорт, удаление).
- [ ] **Триггер экспорта** `trg_report_jobs_export_log` (schema.sql:3096): AFTER
INSERT на `report_jobs` → INSERT `pd_processing_log` с `action='exported'`.
Закрывает требование ст.18 (учёт трансграничной передачи / выгрузки).
- [ ] **Append-only hash chain** (schema.sql:63): `log_hash BYTEA` + триггеры
`BEFORE UPDATE/DELETE` с `RAISE EXCEPTION`. Проверить: цепочка целостна.
SQL: `SELECT id, log_hash IS NULL AS broken FROM pd_processing_log ORDER BY id DESC LIMIT 10;`
### З5. Обращения субъектов ПДн (ст.14 152-ФЗ)
- [ ] **`pd_subject_requests`** (schema.sql:2491): таблица обращений. Поля:
`subject_email`, `subject_phone`, `subject_full_name`, `request_type`
(`access`/`rectification`/`deletion`/`objection`), `deadline_at` (30 дней),
`processing_restricted`.
- [ ] **Триггер дедлайна** `trg_pd_subject_requests_deadline` (schema.sql:3165):
функция `set_pd_subject_request_deadline()` заполняет `deadline_at =
received_at + INTERVAL '30 days'` при INSERT/UPDATE.
Проверить: `SELECT COUNT(*) FROM pd_subject_requests WHERE deadline_at IS NULL;`
— должно быть 0.
- [ ] **`processing_restricted`** (schema.sql:2514, ст.21 ч.5): при `TRUE`
`ProcessingRestrictedException` блокирует операции с ПДн субъекта.
Проверить в коде: `ProcessingRestrictionGuard` вызывается в сервисах
перед mutable-операциями с `deals`/`users`.
- [ ] Индекс (schema.sql:2519): `idx_pd_requests_restricted` — эффективный поиск
активных ограничений. Проверить: он используется в `ProcessingRestrictionGuard`.
### З6. Уведомление РКН и реестр обработки (ст.22 152-ФЗ)
- [ ] **Проверить вручную:** подана ли заявка оператора в реестр Роскомнадзора
на сайте pd.rkn.gov.ru? Это организационная мера, вне кода.
- [ ] **Проверить вручную:** составлен ли внутренний реестр обработки ПДн
(перечень категорий субъектов, целей, сроков, мер защиты)?
Требование ст.22.1 ФЗ-152.
- [ ] **`incidents_log`** (schema.sql:2535): при утечке ПДн — поле
`related_pd_subject_request_ids BIGINT[]`. Проверить: есть ли внутренняя
процедура уведомления РКН в течение 24 ч (ст.21.1, с 01.03.2023)?
### З7. Передача ПДн третьим лицам
- [ ] **Поставщик crm.bp-gr.ru**: получает запросы с телефонами лидов обратно
при синхронизации статусов (`supplier_sync_log`). Проверить наличие договора
на обработку ПДн по поручению (ст.6 ч.3 152-ФЗ).
**Проверить вручную** — юридический документ.
- [ ] **Unisender Go** (email-рассылки с именами пользователей):
**Проверить вручную** — договор поручения на обработку ПДн.
- [ ] **JivoSite** (helpdesk): передаются ли туда email/ФИО клиентов?
**Проверить вручную**.
-68
View File
@@ -1,68 +0,0 @@
---
name: security-go-live
description: Единый go-live security-gate Лидерры перед публикацией в интернете — один воспроизводимый прогон всех проверок безопасности и вердикт «можно/нельзя в прод». Оркеструет ZAP (#68), Nuclei (#69), Ward (#70), pdn-152fz-audit (#71), threat-model (#72) + Semgrep #25 / Trivy #26 / gitleaks #8 / Trail of Bits #39. Используй при «прогон безопасности перед релизом», «можно ли выкатывать», «go-live security check», «финальная проверка безопасности». НЕ для полного 14-фазного аудита портала (audit-portal), отдельной проверки ПДн (pdn-152fz-audit #71) или угроз (threat-model #72).
---
# Security Go-Live — единый gate безопасности перед публикацией
Проектный скил раздела A8 карты «Информационная безопасность». Запускает
**один воспроизводимый прогон всех security-проверок** и выдаёт вердикт
**GO / NO-GO** перед тем, как портал Лидерры становится доступным из интернета.
## Когда использовать
- «Прогони все проверки безопасности перед релизом»
- «Можно ли выкатывать портал в прод по безопасности?»
- «Go-live security check» / «финальная проверка безопасности»
- «Готов ли портал к публикации со стороны ИБ?»
## Что это и чем НЕ является
**Это:** операционный gate — воспроизводимый чек-лист, который прогоняется
каждый раз перед go-live и выдаёт конкретный вердикт с перечнем блокеров.
**Это НЕ:**
-`audit-portal` — тот 14-фазный сквозной аудит качества всего портала
(статанализ, тесты, схема БД, UI-smoke, a11y, coverage, bundle и пр.);
security-go-live — security-only срез, занимает часть дня, не несколько дней.
-`pdn-152fz-audit` #71 — тот глубокий аудит персональных данных и 152-ФЗ;
security-go-live вызывает его как один шаг, не заменяет.
-`threat-model` #72 — тот строит модель угроз как документ (STRIDE, карта
точек входа); security-go-live проверяет, что выявленные угрозы ЗАКРЫТЫ.
## Порядок прогона
Полная процедура — `references/gate.md`. Кратко:
1. **Статика** — gitleaks, Semgrep, Ward (config/env/deps/code), Trail of Bits.
2. **ПДн / 152-ФЗ** — вызвать `pdn-152fz-audit` #71.
3. **Угрозы** — вызвать `threat-model` #72, убедиться что топ-угрозы закрыты.
4. **Динамика (локальная цель по умолчанию)** — Nuclei (`bin/nuclei.exe`),
затем ZAP (spider + active scan). Боевой сервер — только по явной команде.
5. **Вердикт** — GO / NO-GO с явным списком блокеров.
## Выход
```
=== SECURITY GO-LIVE REPORT ===
Дата: YYYY-MM-DD
Версия схемы: <schema-version>
Commit: <HEAD>
[ШАГИ 1-4 — результаты по каждому инструменту]
=== ВЕРДИКТ: GO ✅ / NO-GO ❌ ===
Блокеры (critical/high): <список или "нет">
Предупреждения (medium): <список или "нет">
=== END ===
```
## Связано
- `references/gate.md` — подробная процедура прогона + формат вердикта.
- `pdn-152fz-audit` #71, `threat-model` #72 — вызываются как подшаги.
- ZAP #68 (OWASP, DAST), Nuclei #69 (CLI `bin/nuclei.exe`), Ward #70 (Go CLI).
- gitleaks #8, Semgrep #25, Trivy #26, Trail of Bits #39 — статика.
- ADR-013 (infosec-tooling A8), `docs/security/nuclei-setup.md`,
`docs/security/infosec-vet.md`.
@@ -1,10 +0,0 @@
{
"skill": "security-go-live",
"cases": [
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": true},
{"prompt": "можно ли выкатывать портал в прод по безопасности", "should_trigger": true},
{"prompt": "проведи полный аудит портала", "should_trigger": false, "expected": "audit-portal"},
{"prompt": "проверь только персональные данные", "should_trigger": false, "expected": "pdn-152fz-audit"},
{"prompt": "смоделируй угрозы", "should_trigger": false, "expected": "threat-model"}
]
}
@@ -1,241 +0,0 @@
# Security Go-Live Gate — процедура прогона и формат вердикта
Подробная пошаговая процедура для скила `security-go-live` (#73).
Цель — один воспроизводимый прогон перед каждым выходом портала в интернет.
---
## Гарды
**IS8 — цель по умолчанию локальная.** Все динамические проверки (Nuclei, ZAP)
направляются на локальную или тестовую копию портала (`127.0.0.1`). Боевой
(`crm.bp-gr.ru` или любой публичный IP) — только по явной команде заказчика:
«сканируй прод» / «сканируй боевой».
**IS7 — граница с `audit-portal`.** `security-go-live` — security-only gate:
выдаёт GO/NO-GO по безопасности. Он не заменяет 14-фазный `audit-portal`
(тесты, схема, UI-smoke, a11y, coverage, bundle и пр.). Перед первым
production-деплоем рекомендуется прогнать `audit-portal` **и** `security-go-live`
как два отдельных прогона; при плановых go-live (хотфикс/фича) — достаточно
`security-go-live`.
---
## Шаг 1 — Статика (static analysis)
Запустить последовательно. Каждый инструмент фиксирует результат в разделе
отчёта.
### 1.1 gitleaks — поиск секретов в истории
```powershell
# Полная история
.\bin\gitleaks.exe detect --source . --log-opts "--all"
# Только staged/unstaged (перед коммитом)
.\bin\gitleaks.exe protect --staged
```
Ожидаемо: **0 утечек**. Любой leak = NO-GO (critical).
### 1.2 Semgrep — статический анализ кода
```powershell
npm run sast
```
Ожидаемо: **0 critical/high**. Medium — предупреждение (не блокер).
### 1.3 Ward — Laravel config / env / deps / code
Ward (#70) — Go-бинарь, замена заброшенного Enlightn. Сканирует:
`.env` (8 проверок), `config/*.php` (13 проверок), зависимости Composer
(через OSV.dev), код (секреты, injection, XSS, debug-артефакты, crypto,
CORS/CSRF/mass-assignment, auth).
```powershell
# Если Ward установлен (pending — нет тегов-релизов, pin по commit SHA)
.\bin\ward.exe scan --path app/
```
Если Ward **не установлен** (pending `docs/security/ward-setup.md`) — отметить
в отчёте как `PENDING` и продолжить. Ward — не блокер установки gate,
но должен быть установлен до первого реального go-live.
Ожидаемо: **0 critical**. High — разобрать вручную. Ошибки конфигурации
(APP_DEBUG=true, слабые ключи, открытые CORS) = NO-GO если critical.
### 1.4 Trail of Bits — глубокий on-demand аудит (#39)
Вызывается вручную перед первым публичным релизом или при значительных
изменениях security-периметра. Не требуется при каждом хотфиксе.
```
/differential-review:diff-review # если ревьюим конкретный diff
/audit-context-building:audit-context # для supply-chain аудита
```
Результаты фиксируются в `docs/security/trail-of-bits-YYYY-MM-DD.md`.
---
## Шаг 2 — ПДн / 152-ФЗ
Вызвать скил `pdn-152fz-audit` (#71).
```
/pdn-152fz-audit
```
Прогнать оба режима:
- **Режим 1 (технический):** RLS на таблицах ПДн, маскирование pg_anonymizer,
отсутствие phone/email в логах, pg_anonymizer в дампах.
- **Режим 2 (соответствие 152-ФЗ):** хранение в РФ, согласия, права субъекта
(`pd_subject_requests`), журнал обработки (`pd_processing_log`), уведомление РКН.
Итог: список нарушений (если есть). Нарушения Режима 1 уровня critical (ПДн
в открытых логах/Sentry) = NO-GO.
---
## Шаг 3 — Угрозы (threat model)
Вызвать скил `threat-model` (#72) или открыть последний файл
`docs/security/threat-model-YYYY-MM-DD.md`.
Цель: убедиться, что **топ-приоритетные угрозы из STRIDE** закрыты контрмерами
(rate-limit на login, HMAC на webhook, Sanctum token-auth, CSRF, RLS).
Если актуальная модель угроз отсутствует (нет файла за последние 30 дней) —
запустить `threat-model` перед динамикой.
---
## Шаг 4 — Динамика (dynamic analysis, локальная цель)
> **IS8:** по умолчанию цель — локальная копия. Убедиться, что приложение
> запущено: `php artisan serve` → `http://127.0.0.1:8000`.
### 4.1 Nuclei — широкое сканирование (#69)
Nuclei установлен как CLI-бинарь `bin/nuclei.exe` (MIT, projectdiscovery,
v3.8.0). **Не MCP-сервер.**
**Квирки native-Windows (обязательно соблюдать):**
1. **Цель — `127.0.0.1`, НЕ `localhost`.** Резолвер Nuclei не разрешает
`localhost` на этой машине — цель будет пропущена (квирк зафиксирован в
`docs/security/nuclei-setup.md`).
2. **Низкий rate-limit для dev-сервера.** `php artisan serve` однопоточный;
без ограничений Nuclei перегружает его ложными connection-ошибками.
Всегда использовать `-rate-limit 20 -c 5`.
```powershell
# Стандартный прогон (medium+)
bin\nuclei.exe -u "http://127.0.0.1:8000" `
-rate-limit 20 -c 5 -timeout 5 -duc `
-severity medium,high,critical
# Только технологический стек (быстрый smoke)
bin\nuclei.exe -u "http://127.0.0.1:8000" -tags tech `
-rate-limit 20 -c 5 -timeout 5 -duc
```
Если `bin/nuclei.exe` отсутствует — отметить `PENDING` и продолжить.
Детали установки: `docs/security/nuclei-setup.md`.
Ожидаемо: **0 critical/high**. Medium — разобрать вручную.
### 4.2 ZAP — глубокое DAST (#68)
ZAP (#68) — официальный MCP add-on (`zaproxy/zap-extensions`, Apache-2.0),
alpha v0.1.0. Требует Java 17+ и запущенного ZAP-демона.
Если ZAP **не установлен** (pending Java) — отметить `PENDING` и продолжить.
Детали: `docs/security/zap-setup.md` (когда будет создан).
```
# Через ZAP MCP (когда ZAP установлен)
# 1. Запустить ZAP-демон: zaproxy -daemon -port 8080 -config api.key=<key>
# 2. Spider
ZapStartSpiderTool(url="http://127.0.0.1:8000", contextId=...)
# 3. Active scan
ZapStartActiveScanTool(url="http://127.0.0.1:8000", contextId=...)
# 4. Отчёт
ZapGenerateReportTool(...)
```
Ожидаемо: **0 critical/high**. Medium — разобрать вручную.
Critical/high из ZAP active scan = NO-GO.
---
## Шаг 5 — Сбор находок и вердикт
### Severity → статус
| Severity | Источник | Статус gate |
|---|---|---|
| critical | любой инструмент | **NO-GO** (блокер) |
| high | любой инструмент | **NO-GO** (блокер) |
| medium | любой инструмент | Предупреждение (не блокирует go-live, фиксируется) |
| low / info | любой инструмент | Информационно |
| PENDING | ZAP / Ward / Nuclei не установлены | Условный GO — инструменты должны быть установлены до публичного деплоя |
### Формат отчёта
```
=== SECURITY GO-LIVE REPORT ===
Дата: YYYY-MM-DD
Версия схемы: vX.XX
Commit: <git rev-parse HEAD>
Цель: http://127.0.0.1:<port> (локальная копия)
--- ШАГ 1: СТАТИКА ---
gitleaks: OK (0 утечек) / FAIL (<N> утечек)
Semgrep: OK (0 critical/high) / FAIL (<список>)
Ward: OK / FAIL (<список>) / PENDING (не установлен)
Trail of Bits: OK / SKIP (не применимо к этому прогону)
--- ШАГ 2: ПДн / 152-ФЗ ---
pdn-152fz-audit Режим 1: OK / FAIL (<список>)
pdn-152fz-audit Режим 2: OK / ПРЕДУПРЕЖДЕНИЯ (<список>)
--- ШАГ 3: УГРОЗЫ ---
threat-model: ЗАКРЫТЫ (файл docs/security/threat-model-YYYY-MM-DD.md)
Незакрытые топ-угрозы: <список или "нет">
--- ШАГ 4: ДИНАМИКА ---
Nuclei: OK (0 critical/high) / FAIL (<список>) / PENDING (не установлен)
ZAP: OK (0 critical/high) / FAIL (<список>) / PENDING (не установлен)
=== ВЕРДИКТ: GO ✅ / NO-GO ❌ ===
Блокеры (critical/high):
- <инструмент>: <описание> — <рекомендация>
(или "Блокеров нет")
Предупреждения (medium):
- <инструмент>: <описание>
(или "Предупреждений нет")
PENDING-инструменты (должны быть закрыты до публичного деплоя):
- Ward #70: установка — docs/security/ward-setup.md
- ZAP #68: установка — docs/security/zap-setup.md (pending Java)
(или "Все инструменты установлены")
=== END ===
```
---
## Типичные блокеры и действия
| Находка | Источник | Действие |
|---|---|---|
| APP\_DEBUG=true | Ward / Semgrep | Исправить `.env` перед деплоем |
| Секрет в git-истории | gitleaks | Rotate + `git filter-repo`; НЕ деплоить |
| ПДн в логах Laravel | pdn-152fz-audit | Убрать из LogChannel + Sentry scrubbing |
| CSRF отключён | Ward | Проверить `VerifyCsrfToken` middleware |
| Слабый APP\_KEY | Ward | `php artisan key:generate` |
| Критическая CVE в зависимости | Semgrep / Ward | `composer update` или `npm update` |
| SQL injection / XSS | ZAP / Nuclei | Исправить код, перепрогнать |
| Незакрытая STRIDE-угроза | threat-model | Реализовать контрмеру или принять риск с заказчиком |
-66
View File
@@ -1,66 +0,0 @@
---
name: threat-model
description: Моделирование угроз портала Лидерра по STRIDE — карта точек входа, что меняется при выходе в интернет, приоритизация защиты. Используй при «смоделируй угрозы», «откуда могут атаковать», «что защищать в первую очередь перед публикацией», «карта точек входа», «threat model / STRIDE». НЕ для аудита ПДн/152-ФЗ (pdn-152fz-audit #71), статического security-аудита кода (D3/Semgrep/Trail of Bits), generic архитектурных паттернов (architecture-patterns), go-live прогона (security-go-live #73).
---
# Threat Model — моделирование угроз портала Лидерра
Проектный скил раздела A8 карты «Информационная безопасность». Применяет методологию
**STRIDE** к реальным точкам входа портала и отвечает на главный вопрос перед
публикацией: **что именно меняется, когда в систему может зайти любой из интернета**.
## Когда использовать
- «Смоделируй угрозы» / «откуда могут атаковать» / «что защищать в первую очередь»
- Подготовка к go-live — составление модели угроз как артефакта (отдельно от
чек-листа запуска, который — в `security-go-live #73`)
- Анализ конкретного эндпоинта: «насколько опасен открытый `/api/webhook/{token}`
- Ответ на вопрос заказчика / регулятора «покажи модель угроз»
## Процедура STRIDE для Лидерры
Полный разбор точек входа и таблица угроз — `references/stride-portal.md`.
### Шаги
1. **Определить периметр** — что сейчас открыто наружу vs что будет открыто после
публикации. Основа: список точек входа в `references/stride-portal.md`.
2. **Пройти по STRIDE для каждой точки** — заполнить 6 строк (S/T/R/I/D/E).
Опираться на таблицу в `references/stride-portal.md`; при новых эндпоинтах
добавлять строки по тому же шаблону.
3. **Оценить вероятность × ущерб** — приоритизировать по матрице из `references/stride-portal.md`.
4. **Сформировать список контрмер** — что уже есть (RLS, HMAC, Sanctum, rate-limit),
чего не хватает (rate-limit на login, WAF, 2FA enforcement, и т.д.).
5. **Сохранить результат** в `docs/security/threat-model-YYYY-MM-DD.md`.
## Выход
Файл `docs/security/threat-model-<дата>.md` со структурой:
- Область действия (дата, версия схемы, commit)
- Карта точек входа (таблица)
- STRIDE по каждой точке
- Дельта «был закрытый круг → стал интернет»
- Приоритизированный список рисков с контрмерами
## Границы
-`pdn-152fz-audit` #71 — тот про *персональные данные и 152-ФЗ* (конкретные
таблицы, согласия, права субъекта); threat-model про *вектора атак и защиту
эндпоинтов*.
- ≠ D3 audit-security (#39/#40 Trail of Bits / Semgrep) — те про *статический
анализ кода на уязвимости*; threat-model про *архитектурную карту угроз*.
-`architecture-patterns` #38 — тот generic-паттерны; threat-model — конкретный
портал, конкретные маршруты.
-`security-go-live` #73 — тот *прогоняет конкретный чек-лист* перед релизом
(Nmap, заголовки, CVE, gitleaks, DAST); threat-model *строит модель угроз как
документ* (вход для чек-листа и приоритизации работ).
## Связано
- `references/stride-portal.md` — детальная карта точек входа и STRIDE-таблица.
- `pdn-152fz-audit` #71 — смежный аудит ПДн; часто запускается вместе с threat-model.
- `security-go-live` #73 — операционный прогон после threat-model завершён.
- D3 / Semgrep #25 / Trail of Bits #39 — статический анализ; дополняет threat-model
на уровне кода.
- ADR-013 (infosec-tooling A8).
@@ -1,13 +0,0 @@
{
"skill": "threat-model",
"cases": [
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": true},
{"prompt": "что защищать в первую очередь перед публикацией", "should_trigger": true},
{"prompt": "откуда могут атаковать портал", "should_trigger": true},
{"prompt": "составь карту точек входа", "should_trigger": true},
{"prompt": "сделай threat model по STRIDE", "should_trigger": true},
{"prompt": "проверь соответствие 152-ФЗ", "should_trigger": false, "expected": "pdn-152fz-audit"},
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": false, "expected": "security-go-live"},
{"prompt": "просканируй код на уязвимости семгрепом", "should_trigger": false, "expected": "D3/Semgrep"}
]
}
@@ -1,198 +0,0 @@
# STRIDE — карта угроз портала Лидерра
Основан на реальных маршрутах `app/routes/web.php` (v8.26, 21.05.2026).
Стек: Laravel 13 + Vue 3 + PostgreSQL 16 RLS + Redis, Yandex Cloud `ru-central1`.
---
## Карта точек входа
| # | Точка входа | Маршрут(ы) | Аутентификация |
|---|---|---|---|
| E1 | Вход / регистрация | `POST /api/auth/login`, `POST /api/auth/register` | Публичный |
| E2 | 2FA и коды восстановления | `POST /api/auth/2fa/verify`, `POST /api/auth/2fa/recovery-use` | Публичный (pending-session) |
| E3 | Сброс пароля | `POST /api/auth/forgot`, `POST /api/auth/reset-password` | Публичный |
| E4 | Входящий webhook поставщика | `POST /api/webhook/supplier/{secret}` | URL-secret + IP-allowlist |
| E5 | Входящий webhook тенанта | `POST /api/webhook/{token}` | URL-token + (prod: HMAC X-Webhook-Signature + rate-limit) |
| E6 | API сделок | `GET/POST/PATCH/DELETE /api/deals`, `/api/deals/export`, `/api/deals/transition`, `/api/deals/restore` | Sanctum SPA + tenant |
| E7 | API проектов | `GET/POST/PATCH/DELETE /api/projects/{id}`, `/api/projects/bulk`, `/api/projects/{id}/sync` | Sanctum SPA + tenant |
| E8 | API импорта CSV | `POST /api/imports`, `GET /api/imports/{importLog}`, `/api/imports/unknown-statuses` | Sanctum SPA + tenant |
| E9 | Lookup-эндпоинты | `GET /api/managers`, `GET /api/lead-statuses` | **Без auth** (открытые) |
| E10 | Биллинг тенанта | `POST /api/billing/topup`, `GET /api/billing/wallet`, `/transactions`, `/invoices` | Sanctum SPA + tenant |
| E11 | Charges ledger | `GET /api/billing/charges`, `POST /api/billing/charges/export` | Sanctum SPA + tenant |
| E12 | API-ключи тенанта | `GET /api/api-keys`, `POST /api/api-keys/regenerate` | Sanctum SPA + tenant |
| E13 | Webhook-настройки тенанта | `GET/PUT /api/tenants/me/webhook-settings`, `POST /api/webhooks/test` | Sanctum SPA + tenant |
| E14 | Напоминания | `GET/POST/PATCH/DELETE /api/reminders/{id}` | Sanctum SPA + tenant |
| E15 | Уведомления | `GET/PATCH/POST/DELETE /api/notifications/{id}` | Sanctum SPA + tenant |
| E16 | Отчёты | `GET/POST/DELETE /api/reports/jobs/{id}`, `POST /{id}/retry`, `POST /{id}/cancel` | Sanctum SPA + tenant |
| E17 | Скачивание отчёта | `GET /api/reports/jobs/{id}/file` | Signed URL (без Sanctum) |
| E18 | Дашборд | `GET /api/dashboard/summary` | **Без auth** (MVP-заглушка) |
| E19 | Профиль / уведомления-настройки | `GET/PATCH /api/auth/me`, `PATCH /api/auth/me/notification-preferences` | Sanctum SPA |
| E20 | SaaS-admin: тенанты, биллинг, инциденты, система | `GET/PATCH /api/admin/**` | `saas-admin` middleware |
| E21 | SaaS-admin: импersonation | `POST /api/admin/impersonation/init`, `/verify`, `/end` | `saas-admin` middleware |
| E22 | SaaS-admin: supplier-integration | `GET/POST /api/admin/supplier-integration/**` | `saas-admin` middleware |
| E23 | 2FA setup (авторизованный) | `POST /api/2fa/init`, `/confirm`, `/disable`, `/regenerate-recovery-codes` | Sanctum SPA |
| E24 | SPA-оболочка | `GET /`, `/login`, `/register`, `/deals`, … (20+ маршрутов) | Без auth (Vue shell) |
---
## Дельта «закрытый круг → интернет»
До публикации портал доступен только команде (VPN или фиксированные IP).
После публикации **любой актор из интернета** может обратиться к каждому публичному
эндпоинту. Критические изменения:
| Изменение | Затронутые точки | Почему важно |
|---|---|---|
| Брутфорс и credential stuffing | E1 (login) | Нет rate-limit на `/api/auth/login` (на момент анализа) |
| Энумерация пользователей | E1, E3 | Разные ответы на «существующий / несуществующий email» создают oracle |
| Replay и forgery webhook | E4, E5 | Secret в URL виден в логах прокси/nginx; HMAC на E5 — «prod» (не в dev) |
| Открытые lookup-эндпоинты | E9 | `GET /api/managers`, `GET /api/lead-statuses` без auth — раскрывают ФИО менеджеров |
| Открытый дашборд | E18 | `GET /api/dashboard/summary` без auth — раскрывает KPI текущего тенанта |
| DoS на artisan-сервере | Все | `php artisan serve` не держит нагрузку; нужен nginx/Octane |
| SSRF через webhook-test | E13 | `POST /api/webhooks/test` отправляет запрос на URL из тела — риск SSRF во внутреннюю сеть YC |
| Impersonation без prod-auth | E21 | `saas-admin` middleware в dev-режиме пропускает без проверки (`SAAS_ADMIN_TEST_BYPASS`) |
| Signed URL без срока инвалидации | E17 | Отчёт с ПДн доступен 24 ч по ссылке без повторной аутентификации |
---
## STRIDE по точкам входа
### E1 — Вход / Регистрация (`POST /api/auth/login`, `POST /api/auth/register`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Брутфорс пароля, credential stuffing | Bcrypt-хеш пароля | Нет rate-limit на login |
| **T** Tampering | Подмена `tenant_id` в теле запроса | `tenant_id` берётся из `auth()->user()`, не из тела | — |
| **R** Repudiation | Отрицание входа | `auth_log` пишет login/logout | Нет IP + User-Agent в каждой записи |
| **I** Info disclosure | Энумерация email через разные ответы | Unified-ответ на forgot (E3) | Login может раскрывать «нет такого пользователя» |
| **D** DoS | Флуд регистраций, засорение БД | — | Нет captcha / email-верификации на register |
| **E** Elevation | Регистрация с `is_admin=true` в теле | Mass-assignment guard (fillable) | Проверить `$fillable` в `User` — нет ли `role` |
### E2 — 2FA (`POST /api/auth/2fa/verify`, `POST /api/auth/2fa/recovery-use`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Брутфорс 6-значного TOTP | TOTP 30-сек окно | Нет rate-limit на `/2fa/verify` |
| **T** Tampering | Подмена `pending_user_id` в session | Серверная session | Проверить изоляцию session между тенантами |
| **R** Repudiation | Использование кода восстановления | `auth_log` | Фиксируется ли `recovery_used` событие? |
| **I** Info disclosure | Тайминг-атака на сравнение TOTP | TOTP библиотека (constant-time?) | Проверить реализацию `verifyTwoFactor` |
| **D** DoS | Флуд на `/2fa/verify` истощает session-store | — | Нет rate-limit |
| **E** Elevation | Обход 2FA через `recovery-use` | Коды — одноразовые, хранятся hashed | Если коды в открытом виде — критично |
### E3 — Сброс пароля (`POST /api/auth/forgot`, `POST /api/auth/reset-password`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Захват аккаунта через сброс пароля чужого email | Токен по email | Нет rate-limit на `/forgot` |
| **T** Tampering | Подмена токена сброса | Cryptographic token (Laravel default) | Проверить срок жизни токена (1 ч?) |
| **R** Repudiation | — | — | — |
| **I** Info disclosure | Энумерация email через тайминг ответа | Unified-ответ задокументирован в роутах | Проверить фактическую реализацию ответа |
| **D** DoS | Флуд `/forgot` → очередь email | — | Нет rate-limit → перегрузка Unisender Go |
| **E** Elevation | — | — | — |
### E4 — Webhook поставщика (`POST /api/webhook/supplier/{secret}`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Подделка запроса от crm.bp-gr.ru | URL-secret + IP allowlist (`system_settings.supplier_ip_allowlist`) | Secret виден в логах nginx/прокси |
| **T** Tampering | Подмена payload (телефон, стоимость лида) | — | Нет HMAC на тело; только secret в URL |
| **R** Repudiation | Отрицание доставки лида | `supplier_leads.raw_payload` | Нет timestamp-подписи для доказательства |
| **I** Info disclosure | Secret в URL → в access-логах сервера | IP allowlist сужает круг | Ротация secret при компрометации? |
| **D** DoS | Флуд поддельных лидов → списание баланса | IP allowlist | Если allowlist обходится (SSRF) |
| **E** Elevation | Подмена `tenant_id` в payload | Берётся из `system_settings` глобально | Архитектурно корректно; проверить lookup |
### E5 — Webhook тенанта (`POST /api/webhook/{token}`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Запрос от неавторизованного источника | URL-token из `tenants.webhook_token`; HMAC X-Webhook-Signature (prod) | HMAC только в prod; dev уязвим |
| **T** Tampering | Изменение payload в transit | HMAC-валидация (prod) | В dev отключена — нельзя тестировать на prod-данных |
| **R** Repudiation | — | `supplier_leads.raw_payload` | — |
| **I** Info disclosure | Token в URL виден в логах | Per-token rate-limit | Нет ротации token при смене API-ключа |
| **D** DoS | Replay flood | Per-token rate-limit (prod) | Нет в dev |
| **E** Elevation | Лид с завышенной ценой | Стоимость берётся из `PricingTierResolver`, не из payload | Архитектурно защищено |
### E9 — Открытые lookup-эндпоинты (`GET /api/managers`, `GET /api/lead-statuses`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | — | — | — |
| **T** Tampering | — | — | — |
| **R** Repudiation | — | — | — |
| **I** Info disclosure | ФИО менеджеров без аутентификации | — | **Нет auth** — любой из интернета получает список менеджеров |
| **D** DoS | Флуд запросами | — | Нет rate-limit |
| **E** Elevation | — | — | — |
### E18 — Дашборд без auth (`GET /api/dashboard/summary`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **I** Info disclosure | KPI, баланс, активность тенанта без аутентификации | — | **MVP-заглушка**: auth не включён; в prod обязателен |
| **D** DoS | Тяжёлый агрегационный запрос без auth | — | Доступен без токена |
### E20 — SaaS-admin (`GET/PATCH /api/admin/**`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Доступ к admin-панели без Yandex 360 SSO | `saas-admin` middleware (fail-closed 503 в prod) | SSO не реализован до Б-1; `SAAS_ADMIN_TEST_BYPASS` в prod = полный доступ |
| **T** Tampering | Изменение тарифа, статуса тенанта без аудита | `saas_admin_audit_log` | — |
| **R** Repudiation | Отрицание действий admin | `saas_admin_audit_log` | Нет подписи/2FA для деструктивных операций |
| **I** Info disclosure | Данные всех тенантов | `saas-admin` middleware | SAAS_ADMIN_TEST_BYPASS=true в production = полный дамп |
| **D** DoS | Bulk-delete тенантов | — | Нет подтверждения для деструктивных bulk-операций |
| **E** Elevation | Impersonation любого тенанта | `saas-admin` middleware | Та же уязвимость через bypass |
### E21 — Impersonation (`POST /api/admin/impersonation/init`, `/verify`, `/end`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **S** Spoofing | Имперсонация без реального admin-права | `saas-admin` middleware | Bypass в dev/test режиме |
| **T** Tampering | Изменение `admin_user_id` в токене | Token-based flow | Проверить, что token не forgeble |
| **R** Repudiation | Отрицание сессии имперсонации | `impersonation_tokens` логирует | Нет нотификации целевому тенанту |
| **E** Elevation | Получение прав тенанта через impersonation | Scope ограничен tenant-контекстом | Если RLS bypass во время импersонации |
### E13 — SSRF через webhook-test (`POST /api/webhooks/test`)
| Угроза | Описание | Текущий контроль | Пробел |
|---|---|---|---|
| **T** Tampering | Отправка запроса на внутренний адрес YC | — | **Нет фильтрации URL** — SSRF во внутреннюю сеть Yandex Cloud (metadata service 169.254.169.254) |
| **I** Info disclosure | YC instance metadata (IAM-токен, настройки сети) | — | Критично: SSRF → metadata API → IAM credentials |
---
## Приоритизация рисков
Матрица: **Вероятность** (В — высокая / С — средняя / Н — низкая) ×
**Ущерб** (К — критический / В — высокий / С — средний / Н — низкий).
| Приоритет | Риск | Точка | Вероятность | Ущерб | Контрмера |
|---|---|---|---|---|---|
| 🔴 P0 | SAAS_ADMIN_TEST_BYPASS=true в prod | E20, E21 | В | К | Убедиться, что флаг false в `.env.production`; fail-closed middleware |
| 🔴 P0 | SSRF через `/api/webhooks/test` | E13 | С | К | Валидировать URL: запрещать RFC1918 + link-local + metadata-IP; использовать DNS-rebind защиту |
| 🔴 P0 | `GET /api/dashboard/summary` без auth | E18 | В | В | Добавить `auth:sanctum + tenant` middleware до prod |
| 🔴 P0 | `GET /api/managers`, `GET /api/lead-statuses` без auth | E9 | В | С | Добавить `auth:sanctum + tenant` |
| 🟠 P1 | Нет rate-limit на login / forgot / 2fa/verify | E1, E2, E3 | В | В | Laravel Throttle middleware (e.g. `throttle:5,1`) |
| 🟠 P1 | URL-secret поставщика виден в access-логах | E4 | С | В | Перевести на HMAC-заголовок; ротировать secret; закрыть логи |
| 🟠 P1 | Флуд поддельных лидов → списание баланса | E4, E5 | С | В | IP allowlist жёсткий; HMAC на тело (E4); idempotency-key |
| 🟡 P2 | Энумерация email на login (не только forgot) | E1 | В | С | Unified-ответ на login тоже |
| 🟡 P2 | Флуд регистраций без email-верификации | E1 | С | С | Email verification или captcha |
| 🟡 P2 | Signed URL отчёта 24 ч без аутентификации | E17 | Н | С | Сократить TTL; добавить revocation при logout |
| 🟡 P2 | Нет нотификации тенанту при impersonation | E21 | Н | С | Email/in-app уведомление при входе admin |
| 🟢 P3 | Тайминг-атака на TOTP | E2 | Н | С | Проверить constant-time compare в TwoFactorController |
| 🟢 P3 | Тайминг-атака на email в forgot | E3 | Н | Н | Unified-ответ + jitter sleep |
---
## Что уже защищает портал (baseline)
- **RLS PostgreSQL** — 39 политик; кросс-tenant утечка через SQL закрыта.
- **Sanctum SPA auth** — все бизнес-эндпоинты под `auth:sanctum + tenant`.
- **Per-token rate-limit** — на входящих webhook'ах тенанта (E5).
- **IP allowlist** — на webhook поставщика (E4).
- **HMAC X-Webhook-Signature** — на E5 в prod (не в dev).
- **`auth_log`** — фиксирует login/logout события.
- **`saas_admin_audit_log`** — фиксирует admin-действия.
- **Bcrypt** — хеш пароля; коды восстановления 2FA — hashed.
- **`saas-admin` middleware** — fail-closed 503 в prod (если `SAAS_ADMIN_TEST_BYPASS=false`).
- **Signed URL** — для скачивания отчётов (E17).
- **gitleaks** — pre-commit/pre-push; секреты не должны попасть в репозиторий.
-19
View File
@@ -4,23 +4,4 @@
# Nuclei docs `-u http://...` — nuclei's -u flag is "target URL", not curl basic-auth.
# Rule `curl-auth-user` matches the pattern but it's not authentication.
f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth-user:27
# 2026-05-22 evening — rt-add-project-form.yml в stash (untracked файл captured при stash push -u
# до checkout main). Стэш не пушится, но gitleaks-full-history сканит refs/stash. Эти телефоны —
# реальные данные supplier-формы, не наша утечка; rt-add-project-form.yml в .gitignore.
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:912
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:921
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:941
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:950
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:970
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:979
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3811
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3820
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3840
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3849
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3869
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3878
# 2026-05-22 — nuclei-setup.md curl-auth-user тот же FP что и раньше (f696ca5),
# но коммит другой (05437ba) — параллельная сессия пере-коммитила тот же файл.
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
+6 -25
View File
File diff suppressed because one or more lines are too long
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Supplier\Import\SupplierProjectImporter;
use Illuminate\Console\Command;
/**
* Разовый импорт активных проектов поставщика (аккаунт lkomega) как проектов
* Лидерры под тенантом владельца. По умолчанию dry-run (печатает план, ничего
* не пишет). С --commit пишет в БД через pgsql_supplier (BYPASSRLS), портал НЕ
* трогает. Идемпотентна.
*
* Plan: docs/superpowers/plans/2026-05-22-supplier-projects-import-lkomega.md
*/
class ImportSupplierProjectsCommand extends Command
{
protected $signature = 'supplier:import-projects
{--tenant= : email пользователя тенанта (напр. info@lkomega.ru)}
{--commit : выполнить запись (без флага только dry-run)}';
protected $description = 'Усыновить активные проекты поставщика как проекты Лидерры под тенантом (dry-run по умолчанию)';
public function handle(SupplierProjectImporter $importer): int
{
$email = (string) $this->option('tenant');
if ($email === '') {
$this->error('Укажите --tenant=<email>');
return self::FAILURE;
}
$tenantId = User::on('pgsql_supplier')->where('email', $email)->value('tenant_id');
if ($tenantId === null) {
$this->error("Тенант для email '{$email}' не найден.");
return self::FAILURE;
}
$plan = $importer->buildPlan((int) $tenantId);
$this->info(sprintf('Тенант %s (id=%d). К созданию: %d проектов. Пропущено строк/групп: %d.',
$email, $tenantId, count($plan['planned']), count($plan['skipped'])));
$this->table(
['Тип', 'Идентификатор', 'Тег', 'Регионы', 'Лимит', 'Площадки (external_id)'],
array_map(fn (array $p): array => [
$p['signal_type'],
$this->mask($p['signal_identifier'] ?? ($p['sms_senders'][0] ?? '')),
mb_substr((string) $p['tag'], 0, 30),
$p['regions'] === [] ? 'вся РФ' : implode(',', $p['regions']),
(string) $p['daily_limit_target'],
collect($p['platforms'])->map(fn (array $pl): string => $pl['platform'].':'.$pl['external_id'])->implode(' '),
], $plan['planned']),
);
if ($plan['skipped'] !== []) {
$this->warn('Пропуски:');
foreach ($plan['skipped'] as $s) {
$this->line(sprintf(' - [%s] %s', $s['reason'], $this->mask($s['label'])));
}
}
if (! $this->option('commit')) {
$this->comment('DRY-RUN: ничего не записано. Повторите с --commit для реальной записи.');
return self::SUCCESS;
}
$result = $importer->commit($plan, (int) $tenantId);
$this->info(sprintf('Создано: проектов=%d, supplier_projects=%d, связок=%d.',
$result['created_projects'], $result['created_supplier_projects'], $result['created_links']));
return self::SUCCESS;
}
/** Маскирует цифровые хвосты (телефоны) для вывода (152-ФЗ). */
private function mask(string $value): string
{
return (string) preg_replace_callback('/\d{4,}/', static fn (array $m): string => substr($m[0], 0, 2).str_repeat('*', max(0, strlen($m[0]) - 4)).substr($m[0], -2), $value);
}
}
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ReportJob;
use App\Services\Pd\PdAuditLogger;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
@@ -70,16 +69,6 @@ class ReportsCleanupExpired extends Command
if (! $dryRun) {
Storage::disk('local')->delete($job->file_path);
app(PdAuditLogger::class)->record(
action: 'deleted',
subjectType: 'lead',
subjectId: null,
purpose: 'report_cleanup_expired_'.$job->id,
tenantId: $job->tenant_id,
actorTenantUserId: null,
actorAdminUserId: null,
ip: null,
);
$job->update(['file_path' => null]);
}
$count++;
+31 -14
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
@@ -48,8 +47,6 @@ use Illuminate\Support\Facades\RateLimiter;
*/
class AuthController extends Controller
{
use WritesAuthLog;
/** Лимит попыток входа в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
@@ -81,7 +78,7 @@ class AuthController extends Controller
if (! $user || ! Hash::check($credentials['password'], $user->password_hash)) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent('login_failed', $user?->id, $user?->tenant_id, $credentials['email'], $ip, $request->userAgent(),
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
$user ? 'invalid_password' : 'unknown_email');
$this->maybeNotifySuspiciousLogin($user, $ip);
@@ -93,7 +90,7 @@ class AuthController extends Controller
if (! $user->is_active) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
'account_locked');
return response()->json([
@@ -123,7 +120,7 @@ class AuthController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
$this->logAuthEvent('login_success', $user, $user->email, $ip, $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
@@ -155,8 +152,6 @@ class AuthController extends Controller
Auth::login($user);
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
@@ -175,17 +170,11 @@ class AuthController extends Controller
public function logout(Request $request): JsonResponse
{
$userId = $request->user()?->id;
$tenantId = $request->user()?->tenant_id;
$email = $request->user()?->email;
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$this->logAuthEvent('logout', $userId, $tenantId, $email, $request->ip(), $request->userAgent(), null);
return response()->json(['message' => 'Вы вышли из системы.']);
}
@@ -322,6 +311,34 @@ class AuthController extends Controller
}
}
/**
* Запись события auth_log.
*
* Через DB::table auth_log имеет hash-chain trigger BEFORE INSERT,
* который заполняет log_hash. Eloquent-модели для этой таблицы нет.
* RLS USING без WITH CHECK INSERT не фильтруется.
*/
private function logAuthEvent(
string $event,
?User $user,
?string $email,
?string $ip,
?string $userAgent,
?string $failureReason,
): void {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'tenant_id' => $user?->tenant_id,
'user_id' => $user?->id,
'email' => $email,
'event' => $event,
'ip_address' => $ip,
'user_agent' => $userAgent,
'failure_reason' => $failureReason,
'created_at' => now(),
]);
}
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
private function lockoutResponse(string $throttleKey): JsonResponse
{
@@ -28,9 +28,10 @@ class DashboardController extends Controller
public function summary(Request $request): JsonResponse
{
// Go-live (audit J3): tenant_id из authed-user (auth:sanctum + tenant
// middleware), НЕ из параметра запроса — закрывает кросс-tenant утечку KPI.
$tenantId = (int) $request->user()->tenant_id;
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
@@ -64,7 +64,7 @@ class DealBulkActionController extends Controller
], 422);
}
$updated = DB::transaction(function () use ($validated, $tenantId, $request) {
$updated = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
@@ -98,7 +98,7 @@ class DealBulkActionController extends Controller
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
$logRows = $changed->map(fn (Deal $d) => [
'tenant_id' => $tenantId,
'user_id' => (int) $request->user()->id,
'user_id' => null,
'deal_id' => $d->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => json_encode([
@@ -106,8 +106,6 @@ class DealBulkActionController extends Controller
'to' => $validated['status'],
'source' => 'bulk',
], JSON_UNESCAPED_UNICODE),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => $now,
])->all();
@@ -142,7 +140,7 @@ class DealBulkActionController extends Controller
$tenantId = (int) $request->user()->tenant_id;
$deleted = DB::transaction(function () use ($validated, $tenantId, $request) {
$deleted = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
@@ -171,12 +169,10 @@ class DealBulkActionController extends Controller
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenantId,
'user_id' => (int) $request->user()->id,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_DELETED,
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => $now,
], $targetIds);
@@ -206,7 +202,7 @@ class DealBulkActionController extends Controller
$tenantId = (int) $request->user()->tenant_id;
$restored = DB::transaction(function () use ($validated, $tenantId, $request) {
$restored = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// withTrashed обходит SoftDeletes global scope; whereNotNull —
@@ -237,12 +233,10 @@ class DealBulkActionController extends Controller
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenantId,
'user_id' => (int) $request->user()->id,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_RESTORED,
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => $now,
], $targetIds);
@@ -10,7 +10,6 @@ use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -242,7 +241,7 @@ class DealController extends Controller
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
* принадлежит tenant'у (или не существует) 404.
*/
public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
@@ -275,17 +274,6 @@ class DealController extends Controller
return response()->json(['message' => 'Сделка не найдена.'], 404);
}
$pdLog->record(
action: 'viewed',
subjectType: 'lead',
subjectId: $deal->id,
purpose: 'lead_card_view',
tenantId: (int) $request->user()->tenant_id,
actorTenantUserId: (int) $request->user()->id,
actorAdminUserId: null,
ip: $request->ip(),
);
return response()->json([
'deal' => [
'id' => $deal->id,
@@ -398,12 +386,10 @@ class DealController extends Controller
$deal->comment = $validated['comment'];
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => request()->user()?->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => 'deal.commented',
'context' => ['text' => $validated['comment'] ?? ''],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
@@ -413,12 +399,10 @@ class DealController extends Controller
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => request()->user()?->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
@@ -427,12 +411,10 @@ class DealController extends Controller
$deal->status = $validated['status'];
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => request()->user()?->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
@@ -466,7 +448,7 @@ class DealController extends Controller
}
/** POST /api/deals — manual create */
public function store(Request $request, PdAuditLogger $pdLog): JsonResponse
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'project_name' => 'required|string|max:255',
@@ -540,24 +522,15 @@ class DealController extends Controller
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => request()->user()?->id,
'user_id' => null, // на prod — request()->user()->id
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'manual'],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return $deal;
});
$pdLog->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_manual', tenantId: (int) $deal->tenant_id,
actorTenantUserId: (int) $request->user()->id,
actorAdminUserId: null, ip: $request->ip(),
);
return response()->json([
'deal' => [
'id' => $deal->id,
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -56,17 +55,6 @@ class DealExportController extends Controller
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
app(PdAuditLogger::class)->record(
action: 'exported',
subjectType: 'lead',
subjectId: null,
purpose: 'deals_export_'.$format,
tenantId: $tenantId,
actorTenantUserId: (int) $request->user()->id,
actorAdminUserId: null,
ip: $request->ip(),
);
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
$headers = $format === 'xlsx'
? [
@@ -7,9 +7,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Services\Pd\ImpersonationAuditService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
@@ -39,20 +39,10 @@ class ImpersonationController extends Controller
private const MAX_FAILED_ATTEMPTS = 5;
/**
* SaaS-admin кросс-тенантная зона: запросы к impersonation_tokens / tenants
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
* Иначе на проде (роль crm_app_user, RLS on) без выставленного GUC
* app.current_tenant_id запрос падает SQLSTATE 42704 у saas-admin нет
* tenant-контекста (middleware 'tenant' на /api/admin/* не висит). На dev
* pgsql_supplier = fallback на postgres-superuser, поведение идентично.
*/
private const DB_CONNECTION = 'pgsql_supplier';
/** GET /api/admin/impersonation/active — активные сессии (used_at != null AND session_ended_at == null) */
public function active(): JsonResponse
{
$rows = ImpersonationToken::on(self::DB_CONNECTION)
$rows = ImpersonationToken::query()
->whereNotNull('used_at')
->whereNull('session_ended_at')
->with(['tenant'])
@@ -77,7 +67,7 @@ class ImpersonationController extends Controller
/** GET /api/admin/impersonation/recent — последние 20 завершённых */
public function recent(): JsonResponse
{
$rows = ImpersonationToken::on(self::DB_CONNECTION)
$rows = ImpersonationToken::query()
->whereNotNull('used_at')
->whereNotNull('session_ended_at')
->with(['tenant'])
@@ -102,7 +92,7 @@ class ImpersonationController extends Controller
}
/** POST /api/admin/impersonation/init */
public function init(Request $request, ImpersonationAuditService $audit): JsonResponse
public function init(Request $request): JsonResponse
{
$tenantId = (int) $request->input('tenant_id');
$requestedBy = (int) $request->input('requested_by'); // TODO: $request->user()->id когда saas-admin auth готов
@@ -115,7 +105,7 @@ class ImpersonationController extends Controller
], 422);
}
$tenant = Tenant::on(self::DB_CONNECTION)->find($tenantId);
$tenant = Tenant::find($tenantId);
if (! $tenant) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
@@ -123,7 +113,7 @@ class ImpersonationController extends Controller
// 6-значный код. Числа от 100000 до 999999.
$plainCode = (string) random_int(100_000, 999_999);
$token = ImpersonationToken::on(self::DB_CONNECTION)->create([
$token = ImpersonationToken::create([
'tenant_id' => $tenant->id,
'requested_by' => $requestedBy,
'code_hash' => Hash::make($plainCode),
@@ -132,8 +122,6 @@ class ImpersonationController extends Controller
'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES),
]);
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
// TODO: отправить email на $tenant->contact_email с $plainCode.
$payload = [
'token_id' => $token->id,
@@ -153,12 +141,12 @@ class ImpersonationController extends Controller
}
/** POST /api/admin/impersonation/verify */
public function verify(Request $request, ImpersonationAuditService $audit): JsonResponse
public function verify(Request $request): JsonResponse
{
$tokenId = (int) $request->input('token_id');
$code = $request->string('code')->toString();
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
$token = ImpersonationToken::find($tokenId);
if (! $token) {
return response()->json(['message' => 'Токен не найден.'], 404);
}
@@ -176,13 +164,12 @@ class ImpersonationController extends Controller
}
if (! Hash::check($code, $token->code_hash)) {
// increment атомарен на уровне SQL, а isUsable() независимо гейтит
// failed_attempts >= 5 — поэтому отдельная транзакция не нужна
// (и ломала бы общий PDO в тестах под SharesSupplierPdo).
$token->increment('failed_attempts');
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
$token->update(['invalidated_at' => now()]);
}
DB::transaction(function () use ($token) {
$token->increment('failed_attempts');
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
$token->update(['invalidated_at' => now()]);
}
});
return response()->json([
'message' => 'Неверный код.',
@@ -196,8 +183,6 @@ class ImpersonationController extends Controller
'used_at' => now(),
]);
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
return response()->json([
'token_id' => $token->id,
'tenant_id' => $token->tenant_id,
@@ -207,11 +192,11 @@ class ImpersonationController extends Controller
}
/** POST /api/admin/impersonation/end */
public function end(Request $request, ImpersonationAuditService $audit): JsonResponse
public function end(Request $request): JsonResponse
{
$tokenId = (int) $request->input('token_id');
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
$token = ImpersonationToken::find($tokenId);
if (! $token) {
return response()->json(['message' => 'Токен не найден.'], 404);
}
@@ -230,8 +215,6 @@ class ImpersonationController extends Controller
$token->update(['session_ended_at' => now()]);
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
// TODO: уведомление клиенту по email о завершении (как и в init flow).
return response()->json([
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -22,18 +23,20 @@ class ManagerController extends Controller
/** GET /api/managers?tenant_id={id} */
public function index(Request $request): JsonResponse
{
// Go-live: tenant_id из authed-user (auth:sanctum + tenant middleware),
// НЕ из параметра запроса — закрывает кросс-tenant утечку списка пользователей.
$tenantId = (int) $request->user()->tenant_id;
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$users = DB::transaction(function () use ($tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Явный where(tenant_id) — defense-in-depth поверх RLS: роли с
// BYPASSRLS (crm_supplier_worker / dev-superuser) RLS не применяют,
// поэтому tenant-scope нельзя оставлять только на SET LOCAL.
return User::query()
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_active', true)
->orderBy('first_name')
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
@@ -30,8 +29,6 @@ use Illuminate\Support\Facades\RateLimiter;
*/
class PasswordResetController extends Controller
{
use WritesAuthLog;
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
@@ -72,17 +69,6 @@ class PasswordResetController extends Controller
Password::sendResetLink(['email' => $email]);
$userId = User::where('email', $email)->value('id');
$this->logAuthEvent(
'password_reset_requested',
$userId,
null,
$email,
$request->ip(),
$request->userAgent(),
$userId === null ? 'unknown_email' : null,
);
// Unified ответ независимо от наличия user'а.
return response()->json([
'message' => 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.',
@@ -134,33 +120,12 @@ class PasswordResetController extends Controller
if ($status !== Password::PASSWORD_RESET) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'password_reset_failed',
null,
null,
$email,
$request->ip(),
$request->userAgent(),
(string) $status,
);
return response()->json([
'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.',
'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']],
], 422);
}
$completedUserId = User::where('email', $email)->value('id');
$this->logAuthEvent(
'password_reset_completed',
$completedUserId,
null,
$email,
$request->ip(),
$request->userAgent(),
null,
);
RateLimiter::clear($throttleKey);
return response()->json([
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Jobs\GenerateReportJob;
use App\Models\ReportJob;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -306,12 +305,12 @@ class ReportJobController extends Controller
/**
* DELETE /api/reports/jobs/{id} удалить terminal job + файл.
*/
public function destroy(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
public function destroy(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->user();
return DB::transaction(function () use ($user, $id, $request, $pdLog): JsonResponse {
return DB::transaction(function () use ($user, $id): JsonResponse {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
$job = ReportJob::query()
@@ -336,16 +335,6 @@ class ReportJobController extends Controller
if ($job->file_path !== null) {
Storage::disk('local')->delete($job->file_path);
$pdLog->record(
action: 'deleted',
subjectType: 'lead',
subjectId: null,
purpose: 'report_file_'.$job->id,
tenantId: (int) $job->tenant_id,
actorTenantUserId: (int) $user->id,
actorAdminUserId: null,
ip: $request->ip(),
);
}
$job->delete();
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\UseRecoveryCodeRequest;
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
@@ -33,8 +32,6 @@ use PragmaRX\Google2FA\Google2FA;
*/
class TwoFactorController extends Controller
{
use WritesAuthLog;
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
@@ -73,16 +70,6 @@ class TwoFactorController extends Controller
if (! $valid) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'2fa_verify_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'invalid_code',
);
return response()->json([
'message' => 'Неверный код. Проверьте время на устройстве и попробуйте снова.',
'errors' => ['code' => ['Неверный код.']],
@@ -98,16 +85,6 @@ class TwoFactorController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent(
'2fa_verify_success',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
@@ -174,16 +151,6 @@ class TwoFactorController extends Controller
if (! $matched) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'2fa_recovery_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'invalid_or_used',
);
return response()->json([
'message' => 'Резервный код недействителен или уже использован.',
'errors' => ['code' => ['Резервный код недействителен или уже использован.']],
@@ -201,16 +168,6 @@ class TwoFactorController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent(
'2fa_recovery_used',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
// Кол-во оставшихся неиспользованных кодов — для UI-warning'а
// ("осталось 3 из 8 — рекомендуем перегенерировать").
$remaining = UserRecoveryCode::query()
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Models\UserRecoveryCode;
use Illuminate\Http\JsonResponse;
@@ -27,8 +26,6 @@ use PragmaRX\Google2FA\Google2FA;
*/
class TwoFactorSetupController extends Controller
{
use WritesAuthLog;
private const RECOVERY_CODES_COUNT = 8;
/**
@@ -57,9 +54,6 @@ class TwoFactorSetupController extends Controller
$request->session()->put('auth.pending_totp_secret', $secret);
$this->logAuthEvent('2fa_setup_init', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
// QR-URL формата `otpauth://totp/...` — user сканирует через приложение.
// По стандарту RFC 6238: issuer + label + secret + period.
$qrUrl = $google2fa->getQRCodeUrl(
@@ -127,9 +121,6 @@ class TwoFactorSetupController extends Controller
$request->session()->forget('auth.pending_totp_secret');
$this->logAuthEvent('2fa_setup_confirmed', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
return response()->json([
'recovery_codes' => $plainCodes,
'message' => '2FA включена. Сохраните резервные коды — они показываются один раз.',
@@ -148,9 +139,6 @@ class TwoFactorSetupController extends Controller
$password = $request->string('password')->toString();
if ($password === '' || ! Hash::check($password, $user->password_hash)) {
$this->logAuthEvent('2fa_disable_failed', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), 'invalid_password');
return response()->json([
'message' => 'Неверный пароль.',
'errors' => ['password' => ['Неверный пароль.']],
@@ -166,9 +154,6 @@ class TwoFactorSetupController extends Controller
UserRecoveryCode::query()->where('user_id', $user->id)->delete();
});
$this->logAuthEvent('2fa_disabled', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
return response()->json(['message' => '2FA отключена.']);
}
@@ -202,9 +187,6 @@ class TwoFactorSetupController extends Controller
return $this->generateRecoveryCodes($user->id);
});
$this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
return response()->json([
'recovery_codes' => $plainCodes,
'message' => 'Резервные коды перегенерированы.',
@@ -6,13 +6,11 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OutboundWebhookSubscription;
use App\Support\WebhookUrlGuard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -55,16 +53,6 @@ class WebhookSettingsController extends Controller
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
]);
// SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную
// сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка
// событий) читает из БД только безопасные адреса. NB: будущая доставка
// обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от
// DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный).
$blockReason = WebhookUrlGuard::blockReason($validated['target_url']);
if ($blockReason !== null) {
throw ValidationException::withMessages(['target_url' => [$blockReason]]);
}
$sub = $this->currentSubscription($request);
$plainSecret = null;
@@ -107,25 +95,14 @@ class WebhookSettingsController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
if ($blockReason !== null) {
return response()->json([
'ok' => false,
'status' => null,
'message' => $blockReason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$testPayload = [
'event' => 'webhook.test',
'sent_at' => now()->toIso8601String(),
'message' => 'Тестовая доставка webhook от Лидерра.',
];
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
try {
$response = Http::timeout(10)
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Concerns;
use Illuminate\Support\Facades\DB;
/**
* Запись в auth_log (защищён hash-chain тригером).
* Используется в AuthController, TwoFactorController,
* TwoFactorSetupController, PasswordResetController единственная
* точка записи auth-событий.
*
* Канонические event-strings (расширяемо):
* login_success, login_failed, logout, register_success,
* 2fa_verify_success, 2fa_verify_failed, 2fa_recovery_used, 2fa_recovery_failed,
* 2fa_setup_init, 2fa_setup_confirmed, 2fa_disabled, 2fa_recovery_regenerated,
* password_reset_requested, password_reset_completed, password_reset_failed
*/
trait WritesAuthLog
{
protected function logAuthEvent(
string $event,
?int $userId,
?int $tenantId,
?string $email,
?string $ip,
?string $userAgent,
?string $failureReason,
): void {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'tenant_id' => $tenantId,
'user_id' => $userId,
'email' => $email,
'event' => $event,
'ip_address' => $ip,
'user_agent' => $userAgent,
'failure_reason' => $failureReason,
'created_at' => now(),
]);
}
}
+9 -3
View File
@@ -29,10 +29,16 @@ class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
if (app()->environment('local', 'testing')) {
return $next($request);
}
return $next($request);
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
if (config('app.saas_admin_test_bypass') === true) {
return $next($request);
}
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
}
-13
View File
@@ -15,7 +15,6 @@ use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\DuplicateDetector;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -156,12 +155,6 @@ class ProcessWebhookJob implements ShouldQueue
],
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
}
private function logRejection(Tenant $tenant, string $reason): void
@@ -245,12 +238,6 @@ class ProcessWebhookJob implements ShouldQueue
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
// Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей
// в БД, чтобы при ошибке отправки транзакция уже была зафиксирована.
// NotificationService сам ловит Throwable от Mail::send и логирует —
+1 -27
View File
@@ -15,7 +15,6 @@ use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Bus\Queueable;
@@ -92,20 +91,7 @@ class RouteSupplierLeadJob implements ShouldQueue
LeadDistributor $distributor,
RegionTagResolver $tagResolver,
): void {
$lead = SupplierLead::find($this->supplierLeadId);
// Терминальный случай: лид удалён/не существует — это НЕ транзиентная ошибка,
// повтор бессмыслен. НЕ бросаем ModelNotFoundException: иначе queue->failed()
// пишет строку в failed_webhook_jobs, а RetryFailedSupplierJobsCommand
// бесконечно перезапускает job (retry-шторм, инцидент 21-22.05.2026 —
// 25k+ записей по удалённому лиду №1).
if ($lead === null) {
Log::warning('supplier_lead.not_found_terminal', [
'supplier_lead_id' => $this->supplierLeadId,
]);
return;
}
$lead = SupplierLead::findOrFail($this->supplierLeadId);
// Idempotency guard для retry-сценария ($tries = 3).
// Если лид уже обработан — выходим, не создаём ghost duplicate'ы deal'ов.
@@ -296,12 +282,6 @@ class RouteSupplierLeadJob implements ShouldQueue
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
return false;
}
@@ -324,12 +304,6 @@ class RouteSupplierLeadJob implements ShouldQueue
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
// мог подтянуть deal->project без N+1 lookup'а под RLS.
$deal->setRelation('project', $project);
@@ -210,10 +210,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
// Split the group order across platforms so Σ per-platform == order. The portal does
// NOT divide (verified live 2026-05-21) — the full order on each B = order ×N overspend.
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
$workdaysUnion = [];
foreach ($eligible as $p) {
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
@@ -239,25 +235,24 @@ class SyncSupplierProjectsJob implements ShouldQueue
->get();
if ($existingSps->isEmpty()) {
// Create path: one save PER platform with that platform's divided share
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
// Throws propagate to handle() catch (failover-counter); rows persisted for earlier
// platforms before a throw are recovered next run via the missing-set recovery below.
foreach ($platforms as $platform) {
$dto = new SupplierProjectDto(
platform: $platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$platform] ?? 0,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: [$platform],
);
// Create path: saveProjectMultiFlag → [platform => external_id]
$dto = new SupplierProjectDto(
platform: $platforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $platforms,
);
$idMap = $this->client->saveProjectMultiFlag($dto);
$idMap = $this->client->saveProjectMultiFlag($dto);
// Upsert supplier_projects rows (one per platform)
foreach ($platforms as $platform) {
$externalId = $idMap[$platform] ?? null;
if ($externalId === null) {
continue;
@@ -269,7 +264,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => $order,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -302,21 +297,23 @@ class SyncSupplierProjectsJob implements ShouldQueue
);
if ($deadSps->isNotEmpty()) {
foreach ($deadSps as $sp) {
$recreateDto = new SupplierProjectDto(
platform: $sp->platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$sp->platform] ?? 0,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: [$sp->platform],
);
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
$recreateDto = new SupplierProjectDto(
platform: $deadPlatforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $deadPlatforms,
);
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
foreach ($deadSps as $sp) {
$newId = $recreatedIdMap[$sp->platform] ?? null;
if ($newId !== null) {
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
@@ -338,21 +335,22 @@ class SyncSupplierProjectsJob implements ShouldQueue
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
if ($missingPlatforms !== []) {
foreach ($missingPlatforms as $platform) {
$missingDto = new SupplierProjectDto(
platform: $platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$platform] ?? 0,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: [$platform],
);
$missingDto = new SupplierProjectDto(
platform: $missingPlatforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $missingPlatforms,
);
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
foreach ($missingPlatforms as $platform) {
$externalId = $missingIdMap[$platform] ?? null;
if ($externalId === null) {
continue;
@@ -363,7 +361,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => $order,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -379,9 +377,9 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
// per-platform DTO в update-loop: portal получает правильные srcrt/srcbl/srcmt для
// конкретной строки + её долю лимита ($shares), чтобы Σ по площадкам == order
// (а не order на каждой). Regions/workdays общие для группы.
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
foreach ($existingSps as $sp) {
if ($sp->supplier_external_id === null) {
continue;
@@ -390,7 +388,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
platform: $sp->platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$sp->platform] ?? 0,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
@@ -400,7 +398,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
);
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
$sp->forceFill([
'current_limit' => $shares[$sp->platform] ?? 0,
'current_limit' => $order,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
+83 -77
View File
@@ -14,7 +14,6 @@ use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Services\Supplier\SupplierQuotaAllocator;
use App\Support\RussianRegions;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -117,11 +116,6 @@ class SyncSupplierProjectJob implements ShouldQueue
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
// Split the limit across the platforms so Σ per-platform limits == project limit.
// The portal does NOT divide (verified live 2026-05-21) — replicating the full limit
// to B1/B2/B3 = order ×N (overspend). See SupplierQuotaAllocator::distributeForPlatform.
$shares = SupplierQuotaAllocator::distributeForPlatform((int) $project->daily_limit_target, $platforms);
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
$existingSps = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $identifier)
@@ -130,9 +124,35 @@ class SyncSupplierProjectJob implements ShouldQueue
->get();
if ($existingSps->isEmpty()) {
// Create path: one save PER platform with that platform's divided share
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
$idMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
// Create path: saveProjectMultiFlag → [platform => external_id]
$dto = new SupplierProjectDto(
platform: $platforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $platforms,
);
try {
$idMap = $client->saveProjectMultiFlag($dto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
return;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
return;
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
return;
}
foreach ($platforms as $platform) {
$externalId = $idMap[$platform] ?? null;
@@ -146,7 +166,7 @@ class SyncSupplierProjectJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => (int) $project->daily_limit_target,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -173,7 +193,31 @@ class SyncSupplierProjectJob implements ShouldQueue
if ($deadSps->isNotEmpty()) {
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
$recreatedIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
$recreateDto = new SupplierProjectDto(
platform: $deadPlatforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $deadPlatforms,
);
try {
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
$recreatedIdMap = [];
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
$recreatedIdMap = [];
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
$recreatedIdMap = [];
}
foreach ($deadSps as $sp) {
$newId = $recreatedIdMap[$sp->platform] ?? null;
@@ -188,7 +232,31 @@ class SyncSupplierProjectJob implements ShouldQueue
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
if ($missingPlatforms !== []) {
$missingIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
$missingDto = new SupplierProjectDto(
platform: $missingPlatforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $missingPlatforms,
);
try {
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
$missingIdMap = [];
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
$missingIdMap = [];
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
$missingIdMap = [];
}
foreach ($missingPlatforms as $platform) {
$externalId = $missingIdMap[$platform] ?? null;
@@ -201,7 +269,7 @@ class SyncSupplierProjectJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => (int) $project->daily_limit_target,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -220,7 +288,7 @@ class SyncSupplierProjectJob implements ShouldQueue
platform: $sp->platform,
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: $shares[$sp->platform] ?? 0,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
@@ -230,7 +298,7 @@ class SyncSupplierProjectJob implements ShouldQueue
);
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
$sp->forceFill([
'current_limit' => $shares[$sp->platform] ?? 0,
'current_limit' => (int) $project->daily_limit_target,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -327,68 +395,6 @@ class SyncSupplierProjectJob implements ShouldQueue
$project->save();
}
/**
* Создаёт проекты на портале ПО ОДНОМУ на платформу с её долей лимита ($shares).
*
* Один single-flag save = ровно один rt-проект надёжный id через listProjects-матч.
* Так per-platform лимит = доля (Σ == заказу), а не полный лимит на каждой площадке.
* Per-platform tolerance: tier-escalation / window-defer / прочая ошибка одной площадки
* не валит остальные пропускаем, следующий run (или ночной батч) подберёт недостающее.
*
* @param array<string, int> $shares [platform => лимит площадки]
* @param list<string> $platformsToCreate
* @return array<string, int> [platform => external_id] для успешно созданных
*/
private function createPerPlatform(
SupplierPortalClient $client,
Project $project,
string $identifier,
string $tag,
array $workdays,
array $allRegions,
array $shares,
array $platformsToCreate,
): array {
$idMap = [];
foreach ($platformsToCreate as $platform) {
$dto = new SupplierProjectDto(
platform: $platform,
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: $shares[$platform] ?? 0,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: [$platform],
);
try {
$result = $client->saveProjectMultiFlag($dto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
continue;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
continue;
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: online per-platform save failed for project {$project->id} {$platform} (".get_class($e).'): '.$e->getMessage());
continue;
}
if (isset($result[$platform])) {
$idMap[$platform] = $result[$platform];
}
}
return $idMap;
}
/**
* Bitmask ISO weekday list. bit 0 = Mon (ISO 1) bit 6 = Sun (ISO 7).
*
@@ -10,7 +10,6 @@ use App\Models\ImportUnknownStatus;
use App\Models\Project;
use App\Models\Reminder;
use App\Services\MonthlyPartitionManager;
use App\Services\Pd\PdAuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
@@ -27,7 +26,6 @@ final class HistoricalImportService
public function __construct(
private readonly MonthlyPartitionManager $partitions,
private readonly StatusRuToSlugMapper $statusMapper,
private readonly PdAuditLogger $pdLog,
) {}
/**
@@ -70,7 +68,7 @@ final class HistoricalImportService
}
try {
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug, $log->id);
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
$wasCreated ? $added++ : $updated++;
} catch (Throwable $e) {
$skipped++;
@@ -134,9 +132,9 @@ final class HistoricalImportService
* Идемпотентный upsert одной строки в собственной транзакции.
* Возвращает true создана новая сделка, false обновлена существующая.
*/
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug, int $importLogId): bool
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
{
return DB::transaction(function () use ($tenantId, $userId, $row, $slug, $importLogId): bool {
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$project = Project::firstOrCreate(
@@ -190,17 +188,6 @@ final class HistoricalImportService
$this->syncReminder($tenantId, $userId, $deal, $row);
$this->pdLog->record(
action: 'created',
subjectType: 'lead',
subjectId: $deal->id,
purpose: 'lead_create_import_'.$importLogId,
tenantId: $tenantId,
actorTenantUserId: $userId,
actorAdminUserId: null,
ip: null,
);
return true;
});
}
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Pd;
use App\Models\ImpersonationToken;
use App\Models\SaasAdminAuditLog;
/**
* Оркестратор аудита impersonation: пишет защищённый saas_admin_audit_log
* на init/verify/end и ПДн-след (pd_processing_log) на verify вход админа
* в кабинет тенанта = массовый доступ к ПДн (152-ФЗ).
*/
final class ImpersonationAuditService
{
public function __construct(private readonly PdAuditLogger $pd) {}
public function recordInit(ImpersonationToken $t, int $adminId, ?string $ip): void
{
SaasAdminAuditLog::create([
'admin_user_id' => $adminId,
'action' => 'impersonation.init',
'target_type' => 'tenant',
'target_id' => $t->tenant_id,
'target_tenant_id' => $t->tenant_id,
'payload_before' => null,
'payload_after' => ['token_id' => $t->id, 'expires_at' => $t->expires_at->toIso8601String()],
'reason' => $t->reason,
'ip_address' => $ip ?? '127.0.0.1',
'user_agent' => null,
]);
}
public function recordVerify(ImpersonationToken $t, int $adminId, ?string $ip): void
{
SaasAdminAuditLog::create([
'admin_user_id' => $adminId,
'action' => 'impersonation.verify',
'target_type' => 'tenant',
'target_id' => $t->tenant_id,
'target_tenant_id' => $t->tenant_id,
'payload_before' => ['used_at' => null],
'payload_after' => ['used_at' => now()->toIso8601String()],
'reason' => $t->reason,
'ip_address' => $ip ?? '127.0.0.1',
'user_agent' => null,
]);
// ПДн-след: вход админа в кабинет = массовый доступ к ПДн тенанта.
$this->pd->record(
action: 'viewed', subjectType: 'tenant', subjectId: $t->tenant_id,
purpose: 'impersonation_session_'.$t->id,
tenantId: $t->tenant_id,
actorTenantUserId: null, actorAdminUserId: $adminId, ip: $ip,
);
}
public function recordEnd(ImpersonationToken $t, int $adminId, ?string $ip): void
{
SaasAdminAuditLog::create([
'admin_user_id' => $adminId,
'action' => 'impersonation.end',
'target_type' => 'tenant',
'target_id' => $t->tenant_id,
'target_tenant_id' => $t->tenant_id,
'payload_before' => ['session_ended_at' => null],
'payload_after' => ['session_ended_at' => now()->toIso8601String()],
'reason' => $t->reason,
'ip_address' => $ip ?? '127.0.0.1',
'user_agent' => null,
]);
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Pd;
use Illuminate\Support\Facades\DB;
/**
* Запись в pd_processing_log (152-ФЗ ст.18 ч.2). Hash-chain trigger
* audit_chain_hash() автоматически заполняет log_hash; append-only
* защита триггер audit_block_mutation (UPDATE/DELETE заблокированы).
*
* chk_pd_actor: ровно один актор из tenant_user/admin, либо оба NULL
* (системное действие cron / триггер).
*/
final class PdAuditLogger
{
/** @param string $action one of 'created','viewed','updated','deleted','exported' */
public function record(
string $action,
?string $subjectType,
?int $subjectId,
string $purpose,
?int $tenantId,
?int $actorTenantUserId,
?int $actorAdminUserId,
?string $ip,
): void {
DB::table('pd_processing_log')->insert([
'tenant_id' => $tenantId,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'action' => $action,
'purpose' => $purpose,
'actor_tenant_user_id' => $actorTenantUserId,
'actor_admin_user_id' => $actorAdminUserId,
'ip_address' => $ip,
'created_at' => now(),
]);
}
}
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Import;
/**
* Pure-хелперы перевода полей строки rt-проекта поставщика поля Лидерры.
* Без побочных эффектов и зависимостей только статические функции.
*
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md §4
*/
final class SupplierImportMapper
{
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
private const TYPE_TO_SIGNAL = ['calls' => 'call', 'hosts' => 'site', 'sms' => 'sms'];
public static function platformFromSrc(string $src): ?string
{
return self::SRC_TO_PLATFORM[$src] ?? null;
}
public static function signalTypeFromType(string $type): ?string
{
return self::TYPE_TO_SIGNAL[$type] ?? null;
}
/**
* Строку ГИБДД-кодов («24», «24,77», «24, 77 78») list<int>.
* Пусто/null [].
*
* @return list<int>
*/
public static function parseGibddRegions(?string $regions): array
{
if ($regions === null) {
return [];
}
$parts = preg_split('/[,\s]+/', trim($regions), -1, PREG_SPLIT_NO_EMPTY);
if ($parts === false || $parts === []) {
return [];
}
return array_map(static fn (string $p): int => (int) $p, $parts);
}
/**
* Список дней-строк ["1".."7"] (1=Пн..7=Вс ISO) битовая маска (bit0=Пн).
* Пусто 127 (все дни).
*
* @param list<int|string> $workdays
*/
public static function workdaysToMask(array $workdays): int
{
if ($workdays === []) {
return 127;
}
$mask = 0;
foreach ($workdays as $d) {
$day = (int) $d;
if ($day >= 1 && $day <= 7) {
$mask |= (1 << ($day - 1));
}
}
return $mask === 0 ? 127 : $mask;
}
/**
* sms-content: «sender+keyword» ['sender'=>, 'keyword'=>];
* «sender» (без плюса) ['sender'=>, 'keyword'=>null].
*
* @return array{sender: string, keyword: string|null}
*/
public static function parseSmsContent(string $content): array
{
$plus = strpos($content, '+');
if ($plus === false) {
return ['sender' => $content, 'keyword' => null];
}
return [
'sender' => substr($content, 0, $plus),
'keyword' => substr($content, $plus + 1),
];
}
}
@@ -1,348 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Import;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Support\SupplierRegions;
use Illuminate\Support\Facades\DB;
/**
* Усыновление активных проектов поставщика (аккаунт lkomega) как проектов
* Лидерры. Читает listProjects (read-only), группирует площадки B1/B2/B3 в один
* проект, реверс-маппит регионы, считает лимит как сумму площадок.
*
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md
*/
class SupplierProjectImporter
{
private const DB_CONNECTION = 'pgsql_supplier';
public function __construct(
private readonly SupplierPortalClient $client,
) {}
/**
* @return array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>}
*/
public function buildPlan(int $tenantId): array
{
$rows = $this->client->listProjects();
/** @var list<array{reason: string, label: string}> $skipped */
$skipped = [];
/** @var array<string, array<string, mixed>> $groups */
$groups = [];
foreach ($rows as $row) {
if (($row['status'] ?? false) !== true) {
continue;
}
$platform = SupplierImportMapper::platformFromSrc((string) ($row['src'] ?? ''));
if ($platform === null) {
$skipped[] = ['reason' => 'unsupported_source', 'label' => (string) ($row['name'] ?? $row['content'] ?? '?')];
continue;
}
$signalType = SupplierImportMapper::signalTypeFromType((string) ($row['type'] ?? ''));
if ($signalType === null) {
$skipped[] = ['reason' => 'unsupported_type', 'label' => (string) ($row['name'] ?? '?')];
continue;
}
if ($signalType === 'sms') {
$parsed = SupplierImportMapper::parseSmsContent((string) ($row['content'] ?? ''));
$sender = $parsed['sender'];
if ($sender === '') {
$skipped[] = ['reason' => 'sms_unparseable', 'label' => (string) ($row['name'] ?? '?')];
continue;
}
$key = 'sms|'.$sender;
if (! isset($groups[$key])) {
$groups[$key] = [
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => [$sender],
'sms_keyword' => null,
'tag' => '',
'regions' => [],
'has_all_russia' => false,
'workdays_mask' => 0,
'daily_limit_target' => 0,
'platforms' => [],
];
}
if ($parsed['keyword'] !== null && $parsed['keyword'] !== '' && $groups[$key]['sms_keyword'] === null) {
$groups[$key]['sms_keyword'] = $parsed['keyword'];
}
if (($row['regions_reverse'] ?? false) === true) {
$skipped[] = ['reason' => 'regions_exclude', 'label' => $sender];
$groups[$key]['__excluded'] = true;
}
$this->accumulateRow($groups[$key], $row, $platform);
continue;
}
$identifier = (string) ($row['content'] ?? '');
$key = $signalType.'|'.$identifier;
if (! isset($groups[$key])) {
$groups[$key] = [
'signal_type' => $signalType,
'signal_identifier' => $identifier,
'sms_senders' => [],
'sms_keyword' => null,
'tag' => '',
'regions' => [],
'has_all_russia' => false,
'workdays_mask' => 0,
'daily_limit_target' => 0,
'platforms' => [],
];
}
if (($row['regions_reverse'] ?? false) === true) {
$skipped[] = ['reason' => 'regions_exclude', 'label' => $identifier];
$groups[$key]['__excluded'] = true;
}
$this->accumulateRow($groups[$key], $row, $platform);
}
$planned = [];
foreach ($groups as $g) {
if (($g['__excluded'] ?? false) === true) {
continue;
}
unset($g['__excluded']);
unset($g['has_all_russia']);
$g['delivery_days_mask'] = $g['workdays_mask'] === 0 ? 127 : $g['workdays_mask'];
unset($g['workdays_mask']);
if ($g['tag'] === '') {
$g['tag'] = 'РФ';
}
$g['name'] = $this->deriveName($g);
if ($this->projectExists($tenantId, $g)) {
$skipped[] = ['reason' => 'already_exists', 'label' => $this->groupLabel($g)];
continue;
}
$planned[] = $g;
}
return ['planned' => $planned, 'skipped' => $skipped];
}
/**
* Пишет план в БД: Project + supplier_projects (external_id с портала) + pivot.
* НЕ обращается к порталу. Каждый проект в своей транзакции.
*
* @param array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>} $plan
* @return array{created_projects: int, created_supplier_projects: int, created_links: int}
*/
public function commit(array $plan, int $tenantId): array
{
$createdProjects = 0;
$createdSps = 0;
$createdLinks = 0;
$conn = DB::connection(self::DB_CONNECTION);
foreach ($plan['planned'] as $item) {
$writeItem = function () use ($item, $tenantId, &$createdProjects, &$createdSps, &$createdLinks): void {
/** @var Project $project */
$project = Project::on(self::DB_CONNECTION)->create([
'tenant_id' => $tenantId,
'name' => $item['name'],
'tag' => $item['tag'],
'is_active' => true,
'signal_type' => $item['signal_type'],
'signal_identifier' => $item['signal_identifier'],
'sms_senders' => $item['sms_senders'] !== [] ? $item['sms_senders'] : null,
'sms_keyword' => $item['sms_keyword'],
'regions' => $item['regions'],
'region_mode' => 'include',
'delivery_days_mask' => $item['delivery_days_mask'],
'daily_limit_target' => $item['daily_limit_target'],
]);
$createdProjects++;
foreach ($item['platforms'] as $pl) {
$platform = (string) $pl['platform'];
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
/** @var SupplierProject $sp */
$sp = SupplierProject::on(self::DB_CONNECTION)->firstOrCreate(
['platform' => $platform, 'unique_key' => $uniqueKey, 'subject_code' => null],
[
'signal_type' => $item['signal_type'],
'supplier_external_id' => (string) $pl['external_id'],
'current_limit' => (int) $pl['lim'],
'current_workdays' => $this->maskToList((int) $item['delivery_days_mask']),
'current_regions' => $item['regions'],
'sync_status' => 'ok',
'last_synced_at' => now(),
],
);
if ($sp->wasRecentlyCreated) {
$createdSps++;
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'create',
'http_status' => 200,
'created_at' => now(),
]);
}
$inserted = DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $platform,
'subject_code' => null,
]);
$createdLinks += $inserted;
}
};
// Per-project atomicity (spec §8): сбой посреди группы не должен оставить
// orphan-Project без supplier_projects/pivot. В проде оборачиваем в транзакцию.
// Под тестовым харнессом (SharesSupplierPdo + DatabaseTransactions) общий PDO
// уже в транзакции — повторный BEGIN бросил бы «already active», поэтому пишем
// напрямую (внешняя транзакция теста сама откатится).
if ($conn->getPdo()->inTransaction()) {
$writeItem();
} else {
$conn->transaction($writeItem);
}
}
return [
'created_projects' => $createdProjects,
'created_supplier_projects' => $createdSps,
'created_links' => $createdLinks,
];
}
/**
* Маска дней (bit0=Пн) list<int> [1..7].
*
* @return list<int>
*/
private function maskToList(int $mask): array
{
$out = [];
for ($i = 0; $i < 7; $i++) {
if (($mask & (1 << $i)) !== 0) {
$out[] = $i + 1;
}
}
return $out;
}
/**
* @param array<string, mixed> $group
* @param array<string, mixed> $row
*/
private function accumulateRow(array &$group, array $row, string $platform): void
{
$lim = (int) ($row['lim'] ?? 0);
$group['daily_limit_target'] += $lim;
$group['platforms'][] = [
'platform' => $platform,
'external_id' => (int) ($row['id'] ?? 0),
'lim' => $lim,
];
$rowTag = trim((string) ($row['tag'] ?? ''));
if ($group['tag'] === '' && $rowTag !== '' && $rowTag !== 'РФ') {
$group['tag'] = $rowTag;
}
$group['workdays_mask'] |= SupplierImportMapper::workdaysToMask((array) ($row['workdays'] ?? []));
if (! $group['has_all_russia']) {
$gibdd = SupplierImportMapper::parseGibddRegions(
is_string($row['regions'] ?? null) ? $row['regions'] : ''
);
if ($gibdd === []) {
$group['has_all_russia'] = true;
$group['regions'] = [];
} else {
$liderra = SupplierRegions::mapFromSupplier($gibdd);
$group['regions'] = array_values(array_unique(array_merge($group['regions'], $liderra)));
sort($group['regions']);
}
}
}
/**
* @param array<string, mixed> $group
*/
private function projectExists(int $tenantId, array $group): bool
{
$query = Project::on('pgsql_supplier')
->where('tenant_id', $tenantId)
->where('signal_type', $group['signal_type']);
if ($group['signal_type'] === 'sms') {
$sender = $group['sms_senders'][0] ?? '';
$keyword = $group['sms_keyword'];
return $query
->whereJsonContains('sms_senders', $sender)
->where(fn ($q) => $keyword === null ? $q->whereNull('sms_keyword') : $q->where('sms_keyword', $keyword))
->exists();
}
return $query->where('signal_identifier', $group['signal_identifier'])->exists();
}
/**
* @param array<string, mixed> $group
*/
private function groupLabel(array $group): string
{
return $group['signal_type'] === 'sms'
? (string) ($group['sms_senders'][0] ?? '?')
: (string) ($group['signal_identifier'] ?? '?');
}
/**
* @param array<string, mixed> $group
*/
private function deriveName(array $group): string
{
$tag = trim((string) $group['tag']);
$identifier = $group['signal_type'] === 'sms'
? (string) ($group['sms_senders'][0] ?? '')
: (string) ($group['signal_identifier'] ?? '');
// projects has UNIQUE(tenant_id, name): несколько групп с одинаковым тегом
// («КРК» приходит на десятки разных телефонов) обязаны иметь разные имена.
// Поэтому комбинируем тег + идентификатор. «РФ» — placeholder тега, не часть имени.
$tagPart = ($tag !== '' && $tag !== 'РФ') ? $tag : '';
if ($tagPart !== '' && $identifier !== '') {
$name = $tagPart.' · '.$identifier;
} elseif ($tagPart !== '') {
$name = $tagPart;
} elseif ($identifier !== '') {
$name = $identifier;
} else {
$name = 'проект';
}
return mb_substr($name, 0, 255);
}
}
@@ -8,7 +8,7 @@ use App\Exceptions\Supplier\SupplierAuthException;
class PlaywrightBridge
{
private const TIMEOUT_SECONDS = 75; // 60s Node timeout + 15s safety buffer
private const TIMEOUT_SECONDS = 180; // 60s Node timeout + запас на холодный старт Chromium на маломощных VM (тест-сервер YC 2vCPU/2GB: ~65s wall-clock на refresh-session). До 21.05.2026 было 75с — упиралось на тест-сервере.
private const SCRIPT_RELATIVE_PATH = 'playwright/refresh-session.js';
@@ -9,7 +9,6 @@ use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Support\SupplierRegions;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
@@ -478,10 +477,7 @@ class SupplierPortalClient
'srcseg' => false,
'limit' => $dto->limit,
'workdays' => $workdays,
// DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
// свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
// → Архангельск 29). См. App\Support\SupplierRegions.
'regions' => SupplierRegions::mapToSupplier($dto->regions),
'regions' => $dto->regions,
'regions_reverse' => $dto->regionsReverse,
'status' => $dto->status === 'active',
'show' => true,
@@ -11,19 +11,14 @@ use Illuminate\Support\Collection;
/**
* Pure function: формула заказа у поставщика на (источник × субъект).
*
* Заказ группы eligible-клиентов:
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён портал
* делит лимит сам (R6). Один лимит на группу eligible-клиентов:
*
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
*
* ceil(Σ/3) ёмкость шаринга (лид продаётся ≤3 раз клиентам Лидерры).
* ceil(Σ/3) ёмкость шаринга (лид продаётся ≤3 раз).
* наиб крупнейший клиент должен иметь шанс добрать.
*
* Этот `order` затем ДЕЛИТСЯ между площадками B1/B2/B3 через distributeForPlatform()
* так, чтобы Σ per-platform лимитов == order. Портал НЕ делит сам: проверено вживую
* 2026-05-21 (listProjects) каждый B-проект честно набирает до своего лимита
* независимо, поэтому одинаковый лимит на 3 площадках = заказ ×3 (переплата).
* Plan 3 R6 («портал делит, verified 15→5») оказался ложным split восстановлен.
*
* `allocate()` оставлен с прежней сигнатурой для временной совместимости
* c SyncSupplierProjectsJob внутри использует computeOrder, возвращает
* DTO с одинаковым limit на любую platform/signalType.
@@ -81,7 +76,7 @@ final class SupplierQuotaAllocator
* ceil(Σ/3) ёмкость шаринга (лид продаётся ≤3 раз).
* наиб крупнейший клиент должен иметь шанс добрать.
*
* Возвращает заказ ГРУППЫ; деление между B1/B2/B3 distributeForPlatform().
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 наш split убран).
*
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
*/
@@ -97,40 +92,6 @@ final class SupplierQuotaAllocator
return max($max, (int) ceil($sum / 3));
}
/**
* Делит групповой заказ между площадками так, чтобы СУММА per-platform лимитов == order.
*
* Largest-remainder: каждой площадке floor(order/N), затем по +1 первым (order mod N)
* площадкам в порядке списка. Сумма всегда точно равна order ни переплаты, ни недобора.
*
* Восстанавливает поведение, удалённое в Plan 3 R6 (ошибочное допущение «портал делит сам»).
* Портал НЕ делит каждый B-проект набирает до своего лимита независимо; одинаковый
* лимит на N площадках = заказ ×N (переплата). Verified live 2026-05-21.
*
* @param list<string> $platforms площадки в каноническом порядке (B1<B2<B3)
* @return array<string, int> [platform => лимит этой площадки]
*/
public static function distributeForPlatform(int $order, array $platforms): array
{
$count = count($platforms);
if ($count === 0) {
return [];
}
$order = max(0, $order);
$base = intdiv($order, $count);
$remainder = $order % $count;
$shares = [];
$i = 0;
foreach ($platforms as $platform) {
$shares[$platform] = $base + ($i < $remainder ? 1 : 0);
$i++;
}
return $shares;
}
/**
* @param Collection<int, mixed> $arrays
* @return array<int, int>
-200
View File
@@ -1,200 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Illuminate\Support\Facades\Log;
/**
* Перевод кодов регионов: Лидерра поставщик crm.bp-gr.ru.
*
* Лидерра нумерует субъекты РФ по конституционному порядку (ст. 65), 1..89
* см. {@see RussianRegions}: Красноярский край = 29, Архангельская обл. = 35.
* Поставщик нумерует по автомобильным кодам (ГИБДД): Красноярский = 24,
* Архангельская = 29. Без перевода Sync отправлял Лидерра-код «как есть»
* (`regions => [29]` для Красноярского), а поставщик понимал его как СВОЙ 29 =
* Архангельск у поставщика выбирался ЧУЖОЙ регион. На dev не всплывало
* проверяли на «вся РФ» (пустой regions).
*
* Карта построена сверкой имён {@see RussianRegions::CODE_TO_NAME} live-дерево
* регионов формы «Добавить проект» поставщика (recon 2026-05-21: node-key="id",
* 79 субъектов-листьев). Все 79 кодов поставщика покрыты (биекция на 79).
*
* 10 субъектов Лидерры поставщик НЕ предлагает (нет в дереве) их коды
* отбрасываются при переводе (с warning'ом): Московская обл. (56),
* Ленинградская обл. (53), Крым (13), Севастополь (84), ДНР (6), ЛНР (14),
* Запорожская (43), Херсонская (79), Ненецкий АО (86), Ямало-Ненецкий АО (89).
* Если у проекта это был ЕДИНСТВЕННЫЙ регион у поставщика проект окажется без
* георфильтра (вся РФ). Это ограничение покрытия поставщика, не баг перевода.
*/
final class SupplierRegions
{
/**
* Лидерра-код (конституционный 1..89) => код поставщика (ГИБДД).
*
* @var array<int, int>
*/
public const LIDERRA_TO_SUPPLIER = [
// Республики
1 => 1, // Республика Адыгея
2 => 4, // Республика Алтай
3 => 2, // Республика Башкортостан
4 => 3, // Республика Бурятия
5 => 5, // Республика Дагестан
7 => 6, // Республика Ингушетия
8 => 7, // Кабардино-Балкарская Республика
9 => 8, // Республика Калмыкия
10 => 9, // Карачаево-Черкесская Республика
11 => 10, // Республика Карелия
12 => 11, // Республика Коми
15 => 12, // Республика Марий Эл
16 => 13, // Республика Мордовия
17 => 14, // Республика Саха (Якутия)
18 => 15, // Республика Северная Осетия — Алания
19 => 16, // Республика Татарстан
20 => 17, // Республика Тыва
21 => 18, // Удмуртская Республика
22 => 19, // Республика Хакасия
23 => 20, // Чеченская Республика
24 => 21, // Чувашская Республика
// Края
25 => 22, // Алтайский край
26 => 75, // Забайкальский край
27 => 41, // Камчатский край
28 => 23, // Краснодарский край
29 => 24, // Красноярский край
30 => 59, // Пермский край
31 => 25, // Приморский край
32 => 26, // Ставропольский край
33 => 27, // Хабаровский край
// Области
34 => 28, // Амурская область
35 => 29, // Архангельская область
36 => 30, // Астраханская область
37 => 31, // Белгородская область
38 => 32, // Брянская область
39 => 33, // Владимирская область
40 => 34, // Волгоградская область
41 => 35, // Вологодская область
42 => 36, // Воронежская область
44 => 37, // Ивановская область
45 => 38, // Иркутская область
46 => 39, // Калининградская область
47 => 40, // Калужская область
48 => 42, // Кемеровская область
49 => 43, // Кировская область
50 => 44, // Костромская область
51 => 45, // Курганская область
52 => 46, // Курская область
54 => 48, // Липецкая область
55 => 49, // Магаданская область
57 => 51, // Мурманская область
58 => 52, // Нижегородская область
59 => 53, // Новгородская область
60 => 54, // Новосибирская область
61 => 55, // Омская область
62 => 56, // Оренбургская область
63 => 57, // Орловская область
64 => 58, // Пензенская область
65 => 60, // Псковская область
66 => 61, // Ростовская область
67 => 62, // Рязанская область
68 => 63, // Самарская область
69 => 64, // Саратовская область
70 => 65, // Сахалинская область
71 => 66, // Свердловская область
72 => 67, // Смоленская область
73 => 68, // Тамбовская область
74 => 69, // Тверская область
75 => 70, // Томская область
76 => 71, // Тульская область
77 => 72, // Тюменская область
78 => 73, // Ульяновская область
80 => 74, // Челябинская область
81 => 76, // Ярославская область
// Города федерального значения
82 => 77, // Москва
83 => 78, // Санкт-Петербург
// Автономная область / округа
85 => 79, // Еврейская автономная область
87 => 86, // Ханты-Мансийский автономный округ — Югра
88 => 87, // Чукотский автономный округ
];
/**
* Переводит Лидерра-коды регионов в коды поставщика. Неизвестные (нет у
* поставщика) отбрасываются с warning'ом; sentinel 0 («Вся РФ») игнорируется.
* Результат уникальные коды поставщика по возрастанию.
*
* @param list<int>|array<int|string, int|string> $liderraCodes
* @return list<int>
*/
public static function mapToSupplier(array $liderraCodes): array
{
$out = [];
$dropped = [];
foreach ($liderraCodes as $code) {
$code = (int) $code;
if ($code === 0) {
continue; // sentinel «Вся РФ»
}
if (isset(self::LIDERRA_TO_SUPPLIER[$code])) {
$out[self::LIDERRA_TO_SUPPLIER[$code]] = true;
} else {
$dropped[] = $code;
}
}
if ($dropped !== []) {
Log::warning('supplier.regions.unmapped', [
'liderra_codes' => $dropped,
'note' => 'supplier does not offer these subjects — geo-filter dropped for them',
]);
}
$codes = array_keys($out);
sort($codes);
return $codes;
}
/**
* Инверсия {@see mapToSupplier}: коды поставщика (ГИБДД) Лидерра-коды
* (конституционный порядок). Неизвестные коды поставщика отбрасываются
* с warning'ом. Результат уникальные Лидерра-коды по возрастанию.
*
* @param list<int>|array<int|string, int|string> $supplierCodes
* @return list<int>
*/
public static function mapFromSupplier(array $supplierCodes): array
{
/** @var array<int, int> $supplierToLiderra */
$supplierToLiderra = array_flip(self::LIDERRA_TO_SUPPLIER);
$out = [];
$dropped = [];
foreach ($supplierCodes as $code) {
$code = (int) $code;
if (isset($supplierToLiderra[$code])) {
$out[$supplierToLiderra[$code]] = true;
} else {
$dropped[] = $code;
}
}
if ($dropped !== []) {
Log::warning('supplier.regions.unmapped_reverse', [
'supplier_codes' => $dropped,
'note' => 'supplier code has no Liderra equivalent — dropped on import',
]);
}
$codes = array_keys($out);
sort($codes);
return $codes;
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* SSRF-гард для исходящих webhook-URL.
*
* Webhook target_url задаёт авторизованный админ тенанта. Без проверки он может
* указать внутренний адрес (`https://169.254.169.254/` cloud-metadata,
* `https://127.0.0.1/`, `https://10.0.0.0/8`) и через кнопку «тест» получить
* ответ внутренней службы (SSRF + info-leak). starts_with:https:// этого не ловит.
*
* Политика: блокируем, только если хост РАЗРЕШАЕТСЯ в приватный/зарезервированный
* IP. Неразрешимый хост (NXDOMAIN) не SSRF-вектор, пропускаем (реальный запрос
* упадёт сам). Проверяются все A/AAAA-записи (защита от hostname→private).
*/
final class WebhookUrlGuard
{
/**
* @return string|null Причина блокировки (человекочитаемая) или null, если адрес безопасен.
*/
public static function blockReason(string $url): ?string
{
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return 'Некорректный URL webhook.';
}
$host = trim($host, '[]'); // снять скобки IPv6-литерала
foreach (self::resolve($host) as $ip) {
if (! self::isPublicIp($ip)) {
return 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.';
}
}
return null;
}
/** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
private static function resolve(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return [$host]; // IP-литерал — без DNS
}
$ips = [];
$v4 = gethostbynamel($host);
if (is_array($v4)) {
$ips = array_merge($ips, $v4);
}
$aaaa = @dns_get_record($host, DNS_AAAA);
if (is_array($aaaa)) {
foreach ($aaaa as $rec) {
if (isset($rec['ipv6']) && is_string($rec['ipv6'])) {
$ips[] = $rec['ipv6'];
}
}
}
return array_values(array_unique($ips));
}
private static function isPublicIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$lower = strtolower($ip);
// loopback / unspecified
if ($lower === '::1' || $lower === '::') {
return false;
}
// link-local fe80::/10
if (preg_match('/^fe[89ab]/', $lower) === 1) {
return false;
}
// unique-local fc00::/7
if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) {
return false;
}
// IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4
if (str_contains($lower, '::ffff:')) {
$v4 = substr($lower, (int) strrpos($lower, ':') + 1);
if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
return self::isPublicIp($v4);
}
}
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
return false;
}
}
+7
View File
@@ -28,6 +28,13 @@ return [
'env' => env('APP_ENV', 'production'),
/*
| ВРЕМЕННО (тест-деплой): пропуск гейта SaaS-admin зоны вне local/testing.
| По умолчанию false прод не затронут. Включается только на тест-сервере
| (SAAS_ADMIN_TEST_BYPASS=true). Убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
*/
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
+1 -1
View File
@@ -1347,7 +1347,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 3
count: 2
path: tests/Feature/ImpersonationTest.php
-
+5 -15
View File
@@ -194,12 +194,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
});
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum
// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра
// запроса — закрывает кросс-tenant утечку KPI (как DealController J1).
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
});
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
@@ -231,15 +228,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
});
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из
// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses —
// глобальная таблица (без tenant_id), нужен только auth:sanctum.
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
});
Route::middleware('auth:sanctum')->group(function () {
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
});
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
@@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
/**
* Reset the Auth manager's default guard and cached guard instances back to
* the 'web' SessionGuard.
*
* Necessary because auth:sanctum middleware calls Auth::shouldUse('sanctum')
* on every successfully-authenticated request, which permanently changes
* config('auth.defaults.guard') to 'sanctum' in the shared test application
* instance. Laravel feature tests reuse the same $this->app between HTTP calls,
* so this pollution persists across requests. Any subsequent call to
* Auth::login() (which internally calls Auth::guard()->login()) then resolves
* to the Sanctum RequestGuard which has no login() method and throws a
* BadMethodCallException.
*
* The reset must happen *before* any request whose controller calls Auth::login()
* without an explicit guard argument (i.e. login and 2fa/verify routes).
*/
function resetAuthToWebGuard(): void
{
app('auth')->forgetGuards();
app('auth')->setDefaultDriver('web');
}
it('full auth-flow writes all expected auth_log events', function () {
Notification::fake();
$tenant = Tenant::factory()->create();
// ── Step 1: Register ─────────────────────────────────────────────────────
$this->postJson('/api/auth/register', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
'accept_offer' => true,
'accept_pdn' => true,
])->assertStatus(201);
// logs: register_success
$user = User::where('email', 'flow-test@example.ru')->first();
expect($user)->not->toBeNull();
// ── Step 2: Login (no 2FA yet) — establish session auth ──────────────────
// No prior auth:sanctum request, so no reset needed here.
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// logs: login_success (first direct login, 2FA not yet enabled)
// ── Step 3: 2FA init (session-authenticated via web guard) ───────────────
// auth:sanctum middleware → shouldUse('sanctum') → default becomes 'sanctum'
$this->postJson('/api/2fa/init')->assertOk();
// logs: 2fa_setup_init
$secret = session('auth.pending_totp_secret');
expect($secret)->not->toBeNull();
// ── Step 4: 2FA confirm ───────────────────────────────────────────────────
$google2fa = new Google2FA;
$code = $google2fa->getCurrentOtp($secret);
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
// logs: 2fa_setup_confirmed (totp_enabled now true)
// ── Step 5: Logout ────────────────────────────────────────────────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/auth/logout')->assertOk();
// logs: logout
// ── Step 6: Login with 2FA enabled ────────────────────────────────────────
// auth.defaults.guard is now 'sanctum' from previous auth:sanctum requests.
// Reset to 'web' so Auth::login() inside AuthController::login() finds the
// SessionGuard (which implements login()) rather than the RequestGuard.
resetAuthToWebGuard();
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// requires_2fa=true, pending_user_id stored in session
// ── Step 7: 2FA verify — completes login ─────────────────────────────────
// No auth:sanctum request happened since the last reset, so no reset needed.
$validCode = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/auth/2fa/verify', ['code' => $validCode])->assertOk();
// logs: 2fa_verify_success
// ── Step 8: 2FA disable (session-authenticated from step 7) ──────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/2fa/disable', ['password' => 'secure-pass-1234'])->assertOk();
// logs: 2fa_disabled
// ── Step 9: Logout ────────────────────────────────────────────────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/auth/logout')->assertOk();
// ── Step 10: Login without 2FA — direct login_success ────────────────────
// Reset again: auth.defaults.guard is 'sanctum' from Step 8+9 auth:sanctum.
resetAuthToWebGuard();
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// logs: login_success (direct login, 2FA now disabled)
// ── Step 11: Forgot password ──────────────────────────────────────────────
$this->postJson('/api/auth/logout')->assertOk();
$this->postJson('/api/auth/forgot', [
'email' => 'flow-test@example.ru',
])->assertOk();
// logs: password_reset_requested
// ── Step 12: Reset password ───────────────────────────────────────────────
$token = Password::createToken($user);
$this->postJson('/api/auth/reset-password', [
'token' => $token,
'email' => 'flow-test@example.ru',
'password' => 'new-secure-pass-5678',
'password_confirmation' => 'new-secure-pass-5678',
])->assertOk();
// logs: password_reset_completed
// ── Assert all expected events were recorded for this user ────────────────
$events = DB::table('auth_log')
->where('user_id', $user->id)
->pluck('event')
->all();
expect($events)->toContain(
'register_success',
'2fa_setup_init',
'2fa_setup_confirmed',
'logout',
'login_success',
'2fa_verify_success',
'2fa_disabled',
'password_reset_requested',
'password_reset_completed',
);
});
@@ -1,449 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
it('logout writes auth_log event=logout', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'logout-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
]);
$this->postJson('/api/auth/login', [
'email' => 'logout-log@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$this->postJson('/api/auth/logout')->assertOk();
$row = DB::table('auth_log')
->where('event', 'logout')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('register writes auth_log event=register_success', function () {
Tenant::factory()->create();
$response = $this->postJson('/api/auth/register', [
'email' => 'reg-log-test@example.ru',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertStatus(201);
$user = User::where('email', 'reg-log-test@example.ru')->first();
$row = DB::table('auth_log')
->where('event', 'register_success')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->email)->toBe('reg-log-test@example.ru');
});
it('2fa verify success writes auth_log event=2fa_verify_success', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-log-success@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
// Step 1: login to set pending_user_id in session.
$this->postJson('/api/auth/login', [
'email' => '2fa-log-success@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
// Step 2: verify with valid code.
$validCode = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/auth/2fa/verify', [
'code' => $validCode,
])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_verify_success')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa verify failed writes auth_log event=2fa_verify_failed', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-log-fail@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
// Step 1: login to set pending_user_id in session.
$this->postJson('/api/auth/login', [
'email' => '2fa-log-fail@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
// Step 2: verify with wrong code.
$this->postJson('/api/auth/2fa/verify', [
'code' => '000000',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', '2fa_verify_failed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->toBe('invalid_code');
});
it('2fa recovery used writes auth_log event=2fa_recovery_used', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-recovery-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => 'JBSWY3DPEHPK3PXP',
]);
DB::table('user_recovery_codes')->insert([
'user_id' => $user->id,
'code_hash' => Hash::make('abcd1234'),
'used_at' => null,
]);
// Login to set pending_user_id.
$this->postJson('/api/auth/login', [
'email' => '2fa-recovery-log@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'ABCD-1234',
])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_recovery_used')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa recovery failed writes auth_log event=2fa_recovery_failed', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-recovery-fail-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => 'JBSWY3DPEHPK3PXP',
]);
DB::table('user_recovery_codes')->insert([
'user_id' => $user->id,
'code_hash' => Hash::make('abcd1234'),
'used_at' => null,
]);
// Login to set pending_user_id.
$this->postJson('/api/auth/login', [
'email' => '2fa-recovery-fail-log@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'WRONG-9999',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', '2fa_recovery_failed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->toBe('invalid_or_used');
});
it('2fa setup init writes auth_log event=2fa_setup_init', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-init-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => false,
'totp_secret' => null,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/init')->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_setup_init')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa setup confirm writes auth_log event=2fa_setup_confirmed', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-confirm-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => false,
'totp_secret' => null,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/init')->assertOk();
$secret = session('auth.pending_totp_secret');
$google2fa = new Google2FA;
$code = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_setup_confirmed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa disable success writes auth_log event=2fa_disabled', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-disabled-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/disable', ['password' => 'secret-pass-123'])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_disabled')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa disable wrong password writes auth_log event=2fa_disable_failed', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-disable-fail-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/disable', ['password' => 'wrong-password'])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', '2fa_disable_failed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->toBe('invalid_password');
});
it('2fa regenerate recovery codes writes auth_log event=2fa_recovery_regenerated', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-regen-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'secret-pass-123'])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_recovery_regenerated')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('password_reset_requested writes auth_log with user_id for known email', function () {
Notification::fake();
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'pr-known-log@example.ru',
'password_hash' => Hash::make('old-pass-1234'),
'is_active' => true,
]);
$this->postJson('/api/auth/forgot', [
'email' => 'pr-known-log@example.ru',
])->assertOk();
$row = DB::table('auth_log')
->where('event', 'password_reset_requested')
->where('email', 'pr-known-log@example.ru')
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->user_id)->toBe($user->id)
->and($row->failure_reason)->toBeNull();
});
it('password_reset_requested writes auth_log with unknown_email failure_reason for unknown email', function () {
Notification::fake();
$this->postJson('/api/auth/forgot', [
'email' => 'no-such-pr-log@example.ru',
])->assertOk();
$row = DB::table('auth_log')
->where('event', 'password_reset_requested')
->where('email', 'no-such-pr-log@example.ru')
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->user_id)->toBeNull()
->and($row->failure_reason)->toBe('unknown_email');
});
it('password_reset_completed writes auth_log on successful token reset', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'pr-completed-log@example.ru',
'password_hash' => Hash::make('old-pass-1234'),
'is_active' => true,
]);
$token = Password::createToken($user);
$this->postJson('/api/auth/reset-password', [
'token' => $token,
'email' => 'pr-completed-log@example.ru',
'password' => 'new-strong-pass-1234',
'password_confirmation' => 'new-strong-pass-1234',
])->assertOk();
$row = DB::table('auth_log')
->where('event', 'password_reset_completed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->email)->toBe('pr-completed-log@example.ru');
});
it('password_reset_failed writes auth_log on invalid token', function () {
$tenant = Tenant::factory()->create();
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'pr-failed-log@example.ru',
'password_hash' => Hash::make('old-pass-1234'),
'is_active' => true,
]);
$this->postJson('/api/auth/reset-password', [
'token' => 'invalid-token-zzz',
'email' => 'pr-failed-log@example.ru',
'password' => 'new-strong-pass-1234',
'password_confirmation' => 'new-strong-pass-1234',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', 'password_reset_failed')
->where('email', 'pr-failed-log@example.ru')
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->not->toBeNull();
});
+12 -22
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@@ -37,14 +36,12 @@ function makeDashboardDeal(
]);
}
/** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */
function actingForTenant(Tenant $tenant): void
{
test()->actingAs(User::factory()->for($tenant)->create());
}
it('422 без tenant_id', function () {
$this->getJson('/api/dashboard/summary')->assertStatus(422);
});
it('401 без авторизации', function () {
$this->getJson('/api/dashboard/summary')->assertStatus(401);
it('404 для несуществующего тенанта', function () {
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
});
it('возвращает структуру summary с range по умолчанию 7d', function () {
@@ -53,8 +50,7 @@ it('возвращает структуру summary с range по умолчан
'balance_rub' => '14250.00',
'balance_leads' => 285,
]);
actingForTenant($tenant);
$this->getJson('/api/dashboard/summary')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('range', '7d')
->assertJsonPath('balance.amount_rub', '14250.00')
@@ -71,7 +67,6 @@ it('возвращает структуру summary с range по умолчан
it('leads_received считает только сделки окна, без deleted и is_test', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
@@ -81,32 +76,30 @@ it('leads_received считает только сделки окна, без del
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
$this->getJson('/api/dashboard/summary?range=7d')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
->assertOk()
->assertJsonPath('leads_received.value', 3);
});
it('conversion = доля статуса won в окне', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
$this->getJson('/api/dashboard/summary')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('conversion.value', 25);
});
it('active_projects считает is_active=true + limit из limits', function () {
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
actingForTenant($tenant);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$this->getJson('/api/dashboard/summary')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('active_projects.active', 2)
->assertJsonPath('active_projects.limit', 10);
@@ -114,12 +107,11 @@ it('active_projects считает is_active=true + limit из limits', function
it('funnel группирует живые сделки по статусу', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
$this->getJson('/api/dashboard/summary')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('funnel.new', 2)
->assertJsonPath('funnel.won', 1);
@@ -127,8 +119,7 @@ it('funnel группирует живые сделки по статусу', fu
it('activity возвращает 7 точек и 7 меток', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
$this->getJson('/api/dashboard/summary')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonCount(7, 'activity.points')
->assertJsonCount(7, 'activity.labels');
@@ -138,12 +129,11 @@ it('runway_days использует фикс. 7д-окно независимо
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
for ($i = 0; $i <= 6; $i++) {
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
}
$this->getJson('/api/dashboard/summary?range=today')
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today")
->assertOk()
->assertJsonPath('balance.runway_days', 70);
});
@@ -1,111 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'balance_leads' => 100,
]);
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
test('ActivityLog deal.created содержит user_id, ip_address, user_agent актора', function () {
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.3', 'HTTP_USER_AGENT' => 'TestBrowser/1.0'])
->postJson('/api/deals', [
'project_name' => 'Тест Attribution',
'phone' => '+7 (999) 000-11-22',
]);
$r->assertStatus(201);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $r->json('deal.id'))
->where('event', ActivityLog::EVENT_DEAL_CREATED)
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.3');
expect($row->user_agent)->toBe('TestBrowser/1.0');
});
test('ActivityLog deal.commented содержит user_id, ip_address, user_agent актора', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.4', 'HTTP_USER_AGENT' => 'TestBrowser/2.0'])
->patchJson('/api/deals/'.$deal->id, [
'comment' => 'Новый комментарий',
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $deal->id)
->where('event', 'deal.commented')
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.4');
expect($row->user_agent)->toBe('TestBrowser/2.0');
});
test('ActivityLog deal.assigned содержит user_id, ip_address, user_agent актора', function () {
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
'manager_id' => null,
'assigned_at' => null,
]);
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.5', 'HTTP_USER_AGENT' => 'TestBrowser/3.0'])
->patchJson('/api/deals/'.$deal->id, [
'manager_id' => $manager->id,
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $deal->id)
->where('event', ActivityLog::EVENT_DEAL_ASSIGNED)
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.5');
expect($row->user_agent)->toBe('TestBrowser/3.0');
});
test('ActivityLog deal.status_changed содержит user_id, ip_address, user_agent актора', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.6', 'HTTP_USER_AGENT' => 'TestBrowser/4.0'])
->patchJson('/api/deals/'.$deal->id, [
'status' => 'won',
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $deal->id)
->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED)
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.6');
expect($row->user_agent)->toBe('TestBrowser/4.0');
});
@@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* Task 7 (audit-p1-auth): bulk activity_log rows must carry
* user_id, ip_address, user_agent from the current request.
*
* Three operations: transition / destroy / restore.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
it('bulk transition записывает user_id и ip_address в activity_log', function () {
$deals = Deal::factory()->count(3)
->for($this->tenant)
->for($this->project)
->create(['status' => 'new']);
$this->withServerVariables(['REMOTE_ADDR' => '10.9.8.7'])
->postJson('/api/deals/transition', [
'ids' => $deals->pluck('id')->all(),
'status' => 'won',
])
->assertOk();
$rows = DB::table('activity_log')
->where('event', 'deal.status_changed')
->whereIn('deal_id', $deals->pluck('id'))
->get();
expect($rows)->toHaveCount(3);
foreach ($rows as $row) {
expect((int) $row->user_id)->toBe($this->user->id)
->and((string) $row->ip_address)->toBe('10.9.8.7');
}
});
it('bulk destroy записывает user_id и ip_address в activity_log', function () {
$deals = Deal::factory()->count(2)
->for($this->tenant)
->for($this->project)
->create();
$this->withServerVariables(['REMOTE_ADDR' => '192.168.1.1'])
->deleteJson('/api/deals', [
'ids' => $deals->pluck('id')->all(),
])
->assertOk();
$rows = DB::table('activity_log')
->where('event', 'deal.deleted')
->whereIn('deal_id', $deals->pluck('id'))
->get();
expect($rows)->toHaveCount(2);
foreach ($rows as $row) {
expect((int) $row->user_id)->toBe($this->user->id)
->and((string) $row->ip_address)->toBe('192.168.1.1');
}
});
it('bulk restore записывает user_id и ip_address в activity_log', function () {
$deals = Deal::factory()->count(2)
->for($this->tenant)
->for($this->project)
->create();
// Soft-delete first
$this->deleteJson('/api/deals', [
'ids' => $deals->pluck('id')->all(),
])->assertOk();
// Now restore
$this->withServerVariables(['REMOTE_ADDR' => '172.16.0.5'])
->postJson('/api/deals/restore', [
'ids' => $deals->pluck('id')->all(),
])
->assertOk();
$rows = DB::table('activity_log')
->where('event', 'deal.restored')
->whereIn('deal_id', $deals->pluck('id'))
->get();
expect($rows)->toHaveCount(2);
foreach ($rows as $row) {
expect((int) $row->user_id)->toBe($this->user->id)
->and((string) $row->ip_address)->toBe('172.16.0.5');
}
});
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/**
* Go-live security: lookup/дашборд эндпоинты до этого были открыты (без
* auth-middleware, tenant_id параметром) любой неавторизованный мог получить
* KPI/список пользователей произвольного тенанта по ?tenant_id={чужой}.
*
* Закрытие: auth:sanctum + tenant, tenant_id из authed-user (как DealController J1).
*/
// --- 401 без авторизации ---
test('GET /api/dashboard/summary без авторизации возвращает 401', function () {
$this->getJson('/api/dashboard/summary')->assertStatus(401);
});
test('GET /api/managers без авторизации возвращает 401', function () {
$this->getJson('/api/managers')->assertStatus(401);
});
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
$this->getJson('/api/lead-statuses')->assertStatus(401);
});
// --- cross-tenant: tenant_id из user, параметр чужого тенанта игнорируется ---
test('dashboard/summary берёт tenant из authed-user, игнорирует ?tenant_id чужого', function () {
$mine = Tenant::factory()->create(['balance_rub' => '111.00', 'balance_leads' => 11]);
$other = Tenant::factory()->create(['balance_rub' => '999.00', 'balance_leads' => 99]);
$this->actingAs(User::factory()->for($mine)->create());
$this->getJson("/api/dashboard/summary?tenant_id={$other->id}")
->assertOk()
->assertJsonPath('balance.amount_rub', '111.00');
});
test('managers берёт tenant из authed-user, не отдаёт пользователей чужого тенанта', function () {
$mine = Tenant::factory()->create();
$other = Tenant::factory()->create();
$me = User::factory()->for($mine)->create(['first_name' => 'Свой', 'last_name' => 'Менеджер', 'is_active' => true]);
User::factory()->for($other)->create(['first_name' => 'Чужой', 'last_name' => 'Менеджер', 'is_active' => true]);
$this->actingAs($me);
$names = $this->getJson("/api/managers?tenant_id={$other->id}")
->assertOk()
->json('managers.*.name');
expect($names)->toContain('Свой М.');
expect($names)->not->toContain('Чужой М.');
});
+1 -20
View File
@@ -7,13 +7,8 @@ use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// SaaS-admin impersonation запрашивает impersonation_tokens/tenants через
// BYPASSRLS-подключение pgsql_supplier (RLS-фикс). Под DatabaseTransactions
// данные default-подключения не видны pgsql_supplier до commit'а → SharesSupplierPdo
// шарит PDO между подключениями (как в tests/Feature/Supplier/*).
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
@@ -72,20 +67,6 @@ test('GET /api/admin/impersonation/active возвращает активные
expect($sessions[0]['reason'])->toContain('active session');
});
test('active() читает impersonation_tokens через BYPASSRLS-подключение pgsql_supplier (regression RLS-фикс)', function () {
$connections = [];
DB::listen(function ($query) use (&$connections) {
if (str_contains($query->sql, 'impersonation_tokens')) {
$connections[] = $query->connectionName;
}
});
$this->getJson('/api/admin/impersonation/active')->assertStatus(200);
expect($connections)->not->toBeEmpty();
expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']);
});
test('GET /api/admin/impersonation/recent возвращает завершённые сессии с длительностью', function () {
ImpersonationToken::create([
'tenant_id' => $this->tenant->id,
@@ -48,21 +48,6 @@ function runRouteJob(int $supplierLeadId): void
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
it('is terminal (does not throw / re-queue) when the supplier lead does not exist', function (): void {
// Регрессия retry-шторма 21-22.05.2026: RouteSupplierLeadJob для удалённого лида №1
// бросал ModelNotFoundException -> queue->failed() писал в failed_webhook_jobs ->
// RetryFailedSupplierJobsCommand бесконечно перезапускал (25k+ записей).
// «Лид не найден» — терминальная (не транзиентная) ошибка: повтор бессмыслен.
$missingId = 999999;
expect(SupplierLead::find($missingId))->toBeNull();
// Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
runRouteJob($missingId);
// Никаких побочных эффектов.
expect(Deal::count())->toBe(0);
});
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
+1 -19
View File
@@ -2,8 +2,6 @@
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
@@ -11,23 +9,11 @@ use Illuminate\Support\Facades\DB;
* Тесты GET /api/lead-statuses глобальный lookup статусов воронки.
*
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
* статусов воронки: new/viewed/in_progress/won/lost). Go-live: эндпоинт за
* auth:sanctum (глобальная таблица tenant-middleware не нужен).
* статусов воронки: new/viewed/in_progress/won/lost).
*/
uses(DatabaseTransactions::class);
/** Авторизоваться любым пользователем (lead-statuses требует только auth:sanctum). */
function authLeadStatuses(): void
{
test()->actingAs(User::factory()->for(Tenant::factory())->create());
}
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
$this->getJson('/api/lead-statuses')->assertStatus(401);
});
test('GET /api/lead-statuses возвращает 200 и не пустой список', function () {
authLeadStatuses();
$r = $this->getJson('/api/lead-statuses');
$r->assertStatus(200);
@@ -36,7 +22,6 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
});
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
authLeadStatuses();
$r = $this->getJson('/api/lead-statuses');
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
@@ -47,7 +32,6 @@ test('GET /api/lead-statuses возвращает все 5 системных с
});
test('GET /api/lead-statuses возвращает поля slug, name_ru, color_hex, sort_order, is_system', function () {
authLeadStatuses();
$r = $this->getJson('/api/lead-statuses');
$first = $r->json('lead_statuses.0');
@@ -58,7 +42,6 @@ test('GET /api/lead-statuses возвращает поля slug, name_ru, color_
});
test('GET /api/lead-statuses сортирует по sort_order', function () {
authLeadStatuses();
$r = $this->getJson('/api/lead-statuses');
$sortOrders = collect($r->json('lead_statuses'))->pluck('sort_order')->all();
@@ -68,7 +51,6 @@ test('GET /api/lead-statuses сортирует по sort_order', function () {
});
test('GET /api/lead-statuses включает кастомный slug, добавленный после seed', function () {
authLeadStatuses();
DB::table('lead_statuses')->insert([
'slug' => 'custom_test_'.bin2hex(random_bytes(3)),
'name_ru' => 'Кастомный тест',
+12 -9
View File
@@ -15,8 +15,7 @@ beforeEach(function () {
test('GET /api/managers возвращает active users тенанта', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
// actingAs одного из активных пользователей тенанта — он сам входит в список.
$ivan = User::factory()->for($this->tenant)->create([
User::factory()->for($this->tenant)->create([
'first_name' => 'Иван', 'last_name' => 'Петров', 'is_active' => true,
]);
User::factory()->for($this->tenant)->create([
@@ -26,8 +25,7 @@ test('GET /api/managers возвращает active users тенанта', funct
'first_name' => 'Удалённый', 'is_active' => false,
]);
$this->actingAs($ivan);
$r = $this->getJson('/api/managers');
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
$r->assertStatus(200);
$managers = $r->json('managers');
expect($managers)->toHaveCount(2);
@@ -37,23 +35,28 @@ test('GET /api/managers возвращает active users тенанта', funct
test('GET /api/managers возвращает initials с fallback на email', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$admin = User::factory()->for($this->tenant)->create([
User::factory()->for($this->tenant)->create([
'email' => 'admin@example.ru',
'first_name' => null,
'last_name' => null,
'is_active' => true,
]);
$this->actingAs($admin);
$r = $this->getJson('/api/managers');
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
$r->assertStatus(200);
$manager = $r->json('managers.0');
expect($manager['name'])->toBe('admin@example.ru');
expect($manager['initials'])->toBe('AD');
});
test('GET /api/managers без авторизации возвращает 401', function () {
$this->getJson('/api/managers')->assertStatus(401);
test('GET /api/managers 422 без tenant_id', function () {
$r = $this->getJson('/api/managers');
$r->assertStatus(422);
});
test('GET /api/managers 404 unknown tenant', function () {
$r = $this->getJson('/api/managers?tenant_id=999999');
$r->assertStatus(404);
});
test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () {
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use function Pest\Laravel\get;
// Гейт SaaS-admin зоны (middleware EnsureSaasAdmin). Вне local/testing зона
// закрыта (503), кроме случая включённого временного флага тест-деплоя.
it('blocks saas-admin area outside local/testing without bypass flag', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => false]);
get('/api/admin/tenants')->assertStatus(503);
});
it('allows saas-admin area when test bypass flag is enabled', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => true]);
expect(get('/api/admin/tenants')->status())->not->toBe(503);
});
@@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
/**
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
* по всем трём путям ручной API, поставщик (RouteSupplierLeadJob),
* вебхук (ProcessWebhookJob).
*/
use App\Jobs\ProcessWebhookJob;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
// ──────────────────────────────────────────────────────────────────────────
// Path A: manual deal creation via DealController::store()
// ──────────────────────────────────────────────────────────────────────────
it('writes pd_processing_log created (manual) when deal created via API', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$user = User::factory()->for($tenant)->create();
$this->actingAs($user);
$before = DB::table('pd_processing_log')->where('purpose', 'lead_create_manual')->count();
$r = $this->postJson('/api/deals', [
'project_name' => 'Тест ПД',
'phone' => '+7 (999) 111-22-33',
]);
$r->assertStatus(201);
$dealId = $r->json('deal.id');
$rows = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_manual')
->where('subject_type', 'lead')
->where('subject_id', $dealId)
->where('tenant_id', $tenant->id)
->where('actor_tenant_user_id', $user->id)
->whereNull('actor_admin_user_id')
->count();
expect($rows)->toBe(1);
});
// ──────────────────────────────────────────────────────────────────────────
// Path B: supplier integration via RouteSupplierLeadJob
// ──────────────────────────────────────────────────────────────────────────
it('writes pd_processing_log created (supplier) when deal created via RouteSupplierLeadJob', function () {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'pd-test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'pd-test.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
]);
linkProjectToSupplier($project, $supplier);
$vid = 77741;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79992223344',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_pd-test.ru',
'phone' => '79992223344',
'time' => now()->getTimestamp(),
],
]);
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
expect($deal)->not->toBeNull();
$rows = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_supplier')
->where('subject_type', 'lead')
->where('subject_id', $deal->id)
->where('tenant_id', $tenant->id)
->whereNull('actor_tenant_user_id')
->whereNull('actor_admin_user_id')
->count();
expect($rows)->toBe(1);
});
// ──────────────────────────────────────────────────────────────────────────
// Path C: webhook via ProcessWebhookJob
// ──────────────────────────────────────────────────────────────────────────
it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$vid = 55566;
(new ProcessWebhookJob($tenant->id, [
'vid' => $vid,
'project' => 'B2_PdWebhookTest',
'tag' => 'PdWebhookTest',
'phone' => '79001112233',
'phones' => ['79001112233'],
'time' => time(),
]))->handle();
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
expect($deal)->not->toBeNull();
$rows = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_webhook')
->where('subject_type', 'lead')
->where('subject_id', $deal->id)
->where('tenant_id', $tenant->id)
->whereNull('actor_tenant_user_id')
->whereNull('actor_admin_user_id')
->count();
expect($rows)->toBe(1);
});
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
});
it('pd exported on deals CSV export', function () {
$r = $this->post('/api/deals/export', ['format' => 'csv']);
$r->assertStatus(200);
$pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
expect($pd)->not->toBeNull()
->and($pd->subject_type)->toBe('lead')
->and($pd->subject_id)->toBeNull()
->and($pd->purpose)->toBe('deals_export_csv')
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id);
});
it('pd exported with xlsx purpose', function () {
$r = $this->post('/api/deals/export', ['format' => 'xlsx']);
$r->assertStatus(200);
$pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
expect($pd)->not->toBeNull()
->and($pd->subject_type)->toBe('lead')
->and($pd->subject_id)->toBeNull()
->and($pd->purpose)->toBe('deals_export_xlsx')
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id);
});
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
/**
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
* через исторический импорт (HistoricalImportService).
*/
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
it('writes pd_processing_log created on historical import for each new deal', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$log = ImportLog::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/x.csv',
'dry_run' => false,
]);
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
$rows = array_merge(
(new CsvLeadsParser)->parse($header."\n".'9901,Окна,окна,79161000001,2023/07/10 10:00:00,,,Новые,')->rows,
(new CsvLeadsParser)->parse($header."\n".'9902,Окна,окна,79161000002,2023/07/10 10:00:00,,,Новые,')->rows,
);
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log, $rows);
$pd = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_import_'.$log->id)
->get();
expect($pd)->toHaveCount(2);
foreach ($pd as $r) {
expect($r->subject_type)->toBe('lead')
->and((int) $r->actor_tenant_user_id)->toBe($user->id)
->and($r->actor_admin_user_id)->toBeNull()
->and($r->subject_id)->not->toBeNull()
->and((int) $r->tenant_id)->toBe($tenant->id);
}
});
it('does NOT write pd_processing_log on dry_run import', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$log = ImportLog::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/x.csv',
'dry_run' => true,
]);
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
$rows = (new CsvLeadsParser)->parse($header."\n".'9903,Окна,окна,79161000003,2023/07/10 10:00:00,,,Новые,')->rows;
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log, $rows);
$count = DB::table('pd_processing_log')
->where('purpose', 'lead_create_import_'.$log->id)
->count();
expect($count)->toBe(0);
});
it('does NOT write pd_processing_log on import UPDATE (idempotent re-import)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
// First import — creates the deal
$log1 = ImportLog::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/x.csv',
'dry_run' => false,
]);
$rows1 = (new CsvLeadsParser)->parse($header."\n".'9904,Окна,окна,79161000004,2023/07/10 10:00:00,,,Новые,')->rows;
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log1, $rows1);
// Second import — updates the same deal
$log2 = ImportLog::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'filename' => 'leads2.csv',
'file_path' => 'imports/x2.csv',
'dry_run' => false,
]);
$rows2 = (new CsvLeadsParser)->parse($header."\n".'9904,Окна,окна,79161000004,2023/07/10 10:00:00,,,Оплачено,')->rows;
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log2, $rows2);
// Only the first import wrote a pd log entry
$countLog1 = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_import_'.$log1->id)
->count();
$countLog2 = DB::table('pd_processing_log')
->where('action', 'created')
->where('purpose', 'lead_create_import_'.$log2->id)
->count();
expect($countLog1)->toBe(1)
->and($countLog2)->toBe(0);
});
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
$this->deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
});
it('writes pd_processing_log viewed when deal card opened', function () {
$this->getJson("/api/deals/{$this->deal->id}")->assertOk();
$row = DB::table('pd_processing_log')->where('action', 'viewed')->latest('id')->first();
expect($row)->not->toBeNull()
->and($row->subject_type)->toBe('lead')
->and((int) $row->subject_id)->toBe($this->deal->id)
->and((int) $row->actor_tenant_user_id)->toBe($this->user->id)
->and($row->purpose)->toBe('lead_card_view');
});
it('does not write pd_processing_log for 404 lookups', function () {
$before = DB::table('pd_processing_log')->count();
$this->getJson('/api/deals/999999')->assertNotFound();
expect(DB::table('pd_processing_log')->count())->toBe($before);
});
@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create(['contact_email' => 'tenant-admin@example.ru']);
$this->adminId = DB::table('saas_admin_users')->insertGetId([
'email' => 'admin-saas-'.uniqid().'@liderra.ru',
'full_name' => 'SaaS Admin',
'password_hash' => '$2y$04$dummy-hash-for-test',
'role' => 'support',
'is_active' => true,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
});
it('init writes saas_admin_audit_log impersonation.init', function () {
$reason = 'support investigation '.str_repeat('x', 30);
$r = $this->postJson('/api/admin/impersonation/init', [
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'reason' => $reason,
])->assertOk();
$row = DB::table('saas_admin_audit_log')->where('action', 'impersonation.init')->latest('id')->first();
expect($row)->not->toBeNull()
->and((int) $row->admin_user_id)->toBe($this->adminId)
->and((int) $row->target_id)->toBe($this->tenant->id)
->and($row->reason)->toBe($reason);
});
it('verify writes saas_audit impersonation.verify + pd_processing_log viewed', function () {
$token = ImpersonationToken::create([
'tenant_id' => $this->tenant->id, 'requested_by' => $this->adminId,
'code_hash' => Hash::make('123456'),
'reason' => 'verify case '.str_repeat('y', 30),
'sent_to_email' => 'a@b.ru', 'expires_at' => now()->addMinutes(15),
]);
$this->postJson('/api/admin/impersonation/verify', ['token_id' => $token->id, 'code' => '123456'])->assertOk();
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.verify')->count())->toBe(1)
->and(DB::table('pd_processing_log')
->where('action', 'viewed')
->where('purpose', 'impersonation_session_'.$token->id)
->where('actor_admin_user_id', $this->adminId)
->count())->toBe(1);
});
it('end writes saas_admin_audit_log impersonation.end', function () {
$token = ImpersonationToken::create([
'tenant_id' => $this->tenant->id, 'requested_by' => $this->adminId,
'code_hash' => Hash::make('123456'),
'reason' => 'end case '.str_repeat('z', 30),
'sent_to_email' => 'a@b.ru', 'expires_at' => now()->addMinutes(15),
'used_at' => now()->subMinutes(5),
]);
$this->postJson('/api/admin/impersonation/end', ['token_id' => $token->id])->assertOk();
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.end')->count())->toBe(1);
});
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
/**
* 152-ФЗ integration: pd_processing_log captures the full deal lifecycle
* for one tenant create view export delete.
*
* Uses the deterministic manual-API path (no supplier/webhook jobs) so the
* test is robust and self-contained.
*
* Convention mirrors: DealCreateTest / DealExportPdLogTest /
* DealViewAccessLogTest / ReportFileDeletePdLogTest
*/
use App\Models\Deal;
use App\Models\Project;
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
beforeEach(function () {
Storage::fake('local');
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.(int) $this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'PD Flow Test']);
});
it('records pd events through the whole deal lifecycle (create → view → export → delete)', function () {
// ── 1. CREATE (manual) → pd action='created', purpose='lead_create_manual' ──
$created = $this->postJson('/api/deals', [
'project_name' => $this->project->name,
'phone' => '+7 (999) 123-45-67',
]);
$created->assertStatus(201);
$dealId = (int) $created->json('deal.id');
expect($dealId)->toBeGreaterThan(0);
// ── 2. VIEW → pd action='viewed', purpose='lead_card_view' ──
$this->getJson("/api/deals/{$dealId}")->assertOk();
// ── 3. EXPORT → pd action='exported', purpose='deals_export_csv' ──
// Mirror: DealExportPdLogTest — POST /api/deals/export with format=csv
// We need at least one deal in the tenant for a non-empty export; the
// deal we just created qualifies.
$exported = $this->post('/api/deals/export', ['format' => 'csv']);
$exported->assertStatus(200);
// ── 4. DELETE report file → pd action='deleted', purpose='report_file_{id}' ──
// Mirror: ReportFileDeletePdLogTest — create a DONE ReportJob with file_path,
// then DELETE /api/reports/jobs/{id}.
$job = ReportJob::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'type' => 'deals_export',
'parameters' => ['format' => 'csv', 'date_from' => '2026-01-01', 'date_to' => '2026-12-31'],
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/'.(int) $this->tenant->id.'/pd_flow_test.csv',
]);
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertOk();
// ── ASSERT — scoped to THIS tenant ────────────────────────────────────────
$rows = DB::table('pd_processing_log')
->where('tenant_id', $this->tenant->id)
->get();
$byAction = $rows->groupBy('action');
// All four lifecycle actions must be present.
expect($byAction->has('created'))->toBeTrue()
->and($byAction->has('viewed'))->toBeTrue()
->and($byAction->has('exported'))->toBeTrue()
->and($byAction->has('deleted'))->toBeTrue();
// Correct purpose for each action.
expect($rows->firstWhere('action', 'created')->purpose)->toBe('lead_create_manual');
expect($rows->firstWhere('action', 'viewed')->purpose)->toBe('lead_card_view');
expect($rows->contains(fn ($r) => $r->action === 'exported' && $r->purpose === 'deals_export_csv'))->toBeTrue();
expect($rows->firstWhere('action', 'deleted')->purpose)->toBe('report_file_'.$job->id);
// 'created' and 'viewed' rows are tied to the deal we created.
expect((int) $rows->firstWhere('action', 'created')->subject_id)->toBe($dealId);
expect((int) $rows->firstWhere('action', 'viewed')->subject_id)->toBe($dealId);
// All rows carry the correct actor.
foreach (['created', 'viewed', 'exported', 'deleted'] as $action) {
$row = $rows->firstWhere('action', $action);
expect((int) $row->actor_tenant_user_id)->toBe($this->user->id);
expect($row->actor_admin_user_id)->toBeNull();
}
});
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
beforeEach(function () {
Storage::fake('local');
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.(int) $this->tenant->id);
});
it('writes pd deleted when a report file is destroyed', function () {
$job = ReportJob::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'type' => 'deals_export',
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/'.(int) $this->tenant->id.'/test.csv',
]);
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertOk();
$pd = DB::table('pd_processing_log')
->where('action', 'deleted')
->orderByDesc('id')
->first();
expect($pd)->not->toBeNull()
->and($pd->subject_type)->toBe('lead')
->and($pd->purpose)->toBe('report_file_'.$job->id)
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id)
->and((int) $pd->tenant_id)->toBe((int) $this->tenant->id);
});
it('writes pd deleted (system actor) when cron cleanup-expired runs', function () {
Storage::disk('local')->put('reports/'.(int) $this->tenant->id.'/cron1.csv', 'data');
Storage::disk('local')->put('reports/'.(int) $this->tenant->id.'/cron2.csv', 'data');
ReportJob::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'type' => 'deals_export',
'parameters' => ['format' => 'csv'],
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/'.(int) $this->tenant->id.'/cron1.csv',
'expires_at' => now()->subDay(),
]);
ReportJob::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'type' => 'deals_export',
'parameters' => ['format' => 'csv'],
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/'.(int) $this->tenant->id.'/cron2.csv',
'expires_at' => now()->subDay(),
]);
$this->artisan('reports:cleanup-expired')->assertExitCode(0);
$rows = DB::table('pd_processing_log')
->where('action', 'deleted')
->where('purpose', 'like', 'report_cleanup_expired_%')
->where('tenant_id', $this->tenant->id)
->get();
expect($rows)->toHaveCount(2);
foreach ($rows as $r) {
expect($r->actor_tenant_user_id)->toBeNull()
->and($r->actor_admin_user_id)->toBeNull()
->and($r->subject_type)->toBe('lead');
}
});
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create();
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'info@lkomega.ru']);
$client = Mockery::mock(SupplierPortalClient::class);
$client->shouldReceive('listProjects')->andReturn([
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
]);
$this->app->instance(SupplierPortalClient::class, $client);
});
test('dry-run prints plan and writes nothing', function (): void {
Http::fake();
$this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru'])
->assertExitCode(0);
expect(Project::on('pgsql_supplier')->where('tenant_id', $this->tenant->id)->count())->toBe(0);
Http::assertNothingSent();
});
test('--commit writes projects', function (): void {
Http::fake();
$this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru', '--commit' => true])
->assertExitCode(0);
expect(Project::on('pgsql_supplier')
->where('tenant_id', $this->tenant->id)
->where('signal_identifier', '79991112233')->count())->toBe(1);
Http::assertNothingSent();
});
test('unknown tenant email → non-zero exit, no write', function (): void {
$this->artisan('supplier:import-projects', ['--tenant' => 'nobody@nowhere.ru', '--commit' => true])
->assertExitCode(1);
});
@@ -99,9 +99,7 @@ it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (sin
&& $request['srcrt'] === false
&& $request['srcbl'] === true
&& $request['srcmt'] === false
// Лидерра-код 77 (Тюменская обл., конституционный порядок) переводится
// в код поставщика 72 (ГИБДД). См. App\Support\SupplierRegions.
&& $request['regions'] === [72]
&& $request['regions'] === [77]
&& $request['regions_reverse'] === true
&& $request['status'] === false;
});
@@ -1,262 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Import\SupplierProjectImporter;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* @param list<array<string, mixed>> $rows
*/
function importerWithRows(array $rows): SupplierProjectImporter
{
$client = Mockery::mock(SupplierPortalClient::class);
$client->shouldReceive('listProjects')->andReturn($rows);
return new SupplierProjectImporter($client);
}
test('buildPlan groups B1/B2/B3 call rows into one planned project, limit = sum', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(1);
$p = $plan['planned'][0];
expect($p['signal_type'])->toBe('call');
expect($p['signal_identifier'])->toBe('79991112233');
expect($p['daily_limit_target'])->toBe(18);
expect($p['delivery_days_mask'])->toBe(31);
expect($p['tag'])->toBe('Каранга');
expect($p['regions'])->toBe([]);
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
expect(collect($p['platforms'])->firstWhere('platform', 'B1')['external_id'])->toBe(4001);
});
test('buildPlan skips inactive rows (status=false)', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79995550000', 'tag' => 'X', 'lim' => '5', 'status' => false, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
});
test('buildPlan skips dop2 (unsupported source) and reports it', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '6001', 'src' => 'dop2', 'type' => 'calls', 'content' => '79996660000', 'tag' => 'X', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
expect(collect($plan['skipped'])->pluck('reason'))->toContain('unsupported_source');
});
test('buildPlan reverse-maps regions and unions across platforms', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '7001', 'src' => 'rt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
['id' => '7002', 'src' => 'bl', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '77', 'workdays' => [], 'regions_reverse' => false],
['id' => '7003', 'src' => 'mt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
])->buildPlan($tenant->id);
expect($plan['planned'][0]['regions'])->toBe([29, 82]);
});
test('buildPlan treats any empty-regions platform as all-Russia', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '7101', 'src' => 'rt', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
['id' => '7102', 'src' => 'bl', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '', 'workdays' => [], 'regions_reverse' => false],
])->buildPlan($tenant->id);
expect($plan['planned'][0]['regions'])->toBe([]);
});
test('buildPlan skips group when any active row has regions_reverse=true', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '7201', 'src' => 'rt', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
['id' => '7202', 'src' => 'bl', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
});
test('buildPlan groups sms by sender: B2 (sender+keyword) and B3 (sender)', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '8001', 'src' => 'bl', 'type' => 'sms', 'content' => '79001234567+KVARTIRA', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
['id' => '8002', 'src' => 'mt', 'type' => 'sms', 'content' => '79001234567', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(1);
$p = $plan['planned'][0];
expect($p['signal_type'])->toBe('sms');
expect($p['signal_identifier'])->toBeNull();
expect($p['sms_senders'])->toBe(['79001234567']);
expect($p['sms_keyword'])->toBe('KVARTIRA');
expect($p['daily_limit_target'])->toBe(8);
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
});
test('buildPlan handles sms B3-only (no keyword)', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '8101', 'src' => 'mt', 'type' => 'sms', 'content' => '79009998877', 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(1);
expect($plan['planned'][0]['sms_senders'])->toBe(['79009998877']);
expect($plan['planned'][0]['sms_keyword'])->toBeNull();
expect($plan['planned'][0]['platforms'][0]['platform'])->toBe('B3');
});
test('buildPlan skips a group whose Project already exists for the tenant', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79993332211',
]);
$plan = importerWithRows([
['id' => '9001', 'src' => 'rt', 'type' => 'calls', 'content' => '79993332211', 'tag' => 'X', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
expect(collect($plan['skipped'])->pluck('reason'))->toContain('already_exists');
});
test('commit creates Project + supplier_projects (external_id from portal) + pivot, no portal write', function (): void {
Http::fake(); // ловушка: НИ один HTTP не должен уйти на портал
$tenant = Tenant::factory()->create();
$importer = importerWithRows([
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
]);
$plan = $importer->buildPlan($tenant->id);
$result = $importer->commit($plan, $tenant->id);
expect($result['created_projects'])->toBe(1);
$project = Project::on('pgsql_supplier')
->where('tenant_id', $tenant->id)
->where('signal_identifier', '79991112233')
->first();
expect($project)->not->toBeNull();
expect($project->daily_limit_target)->toBe(18);
expect($project->is_active)->toBeTrue();
expect($project->regions)->toBe([29]);
expect($project->delivery_days_mask)->toBe(31);
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79991112233')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->sort()->values()->all())->toBe(['4001', '4002', '4003']);
expect($sps->pluck('sync_status')->unique()->all())->toBe(['ok']);
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(6);
$pivot = DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->count();
expect($pivot)->toBe(3);
Http::assertNothingSent();
});
test('commit reuses an existing supplier_project row instead of duplicating', function (): void {
Http::fake();
$tenant = Tenant::factory()->create();
// supplier_project уже есть (например, создан webhook resolveOrStub ранее)
SupplierProject::on('pgsql_supplier')->forceCreate([
'platform' => 'B1',
'signal_type' => 'call',
'unique_key' => '79994445566',
'subject_code' => null,
'supplier_external_id' => 'EXIST1',
'current_limit' => 6,
'current_workdays' => [1, 2, 3, 4, 5],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
$importer = importerWithRows([
['id' => '4500', 'src' => 'rt', 'type' => 'calls', 'content' => '79994445566', 'tag' => 'Y', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
]);
$plan = $importer->buildPlan($tenant->id);
$importer->commit($plan, $tenant->id);
// по-прежнему ровно 1 supplier_project с этим ключом+платформой (реюз, не дубль)
expect(SupplierProject::on('pgsql_supplier')
->where('unique_key', '79994445566')->where('platform', 'B1')->count())->toBe(1);
// pivot привязал существующую строку к новому проекту
$project = Project::on('pgsql_supplier')->where('signal_identifier', '79994445566')->first();
$sp = SupplierProject::on('pgsql_supplier')->where('unique_key', '79994445566')->first();
expect(DB::connection('pgsql_supplier')->table('project_supplier_links')
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->count())->toBe(1);
});
test('buildPlan unions workdays across platforms with different schedules', function (): void {
$tenant = Tenant::factory()->create();
// B1 = Пн-Ср [1,2,3] → mask 0b0000111 = 7; B2 = Чт-Пт [4,5] → mask 0b0011000 = 24;
// union = 31 (Пн-Пт). Тест проверяет реальный OR-merge, не одинаковые расписания.
$plan = importerWithRows([
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3']],
['id' => '5002', 'src' => 'bl', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['4', '5']],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(1);
expect($plan['planned'][0]['delivery_days_mask'])->toBe(31);
});
test('buildPlan skips sms group when any active row has regions_reverse=true', function (): void {
$tenant = Tenant::factory()->create();
$plan = importerWithRows([
['id' => '6001', 'src' => 'bl', 'type' => 'sms', 'content' => '79007776655+CODE', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
['id' => '6002', 'src' => 'mt', 'type' => 'sms', 'content' => '79007776655', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
])->buildPlan($tenant->id);
expect($plan['planned'])->toHaveCount(0);
expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
});
test('deriveName uses sms sender as fallback when tag is empty', function (): void {
$tenant = Tenant::factory()->create();
// tag='РФ' → попадает в fallback; sms → должен взять sender, а не 'проект'.
$plan = importerWithRows([
['id' => '7001', 'src' => 'mt', 'type' => 'sms', 'content' => '79001112222', 'tag' => 'РФ', 'lim' => '2', 'status' => true, 'regions' => '', 'workdays' => []],
])->buildPlan($tenant->id);
expect($plan['planned'][0]['name'])->toBe('79001112222');
});
@@ -80,56 +80,6 @@ it('online mode creates single-group supplier_projects with full regions + pivot
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
it('online create DIVIDES the limit across B1/B2/B3 so supplier total == project limit (not ×3)', function (): void {
// Money-loss regression (owner-reported 2026-05-21, verified live): the limit was
// replicated full to all 3 platforms (18 → 18/18/18 = supplier could deliver up to 54).
// The portal does NOT divide — each B-project honours its own limit independently.
// Fix: split the limit so Σ per-platform == project limit (18 → 6/6/6).
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79991110000',
'is_active' => true,
'daily_limit_target' => 18,
'regions' => [],
'delivery_days_mask' => 127,
]);
$capturedLimits = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedLimits) {
$body = $request->data();
$capturedLimits[] = $body['limit'] ?? null;
return Http::response(['status' => 'OK', 'message' => '', 'id' => '3000'], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '3001', 'src' => 'rt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
['id' => '3002', 'src' => 'bl', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
['id' => '3003', 'src' => 'mt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
]], 200),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$sps = SupplierProject::where('unique_key', '79991110000')->get();
expect($sps)->toHaveCount(3);
// Σ per-platform limits == the project limit — the loss-prevention invariant.
expect($sps->sum('current_limit'))->toBe(18);
foreach ($sps as $sp) {
expect($sp->current_limit)->toBe(6); // 18 / 3 platforms
}
// Every limit pushed to the portal is the divided share, never the full 18.
$sent = array_values(array_filter($capturedLimits, fn ($l) => $l !== null));
expect($sent)->not->toBeEmpty();
foreach ($sent as $l) {
expect((int) $l)->toBe(6);
}
});
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
@@ -211,16 +161,6 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
]);
}
// listProjects (dead-donor liveness check) must see the seeded donors as alive,
// so the update path runs without recreating (and without hitting the real portal).
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '99B1', 'src' => 'rt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
['id' => '99B2', 'src' => 'bl', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
['id' => '99B3', 'src' => 'mt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
]], 200),
]);
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
});
@@ -229,11 +169,9 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
$sps = SupplierProject::where('unique_key', '79991234567')->get();
expect($sps)->toHaveCount(3);
// 9 split across B1/B2/B3 = 3/3/3 (Σ == 9 = project limit, not 9 on each = 27).
expect($sps->sum('current_limit'))->toBe(9);
foreach ($sps as $sp) {
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
expect($sp->current_limit)->toBe(3);
expect($sp->current_limit)->toBe(9);
}
});
@@ -159,7 +159,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
// Order: 2 projects on one (source × subject) → computeOrder
// ---------------------------------------------------------------------------
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
@@ -200,49 +200,19 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 (the GROUP order), then split
// across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend).
$sps = SupplierProject::on('pgsql_supplier')
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
$sp = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'order-test.example.com')
->get();
->where('platform', 'B1')
->first();
expect($sp)->not->toBeNull();
expect($sp->current_limit)->toBe(20);
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
expect($sps)->toHaveCount(3);
expect($sps->sum('current_limit'))->toBe(20);
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7);
});
test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void {
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79135161263',
'daily_limit_target' => 18,
'delivery_days_mask' => 127,
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
]], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Assert only THIS group's rows (the nightly job syncs every active project in the DB).
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get();
expect($sps)->toHaveCount(3);
expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54)
expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all())
->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3
expect(SupplierProject::on('pgsql_supplier')
->where('unique_key', 'order-test.example.com')
->count())->toBe(3);
});
// ---------------------------------------------------------------------------
@@ -27,13 +27,13 @@ test('GET webhook-settings возвращает подписку тенанта'
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://93.184.216.34/hook',
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
expect($response->json('data'))->not->toHaveKey('secret_hash');
});
@@ -55,11 +55,11 @@ test('GET webhook-settings изолирован по тенанту', function (
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://93.184.216.34/hook',
'target_url' => 'https://crm.example.ru/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
expect($response->json('data.secret'))->toStartWith('whsec_');
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
@@ -72,15 +72,15 @@ test('PUT webhook-settings обновляет URL существующей по
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://8.8.8.8/hook',
'target_url' => 'https://old.example.ru/hook',
]);
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://1.1.1.1/hook',
'target_url' => 'https://new.example.ru/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://1.1.1.1/hook');
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
expect($response->json('data'))->not->toHaveKey('secret');
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
@@ -91,20 +91,12 @@ test('PUT webhook-settings: 422 при не-https URL', function () {
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
});
test('PUT webhook-settings: 422 для приватного/служебного IP в target_url (SSRF), не сохраняет', function () {
$this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://169.254.169.254/hook',
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
Http::fake(['*' => Http::response(['ok' => true], 200)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://93.184.216.34/hook',
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->postJson('/api/webhooks/test');
@@ -112,7 +104,7 @@ test('POST webhooks/test отправляет запрос и возвращае
$response->assertOk();
expect($response->json('ok'))->toBeTrue();
expect($response->json('status'))->toBe(200);
Http::assertSent(fn ($req) => $req->url() === 'https://93.184.216.34/hook');
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
});
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
@@ -120,7 +112,7 @@ test('POST webhooks/test возвращает ok=false при ошибке endpo
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://93.184.216.34/hook',
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->postJson('/api/webhooks/test');
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use App\Support\WebhookUrlGuard;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
});
// --- unit: WebhookUrlGuard (IP-литералы, без DNS) ---
test('WebhookUrlGuard блокирует приватные/зарезервированные/loopback IP', function (string $url) {
expect(WebhookUrlGuard::blockReason($url))->not->toBeNull();
})->with([
'https://127.0.0.1/hook', // loopback
'https://10.0.0.1/hook', // private A
'https://172.16.0.1/hook', // private B
'https://192.168.1.1/hook', // private C
'https://169.254.169.254/hook', // link-local / cloud metadata
'https://[::1]/hook', // IPv6 loopback
]);
test('WebhookUrlGuard пропускает публичный IP', function () {
expect(WebhookUrlGuard::blockReason('https://93.184.216.34/hook'))->toBeNull();
});
test('WebhookUrlGuard отклоняет битый URL', function () {
expect(WebhookUrlGuard::blockReason('not-a-url'))->not->toBeNull();
});
// --- endpoint: webhooks/test не должен бить во внутреннюю сеть ---
test('POST webhooks/test блокирует приватный IP target_url (SSRF) и не шлёт запрос', function () {
Http::fake();
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://169.254.169.254/hook',
]);
$this->postJson('/api/webhooks/test')->assertStatus(422);
Http::assertNothingSent();
});
test('POST webhooks/test пропускает публичный target_url', function () {
Http::fake(['*' => Http::response(['ok' => true], 200)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://93.184.216.34/hook',
]);
$this->postJson('/api/webhooks/test')
->assertOk()
->assertJsonPath('ok', true);
Http::assertSentCount(1);
});
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
it('writes auth_log row with all fields', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$dummy = new class
{
use WritesAuthLog;
public function fire(?int $userId, ?int $tenantId): void
{
$this->logAuthEvent('login_success', $userId, $tenantId, 'a@b.c', '1.2.3.4', 'UA', null);
}
};
$dummy->fire($user->id, $tenant->id);
$row = DB::table('auth_log')->latest('id')->first();
expect($row->event)->toBe('login_success')
->and($row->actor_type)->toBe('tenant_user')
->and((int) $row->user_id)->toBe($user->id)
->and((int) $row->tenant_id)->toBe($tenant->id)
->and((string) $row->ip_address)->toBe('1.2.3.4')
->and($row->user_agent)->toBe('UA');
});
it('actor_type=tenant_user even if user NULL (anti-enumeration)', function () {
$dummy = new class
{
use WritesAuthLog;
public function fire(?int $userId, ?int $tenantId): void
{
$this->logAuthEvent('login_failed', $userId, $tenantId, 'x@y.z', null, null, 'no_such_user');
}
};
$dummy->fire(null, null);
$row = DB::table('auth_log')->latest('id')->first();
expect($row->actor_type)->toBe('tenant_user')->and($row->user_id)->toBeNull();
});
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Services\Pd\ImpersonationAuditService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->adminId = DB::table('saas_admin_users')->insertGetId([
'email' => 'admin-imp-'.uniqid().'@liderra.ru',
'full_name' => 'SaaS Admin',
'password_hash' => '$2y$04$dummy-hash-for-test',
'role' => 'support',
'is_active' => true,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
$this->token = ImpersonationToken::create([
'tenant_id' => $this->tenant->id,
'requested_by' => $this->adminId,
'code_hash' => 'h',
'reason' => 'support case '.str_repeat('x', 30),
'sent_to_email' => 'a@b.ru',
'expires_at' => now()->addMinutes(15),
]);
});
it('recordInit writes saas_admin_audit_log action=impersonation.init', function () {
app(ImpersonationAuditService::class)->recordInit($this->token, adminId: $this->adminId, ip: '1.2.3.4');
$row = DB::table('saas_admin_audit_log')->where('action', 'impersonation.init')->latest('id')->first();
expect($row)->not->toBeNull()
->and((int) $row->target_id)->toBe($this->tenant->id)
->and($row->reason)->toBe($this->token->reason);
});
it('recordVerify writes BOTH saas_audit and pd_processing_log', function () {
app(ImpersonationAuditService::class)->recordVerify($this->token, adminId: $this->adminId, ip: '1.2.3.4');
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.verify')->count())->toBe(1)
->and(DB::table('pd_processing_log')
->where('action', 'viewed')
->where('purpose', 'impersonation_session_'.$this->token->id)
->where('actor_admin_user_id', $this->adminId)
->count())->toBe(1);
});
it('recordEnd writes saas_admin_audit_log action=impersonation.end', function () {
app(ImpersonationAuditService::class)->recordEnd($this->token, adminId: $this->adminId, ip: '1.2.3.4');
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.end')->count())->toBe(1);
});
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
it('inserts pd_processing_log row with all fields', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
app(PdAuditLogger::class)->record(
action: 'viewed', subjectType: 'lead', subjectId: 123,
purpose: 'lead_card_view', tenantId: $tenant->id,
actorTenantUserId: $user->id, actorAdminUserId: null, ip: '10.0.0.1',
);
$row = DB::table('pd_processing_log')->latest('id')->first();
expect($row->action)->toBe('viewed')
->and($row->subject_type)->toBe('lead')
->and((int) $row->subject_id)->toBe(123)
->and((int) $row->actor_tenant_user_id)->toBe($user->id)
->and((string) $row->ip_address)->toBe('10.0.0.1');
});
it('allows system actor (both NULL) per chk_pd_actor', function () {
$tenant = Tenant::factory()->create();
$before = DB::table('pd_processing_log')->count();
app(PdAuditLogger::class)->record(
action: 'exported', subjectType: 'lead', subjectId: null,
purpose: 'cron_cleanup', tenantId: $tenant->id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
expect(DB::table('pd_processing_log')->count())->toBe($before + 1);
});
it('rejects two-actor row (chk_pd_actor violation)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
expect(fn () => app(PdAuditLogger::class)->record(
action: 'viewed', subjectType: 'lead', subjectId: 1,
purpose: 'x', tenantId: $tenant->id,
actorTenantUserId: $user->id, actorAdminUserId: 999999, ip: null,
))->toThrow(QueryException::class);
});
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\Import\SupplierImportMapper;
use Tests\TestCase;
uses(TestCase::class);
test('platformFromSrc maps rt/bl/mt to B1/B2/B3, others null', function (): void {
expect(SupplierImportMapper::platformFromSrc('rt'))->toBe('B1');
expect(SupplierImportMapper::platformFromSrc('bl'))->toBe('B2');
expect(SupplierImportMapper::platformFromSrc('mt'))->toBe('B3');
expect(SupplierImportMapper::platformFromSrc('dop2'))->toBeNull();
expect(SupplierImportMapper::platformFromSrc(''))->toBeNull();
});
test('signalTypeFromType maps calls/hosts/sms', function (): void {
expect(SupplierImportMapper::signalTypeFromType('calls'))->toBe('call');
expect(SupplierImportMapper::signalTypeFromType('hosts'))->toBe('site');
expect(SupplierImportMapper::signalTypeFromType('sms'))->toBe('sms');
expect(SupplierImportMapper::signalTypeFromType('unknown'))->toBeNull();
});
test('parseGibddRegions splits comma/space string of codes; empty → []', function (): void {
expect(SupplierImportMapper::parseGibddRegions('24'))->toBe([24]);
expect(SupplierImportMapper::parseGibddRegions('24,77'))->toBe([24, 77]);
expect(SupplierImportMapper::parseGibddRegions('24, 77 78'))->toBe([24, 77, 78]);
expect(SupplierImportMapper::parseGibddRegions(''))->toBe([]);
expect(SupplierImportMapper::parseGibddRegions(null))->toBe([]);
});
test('workdaysToMask converts string day list to bitmask (bit0=Mon)', function (): void {
expect(SupplierImportMapper::workdaysToMask(['1', '2', '3', '4', '5']))->toBe(31);
expect(SupplierImportMapper::workdaysToMask(['1', '2', '3', '4', '5', '6', '7']))->toBe(127);
expect(SupplierImportMapper::workdaysToMask([]))->toBe(127);
});
test('parseSmsContent splits sender+keyword; sender-only when no plus', function (): void {
expect(SupplierImportMapper::parseSmsContent('79001234567+KVARTIRA'))
->toBe(['sender' => '79001234567', 'keyword' => 'KVARTIRA']);
expect(SupplierImportMapper::parseSmsContent('79001234567'))
->toBe(['sender' => '79001234567', 'keyword' => null]);
expect(SupplierImportMapper::parseSmsContent(''))
->toBe(['sender' => '', 'keyword' => null]);
});
@@ -24,37 +24,6 @@ it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (
'empty' => [[], 0],
]);
// distributeForPlatform: split the group order across N supplier platforms so the
// SUM of per-platform limits == order (portal does NOT divide — verified live 2026-05-21,
// each B1/B2/B3 honors its own limit independently → must split ourselves). Largest-remainder.
it('distributeForPlatform splits order so per-platform limits sum to the order', function (array $platforms, int $order, array $expected): void {
expect(SupplierQuotaAllocator::distributeForPlatform($order, $platforms))->toBe($expected);
})->with([
// Even split (the common case — the owner reported 18 → 18/18/18 instead of 6/6/6)
'call/site 18→6/6/6' => [['B1', 'B2', 'B3'], 18, ['B1' => 6, 'B2' => 6, 'B3' => 6]],
'call/site 24→8/8/8' => [['B1', 'B2', 'B3'], 24, ['B1' => 8, 'B2' => 8, 'B3' => 8]],
'call/site 3→1/1/1' => [['B1', 'B2', 'B3'], 3, ['B1' => 1, 'B2' => 1, 'B3' => 1]],
// Uneven split — largest remainder: leading platforms get the +1, sum stays exact
'call/site 10→4/3/3' => [['B1', 'B2', 'B3'], 10, ['B1' => 4, 'B2' => 3, 'B3' => 3]],
'call/site 20→7/7/6' => [['B1', 'B2', 'B3'], 20, ['B1' => 7, 'B2' => 7, 'B3' => 6]],
// SMS+keyword (2 platforms)
'sms+kw 5→3/2' => [['B2', 'B3'], 5, ['B2' => 3, 'B3' => 2]],
'sms+kw 2→1/1' => [['B2', 'B3'], 2, ['B2' => 1, 'B3' => 1]],
// SMS without keyword (1 platform) — no split, full order
'sms 7→7' => [['B3'], 7, ['B3' => 7]],
// Edge: zero order
'zero' => [['B1', 'B2', 'B3'], 0, ['B1' => 0, 'B2' => 0, 'B3' => 0]],
]);
it('distributeForPlatform always conserves the order (sum invariant)', function (int $order, int $count): void {
$platforms = array_slice(['B1', 'B2', 'B3'], 0, $count);
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
expect(array_sum($shares))->toBe($order);
})->with([
[1, 3], [2, 3], [7, 3], [13, 3], [100, 3], [101, 2], [99, 1], [0, 3],
]);
// Orthogonal smoke tests on allocate() — preserved from pre-T3 coverage; assert
// invariants independent of the order formula (workdays/regions union, null-on-no-eligible).
@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\SupplierRegions;
use Tests\TestCase;
// Бутстрапим приложение — mapToSupplier() пишет Log::warning при отбросе непереводимых.
uses(TestCase::class);
// Regression: Лидерра нумерует субъекты по конституционному порядку (RussianRegions,
// Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
// Архангельск=29). Sync слал Лидерра-код как есть → у поставщика выбирался ЧУЖОЙ регион.
// SupplierRegions::mapToSupplier переводит Лидерра-код → код поставщика.
it('translates Liderra constitutional codes to supplier (ГИБДД) codes', function (): void {
expect(SupplierRegions::mapToSupplier([29]))->toBe([24]); // Красноярский край
expect(SupplierRegions::mapToSupplier([35]))->toBe([29]); // Архангельская обл.
expect(SupplierRegions::mapToSupplier([24]))->toBe([21]); // Чувашская Республика
expect(SupplierRegions::mapToSupplier([82]))->toBe([77]); // Москва
expect(SupplierRegions::mapToSupplier([83]))->toBe([78]); // Санкт-Петербург
});
it('returns empty for all-Russia (no regions)', function (): void {
expect(SupplierRegions::mapToSupplier([]))->toBe([]);
});
it('ignores sentinel 0 (Вся РФ)', function (): void {
expect(SupplierRegions::mapToSupplier([0]))->toBe([]);
});
it('drops regions the supplier does not offer', function (): void {
// Поставщик НЕ предлагает: Московская (56), Ленинградская (53), Крым (13), новые территории.
expect(SupplierRegions::mapToSupplier([56]))->toBe([]); // Московская обл.
expect(SupplierRegions::mapToSupplier([53]))->toBe([]); // Ленинградская обл.
expect(SupplierRegions::mapToSupplier([13]))->toBe([]); // Крым
// mixed: оставляем переводимые, отбрасываем непереводимые
expect(SupplierRegions::mapToSupplier([29, 56]))->toBe([24]); // Красноярский kept, Московская dropped
});
it('dedupes and sorts supplier codes', function (): void {
// 35→29 (Архангельск), 29→24 (Красноярский), дубль 35 → unique+sorted [24,29]
expect(SupplierRegions::mapToSupplier([35, 29, 35]))->toBe([24, 29]);
});
it('every map entry points to a distinct supplier code (no collisions)', function (): void {
$targets = array_values(SupplierRegions::LIDERRA_TO_SUPPLIER);
expect(count($targets))->toBe(count(array_unique($targets)));
});
test('mapFromSupplier inverts LIDERRA_TO_SUPPLIER bijection', function (): void {
// ГИБДД 24 → Лидерра 29 (Красноярский); ГИБДД 77 → Лидерра 82 (Москва)
expect(SupplierRegions::mapFromSupplier([24]))->toBe([29]);
expect(SupplierRegions::mapFromSupplier([77]))->toBe([82]);
});
test('mapFromSupplier maps multiple codes, sorted ascending, deduped', function (): void {
// ГИБДД 77→82 (Москва), 78→83 (СПб), 24→29 (Красноярский)
expect(SupplierRegions::mapFromSupplier([78, 24, 77, 24]))->toBe([29, 82, 83]);
});
test('mapFromSupplier drops unknown supplier codes', function (): void {
// 999 нет в карте → отброшен; 24 → 29
expect(SupplierRegions::mapFromSupplier([999, 24]))->toBe([29]);
});
test('mapFromSupplier returns [] for empty input', function (): void {
expect(SupplierRegions::mapFromSupplier([]))->toBe([]);
});
+19 -40
View File
@@ -1,6 +1,17 @@
# Глоссарий проекта Лидерра
# Формат: одно слово на строке. Кириллица в нижнем регистре.
# Test-deploy Yandex Cloud (2026-05-21)
hba
htpasswd
lsb
nslookup
scp
хостить
tos
прода
ребута
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
iconify
@@ -1588,44 +1599,12 @@ lemed
батч
ретраит
шеринге
unactivated
# Серверный слой защиты SEC-1..7 (2026-05-22)
бэкапа
баны
алертинг
алертингом
htpasswd
ignoreip
libnginx
crs
coraza
usr
# ПИЛОТ.md эксплуатационные термины (2026-05-22)
ротирован
разлогинятся
крэше
стектрейсы
закэширован
scp
крашей
PGDG
лок
SMTPS
юните
бакет
MTA
алиас
прода
попап
COEP
Самобана
CDP
волатилен
синке
субдомен
субдомена
субдомены
артизан
Артизан
# Supplier dead-donor fix + баннер 18:00 (2026-05-21)
дрейфнувшей
дропа
коммитах
доустановлены
дочерпывание
creds
незавершёнку
+3 -10
View File
@@ -1,12 +1,8 @@
# Plugin Stack Rules — Superpowers + Frontend Design (v3.21)
# Plugin Stack Rules — Superpowers + Frontend Design (v3.19)
**Дата:** 21.05.2026
**Дата:** 19.05.2026
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
**v3.21** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco) → в R10.1 Блок 1 note (Ward) + Блок 3 (ZAP MCP-row) снят статус PENDING INSTALL. Содержательных изменений R0–R16: 0; счётчики/состав без изменений. Связано: Tooling v2.21, Pravila v1.38, CLAUDE.md v2.25; setup-доки `docs/security/{zap,ward}-setup.md`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
**v3.20** — A8 infosec-tooling: R10.1 Блок 1 note +infosec-tooling (#69 Nuclei + #70 Ward — CLI-бинари; #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live — self-authored project-скилы) + Блок 3 +OWASP ZAP MCP (#68, PENDING INSTALL — нет Java). Nuclei установлен+verified (CLI, не MCP); Ward заменил Enlightn (abandoned/L13), PENDING INSTALL — нет Go. Каждый внешний инструмент прошёл провенанс-вет IS9 ДО установки (риск ToxicSkills). Новая 17-я off-phase подкатегория infosec-tooling, раздел A8 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.20, Pravila v1.37, CLAUDE.md v2.24, ADR-014 (IS1IS9); план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
**v3.19** — A1 backend-tooling: R10.1 Блок 1 note +backend-tooling (#64 Rector + #65 PHP Insights — Composer dev-deps; #66 laravel-backend-patterns — self-authored project-скил; #67 NightOwl — DEFERRED, MCP при активации). Новая 16-я off-phase подкатегория backend-tooling, раздел A1 карты. R15.6 +backend-tooling в список категорий. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.19, Pravila v1.35, CLAUDE.md v2.22, ADR-013; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
**v3.18** — finance-tooling (C6+C7): R10.1 Блок 1 +finance plugin (#61, marketplace `finance@knowledge-work-plugins`, homed C7, cross-ref C6) + note (+billing-audit #62 / ru-tax-accounting #63 — self-authored project-скилы). Новая 15-я off-phase подкатегория finance-tooling, разделы C6/C7 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.18, Pravila v1.34, CLAUDE.md v2.21, ADR-012; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
@@ -463,8 +459,6 @@ Stack — **головной**. Все плагины вне stack'а — **ин
**Блок 1 — note (v3.19):** **Rector** (Tooling #64) + **PHP Insights** (Tooling #65) — Composer dev-dependencies (`rector/rector` + `driftingly/rector-laravel`; `nunomaduro/phpinsights`), **не** marketplace-плагины и **не** в `enabledPlugins` (как deptrac #43 / promptfoo #48). CLI-инструменты: Rector — авто-рефакторинг/version-upgrade (`composer rector`/`rector:fix`), manual/CI, dry-run baseline 16 файлов → **не** блокирующий lefthook; PHP Insights — метрики complexity/architecture (`composer insights`), on-demand/CI с порогами → **не** блокирующий (BT9). **laravel-backend-patterns** (Tooling #66) — self-authored project-скил в `.claude/skills/laravel-backend-patterns/`, **линтуется** (LINT1, как billing-audit/process-*). **NightOwl** (Tooling #67) — `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent`, **DEFERRED** (native-Windows нет pcntl/posix; OSS без MCP; hosted 152-ФЗ); при активации (Linux/Б-1) — MCP в Блок 3 или Boost `database-query`. Категория **backend-tooling** (16-я off-phase подкатегория, раздел A1 карты), вне R6.0/R6.1/R14. ADR-013.
**Блок 1 — note (v3.20):** **Nuclei** (Tooling #69) + **Ward** (Tooling #70) — CLI-бинари (как deptrac #43 / gitleaks / squawk), **не** marketplace-плагины и **не** в `enabledPlugins`. Nuclei (`projectdiscovery/nuclei` v3.8.0, MIT, Go) — `bin/nuclei.exe`, **установлен+verified**; широкое сканирование известных уязвимостей; **CLI, не MCP** (nuclei не говорит на MCP → нет Блока 3 / l1-watcher alias). Ward (`Eljakani/ward`, MIT, Go) — безопасность настроек Laravel; **ЗАМЕНИЛ Enlightn** (abandoned/L13); **установлен 21.05** портативно (собран portable Go → `bin/ward.exe` v0.4.1, `docs/security/ward-setup.md`). **pdn-152fz-audit** (#71) + **threat-model** (#72) + **security-go-live** (#73) — self-authored project-скилы в `.claude/skills/`, **линтуются** (LINT1, как billing-audit/process-*). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills). Категория **infosec-tooling** (17-я off-phase подкатегория, раздел A8 карты), вне R6.0/R6.1/R14. ADR-014 (IS1IS9).
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
@@ -499,7 +493,6 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **openapi-mcp-server** *(`openapi` сервер, tools `mcp__openapi__*`)* | `.mcp.json` (stdio MCP, env `OPENAPI_SPEC_URL` или локальный файл) | **integration-tooling MCP** — OpenAPI/Swagger-спецификации интеграций (inspect, introspect внешних API). Категория: **integration-tooling** (Tooling §4.22 #47). Раздел A3 карты «Программирование — интеграции (API, вебхуки)». Off-phase | при работе с внешними API-интеграциями (introspection спецификаций). **READ-ONLY introspection** — не мутировать внешние API из Claude. Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. Вне R6/R14 |
| **Jupyter MCP** *(`jupyter` сервер)***DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: Python ML-окружение | **ml-ai-tooling MCP** — исполняемые ноутбуки (классический ML: обучение моделей). Категория: **ml-ai-tooling** (Tooling §4.25 #50). Раздел A11 карты «ML / AI-разработка». Off-phase | DEFERRED — на native-Windows машине нет Python ML-рантайма и нет модели для обучения. Зарегистрирован как pending-слот (как Figma MCP); устанавливается отдельной severable-задачей при появлении конкретной модели. Вне R6/R14 |
| **n8n-mcp** *(`n8n` сервер)***DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: принятие n8n в стек портала | **business-process MCP** — workflow-движок платформы n8n (построение/запуск автоматизированных workflow). Категория: **business-process** (Tooling §4.29 #54). Раздел C10 карты «Бизнес-процессы (общее)». Off-phase | DEFERRED — стек Лидерры не содержит n8n (движок процессов = очередь Laravel + события/джобы); принятие n8n как инфраструктуры — отдельное архитектурное решение (свой ADR), не выбор инструмента (N8N1). Зарегистрирован как pending-слот (как Figma MCP / Jupyter MCP); устанавливается отдельной severable-задачей. Вне R6/R14 |
| **OWASP ZAP MCP** *(`zap` сервер, официальный ZAP «MCP Integration» add-on)***установлен 21.05** | `bin/ZAP_2.17.0/` + MCP-аддон `mcp-alpha-0.0.1` на portable Temurin JRE 17 (`bin/_runtimes/`, без choco); MCP-эндпоинт (SSE) регистрируется в `.mcp.json` при запущенном ZAP-демоне (`docs/security/zap-setup.md`) | **infosec-tooling MCP** — глубокая боевая DAST работающего портала (spider + active scan: обход входа, инъекции, XSS). Категория: **infosec-tooling** (Tooling §4.43 #68). Раздел A8 карты. Off-phase | Установлен (daemon API verified → 2.17.0); MCP-аддон alpha. Цель по умолчанию **локальная копия** (127.0.0.1), бой — только по явной команде (IS8). READ-only сканер. Провенанс OWASP/Checkmarx (IS9-вет). Не trigger'ит R6.0/R6.1 и не входит в R14 pipeline. Вне R6/R14. ADR-014 |
**Отмена:** через удаление из `~/.claude.json` или `.mcp.json`. Live-override через `/команду` для MCP не предусмотрен — MCP-серверы не имеют slash-интерфейса.
@@ -832,7 +825,7 @@ Pravila §12 (Superpowers инвокация первой), §14 (queen-роут
- **UI-пул** (#31 UPM, #32 21st) — здесь R15 не применяется; R14 pipeline ведёт (это UI-задачи по природе).
- **infrastructure** (#33 claude-md-management) — единственный канал для правок CLAUDE.md (Pravila §5 п.10 + R10.1 Блок 1).
- **authoring-tooling** (#56-#58) — политика триггеров: skill-creator ≥3 повторений workflow → новый скил; hookify повторяющаяся ошибка → новый хук (с pre-check HK1); plugin-dev — для расширений plugin-grain.
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support / finance-tooling / backend-tooling / infosec-tooling** — следуют routing-off-phase.md.
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support / finance-tooling / backend-tooling** — следуют routing-off-phase.md.
### 15.7. Тип правила и enforcement
+5 -17
View File
@@ -1,16 +1,10 @@
# Правила работы Claude в проекте «Лидерра»
**Версия:** v1.38 (21.05.2026)
**Дата:** 21.05.2026
**Версия:** v1.35 (20.05.2026)
**Дата:** 20.05.2026
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
**Что изменилось в v1.38 относительно v1.37:** A8 infosec install-sync — ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco, по выбору заказчика «оба портативно») → в §13.2 абзаце «Off-phase infosec-tooling» статус **PENDING INSTALL снят** для обоих (ZAP: ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17; Ward: собран portable Go → `bin/ward.exe` v0.4.1); setup-доки `docs/security/{zap,ward}-setup.md`. Архитектурных изменений §§1–16: 0. Связано: Tooling v2.21, PSR_v1 v3.21, CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
**Что изменилось в v1.37 относительно v1.36:** A8 infosec-tooling — §13.2 +абзац «Off-phase infosec-tooling»: #68 OWASP ZAP (MCP DAST, **PENDING INSTALL** — нет Java), #69 Nuclei (CLI, установлен+verified), #70 Ward (CLI, заменил abandoned Enlightn, **PENDING INSTALL** — нет Go), #71 pdn-152fz-audit + #72 threat-model + #73 security-go-live (self-authored project-скилы). 17-я off-phase подкатегория, раздел A8. Провенанс-вет IS9 каждого внешнего ДО установки (риск ToxicSkills). Серверный слой (WAF/DDoS/мониторинг и т.д.) — out of scope, открытые вопросы SEC-1..SEC-7 (Б-1). Не UI → вне R6.0/R6.1/R14. Границы — ADR-014 (IS1–IS9). Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.20, PSR_v1 v3.20, CLAUDE.md v2.24; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`. **NB:** перенумеровано v1.36→v1.37 при ребейзе на origin/main — v1.36 параллельно занят observer missed-activations.
**Что изменилось в v1.36 относительно v1.35:** §16.4 расширен симметрией missed activation (условное правило): §16.4 заголовок уточнён «(условное)»; тело расширено — поведенческое правило теперь содержит условие «если профильной задачи в эпизодах не было»; добавлено **симметричное правило (missed activation)**: эпизоды с профильной классификацией без активации релевантного non-dormant узла — сигнал, surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`, не блок коммита; хранение mapping в `tools/observer-classification-map.json` + `tools/.node-dormancy.json` (двойной сигнал dormant=true ИЛИ DEFERRED в boundaries); DEFERRED-узлы (#17/#44/#50/#54/#67) — в missed activations не учитываются. Архитектурных изменений в §§1–15: 0. Связано: план `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`.
**Что изменилось в v1.35 относительно v1.34:** A1 backend-tooling — §13.2 +абзац «Off-phase backend-tooling»: #64 Rector + rector-laravel (Composer dev-dep, авто-рефакторинг/version-upgrade, manual/CI — dry-run baseline 16 файлов, не блокирующий), #65 PHP Insights (Composer dev-dep, метрики complexity/architecture, on-demand/CI — не блокирующий), #66 laravel-backend-patterns (self-authored project-скил, backend-конвенции Лидерры), #67 NightOwl (self-hosted runtime-телеметрия — **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ). 16-я off-phase подкатегория, раздел A1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-013. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.19, PSR_v1 v3.19, CLAUDE.md v2.22; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
**Что изменилось в v1.34 относительно v1.33:** finance-tooling (C6+C7) — §13.2 +абзац «Off-phase finance-tooling»: #61 finance plugin (marketplace `finance@knowledge-work-plugins`, Anthropic Verified, homed C7, cross-ref C6; РФ-применимость частична — US-GAAP-скилы ⚠️, SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 billing-audit (self-authored project-скил, C6 — денежные инварианты биллинга), #63 ru-tax-accounting (self-authored project-скил, C7 — РСБУ/НК РФ). 15-я off-phase подкатегория. Не UI → вне R6.0/R6.1/R14. Границы — ADR-012. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.18, PSR_v1 v3.18, CLAUDE.md v2.21; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
@@ -772,8 +766,6 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**Off-phase backend-tooling (A1, v1.35, 20.05.2026):** Инструменты раздела A1 карты «Программирование — backend» — #64 `Rector` + `rector-laravel` (Tooling §4.39; Composer dev-dependencies `rector/rector` + `driftingly/rector-laravel`, авто-рефакторинг/version-upgrade; конфиг `app/rector.php` deadCode+codeQuality conservative; постура manual/CI `composer rector`/`rector:fix` — dry-run baseline 16 файлов → **не** блокирующий lefthook, прецедент promptfoo ML1), #65 `PHP Insights` (Tooling §4.40; Composer dev-dependency `nunomaduro/phpinsights`; метрики complexity/architecture; конфиг `app/config/insights.php` — SyntaxCheck removed из-за Windows subprocess-краша, style-ось off — владелец Pint, BT4; постура on-demand/CI `composer insights` с порогами → **не** блокирующий, BT9), #66 `laravel-backend-patterns` (Tooling §4.41; self-authored project-скил `.claude/skills/laravel-backend-patterns/` — backend-конвенции Лидерры: слоистость/RLS-aware/bcmath-деньги/идемпотентность/partition-aware; **линтуется**, LINT1), #67 `NightOwl` (Tooling §4.42; `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent` — коррелированный runtime-трейс; **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ; pending Б-1/Linux). Плюс reuse существующих узлов A1 (Boost #10, Pint #11, Larastan #12). **Шестнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Rector/PHP Insights **не гейтят коммит** (manual/CI — избегаем дубля с Pint/Larastan/deptrac + авто-мутации кода). Границы — ADR-013 (BT1–BT9). Регулируется PSR_v1 R10.1 Блок 1 note. Установлено 20.05.2026 на ветке `worktree-a1-backend-tooling`; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
**Off-phase infosec-tooling (A8, v1.38, 21.05.2026):** Инструменты раздела A8 карты «Информационная безопасность» — портал готовится к публичному запуску в интернете. #68 `OWASP ZAP` (Tooling §4.43; официальный ZAP «MCP Integration» add-on `zaproxy/zap-extensions`, Apache-2.0; глубокая боевая DAST — обход входа, инъекции, XSS; MCP-сервер; **установлен 21.05** портативно — ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17, без choco, `docs/security/zap-setup.md`; цель по умолчанию локальная 127.0.0.1, бой только по явной команде — IS8), #69 `Nuclei` (Tooling §4.44; `projectdiscovery/nuclei` v3.8.0 MIT, Go-бинарь `bin/nuclei.exe` — широкая проверка известных уязвимостей/экспозиции/TLS; **CLI, не MCP**; **установлен+verified** на живом портале; квирки native-Windows: цель `127.0.0.1` не `localhost`, низкий rate-limit для однопоточного dev-сервера), #70 `Ward` (Tooling §4.45; `Eljakani/ward` MIT, Go CLI — безопасность настроек Laravel: .env/config/заголовки/cookie/secrets/deps; **ЗАМЕНИЛ Enlightn** — тот abandoned + без поддержки Laravel 13; **установлен 21.05** портативно — собран portable Go → `bin/ward.exe` v0.4.1, без choco, `docs/security/ward-setup.md`), #71 `pdn-152fz-audit` + #72 `threat-model` + #73 `security-go-live` (Tooling §4.46-4.48; self-authored project-скилы `.claude/skills/` — аудит ПДн+соответствие 152-ФЗ / STRIDE-моделирование угроз going-public / go-live security-gate оркестратор; **линтуются**, LINT1). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills ≈13% security-скилов с дефектами). **Семнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Серверный слой защиты (WAF / anti-brute-force / DDoS / мониторинг вторжений / secrets-vault / TLS-HSTS-CSP / бэкапы+IR-runbook) — **out of scope**, открытые вопросы инфраструктуры (привязка к Б-1, SEC-1..SEC-7). Границы — ADR-014 (IS1–IS9). Регулируется PSR_v1 R10.1 Блок 1 note (Nuclei/Ward CLI + 3 скила) + Блок 3 (ZAP MCP). Установлено 21.05.2026 на ветке `worktree-a8-infosec-tooling`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
### 13.3. Скоуп
| Тип задачи | Кто отвечает |
@@ -990,15 +982,11 @@ git fetch origin && git log HEAD..origin/main --oneline
Все 5 — механические, 0 LLM-вызовов в hot path.
### 16.4. Поведенческое правило «не использован ≠ проблема» (условное)
### 16.4. Поведенческое правило «не использован ≠ проблема»
Узел «мозга», не задействованный в реальной работе, **не** считается проблемой и **не** подлежит автоматической пометке **при условии, что профильной задачи для него в эпизодах не было**. Это — capability-readiness, осознанная стратегия заказчика.
Узел «мозга», не задействованный на реальной задаче, **не** считается проблемой и **не** подлежит автоматической пометке. Это — capability-readiness, осознанная стратегия заказчика. См. `memory/feedback_brain_unused_tools_not_problem.md`.
**Симметричное правило (missed activation):** если в эпизодах присутствует **хотя бы один** эпизод с `primary_rationale.task_classification`, соответствующим набору рекомендуемых узлов из `tools/observer-classification-map.json`, при этом `primary_rationale.node_chosen === 'direct'` и среди рекомендуемых узлов есть хотя бы один non-dormant (по `tools/.node-dormancy.json`, экстракт из [Tooling Прил.Н §3.5/§4.X](Tooling_v8_3.md) с двойным сигналом: `dormant: true` ИЛИ ключевое слово `DEFERRED` в колонке boundaries) — это **сигнал**, кандидат на разбор. Surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`. Не блок коммита, не auto-edit.
**Исключения:** DEFERRED-узлы (на момент v1.36 — #17 pg_partman, #44 Figma MCP, #50 Jupyter MCP, #54 n8n-mcp, #67 NightOwl) — для них «не активирован» = ожидаемое состояние, в missed activations не учитываются.
См. `memory/feedback_brain_unused_tools_not_problem.md`.
**Исключение**: deprecated upstream-пакеты или физически сломанные инструменты (отдельная категория, `npm audit` / `composer outdated`).
### 16.5. Не override-floor §9
+9 -133
View File
File diff suppressed because one or more lines are too long
-120
View File
@@ -1,120 +0,0 @@
# ADR-014: A8 infosec-tooling — наполнение раздела карты A8
**Status:** Accepted (amended 21.05.2026 — ZAP #68 + Ward #70 установлены портативно, статус PENDING INSTALL снят; см. Decision п.1/п.3 + Consequences)
**Date:** 2026-05-21
**Контекст:** эпик A8 infosec-tooling, spec `docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md`, plan `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`, провенанс-вет `docs/security/infosec-vet.md`.
## Context
Раздел карты A8 «Информационная безопасность» формально существовал, но дедицированных
узлов не имел — в него были лишь кросс-тегированы существующие фазовые инструменты
(Semgrep #25, gitleaks #8). Портал Лидерра подходит к публичному запуску в интернете;
заказчик попросил подобрать 5–7 плагинов (GitHub + Anthropic), закрывающих потребности
безопасности портала.
Дефициты чистого A8 (технические инструменты защиты *работающего* портала — отдельно
от процесса аудита D3, статики кода, БД-инструментов): динамическая «боевая» проверка
(DAST) отсутствовала полностью; широкая проверка на известные уязвимости/экспозицию;
Laravel-специфичная безопасность конфигурации; защита ПДн + соответствие 152-ФЗ;
моделирование угроз под выход в интернет; единый go-live security-gate.
D3 (audit-security) уже покрывает Anthropic-арсенал (Security Guidance хук,
`/security-review`, Trail of Bits скилы). DAST-движка и Laravel-сканера у Anthropic нет
→ внешние GitHub-инструменты обоснованы. Для 152-ФЗ и угроз-под-наш-портал готового
(знающего РФ-закон и устройство Лидерры) не существует → self-authored скилы.
**Решения заказчика (зафиксированы):** охват — мои инструменты + серверный слой (двумя
слоями); ПДн/152-ФЗ — целиком; «боевая» DAST — да; подход — готовые движки + свои скилы
для project-specific слотов.
## Decision
1. **OWASP ZAP (#68)** — официальный ZAP «MCP Integration» add-on (`zaproxy/zap-extensions`,
Apache-2.0). Глубокая DAST (spider + active scan): обход входа, инъекции, XSS.
- **Постура:** on-demand, READ-only сканер, цель по умолчанию **локальная копия**
(127.0.0.1), бой — только по явной команде (IS8). MCP-сервер в `.mcp.json`.
- **Статус: УСТАНОВЛЕН 21.05.2026** (портативно, без choco) — ZAP cross-platform 2.17.0
с MCP-аддоном `mcp-alpha-0.0.1` на portable Temurin JRE 17 (`bin/ZAP_2.17.0/`, gitignored);
daemon API verified → 2.17.0. Add-on alpha. Доку: `docs/security/zap-setup.md`.
2. **Nuclei (#69)**`projectdiscovery/nuclei` v3.8.0 (MIT), Go-бинарь `bin/nuclei.exe`.
Широкая проверка по YAML-шаблонам (известные CVE, экспозиция, TLS).
- **Тип: CLI-инструмент, НЕ MCP-сервер.** Nuclei не говорит на протоколе MCP;
обёртка в MCP-сервер = доп. attack surface. Интегрирован как CLI (как gitleaks #8 /
squawk #15 / Trivy #26), вызывается по требованию скилом #73. Поэтому `.mcp.json`-блок
и l1-watcher alias для #69 **не нужны**.
- **Статус: УСТАНОВЛЕН + verified** (13 060 шаблонов; smoke: 1057 запросов к живому
порталу, скан завершён). Квирки: цель `127.0.0.1` (не `localhost` — резолвер),
`-rate-limit 20 -c 5` для однопоточного dev-сервера. Доку: `docs/security/nuclei-setup.md`.
3. **Ward (#70)**`Eljakani/ward` (MIT, Go CLI). Сканер misconfig/secrets Laravel:
.env (8 проверок) + config/*.php (13) + deps (OSV.dev) + код (7 категорий).
- **ЗАМЕНИЛ Enlightn** (исходный план): Enlightn оказался abandoned (Packagist) +
официально без поддержки Laravel 13 (PR L12 висит 3+ мес). Ward — Go-бинарь, **не
зависит от версии Laravel** → проблема снята. Заказчик выбрал «подобрать замену».
Обоснование — `docs/security/infosec-vet.md` §ПЕРЕСМОТР #70. Pin по commit SHA (релизов нет).
- **Тип: CLI-инструмент** (как Nuclei), не MCP, не Composer dev-dep.
- **Статус: УСТАНОВЛЕН 21.05.2026** (портативно, без choco) — собран из исходника через
portable Go 1.26.3 (`go install github.com/eljakani/ward@v0.4.1`) → `bin/ward.exe` v0.4.1;
smoke `app/` → 2 находки (High APP_DEBUG, Medium APP_ENV). Доку: `docs/security/ward-setup.md`.
- Caveat: молодой (фев 2026), single-maintainer → bus-factor; митигация — версия-pin + MIT-форк.
4. **pdn-152fz-audit (#71)** — self-authored project-скил. Аудит ПДн + соответствие 152-ФЗ
(2 режима: техника + закон), заземлён в `db/schema.sql`. Активен.
5. **threat-model (#72)** — self-authored project-скил. STRIDE под наш портал, going-public,
заземлён в `app/routes/`. Активен.
6. **security-go-live (#73)** — self-authored project-скил, оркестратор go-live security-gate:
#68#72 + Semgrep #25 / Trivy #26 / gitleaks #8 / Trail of Bits #39 → вердикт GO/NO-GO. Активен.
**Серверный слой защиты** (WAF, anti-brute-force/rate-limit, DDoS, intrusion monitoring,
secrets vault, TLS/HSTS/CSP, бэкапы + IR-runbook) — **out of scope** этого эпика (не плагины);
фиксируется как открытые вопросы инфраструктуры (привязка к Б-1).
## Boundaries (конфликт-аудит)
- **IS1** ZAP #68 ↔ Semgrep #25: динамика (бьёт работающий портал) vs статика (читает код) — разные классы.
- **IS2** Nuclei #69 ↔ ZAP #68: широта (известные дыры / экспозиция по шаблонам) vs глубина (логика приложения / активные инъекции) — комплементарны.
- **IS3** Ward #70 ↔ Larastan #12 / Semgrep #25: misconfig/secrets/deps-сканер Laravel vs типы / generic-паттерны. Dep-скан Ward пересекается с Trivy #26 / Dependabot #27 — информационно, не гейт.
- **IS4** pdn-152fz-audit #71 ↔ pg_anonymizer #29: аудит + направление (где ПДн, всё ли закрыто) vs инструмент маскирования.
- **IS5** pdn-152fz-audit #71 ↔ D2 (право/юрист): техника + 152-ФЗ-чек-лист vs юридическое оформление документов.
- **IS6** threat-model #72 ↔ Trail of Bits `audit-context-building` #39: наш портал + STRIDE + going-public vs generic deep code-audit.
- **IS7** security-go-live #73`audit-portal`: только безопасность + go-live-вердикт vs полный 14-фазный аудит; #73 *вызывает* D3, не заменяет.
- **IS8** «боевая» проверка (#68/#69) на бою: гард — по умолчанию локальная/тестовая копия (127.0.0.1); бой только осознанно и аккуратно.
- **IS9** провенанс-гейт: каждый внешний (ZAP/Nuclei/Ward) читается и проверяется на происхождение ДО установки (риск ≈13% ToxicSkills) — расширение процедуры `docs/audit/` attack-surface. Артефакт — `docs/security/infosec-vet.md`.
## Alternatives Considered
- **Enlightn (#70 исходный)** — отклонён: abandoned (Packagist), `composer.json` без Laravel 13, мейнтейнер не отвечает 3+ мес. Заменён Ward.
- **Готовые маркетплейс-скилы threat-model / compliance** (fr33d3m0n, josemlopez, sickn33, и пр.) — отклонены для #71/#72: generic-методика (GDPR/SOC2, не 152-ФЗ; не знают устройство Лидерры) + риск ToxicSkills. Берутся как референс, не установка.
- **Larafence** — отклонён: не выпущен (Q2 2026) + TALL/Livewire-стек (у нас Vue).
- **Psalm + plugin-laravel taint-analysis** — не для слота #70: код-SAST (taint), пересекается с Semgrep #25 (IS3); не config-сканер.
- **`laravel/agent-skills`** (официальный, чистый провенанс) — не security-сканер (общий Laravel-скил); опциональное доп. позже, не замена слота.
- **Платные tiers** (Enlightn Pro, Snyk, ProjectDiscovery Cloud) — только OSS (РФ-резидентность, near-zero cost).
- **Дедицированный dependency/SBOM-инструмент** — не добавляем: покрыто Dependabot #27 + Trivy #26 + ToB #39 + GitHub MCP (дубль §5 п.6).
## Consequences
**Positive:**
- A8 непуст: 0 → 6 дедицированных узлов. **Все установлены (21.05.2026):** Nuclei #69 + Ward #70 (CLI в `bin/`) + ZAP #68 (portable JRE 17, daemon verified) + 3 скила #71/#72/#73.
- Новая off-phase подкатегория `infosec-tooling` (17-я).
- Провенанс-вет (IS9) каждого внешнего инструмента до установки — расширяет ADR-003-дисциплину; чужие security-скилы в чувствительные слоты (#71/#72) не тащим (ToxicSkills).
- 152-ФЗ + угрозы-под-наш-портал сделаны своими скилами (РФ-/project-specific), а не generic-готовым.
- DAST-движки таргетят локальную копию по умолчанию (IS8) — безопасно для боевого портала.
**Negative:**
- ZAP #68 (alpha MCP + Java) и Ward #70 (Go) — **установлены портативно 21.05.2026** (без choco, по выбору заказчика «оба портативно»; setup-доки `docs/security/{zap,ward}-setup.md`). Footprint ~1.2 ГБ (Go SDK + JRE + ZAP) в `bin/*` gitignored. go-live-gate #73: шаг ZAP возвращает PENDING лишь при незапущенном ZAP-демоне (MCP-режим требует живого демона).
- Ward — молодой single-maintainer проект (bus-factor); митигация SHA-pin + MIT-форкабельность.
- Nuclei добавляет 126 МБ бинарь в `bin/` (gitignored, машинно-локальный) + 13k шаблонов.
- ПДн-скил полагается на pg_anonymizer, который сам DEFERRED (OPEN-И-24, фаза 3) — чек-лист честно помечает «проверить вручную».
## Related Decisions
- **ADR-002** — tenant isolation via RLS; её правило драйвит ПДн-аудит (#71) и его технический режим.
- **ADR-003** — D3 audit-security toolset; A8 — технический домен, граница: #73 *вызывает* D3-инструменты, не заменяет (IS7); провенанс-дисциплина IS9 наследует «defer непроверенного» из ADR-003.
## References
- `docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md` — design.
- `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md` — plan.
- `docs/security/infosec-vet.md` — IS9 провенанс-вет (вкл. §ПЕРЕСМОТР #70 Enlightn→Ward).
- `docs/security/nuclei-setup.md` — установка/квирки Nuclei.
- `docs/Открытые_вопросы_v8_3.md` — серверный слой (open questions).
-33
View File
@@ -100,36 +100,3 @@ The observer episode is extended to `schema_version: 2` so a real factor analysi
- Pravila §12 / §14 / §15 (hard-floor for router procedure step 1)
- PSR_v1 R15 (off-phase routing extends to brain governance)
- memory: `feedback_brain_unused_tools_not_problem.md`, `project_brain_governance_design.md`
## Amendment 2026-05-21: Conditional missed-activation rule (§16.4 v1.36)
The original §16.4 stated unconditionally that an unused node is not a problem. Real-world episodes show this is too permissive: when a profile-classified task (e.g. `refactor`) runs with `node_chosen === 'direct'` and a relevant non-dormant node exists in Tooling Прил.Н, the absence of activation IS a signal (router miss, not a problem in the node itself).
The rule now reads:
- **Unused + no profile task** → still not an alert (capability-readiness).
- **Unused + profile task present** → "missed activation", surfaced in STATUS.md C5 and `/brain-retro`. Not a commit block.
**Mapping artefacts:**
- `tools/observer-classification-map.json` — manual mapping `classification → recommended_node_ids[]` (single source of truth). 10 classification buckets, populated from the real `tools/observer-transcript-parser.mjs` `classifyTask` dictionary (bugfix / cleanup / feature / memory-sync / monitoring / other / planning / question / refactor / analysis).
- `tools/.node-dormancy.json` — generated from Прил.Н by `tools/extract-node-dormancy.mjs` (pre-commit job `extract-node-dormancy` in `lefthook.yml`). Uses a **two-signal** availability check: `dormant: true` in the 9-attribute row OR keyword `DEFERRED` in the boundaries column. Both signals normalize to the same JSON value, so consumers don't distinguish "permanent dormant" (#17) from "deferred-pending" (#44 / #50 / #54 / #67) — they're all "cannot activate right now".
- `tools/missed-activations.mjs` — pure deterministic matcher. Exports `detectMissedActivations(episodes, classificationMap, dormancy)`. No fs, no exec.
**Detection threshold:** single episode (per user decision 2026-05-21). No smoothing; every qualifying episode counts.
**DEFERRED exclusion:** nodes flagged as unavailable in `.node-dormancy.json` are filtered before counting. Current dormant set: #1 (replaced), #17 (pg_partman, native-Windows), #44 (Figma MCP, no Figma account), #50 (Jupyter MCP, no Python ML env), #54 (n8n-mcp, n8n not in stack), #67 (NightOwl, pending Б-1 / Linux).
**Surfacing:**
- C5 `observer-coverage-checker` includes `missed.totalMissed` in its return value; the CLI emits `WARN — missed activations: N (see /brain-retro)` when N > 0.
- `status-md-generator` renders `missed_activations: N` in the metrics block; C5 row turns ⚠️ when N > 0.
- `/brain-retro` `analyze(episodes, { classificationMap, dormancy })` returns `missedActivations: { totalMissed, byNode, byClassification }` — the retro skill renders a per-node + per-classification breakdown.
**Initial measurement on May 2026 episodes:** 16 missed activations, dominated by memory-sync × 7 (CLAUDE.md edits without `#33 claude-md-management` chosen) and feature × 4 (no Superpowers brainstorming invocation). This is the kind of "router miss" signal the rule is designed to surface, not a problem in the unactivated nodes themselves.
**Linkage:**
- Pravila §16.4 v1.36 (2026-05-21).
- Plan: `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`.
- Spec / decision rationale: this amendment.
+6 -36
View File
@@ -21,11 +21,11 @@ function pos(ring, angleDeg) {
const NODES = [
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
{ id: 'pravila', label: 'Pravila v1.38', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.26', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.21', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.22', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
{ id: 'router_procedure', label: 'router-procedure v1.3', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
{ id: 'pravila', label: 'Pravila v1.35', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.22', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.19', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.19', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
{ id: 'router_procedure', label: 'router-procedure v1.2', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
@@ -95,14 +95,6 @@ const NODES = [
{ id: 'php_insights', label: 'PHP Insights\n(dev-dep)', group: 'plugins', size: 18, ring: 2, ...pos(2, 220) },
{ id: 'backend_patterns', label: 'backend-patterns\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 417) },
{ id: 'nightowl', label: 'NightOwl\n(DEFERRED)', group: 'mcp', size: 16, ring: 3, ...pos(3, 427) },
// A8 infosec-tooling (21.05.2026) — раздел «Информационная безопасность»
{ id: 'mcp_zap', label: 'MCP: OWASP ZAP\n(DAST)', group: 'mcp', size: 18, ring: 5, ...pos(5, 360) },
{ id: 'nuclei', label: 'Nuclei\n(CLI, известные уязвимости)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 370) },
{ id: 'ward', label: 'Ward\n(CLI, Laravel безопасность)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 380) },
{ id: 'sk_pdn_152fz', label: 'ПДн / 152-ФЗ\n(скил)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 437) },
{ id: 'sk_threat_model', label: 'Моделирование угроз\nSTRIDE (скил)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 447) },
{ id: 'sk_security_golive', label: 'Прогон перед\nпубликацией (скил)', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 457) },
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
@@ -430,25 +422,6 @@ const EDGES = [
E('mcp_boost', 'backend_patterns', 'Eloquent-контекст'),
E('nightowl', 'mcp_sentry', 'трейс ↔ ошибки\n(BT7, ADR-013)'),
// ── A8 INFOSEC-TOOLING (21.05.2026) — связи 6 новых узлов + L15 chain ──
E('tooling', 'mcp_zap', '§4.X #A8 — реестр (DAST)'),
E('tooling', 'nuclei', '§4.X #A8 — реестр (CVE CLI)'),
E('tooling', 'ward', '§4.X #A8 — реестр (Laravel security)'),
E('tooling', 'sk_pdn_152fz', '§4.X #A8 — реестр (ПДн скил)'),
E('tooling', 'sk_threat_model', '§4.X #A8 — реестр (STRIDE скил)'),
E('tooling', 'sk_security_golive', '§4.X #A8 — реестр (go-live скил)'),
// sk_security_golive оркеструет — L15 security go-live chain
E('sk_security_golive', 'mcp_zap', 'оркеструет (L15)'),
E('sk_security_golive', 'nuclei', 'оркеструет (L15)'),
E('sk_security_golive', 'ward', 'оркеструет (L15)'),
E('sk_security_golive', 'sk_pdn_152fz', 'оркеструет (L15)'),
E('sk_security_golive', 'sk_threat_model', 'оркеструет (L15)'),
// L15 — reuse: существующие A8/D3 узлы
E('sk_security_golive', 'mcp_semgrep', 'L15 go-live chain'),
E('sk_security_golive', 'lh_gitleaks', 'L15 go-live chain'),
E('sk_security_golive', 'tob_skills', 'L15 go-live chain'),
E('sk_security_golive', 'sec_guidance', 'L15 go-live chain'),
// ══════════════════════════════════════════════════
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
@@ -553,7 +526,7 @@ const SECTIONS = [
{ id: 'E7', bucket: 'E', label: 'Исследования' },
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
];
// Узел -> раздел. Покрывает все 147 узлов карты (141 base + 6 A8 infosec).
// Узел -> раздел. Покрывает все 134 узла карты.
const NODE_SECTION = {
// правила (4)
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
@@ -618,9 +591,6 @@ const NODE_SECTION = {
finance_plugin: 'C7', billing_audit: 'C6', ru_tax: 'C7',
// A1 backend-tooling (20.05.2026) — раздел «Программирование — backend»
rector: 'A1', php_insights: 'A1', backend_patterns: 'A1', nightowl: 'A1',
// A8 infosec-tooling (21.05.2026) — раздел «Информационная безопасность»
mcp_zap: 'A8', nuclei: 'A8', ward: 'A8',
sk_pdn_152fz: 'A8', sk_threat_model: 'A8', sk_security_golive: 'A8',
};
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
+6 -91
View File
@@ -273,7 +273,7 @@ const NODE_DETAILS = {
'Править можно только через скил `/claude-md-management:claude-md-improver` или `:revise-claude-md` (правило §5 п.10). Прямые Edit/Write блокируются хуком предупреждения.',
[{ name: 'Pravila', cond: 'всегда подчинён (уровень 2a)' }],
[
{ name: 'Tooling v2.22', cond: 'ссылается как на реестр инструментов' },
{ name: 'Tooling v2.15', cond: 'ссылается как на реестр инструментов' },
{ name: 'плагин claude-md-management', cond: 'правило §5 п.10 — единственный канал правок' }
],
[
@@ -296,7 +296,7 @@ const NODE_DETAILS = {
[{ name: 'CLAUDE.md', desc: 'CLAUDE.md §5 п.10 требует править только через скил claude-md-management, а PSR_v1 это ограничение не повторяет — риск прямых Edit', type: 'GREEN' }]
),
tooling: nd(
'Реестр 93 позиций — 73 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'Реестр 80 позиций — 60 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
[
@@ -1503,83 +1503,6 @@ const NODE_DETAILS = {
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
[]
),
// ── A8 INFOSEC-TOOLING (#68-73, добавлены 22.05.2026 follow-up к A8-эпику 21.05) ──
mcp_zap: nd(
'MCP-сервер (OWASP ZAP add-on, alpha) — глубокая боевая динамическая проверка работающего портала: обход входа, инъекции (SQL/XSS), проблемы сессий/CSRF на живых endpoint-ах. ZAP 2.17.0 + MCP-аддон mcp-alpha-0.0.1 на portable Temurin JRE 17 (не системная Java).',
'Перед публикацией портала в интернет — для динамического security-gate перед релизом; вызывается скилом security-go-live (#73) как шаг динамики.',
'Цель по умолчанию — локальная копия 127.0.0.1; бой только по явной команде (граничное условие IS8, ADR-014). MCP-аддон в alpha — API может меняться. Требует запущенного ZAP-демона на portable JRE; без демона MCP-режим возвращает PENDING.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 3 — MCP-сервер при включённом демоне' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет ZAP как шаг динамики; связка L15' },
{ name: 'Nuclei (#69)', cond: 'комплементарны — широта (Nuclei) + глубина (ZAP); ADR-014 IS2' }
],
[]
),
nuclei: nd(
'CLI-инструмент (Go-бинарь bin/nuclei.exe v3.8.0 + 13 060 шаблонов) — широкое быстрое сканирование известных уязвимостей: CVE, дефолтные креды, открытые двери (.env/.git), утечки конфигов, слабый TLS, fingerprint стека. НЕ MCP — nuclei не говорит на MCP, обёртка стала бы доп. attack surface.',
'Регулярный security-scan живого портала; вызывается скилом security-go-live (#73). Срабатывает в задаче «прогнать сканер уязвимостей по порталу».',
'Цель — IP-литерал (127.0.0.1, не localhost — резолвер падает на native-Windows). Низкий rate-limit для однопоточного dev-сервера (-rate-limit 20 -c 5). Безопасный режим: исключать теги fuzz/dos/intrusive/brute-force при сканах боевого. Гард IS8.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 — CLI-инструмент' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет Nuclei как шаг широкого сканирования; связка L15' },
{ name: 'OWASP ZAP MCP (#68)', cond: 'комплементарны (широта Nuclei + глубина ZAP); ADR-014 IS2' }
],
[]
),
ward: nd(
'CLI-инструмент (Go-бинарь bin/ward.exe v0.4.1) — сканер misconfig и секретов в Laravel: .env (8 проверок), config/*.php (13), deps через OSV.dev (live), код (7 категорий — secrets/injection/XSS/debug-артефакты/crypto/config CORS-CSRF-mass-assignment/auth). Go-бинарь → не зависит от версии Laravel.',
'Аудит безопасности настроек Laravel при ревью .env/config или подготовке к релизу; вызывается скилом security-go-live (#73).',
'CLI, не MCP, не Composer dev-dep — отдельный путь установки (portable Go SDK). Молодой проект (фев 2026), single-maintainer — bus-factor; митигация — версия-pin v0.4.1 и MIT-форкабельность. Заменил Enlightn (тот abandoned + без поддержки Laravel 13).',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 — CLI-инструмент' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет Ward как шаг Laravel-misconfig; связка L15' },
{ name: 'Larastan (#12), Semgrep MCP (#25)', cond: 'комплементарны — Ward бьёт misconfig/secrets/deps, Larastan/Semgrep — типы/паттерны; ADR-014 IS3' }
],
[]
),
sk_pdn_152fz: nd(
'Project-скил — аудит персональных данных и соответствие 152-ФЗ. Два режима: технический (где лежат ПДн в схеме/коде, RLS, маскирование через pg_anonymizer, утечки в логах/CSV-экспортах) + юридический (хранение в РФ, согласия, сроки/удаление, реестр обработки, уведомление РКН, права субъекта).',
'При вопросах «проверь ПДн», «утекают ли персональные данные», «соответствие 152-ФЗ», «где хранятся телефоны лидов», перед публичным запуском. Вызывается также security-go-live (#73) как шаг ПДн.',
'Project-скил (self-authored, .claude/skills/pdn-152fz-audit/). Заземлён в db/schema.sql — даёт оценку, не правит код. Не подменяет юридическое оформление (D2: договоры/политики).',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 — self-authored project-скил' }],
[],
[
{ name: 'pg_anonymizer (#29)', cond: 'аудит проверяет маскирование, pg_anonymizer — инструмент; ADR-014 IS4' },
{ name: 'скил security-go-live (#73)', cond: 'оркеструет как шаг 152-ФЗ; связка L15' }
],
[]
),
sk_threat_model: nd(
'Project-скил — моделирование угроз портала по STRIDE. Карта точек входа (login/2FA/recovery, supplier webhooks, deals API, админка, impersonation, CSV-импорт; заземлён в app/routes/), что меняется при выходе в интернет, приоритизация защиты. Результат — docs/security/threat-model-<date>.md.',
'Перед публикацией портала в интернет; при вопросах «смоделируй угрозы», «откуда могут атаковать», «карта точек входа». Вызывается также security-go-live (#73).',
'Project-скил под наш портал (не generic STRIDE). Не подменяет deep code-audit (Trail of Bits #39); фокус — атакующая поверхность, не уязвимости в реализации.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет STRIDE как шаг приоритизации; связка L15' },
{ name: 'скил pdn-152fz-audit (#71)', cond: 'STRIDE → угрозы на ПДн → ПДн-аудит' }
],
[]
),
sk_security_golive: nd(
'Project-скил — единый go-live security-gate перед публикацией портала в интернет. Оркеструет OWASP ZAP (#68) + Nuclei (#69) + Ward (#70) + pdn-152fz-audit (#71) + threat-model (#72) + Semgrep (#25) / gitleaks (#8) / Trivy (#26) / Trail of Bits (#39) → собирает вердикт GO / NO-GO.',
'Перед каждой публикацией боевого портала или большим релизом; при вопросах «готов ли портал к публикации по безопасности», «финальная проверка безопасности перед релизом».',
'Не подменяет полный 14-фазный audit-portal (тот шире); фокус — security-only срез часть дня. ZAP-шаг возвращает PENDING если ZAP-демон не запущен. Цель по умолчанию локальная (IS8).',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1' }],
[
{ name: 'OWASP ZAP MCP (#68)', cond: 'вызывает как шаг динамики (глубина)' },
{ name: 'Nuclei (#69)', cond: 'вызывает как шаг широкого сканирования' },
{ name: 'Ward (#70)', cond: 'вызывает как шаг Laravel-misconfig' },
{ name: 'скил pdn-152fz-audit (#71)', cond: 'вызывает как шаг ПДн' },
{ name: 'скил threat-model (#72)', cond: 'вызывает как шаг STRIDE' }
],
[{ name: 'Semgrep MCP (#25), gitleaks (#8), Trivy (#26), Trail of Bits (#39)', cond: 'статический слой — выполняются как часть оркестрации; связка L15' }],
[]
),
};
// ════════════════════════════════════════════════════
@@ -1740,10 +1663,10 @@ const META_WINDOW = '0920.05.2026'; // окно подсчёта исп
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | 'интеграция' | 'DEFERRED' | '—'
const NODE_META = {
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
pravila: { since: '06.05.2026', changed: '21.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '22.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '21.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '22.05.2026', uses: null, usesSrc: '—' },
pravila: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
// ── ПЛАГИНЫ (5) ──
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
@@ -1937,14 +1860,6 @@ const NODE_META = {
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
// ── A8 INFOSEC-TOOLING (#68-73, добавлены 22.05.2026 follow-up к A8-эпику 21.05) ──
mcp_zap: { since: '21.05.2026', changed: '22.05.2026', uses: 1, usesSrc: 'интеграция (install)' },
nuclei: { since: '21.05.2026', changed: '22.05.2026', uses: 2, usesSrc: 'инспекция (2 скана)' },
ward: { since: '21.05.2026', changed: '22.05.2026', uses: 1, usesSrc: 'инспекция (smoke app/)' },
sk_pdn_152fz: { since: '21.05.2026', changed: '21.05.2026', uses: 1, usesSrc: 'интеграция' },
sk_threat_model: { since: '21.05.2026', changed: '21.05.2026', uses: 1, usesSrc: 'интеграция (3 эндпоинта)' },
sk_security_golive: { since: '21.05.2026', changed: '22.05.2026', uses: 1, usesSrc: 'скил (orchestration)' },
};
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
@@ -1,97 +0,0 @@
# Runbook: импорт проектов lkomega → info@lkomega.ru
Разовая операция на боевом liderra.ru (`111.88.246.137`). Усыновляет активные
проекты поставщика crm.bp-gr.ru (аккаунт lkomega) как проекты Лидерры под
тенантом info@lkomega.ru. **Портал не трогается** (никаких save/update/delete).
Plan: `docs/superpowers/plans/2026-05-22-supplier-projects-import-lkomega.md`
Spec: `docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md`
## Деплой команды
Скопировать на сервер в `/var/www/liderra/app` (бэкап заменяемого `SupplierRegions.php`):
- `app/Support/SupplierRegions.php` (изменён — добавлен `mapFromSupplier`)
- `app/Services/Supplier/Import/SupplierImportMapper.php` (новый)
- `app/Services/Supplier/Import/SupplierProjectImporter.php` (новый)
- `app/Console/Commands/ImportSupplierProjectsCommand.php` (новый)
```bash
cp /var/www/liderra/app/app/Support/SupplierRegions.php /var/www/liderra/app/app/Support/SupplierRegions.php.bak-$(date +%Y%m%d-%H%M%S)
# scp 4 файла...
cd /var/www/liderra/app && php artisan optimize:clear # сброс кэша команд/конфига
```
Команда — не очередь/воркер, `queue:restart` не нужен.
## Шаг 1 — dry-run (показать план, ничего не пишет)
```bash
cd /var/www/liderra/app && php artisan supplier:import-projects --tenant=info@lkomega.ru
```
Вывод: число проектов к созданию, таблица (тип / идентификатор[маскирован] /
тег / регионы / лимит / площадки B1:id …), список пропусков (`unsupported_source`
для dop2, `regions_exclude`, `sms_unparseable`, `already_exists`).
**Показать заказчику → получить «ок».**
## Шаг 2 — реальный прогон
```bash
cd /var/www/liderra/app && php artisan supplier:import-projects --tenant=info@lkomega.ru --commit
```
Вывод: `Создано: проектов=N, supplier_projects=M, связок=K.`
## Шаг 3 — пост-проверка
```bash
# Число проектов под тенантом (подставить tenant_id info@lkomega.ru):
php artisan tinker --execute="echo App\Models\Project::on('pgsql_supplier')->where('tenant_id', <ID>)->count();"
```
- Выборочно сверить 2–3 проекта: `daily_limit_target` = сумме площадок; регионы корректны (ГИБДД→Лидерра).
- **Проверить целостность площадок каждого проекта** (см. оговорку ниже):
каждый проект должен иметь столько связок `project_supplier_links`, сколько площадок было в группе (обычно 3).
```bash
php artisan tinker --execute="App\Models\Project::on('pgsql_supplier')->where('tenant_id',<ID>)->get()->each(fn(\$p)=>print(\$p->id.': '.\$p->supplierProjects()->count().PHP_EOL));"
```
- Подтвердить, что на портале crm.bp-gr.ru **НЕ появилось новых проектов** (команда его не дёргает).
## Атомарность
`commit()` оборачивает запись **каждого проекта в отдельную транзакцию** на проде
(`DB::connection('pgsql_supplier')->transaction(...)` — Project + все `supplier_projects` +
все pivot-связки группы атомарно). Сбой посреди группы → транзакция откатывается → ни
проекта, ни partial-связок не остаётся, БД консистентна. Уже созданные ДО сбоя проекты
сохраняются (per-group, не per-run).
В прод-команде это включается автоматически: гейт `getPdo()->inTransaction()` — false на
проде → BEGIN/COMMIT per item; true только под тестовым харнессом `SharesSupplierPdo`
(общий PDO уже в транзакции) → внутренний BEGIN пропускается, чтобы избежать
«already active transaction» в Pest.
При ошибке посреди прогона — просто запустить `--commit` повторно: идемпотентность
(`already_exists` по tenant+signal + `firstOrCreate` по `(platform, unique_key,
subject_code)`) пропустит уже импортированные проекты и до-создаст оставшиеся.
## Откат
Импортированные проекты под тенантом — soft-archive через ЛК или:
```php
App\Models\Project::on('pgsql_supplier')->where('tenant_id', <ID>)
->update(['is_active' => false, 'archived_at' => now()]);
```
`supplier_projects`/pivot можно оставить (они указывают на реальные портальные проекты,
их используют и другие потоки).
## NB про среду
- На worktree-сборке 2 теста `SupplierPortalClient*Test` падают из-за отсутствия node-модуля
`playwright` — это известный worktree-only квирк (не регрессия), на боевом/основном
checkout с `node_modules` они зелёные.
- Larastan: production-код чист; test-only `TestCall`/Mockery (квирк #25) добавляются в
`phpstan-baseline.neon` на чистом checkout при интеграции (не из worktree — там дрейф
ide-helper искажает счётчики).
+130
View File
@@ -0,0 +1,130 @@
# Лидерра — тест-сервер (Yandex Cloud) — runbook
**Создан:** 2026-05-21. Тестовое окружение для ручной проверки (заказчик + Claude). Не продакшен.
Спека: `docs/superpowers/specs/2026-05-21-test-deploy-yandex-cloud-design.md`.
План: `docs/superpowers/plans/2026-05-21-test-deploy-yandex-cloud.md`.
## Доступ
- **URL (HTTP, временно):** `http://111.88.246.137` — статический IP YC.
- **HTTPS / домен:** добавляется после покупки домена (см. «Включить HTTPS»).
- **Дверь сайта (HTTP Basic Auth):** логин `liderra` — пароль в `/home/ubuntu/liderra-secrets.txt` на сервере (ключ `basic_auth`).
- **Демо-вход в портал:** `admin@demo.local` / `password` (tenant `demo`, 3 проекта, демо-сделки).
- **SSH:** `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137` (ключ на dev-машине; пароль входа отключён).
- **YC:** облако `cloud-sasha261185`, каталог `default`, VM `liderra-test` (ru-central1-a, 2vCPU/2GB/20%), SG `liderra-test-sg` (22/80/443).
## Состав
- Ubuntu 24.04: nginx (Basic Auth, webhook `/api/webhook/*` без auth) → PHP-FPM 8.3 → Laravel.
- PostgreSQL 16 (БД `liderra`), Redis (sessions+cache+queue, predis).
- Код в `/var/www/liderra/app`; фронтенд `public/build` (собирается на dev, заливается scp).
- Службы: `liderra-queue.service` (queue worker, systemd, enabled) + cron `/etc/cron.d/liderra-scheduler` (schedule:run). Все автозапускаются после ребута.
## Важные отклонения от прод-дизайна (на решение позже)
- **DB-роль приложения = `crm_app_user` (RLS включена)** — изоляция бизнес-данных между клиентами
**работает** (deals/projects/billing/… строгие политики). Чтобы вход работал под строгой ролью,
RLS-политики на таблицах `users` + `auth_log` сделаны «дружелюбными ко входу»: пропускают запрос,
когда tenant-контекст ещё не установлен (auth/login), и фильтруют по тенанту после. Это server-only
правка политик (не в schema.sql); для прода — кандидат в нормативную схему.
- **Админка SaaS `/admin/*` под `crm_app_user` НЕ работает** (нет доступа к saas-таблицам — REVOKE).
Для теста «от лица клиентов» не нужна. Понадобится — переключать admin-запросы на `crm_admin_user`
(connection-switch в middleware `EnsureSaasAdmin`) — отдельная доработка.
- **`SAAS_ADMIN_TEST_BYPASS=true`** — временный флаг (для будущей админки). Убрать после Yandex SSO (Б-1).
- **Почта** = `log` (письма в файл). **APP_DEBUG=false**, **APP_ENV=production**.
- Установлены dev-зависимости (faker нужен для сидов).
## Тестовые клиенты
| Логин | Пароль | Компания |
|---|---|---|
| `admin@demo.local` | `password` | Demo (3 проекта + демо-сделки) |
| `client1@liderra.test` | `password` | Компания 1 (2 проекта) |
| `client2@liderra.test` | `password` | Компания 2 (2 проекта) |
| `client3@liderra.test` | `password` | Компания 3 (2 проекта) |
| `client4@liderra.test` | `password` | Компания 4 (2 проекта) |
Изоляция проверена вживую: каждый видит только свои проекты (HTTP-логин + `/api/projects`).
## Каналы миграции с поставщиком (настроены 2026-05-21)
Все 3 канала с `crm.bp-gr.ru` подняты и проверены вживую на тест-сервере.
### Предпосылки (доустановлены сверх базового деплоя — в исходном runbook их не было)
- **Node.js 20** (NodeSource) + **Playwright** (`app/playwright/node_modules`, `npm install`) + **Chromium**
в `/var/www/.cache/ms-playwright/` (HOME у `www-data` = `/var/www`; ставить через
`sudo HOME=/var/www .../playwright install chromium` затем `chown -R www-data:www-data /var/www/.cache`,
иначе artisan от www-data не находит браузер). Без них логин к поставщику (Yii2-форма, JS) не работает
→ CSV-сверка и экспорт мертвы (`PlaywrightBridge exit code 127: node: not found`).
- `PlaywrightBridge::TIMEOUT_SECONDS` поднят **75 → 180** (`app/app/Services/Supplier/PlaywrightBridge.php`):
на 2 ГБ VM холодный старт Chromium ~65 c, в 75 не укладывался. Бэкап `*.bak.20260521`.
- `.env`: `SUPPLIER_LOGIN` / `SUPPLIER_PASSWORD` (те же, что на dev). Бэкап `.env.bak.20260521-*`.
- `system_settings.supplier_webhook_secret` — 48-hex (DemoSeeder ставит короткий → guard `<32` → webhook молча 404).
Копия в `/home/ubuntu/liderra-secrets.txt`.
- `system_settings.supplier_ip_allowlist` = `["0.0.0.0/0"]` — на `APP_ENV=production` пустой массив fail-closed (404 всем).
**TODO: сузить** до IP поставщика (в логе видели `92.53.65.242`).
### Канал 1 — приём webhook'а (вход, основной)
- POST `http://111.88.246.137/api/webhook/supplier/<secret>` (nginx `^~ /api/webhook/` без Basic Auth).
- Проверено: правильный secret → 202, дубль `vid` → 200 `already_processed`, битый secret → 404.
### Канал 2 — CSV-дочерпывание (вход, резерв)
- `CsvReconcileJob`, scheduler каждые 30 мин (cron `schedule:run` ежеминутно). Прогон вживую: 185 строк, status `ok`, drift 0.
- Ручной запуск: `sudo -u www-data php artisan tinker --execute='App\Jobs\Supplier\CsvReconcileJob::dispatchSync()'`.
### Канал 3 — экспорт проектов (выход)
- `SupplierProjectChannel::createProject` / `SupplierPortalClient::deleteProject`. Проверено: create+delete
тестового проекта (`external_id=12764235`), сверка `listProjects` — следов у поставщика нет.
### Supplier-портал
- `crm.bp-gr.ru → /admin/user/api`: «Апи ссылка» = `http://111.88.246.137/api/webhook/supplier/<secret>`,
«Апи протокол» = HTTP, «Апи статус» = Активный. Поставщик HTTP-URL принимает.
- ⚠️ Поле URL **одно** → после переключения на тест-сервер dev-машина живых лидов **не получает**.
- Сессия логина: Redis DB 1, ключ `liderra-database-liderra-cache-supplier:session` (TTL 6h, refresh-крон/`supplier:session:refresh`).
### Сделать позже
- Привязать `client1..4` к реальным каналам поставщика через pivot `project_supplier_links` (иначе лиды = ghost без сделок).
- HTTPS после покупки домена → URL у поставщика на https.
- Сузить `supplier_ip_allowlist`.
## Обновить версию
На dev-машине:
```powershell
npm --prefix app run build
git -C <repo> archive --format=tar HEAD app db -o $env:TEMP\liderra.tar
scp -i ~/.ssh/liderra_deploy $env:TEMP\liderra.tar ubuntu@111.88.246.137:/tmp/
scp -i ~/.ssh/liderra_deploy -r app\public\build ubuntu@111.88.246.137:/tmp/build
```
На сервере:
```bash
tar -xf /tmp/liderra.tar -C /var/www/liderra
rm -rf /var/www/liderra/app/public/build && cp -r /tmp/build /var/www/liderra/app/public/build
bash /var/www/liderra/redeploy.sh
```
## Включить HTTPS (после покупки домена)
1. DNS: A-запись `test.<домен>` (и/или `demo.<домен>` для subdomain-tenant) → `111.88.246.137`.
2. На сервере: в `/etc/nginx/sites-available/liderra` заменить `server_name _;` на домен, `nginx -t && systemctl reload nginx`.
3. `sudo certbot --nginx -d test.<домен> --non-interactive --agree-tos -m <email> --redirect`.
4. В `.env` обновить `APP_URL=https://test.<домен>`, затем `php artisan optimize`.
## Остановить / удалить (прекратить оплату)
- Остановить VM: `yc compute instance stop liderra-test` (диск/IP сохраняются, мелкая плата).
- Удалить совсем: `yc compute instance delete liderra-test` + `yc vpc address delete <id>`.
## После теста — обязательно
- **Отозвать OAuth-токен Yandex Cloud** (Яндекс ID → Безопасность → сторонние приложения).
- При переходе к прод-конфигу: убрать `SAAS_ADMIN_TEST_BYPASS`, вернуть `crm_app_user` (после auth-rework).
+6 -6
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-22T11:27:52.849Z
Last updated: 2026-05-21T08:42:35.722Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -8,15 +8,15 @@ Last updated: 2026-05-22T11:27:52.849Z
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ⚠️ | 41 episode(s) this month · Stop-hook + post-commit OK · 16 missed activation(s) — see /brain-retro |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 15 chains in sync |
| C5 Observer-coverage | | 71 episode(s) this month · Stop-hook + post-commit OK |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 41 episodes this month, 0 observer_error markers, 5 PII matches before filter
- Observer evidence: 71 episodes this month, 0 observer_error markers, 52 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 5
- Last /brain-retro: 3 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 16. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
- Last /brain-retro: 2 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Алерт-индикаторы
+1 -2
View File
@@ -1,4 +1,4 @@
# Router procedure v1.3
# Router procedure v1.2
**Status:** active (introduced 2026-05-19, spec dd5bded, ADR-011; backend-tooling 2026-05-20, ADR-013)
@@ -72,4 +72,3 @@ Every turn — implicitly by Claude at session start, explicitly when routing is
- **v1.0 (2026-05-19)** — initial fixation. Replaces implicit-scattered routing. ADR-011.
- **v1.1 (2026-05-20)** — finance-tooling узлы #61-#63 добавлены в реестр Tooling §4.36-§4.38 (читаются step 3) и routing-off-phase.md (+3 строки routing + связка L13). Структурных правок процедуры нет. ADR-012.
- **v1.2 (2026-05-20)** — A1 backend-tooling узлы #64-#67 добавлены в реестр Tooling §4.39-§4.42 (читаются step 3) и routing-off-phase.md (+4 строки routing + связка L14). NightOwl #67 — DEFERRED (native-Windows без pcntl/posix). Структурных правок процедуры нет. ADR-013.
- **v1.3 (2026-05-21)** — A8 infosec-tooling узлы #68-#73 добавлены в реестр Tooling §4.43-§4.48 (читаются step 3) и routing-off-phase.md (+6 строк routing + связка L15 security go-live). #69 Nuclei/#70 Ward — CLI (не MCP); #68 ZAP/#70 Ward — pending install. Структурных правок процедуры нет. ADR-014.
+1 -8
View File
@@ -12,7 +12,7 @@
> **Источник истины.** Tooling §4.X (детальное описание каждого узла), Pravila §13.2
> (категоризация off-phase), PSR_v1 R10.1 (3-блочный реестр ролей).
>
> **Версия.** 1.5 (21.05.2026 — A8 install-sync: #68 ZAP + #70 Ward установлены портативно → строки routing #68/#70 обновлены, статус pending install снят, setup-доки `docs/security/{zap,ward}-setup.md`). 1.4 (21.05.2026 — A8 infosec-tooling: +6 строк routing #68-#73 + связка L15 (security go-live chain), ADR-014; #69 Nuclei/#70 Ward — CLI (не MCP), #68 ZAP/#70 Ward pending install. 1.3 (20.05.2026) — A1 backend-tooling: +4 строки routing #64-#67 + связка L14 + scope §4.11→§4.42, ADR-013. v1.2 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
> **Версия.** 1.3 (20.05.2026 — A1 backend-tooling: +4 строки routing #64-#67 + связка L14 + scope §4.11→§4.42, ADR-013. v1.2 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
> конверсии» → process-analysis #53 (M3); +note про UI-пул #31/#32 как делегирующие
> строки, не R15-routed (M1). v1.0 — Rec3 SYSTEM-аудита). Триггеры — формулировки
> заказчика или явные ключевые слова в промпте.
@@ -62,12 +62,6 @@
| Метрики качества / сложности / архитектуры PHP-кода | **PHP Insights** | #65 | backend-tooling | on-demand/CI (`composer insights`), не блокирующий (BT9, ADR-013) |
| Как писать backend в Лидерре (контроллер/сервис/джоб, RLS, деньги, идемпотентность, партиции) | **laravel-backend-patterns** (project-скил) | #66 | backend-tooling | trigger-based; ≠ #38 generic / ≠ #62 audit (ADR-013) |
| Коррелированный runtime-трейс request↔job↔query (self-hosted) | **NightOwl** | #67 | backend-tooling | **DEFERRED** — нет pcntl/posix на Windows; pending Б-1 (ADR-013) |
| Глубокая «боевая» проверка работающего портала (обход входа, инъекции, XSS) | **OWASP ZAP** (MCP) | #68 | infosec-tooling | DAST; цель по умолч. 127.0.0.1 (IS8); установлен портативно (portable JRE 17, `docs/security/zap-setup.md`); ADR-014 |
| Известные уязвимости / открытые двери / слабый TLS снаружи | **Nuclei** (CLI) | #69 | infosec-tooling | `bin/nuclei.exe`, цель **127.0.0.1** (не localhost); CLI не MCP; ADR-014 |
| Безопасность настроек Laravel (.env/config/заголовки/cookie/secrets/deps) | **Ward** (CLI) | #70 | infosec-tooling | Go-бинарь `bin/ward.exe` v0.4.1; заменил Enlightn (abandoned/L13); установлен портативно (`docs/security/ward-setup.md`); ADR-014 |
| Аудит ПДн / соответствие 152-ФЗ | **pdn-152fz-audit** (project-скил) | #71 | infosec-tooling | 2 режима техника+закон; ≠ pg_anonymizer #29 (IS4) / D2 (IS5) |
| Моделирование угроз STRIDE / что защищать перед публикацией | **threat-model** (project-скил) | #72 | infosec-tooling | going-public; ≠ ToB #39 generic (IS6) |
| Прогон безопасности перед релизом / go-no-go | **security-go-live** (project-скил) | #73 | infosec-tooling | оркеструет #68-72 + D3; ≠ audit-portal (IS7) |
| Отладка production runtime errors через self-hosted Sentry | **Sentry MCP** | #34 | debug-runtime | READ-ONLY, pending Б-1 deployment |
| Отладка Redis/Memurai очередей / кэша / Pest-квирков 73/77 | **Redis MCP** | #35 | debug-runtime | READ-ONLY обязательно |
| Правки `CLAUDE.md` | **claude-md-management** | #33 | infrastructure | §5 п.10 — единственный канал |
@@ -105,7 +99,6 @@
| L12 | `claude-md-management` (#33) + `revise-claude-md` skill | Захват session-learnings → CLAUDE.md update. Единственный канал §5 п.10. |
| L13 | `billing-audit` (#62) + `Pest` (#18) + `Boost` (#10) + `Sentry`/`Redis` (#34/#35) → `ru-tax-accounting` (#63) | Финансовая цепочка: аудит денежных инвариантов кода (billing-audit) тестами (Pest) на моделях (Boost) с runtime-фактами (Sentry/Redis) → перевод выверенной выручки в учётно-налоговый контекст (ru-tax). C6→C7. Граница — ADR-012. |
| L14 | `Rector` (#64) → `PHP Insights` (#65) → `Larastan` (#12) → `deptrac` (#43) | backend-quality chain: авто-трансформация кода (Rector) → метрики сложности/архитектуры (PHP Insights) → типовой статанализ (Larastan) → fitness направления слоёв (deptrac). Все на одном PHP-коде, разные оси. Anti-pattern: Rector-автоправка и PHP Insights-метрика — разные фазы, не один блокирующий шаг (ADR-013). |
| L15 | `security-go-live` (#73) → статика (`gitleaks` #8 / `Semgrep` #25 / `Ward` #70 / `Trail of Bits` #39) → `pdn-152fz-audit` (#71) → `threat-model` (#72) → динамика (`Nuclei` #69 широта → `OWASP ZAP` #68 глубина, цель 127.0.0.1 IS8) | security go-live chain: единый прогон перед публикацией → вердикт GO/NO-GO. #73 оркеструет, не заменяет D3 (IS7). Anti-pattern: ZAP/Nuclei в pre-commit хук (тяжёлые, нужна запущенная цель); #73 ≠ audit-portal (полный 14-фазный аудит). ADR-014. |
**Anti-pattern связок** (не комбинировать в одной задаче):
-350
View File
@@ -1,350 +0,0 @@
# Провенанс-вет внешних инструментов A8 infosec-tooling (IS9)
**Дата:** 2026-05-21
**Вет-код:** IS9 (согласно ADR-003 + spec §8)
**Инструменты:** #68 OWASP ZAP MCP, #69 Nuclei MCP, #70 Enlightn
**Статус:** ЗАВЕРШЁН
---
## Назначение документа
Перед установкой любого внешнего инструмента в раздел A8 «Информационная безопасность» выполнен обязательный провенанс-вет (IS9). Основание: ~13 % security-скилов из маркетплейсов несут критичные дефекты, часть пытается красть учётные данные (исследование ToxicSkills, Snyk + SentinelOne 2025). ADR-003 закрепляет принцип: community-инструменты с непроверенным происхождением — defer (именно так были отложены «Claude Code Canary» и «Plugin Security Auditor» в D3).
Документ является артефактом IS9 и читается в Tasks 2–4 плана как единственный авторитетный источник «какой репозиторий/версию устанавливать».
---
## Методология вета
Для каждого инструмента:
1. Прочитан README + ключевые исходники через GitHub API / WebFetch (факты, не память).
2. Проверены: репозиторий, владелец/организация, лицензия, звёзды, активность коммитов, дата последнего релиза.
3. Оценено: что инструмент **исполняет** (методы, сетевые вызовы, телеметрия, аутентификация).
4. Для кандидатов с неприемлемым провенансом — зафиксирована причина отклонения.
Все данные получены из GitHub API (`gh api`) и WebFetch на дату 2026-05-21. Ссылки указывают на конкретные SHA/теги там, где пин-версия зафиксирована.
---
## #68 — OWASP ZAP MCP (слот DAST)
### Кандидат A: официальный ZAP «MCP Integration» add-on
**Репозиторий:** `zaproxy/zap-extensions` (org: `zaproxy`, Apache-2.0)
**Родительский проект:** `zaproxy/zaproxy` — OWASP ZAP by Checkmarx
| Параметр | Значение |
|---|---|
| Владелец | Организация `zaproxy` (OWASP-проект, под управлением Checkmarx с 2022) |
| Лицензия | Apache-2.0 |
| Звёзды (zaproxy/zaproxy) | **15 152** (2026-05-21) |
| Последний коммит в zaproxy | 2026-05-20 (вчера) |
| Статус add-on MCP | v0.1.0, alpha, опубликован 2026-04-02 |
| Релиз zap-extensions | непрерывный (20.05.2026 — webdriver-related releases, 08.05.2026 — automation-v0.60.0) |
**Что исполняет (код прочитан):**
Источники: `addOns/mcp/src/main/java/org/zaproxy/addon/mcp/tools/`
Add-on экспонирует 15 MCP-инструментов, все — обращения к локальному ZAP-инстансу по API:
- `ZapStartScanTool`, `ZapStartActiveScanTool`, `ZapStartSpiderTool`, `ZapStartAjaxSpiderTool` — запускают сканирование указанного URL.
- `ZapGetActiveScanStatusTool`, `ZapGetPassiveScanStatusTool`, `ZapGetSpiderStatusTool`, `ZapGetAjaxSpiderStatusTool` — читают статус.
- `ZapStopActiveScanTool`, `ZapStopAjaxSpiderTool`, `ZapStopSpiderTool` — останавливают сканирование.
- `ZapCreateContextTool` — создаёт контекст сканирования.
- `ZapGenerateReportTool` — генерирует отчёт.
- `ZapInfoTool`, `ZapVersionTool` — информационные.
Весь трафик идёт **только к локальному ZAP-инстансу** (ZAP API). Никаких внешних URL, токенов или телеметрии в исходниках нет. Это add-on к самому ZAP — не standalone-сервер.
**Особенности:**
- Статус «alpha» (v0.1.0) — API будет меняться. Официальный блог-пост Simon Bennetts (автора ZAP) от 02.04.2026 предупреждает: «alpha release».
- Устанавливается как ZAP add-on через Marketplace ZAP, не как отдельный MCP-сервер.
- Требует запущенного ZAP-демона (`zaproxy -daemon -port 8080 -config api.key=<key>`).
- Конфигурация `.mcp.json` будет направлять Claude к локальному ZAP API (не к внешнему сервису).
**Провенанс-вывод:** Провенанс МАКСИМАЛЬНО ЧИСТЫЙ. OWASP + Checkmarx — индустриально признанный security-проект с 15 000+ звёзд и непрерывной активностью. Add-on разработан теми же людьми что и сам ZAP. Код исполняет ТОЛЬКО локальные ZAP API-вызовы.
---
### Кандидат B: `dtkmn/mcp-zap-server`
| Параметр | Значение |
|---|---|
| Владелец | `dtkmn` — физическое лицо (Daniel Tse, см. ссылки в README на `danieltse.org`) |
| Лицензия | Apache-2.0 |
| Звёзды | **54** (2026-05-21) |
| Последний коммит | 2026-05-21 (вчера), v0.8.0 от 10.05.2026 |
| Стек | Java / Spring Boot + Docker Compose |
**Что исполняет (README прочитан):**
- Отдельный Spring Boot–сервис (Docker Compose), обёртывающий ZAP через HTTP.
- Запускается через `./dev.sh` → Docker Compose стек (ZAP + Spring Boot + Open WebUI + Juice Shop + Petstore).
- MCP-endpoint: `http://localhost:7456/mcp`.
- **Требует Docker** — несовместимо с native-Windows без Docker Desktop/WSL2. Проект использует native-Windows без Docker (strategy: `project_phase1_strategy.md`).
- README явно: «This project is not affiliated with or endorsed by OWASP or the OWASP ZAP project».
- Ряд коммитов вида «docs: add sponsorship information to README» (3 из 5 последних), 6 открытых issues.
**Провенанс-вывод:** Один разработчик, не аффилирован с OWASP, требует Docker. Для нашего native-Windows стека **технически несовместим**. Дополнительно: провенанс значительно слабее кандидата A.
---
### Решение для #68
**ПРИНЯТ Кандидат A — официальный ZAP MCP add-on (`zaproxy/zap-extensions`, addOns/mcp)**
| Поле | Значение |
|---|---|
| Источник | `zaproxy/zap-extensions`, путь `addOns/mcp/` |
| Текущая версия | v0.1.0 (alpha), выпущен 2026-04-02 |
| Pin | add-on устанавливается через ZAP Marketplace — pin по текущей версии в `.zap/` конфиге |
| Лицензия | Apache-2.0 |
| Ограничение | alpha-статус: API ещё нестабильно; задокументировать в `docs/security/zap-setup.md` |
| Кандидат B | ОТКЛОНЁН (Docker-зависимость несовместима с native-Windows; провенанс слабее) |
---
## #69 — Nuclei MCP (слот широкого сканирования)
### Движок: `projectdiscovery/nuclei`
| Параметр | Значение |
|---|---|
| Владелец | Организация `projectdiscovery` (специализированная security-компания) |
| Лицензия | MIT |
| Звёзды | **28 777** (2026-05-21) |
| Последний коммит | 2026-05-20 |
| Последний релиз | v3.8.0 от 2026-04-18 |
| Телеметрия | Нет по умолчанию; `-dashboard` флаг для опциональной загрузки результатов в PD Cloud — не активируем |
Движок — чистый провенанс. MIT, активно разрабатывается, 28k+ звёзд.
---
### Кандидат A: `cyproxio/mcp-for-security` (nuclei-mcp)
| Параметр | Значение |
|---|---|
| Владелец | `cyproxio` — организация, но... |
| Статус | **DEPRECATED** — последний коммит 2026-03-30 с сообщением «deprecate: migrate to Bolt. This repository is no longer actively maintained» |
| Лицензия | MIT |
| Звёзды | 611 |
**Провенанс-вывод:** Репозиторий **официально заброшен** автором 30.03.2026. Устанавливать депрекированный wrapper-сервер в раздел безопасности — нарушение принципа ADR-003 («community-инструменты с непроверенным происхождением — defer»). **ОТКЛОНЁН.**
---
### Кандидат B: `addcontent/nuclei-mcp`
| Параметр | Значение |
|---|---|
| Владелец | `addcontent` — физическое лицо, 34 публичных репозитория, аккаунт создан 2020-01-11, bio/company/location не заполнены |
| Лицензия | MIT |
| Звёзды | **47** (2026-05-21) |
| Последний коммит | 2025-08-04 (~9 месяцев назад) |
| Последний релиз | v0.1.0 (alpha), 2025-08-04 |
**Анализ кода (прочитан go.mod + README):**
- Зависит от `projectdiscovery/nuclei/v3 v3.4.7` (не самая свежая, v3.8.0 вышла в апреле 2026).
- README содержит placeholder `github.com/your-org/nuclei-mcp` в Install-инструкциях — признак того, что репозиторий собран по шаблону и не дорабатывался.
- Владелец анонимен: нет bio, нет company, нет location, нет признаков профессиональной security-деятельности.
- Последняя активность — 9 месяцев назад (alpha-статус, неполный README).
**Провенанс-вывод:** Анонимный владелец + заброшенный (9 месяцев без активности) + остатки placeholder-текста в README = непрозрачный провенанс. **ОТКЛОНЁН по критерию ADR-003.**
---
### Решение для #69: собственная тонкая обвязка (self-authored wrapper)
Оба сторонних wrapper'а отклонены (один — deprecated, другой — анонимный/заброшенный). Движок `projectdiscovery/nuclei` (MIT, 28k+ звёзд) — чистый. Доступен как Go-бинарь `nuclei.exe`.
**Решение:** Запускать `nuclei.exe` напрямую через тонкую self-authored обвязку в `.mcp.json` — простой `command`/`args` MCP-блок, вызывающий бинарь с нужными флагами. Этот подход:
- Минимизирует attack surface (нет чужого обёрточного кода между Claude и `nuclei.exe`).
- Является стандартной практикой для CLI-инструментов без готового MCP-сервера.
- Не требует установки дополнительного npm/go-пакета.
- Nuclei.exe — чистый MIT-бинарь от projectdiscovery (известная security-компания).
| Поле | Значение |
|---|---|
| Источник движка | `projectdiscovery/nuclei`, релиз `v3.8.0` |
| URL | https://github.com/projectdiscovery/nuclei/releases/tag/v3.8.0 |
| Pin | `v3.8.0` (Windows бинарь: `nuclei_3.8.0_windows_amd64.zip`) |
| Лицензия | MIT |
| Wrapper | Self-authored (`.mcp.json` блок с `command: "nuclei.exe"`, `args: [...]`) |
| Оба кандидата-wrapper | ОТКЛОНЕНЫ (deprecated / анонимный провенанс) |
---
## #70 — Enlightn (слот Laravel security-конфигурации)
### `enlightn/enlightn`
| Параметр | Значение |
|---|---|
| Владелец | Организация `enlightn` (Enlightn Software, Paras Malhotra) |
| Лицензия | LGPL-3.0 (основной пакет), MIT (security-checker sub-dep) |
| Звёзды | **987** (2026-05-21) |
| Последний релиз | v2.10.0 от 2024-04-05 (~13 месяцев назад) |
| Последний коммит | 2024-04-05 (~13 месяцев без коммитов) |
| Статус на Packagist | **«abandoned and no longer maintained»** |
**Что проверяет (код прочитан, Security-анализаторы):**
22 Security-анализатора в `src/Analyzers/Security/`:
- `AppDebugAnalyzer.php` — APP_DEBUG не включён в продакшне
- `AppKeyAnalyzer.php` — APP_KEY установлен
- `CSRFAnalyzer.php` — CSRF-защита активна
- `EncryptedCookiesAnalyzer.php` — куки зашифрованы
- `HSTSHeaderAnalyzer.php` — HSTS-заголовок установлен
- `HttpOnlyCookieAnalyzer.php` — HttpOnly flag на куках
- `LoginThrottlingAnalyzer.php` — rate-limit на форме входа
- `MassAssignmentAnalyzer.php` — защита от mass-assignment
- `XSSAnalyzer.php` — XSS-защита
- `FilePermissionsAnalyzer.php`, `PHPIniAnalyzer.php`, `EnvAccessAnalyzer.php`
- `VulnerableDependencyAnalyzer.php` — CVE в зависимостях
- `FrontendVulnerableDependencyAnalyzer.php` — CVE во frontend-зависимостях
- И другие (FillableForeignKey, HashingStrength, UnguardedModels и пр.)
Плюс 19 Performance + 29 Reliability анализаторов (итого 70 в OSS; README заявляет «66» — расхождение несущественно).
**Телеметрия:** Пакет использует `guzzlehttp/guzzle` — HTTP-клиент. Sub-dep `enlightn/security-checker` (MIT) обращается к Security Advisories Database для получения актуальных данных CVE (кэширует локально). Это **не телеметрия**, а функциональный запрос (как Dependabot). Запрос ограничен базой advisory-данных, не содержит идентификаторов проекта. Outbound: ТОЛЬКО к `advisory-db`.
**Критическое ограничение — совместимость с Laravel 13:**
`composer.json` объявляет `"laravel/framework": "^9.0|^10.0|^11.0"`. Laravel 13 вне объявленного диапазона.
- PR на Laravel 12 ([#200](https://github.com/enlightn/enlightn/pull/200), открыт 2025-02-17) — **не смержен** спустя 3 месяца активных просьб.
- Мейнтейнер не отвечает на issues и PR — множественные жалобы пользователей.
- Packagist: пакет помечен «abandoned».
- Последний коммит: 2024-04-05. Laravel 13 вышел в 2025.
**Обходной путь:** Composer позволяет установить с `--ignore-platform-reqs` или через форк. Существуют unofficial forks (напр. `ivqonsanada/enlightn`, `exin/enlightn`), но их провенанс — частные лица без верификации.
**Провенанс самого пакета:** Достаточный. Enlightn Software — реальная компания, Paras Malhotra — публичная личность, пакет с 987 звёздами и 3+ млн установок. Провенанс ПРИНЯТ.
**Но функциональность заблокирована**: несовместимость с Laravel 13 — технический блок.
---
### Решение для #70
**ПРИНЯТ С БЛОКЕРОМ — `enlightn/enlightn v2.10.0`, с условием по Laravel 13**
| Поле | Значение |
|---|---|
| Источник | `enlightn/enlightn` |
| Pin-версия | `v2.10.0` (последний стабильный) |
| Лицензия | LGPL-3.0 (совместима с проприетарным использованием) |
| Телеметрия | Нет; security-checker делает outbound к advisory-db (только CVE-данные) |
| Провенанс | Принят (Enlightn Software, публичный мейнтейнер) |
| **Блокер** | `composer.json` ограничивает `laravel/framework ^9\|^10\|^11` — Laravel 13 НЕ входит |
| **Путь установки** | `composer require enlightn/enlightn --dev --ignore-platform-reqs` ИЛИ переключиться на форк `exin/enlightn` (Task 4 spike) |
| Альтернативные форки | `ivqonsanada/enlightn`, `exin/enlightn` — оба неверифицированы; провенанс NOT VETTED |
| Рекомендация | Task 4 — проверить `--ignore-platform-reqs` на реальной установке; если не работает — оценить форк или принять ограниченный subset работающих проверок |
**Примечание для Task 4:** Несмотря на объявленный диапазон, многие Laravel-пакеты фактически работают на версиях выше заявленного (особенно если Laravel 13 является minor evolution от 11). Задача Task 4 — подтвердить эмпирически. Если установка и `php artisan enlightn` работают — блокер снимается практически. Если нет — зафиксировать как IS-BLOCKED и рассмотреть форк `exin/enlightn` (отдельный провенанс-вет).
---
## Итоговая таблица
| # | Инструмент | Репозиторий / источник | Лицензия | Провенанс-заметка | Вердикт | Pin-версия |
|---|---|---|---|---|---|---|
| 68 | OWASP ZAP MCP add-on | `zaproxy/zap-extensions`, `addOns/mcp/` | Apache-2.0 | OWASP + Checkmarx, 15k+ звёзд, непрерывная активность, код исполняет только локальные ZAP API-вызовы | **ПРИНЯТ** | v0.1.0 (alpha, устанавливается через ZAP Marketplace) |
| 68 | ~~dtkmn/mcp-zap-server~~ | `dtkmn/mcp-zap-server` | Apache-2.0 | Физ. лицо, 54 звезды, не аффилирован с OWASP; требует Docker (несовместим с native-Windows) | **ОТКЛОНЁН** | — |
| 69 | Nuclei (self-authored wrapper) | `projectdiscovery/nuclei` v3.8.0 + own `.mcp.json` wrapper | MIT | ProjectDiscovery org, 28k+ звёзд, активна; self-authored wrapper минимизирует attack surface | **ПРИНЯТ** | v3.8.0 |
| 69 | ~~cyproxio/mcp-for-security~~ | `cyproxio/mcp-for-security` | MIT | **Официально deprecated** 30.03.2026: «no longer actively maintained» | **ОТКЛОНЁН** | — |
| 69 | ~~addcontent/nuclei-mcp~~ | `addcontent/nuclei-mcp` | MIT | Анонимный владелец (нет bio/company/location), заброшен 9+ мес, placeholder в README | **ОТКЛОНЁН** | — |
| 70 | ~~Enlightn~~ | `enlightn/enlightn` | LGPL-3.0 | Провенанс чистый, НО пакет abandoned (Packagist), `composer.json` не поддерживает Laravel 13, мейнтейнер не отвечает 3+ мес | **ОТКЛОНЁН → ЗАМЕНЁН на Ward** (см. пересмотр ниже, 2026-05-21) | — |
| 70 | **Ward** | `Eljakani/ward` | MIT | El Jakani Yassine (named, 43 followers), 316★/19 forks, Laravel-News-featured; **Go-бинарь → не зависит от версии Laravel** (проблема Enlightn снята); локально (OSV.dev только для deps). Caveat: молодой (фев 2026), single-maintainer, без тегов-релизов | **ПРИНЯТ** (замена #70) | pin по commit SHA (релизов нет) |
---
## Отклонённые провенанс-случаи — сводка
| Кандидат | Причина отклонения |
|---|---|
| `dtkmn/mcp-zap-server` | Docker-зависимость несовместима с native-Windows; провенанс — физ. лицо, 54 звезды |
| `cyproxio/mcp-for-security` | Официально deprecated автором 30.03.2026 |
| `addcontent/nuclei-mcp` | Анонимный владелец + 9 мес. без активности + placeholder-README = непрозрачный провенанс (ADR-003 критерий) |
---
## Примечания к Task 2–4 (исполнитель)
- **Task 2 (ZAP):** Установить ZAP v2.17.0 (`zaproxy/zaproxy` → latest: v2.17.0, 2025-12-15) + MCP add-on через ZAP Marketplace → `Tools > Add-ons > Search: MCP`. Потребуется Java 17+. Задокументировать в `docs/security/zap-setup.md`.
- **Task 3 (Nuclei):** Скачать `nuclei_3.8.0_windows_amd64.zip` из https://github.com/projectdiscovery/nuclei/releases/tag/v3.8.0. Написать `.mcp.json` блок `"nuclei"` с `command: "path/to/nuclei.exe"`. Задокументировать в `docs/security/nuclei-setup.md`.
- **Task 4 (Enlightn):** `composer require enlightn/enlightn:^2.10 --dev --ignore-platform-reqs`. Проверить, что `php artisan enlightn` запускается и возвращает отчёт. Если работает — блокер практически снят. Если нет — зафиксировать в `docs/security/enlightn-setup.md` как DEFERRED и провести отдельный вет для `exin/enlightn`.
- **Форки Enlightn** (`ivqonsanada/enlightn`, `exin/enlightn`): не прошли вет в рамках этой задачи. Если нужны — провести отдельный IS9-вет как новый sub-артефакт.
---
## ПЕРЕСМОТР #70: Enlightn → Ward (2026-05-21, решение заказчика)
Заказчик выбрал «подобрать замену на GitHub и Anthropic» вместо установки заброшенного Enlightn или неверифицированного форка.
**Рассмотрены кандидаты-замены:**
| Кандидат | Источник | Вердикт | Причина |
|---|---|---|---|
| **Ward** | `Eljakani/ward` (Go, MIT) | **ПРИНЯТ** | Прямая замена ниши Enlightn; Go-бинарь → нет зависимости от версии Laravel |
| Larafence | larafence.com | ОТКЛОНЁН | Не выпущен (Q2 2026) + TALL/Livewire-стек (у нас Vue) |
| Psalm + plugin-laravel taint | `vimeo/psalm` (MIT) | НЕ для этого слота | Отличный, но это код-SAST (taint) — пересекается с Semgrep #25 (IS3); не config-сканер |
| `laravel/agent-skills` | `laravel/agent-skills` (official) | НЕ scanner | Официальный (Taylor Otwell, 622★) и чистый, но это общий Laravel-скил (`laravel`/`laravel-cloud`/`laravel-nightwatch`), не security-сканер. Опциональное доп. позже, не замена #70 |
| `sickn33/laravel-security-audit`, `netresearch/security-audit-skill`, `edulazaro/laraclaude` | community-скилы | НЕ взяты | Риск ToxicSkills + individual-провенанс; для чувствительного слота не берём |
**Ward — провенанс-вет (live `gh api`, 2026-05-21):**
| Параметр | Значение |
|---|---|
| Репозиторий | `Eljakani/ward` |
| Описание | «Security scanner built for Laravel, detects misconfigurations, vulnerabilities, and exposed secrets with a beautiful TUI» |
| Лицензия | **MIT** (есть `LICENSE`) |
| Звёзды / форки | 316 / 19 |
| Язык | Go (бинарь) |
| Создан / последний коммит | 2026-02-15 / 2026-03-07 |
| Релизы | нет тегов → **pin по commit SHA** |
| Владелец | El Jakani Yassine (named, 43 followers, аккаунт с 2019) |
| Что сканирует | .env (8 проверок) + config/*.php (13) + deps (OSV.dev live) + код (7 категорий: secrets/injection/XSS/debug-артефакты/crypto/config CORS-CSRF-mass-assignment/auth) |
| Сеть | Локально; OSV.dev только для deps (как Enlightn security-checker — не телеметрия) |
**Почему Ward лучше Enlightn для нашего случая:**
1. **Go-бинарь** (как Nuclei #69) → НЕТ ограничения `composer.json` по версии Laravel → работает на Laravel 13 без хаков (`--ignore-platform-reqs` не нужен).
2. MIT, named author, активно рекомендуется (Laravel News, 2026), 316★.
3. Покрытие шире Enlightn: env + config + deps + код.
**Caveat (зафиксирован):** молодой проект (3 мес), single-maintainer, без тегов-релизов. Митигация: pin по commit SHA; MIT → можно форкнуть при забрасывании. Записать в `docs/security/ward-setup.md` (Task 4).
**Эффект на план:** слот #70 меняет инструмент Enlightn → Ward. Номер #70 и ниша (Laravel config security scanner) сохраняются. Тип меняется: было «Composer dev-dep + `php artisan enlightn`», стало «Go-бинарь CLI `ward` (как Nuclei/gitleaks/Trivy)». Граница IS3 (config-сканер vs Larastan #12 типы / Semgrep #25 generic-паттерны) сохраняется. Task 4 переписывается под Ward.
---
## Верификация данных
Все факты получены из live-запросов GitHub API и WebFetch на 2026-05-21:
- `gh api repos/zaproxy/zaproxy` — stars=15152, pushed_at=2026-05-20
- `gh api repos/zaproxy/zap-extensions/contents/addOns/mcp/CHANGELOG.md` — v0.0.1 released 2026-04-02; v0.1.0 current
- `gh api repos/zaproxy/zap-extensions/contents/addOns/mcp/src/main/java/org/zaproxy/addon/mcp/tools/` — 15 tool files listed
- `gh api repos/dtkmn/mcp-zap-server` — stars=54, Docker-зависимость подтверждена README
- `gh api repos/projectdiscovery/nuclei` — stars=28777, pushed_at=2026-05-20, license=MIT
- `gh api repos/projectdiscovery/nuclei/releases/latest` — v3.8.0, 2026-04-18
- `gh api repos/cyproxio/mcp-for-security/commits` — последний коммит 2026-03-30 «deprecate: migrate to Bolt»
- `gh api repos/addcontent/nuclei-mcp` — stars=47, pushed_at=2025-08-04; README содержит `your-org` placeholder
- `gh api users/addcontent` — bio=null, company=null, location=null
- `gh api repos/enlightn/enlightn` — stars=987, pushed_at=2024-06-15, license=NOASSERTION (LGPL)
- `gh api repos/enlightn/enlightn/releases/latest` — v2.10.0, 2024-04-05
- `gh api repos/enlightn/enlightn/contents/composer.json` — laravel/framework `^9.0|^10.0|^11.0`
- `gh api repos/enlightn/enlightn/issues/200` — Laravel 12 PR открыт 2025-02-17, не смержен
- WebFetch `zaproxy.org/blog/2026-04-02-zap-mcp-server/` — alpha announcement, Simon Bennetts
- WebFetch `raw.githubusercontent.com/enlightn/enlightn/master/README.md` — 66 OSS checks, abandoned status
- WebFetch `raw.githubusercontent.com/enlightn/enlightn/master/LICENSE.md` — LGPL-3.0, Copyright Enlightn Software / Paras Malhotra
- WebFetch `raw.githubusercontent.com/projectdiscovery/nuclei/main/README.md` — MIT, optional cloud dashboard, no default telemetry
-50
View File
@@ -1,50 +0,0 @@
# Nuclei (#69) — установка и использование
**Узел A8:** #69 — широкое сканирование на известные уязвимости / небезопасную экспозицию.
**Источник (IS9-вет принят):** `projectdiscovery/nuclei` v3.8.0, MIT (см. `infosec-vet.md`).
**Тип:** CLI-сканер (Go-бинарь) — **не MCP-сервер** (см. «Решение по интеграции» ниже).
---
## Установка (native-Windows)
Готовый бинарь (Go не требуется):
```powershell
# v3.8.0 windows amd64, pin из IS9-вета
Invoke-WebRequest -Uri "https://github.com/projectdiscovery/nuclei/releases/download/v3.8.0/nuclei_3.8.0_windows_amd64.zip" -OutFile "$env:TEMP\nuclei.zip"
Expand-Archive "$env:TEMP\nuclei.zip" -DestinationPath "$env:TEMP\nuclei" -Force
Copy-Item "$env:TEMP\nuclei\nuclei.exe" "bin\nuclei.exe"
bin\nuclei.exe -update-templates -silent
```
- **Расположение:** `bin/nuclei.exe` (рядом с gitleaks/lychee/squawk; `bin/*.exe` в `.gitignore` → бинарь машинно-локальный, в репозиторий не коммитится).
- **Шаблоны:** `~/AppData/Roaming/nuclei` + `~/nuclei-templates` (13 060 yaml, v10.4.3 на 2026-05-21).
- **Verified:** `nuclei -version` → v3.8.0 ✓.
## Квирки native-Windows (важно)
1. **Цель — `127.0.0.1`, НЕ `localhost`.** Резолвер nuclei на этой машине падает на `localhost` (`[INF] Skipped localhost:8000 ... no address found for host`), хотя `curl http://localhost:8000` → 200. Всегда указывать явный IPv4: `-u http://127.0.0.1:<port>`.
2. **Низкий rate-limit/concurrency для dev-сервера.** `php artisan serve` однопоточный — под нагрузкой полного скана даёт массу connection-ошибок (в smoke: 1698 errors на 1057 запросов). Для локальной цели: `-rate-limit 20 -c 5` (или ниже). Это не уязвимости, а таймауты/résets перегруженного dev-сервера.
3. **`-duc`** (disable update check) — в офлайн/CI-прогонах, чтобы не дёргать сеть на проверку версии.
## Smoke (verified 2026-05-21)
```powershell
bin\nuclei.exe -u "http://127.0.0.1:8000" -tags tech -stats -timeout 5 -no-color -duc
```
Результат: 931 шаблон загружен, 1057/1059 запросов отправлено к цели, скан завершён (`Scan completed`), **Matched: 0** (чисто на теге `tech` — ожидаемо для dev-портала). Доказывает: nuclei устанавливается, видит и сканирует живой портал. (Первый прогон по `localhost` цель пропустил — см. квирк 1; по `127.0.0.1` отработал.)
## Решение по интеграции: CLI, не MCP
В IS9-вете слот #69 предполагал «self-authored MCP-wrapper». При реализации уточнено: **nuclei не говорит на протоколе MCP** — обернуть его в MCP-сервер = писать собственный MCP-серверный код (доп. attack surface + поддержка). Вместо этого nuclei интегрируется как **CLI-инструмент** — ровно как уже существующие security-CLI проекта (gitleaks #8, squawk #15, Trivy #26): бинарь в `bin/`, вызывается по требованию из Bash скилом go-live (#73). Преимущества: ноль чужого/своего обёрточного кода между Claude и бинарём; единообразие с тулчейном; минимальный attack surface. Следствие: для #69 **не нужны** `.mcp.json`-блок и l1-watcher alias (они только для настоящих MCP-серверов; #68 ZAP — единственный MCP в наборе).
## Использование
```powershell
# Цель ВСЕГДА 127.0.0.1 (квирк 1); бережный режим для dev (квирк 2)
bin\nuclei.exe -u "http://127.0.0.1:8000" -rate-limit 20 -c 5 -timeout 5 -duc -severity medium,high,critical
```
Гард IS8: по умолчанию — локальная/тестовая копия (127.0.0.1). Боевой сервер — только по явной команде заказчика.
-116
View File
@@ -1,116 +0,0 @@
# pg_audit (#28) + pg_anonymizer (#29) — установка на боевом сервере
**Статус:** ✅ установлены на боевом `liderra.ru` 22.05.2026. PostgreSQL 16.14, БД `liderra`.
Это два расширения PostgreSQL фазы 3 (Compliance), которые нельзя было поставить на dev native-Windows PG (расширения там недоступны — см. `memory/project_phase1_strategy.md`). Внедрены, когда появился боевой Linux-сервер.
Сервер: VM `liderra-test` (Ubuntu 24.04), `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137`, кластер `16/main` (порт 5432), приложение `/var/www/liderra/app`.
---
## Бэкап перед работами (обязательно)
```bash
sudo -u postgres pg_dump -Fc -d liderra > /home/ubuntu/backups/liderra-pre-<TS>.dump
```
Точка отката от 22.05.2026: `/home/ubuntu/backups/liderra-pre-pgaudit-anon-20260522-010441.dump` (custom format, 1170 объектов).
---
## #28 pg_audit — журнал аудита БД (152-ФЗ)
**Что даёт:** server-side журнал DDL / изменений прав / записей данных в дополнение к прикладным `auth_log`, `pd_processing_log`, `incidents_log`.
**Установка (выполнено):**
```bash
sudo apt-get install -y postgresql-16-pgaudit # из штатного репозитория Ubuntu
sudo -u postgres psql -c "ALTER SYSTEM SET shared_preload_libraries = 'pgaudit';"
sudo systemctl restart postgresql@16-main # ← единственный перезапуск (~2с простоя)
sudo -u postgres psql -d liderra -c "CREATE EXTENSION pgaudit;"
sudo -u postgres psql -c "ALTER SYSTEM SET pgaudit.log = 'ddl, role, write';"
sudo -u postgres psql -c "ALTER SYSTEM SET pgaudit.log_parameter = 'off';" # ПДн НЕ в логах
sudo -u postgres psql -c "ALTER SYSTEM SET pgaudit.log_catalog = 'off';"
sudo -u postgres psql -c "SELECT pg_reload_conf();"
```
**Важно:** `pgaudit.log_parameter = off` — значения SQL-параметров (телефоны/почты лидов) НЕ попадают в логи, иначе аудит сам стал бы утечкой ПДн.
**Где логи:** `/var/log/postgresql/postgresql-16-main.log`, строки вида `AUDIT: SESSION,...`.
**Проверка:** `CREATE TABLE _smoke(id int); INSERT INTO _smoke VALUES (1); DROP TABLE _smoke;` → в логе три строки `AUDIT: ... DDL/WRITE/DDL` с `<not logged>` вместо значений.
---
## #29 pg_anonymizer (anon) — маскирование ПДн в выгрузках
**Что даёт:** маскированные дампы базы (телефоны → `+7******XX`, почты → `iv***.ru`), чтобы реальные ПДн не попадали в dev/staging. Правило §5.1 правил Claude.
**Версия:** anon 3.0.13 — это **Rust/pgrx 0.18.0** расширение; готового пакета нет ни в Ubuntu, ни в PGDG → собрано из исходников.
**Сборка (выполнено, ~15 мин):**
```bash
# build-deps (после — удалены, см. ниже)
sudo apt-get install -y build-essential postgresql-server-dev-16 pkg-config git
# Rust toolchain (в ~/.cargo, ~/.rustup — после удалены)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
source "$HOME/.cargo/env"
cargo install cargo-pgrx --version 0.18.0 --locked # версия = pgrx из Cargo.lock
cargo pgrx init --pg16 /usr/lib/postgresql/16/bin/pg_config # системный PG, без скачивания
git clone --depth 1 https://gitlab.com/dalibo/postgresql_anonymizer.git /tmp/anon && cd /tmp/anon
make extension PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config PGVER=pg16 # длинная компиляция
sudo make install PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config PGVER=pg16 # просто cp (cargo root-у не нужен)
```
**Подключение (выполнено) — БЕЗ перезапуска:**
```bash
sudo -u postgres psql -d liderra -c "CREATE EXTENSION anon CASCADE;"
sudo -u postgres psql -d liderra -c "SELECT anon.init();"
```
**Загрузка ПО ТРЕБОВАНИЮ (важно для производительности):** anon НЕ подключён через `session_preload_libraries` на всю БД — иначе 9.6 МБ `anon.so` грузились бы при каждом подключении портала. В сессии маскирования библиотека загружается явно:
```sql
LOAD 'anon';
SELECT anon.partial('+79161234567', 2, '******', 2); -- → +7******67
```
**Сделать маскированный дамп** (anon боевые данные не меняет — только при явном `anonymize_database()`, которого на проде не запускаем):
```bash
# вариант через pg_dump_anon (грузит anon сам) либо вручную в сессии с LOAD 'anon'
```
**Файлы:** `anon.so` + `anon.control` в `/usr/lib/postgresql/16/lib/` и `/usr/share/postgresql/16/extension/` — это standalone-файлы, не принадлежат apt-пакету (сохраняются при autoremove). После **мажорного** апгрейда PostgreSQL расширение нужно пересобрать (re-clone + rebuild).
---
## ⚠️ Незапланированный апгрейд PG + закрепление версии
Установка `postgresql-server-dev-16` из PGDG потянула апгрейд боевого `postgresql-16` **16.13 → 16.14** (сборка PGDG) с авто-перезапуском кластера. Минорный патч — данные целы, портал здоров. Закреплено против повтора:
```bash
sudo mv /etc/apt/sources.list.d/pgdg.list /etc/apt/sources.list.d/pgdg.list.disabled
sudo apt-mark hold postgresql-16 postgresql-client-16 # в `dpkg -l` статус 'hi', не 'ii'
```
Для будущего патча PostgreSQL — `sudo apt-mark unhold postgresql-16 postgresql-client-16` осознанно.
## Очистка build-инструментов (выполнено)
```bash
rm -rf ~/.cargo ~/.rustup ~/.pgrx /tmp/anon # Rust + исходники (~2.2 ГБ)
sudo apt-get purge -y build-essential postgresql-server-dev-16 pkg-config
sudo apt-get autoremove --purge -y # gcc/llvm/clang orphans
```
Расширения (`pgaudit.so`, `anon.so`) — отдельные файлы, очистку build-тулчейна переживают.
---
## Серверный слой защиты — отдельно
WAF / anti-brute / DDoS / мониторинг / секреты / TLS-HSTS-CSP / бэкапы — это инфраструктура, не расширения БД. Открытые вопросы **SEC-1..SEC-7** (`docs/Открытые_вопросы_v8_3.md`), привязка к Б-1.
-203
View File
@@ -1,203 +0,0 @@
# Серверный слой защиты боевого сервера (SEC-1..SEC-7) — установка и управление
**Статус:** развёрнут на боевом тест-сервере `liderra.ru` 22.05.2026. Это серверный слой защиты (инфраструктура), вынесенный из A8 infosec-tooling эпика как открытые вопросы SEC-1..SEC-7 (ADR-014 §9). Источник фактов и истории — `memory/project_server_hardening.md`.
Сервер: VM `liderra-test` (Ubuntu 24.04), `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137` (доступ только по ключу, пароль отключён). Стек на одной VM: nginx 1.24 / php8.3-fpm / PostgreSQL 16 / redis. **Ресурсы тесные: 1.9 ГБ RAM / 2 CPU / ~12 ГБ свободно диска** → тяжёлые сервисы (self-host Sentry ~4 ГБ+) не помещаются.
**Гигиена изменений (соблюдалась везде):** перед каждым изменением nginx — `cp` бэкап конфига + `nginx -t` + `reload`-или-восстановление из бэкапа при провале `nginx -t`. Все правки через `reload` (не `restart`) — простоя сайта не было. Изменения файловые → переживают reboot.
| SEC | Тема | Статус |
|---|---|---|
| SEC-1 | WAF (веб-фаервол) | ✅ боевой режим |
| SEC-2 | Анти-перебор паролей | ✅ сделано |
| SEC-3 | DDoS-защита | ⏸ отложено (цена) |
| SEC-4 | Мониторинг + алертинг | ✅ лёгкий |
| SEC-5 | Хранилище секретов | 🟦 частично (app-интеграция блокирована) |
| SEC-6 | TLS / HSTS / CSP | ✅ сделано |
| SEC-7 | Бэкапы + реагирование | ✅ бэкапы; IR-runbook реюз |
---
## SEC-6 — HTTPS + защитные заголовки ✅
Был **только HTTP** (пароли/ПДн открытым текстом). Развёрнут certbot Let's Encrypt для `liderra.ru` + `www.liderra.ru` (`/etc/letsencrypt/live/liderra.ru/`, авто-обновление certbot).
nginx переписан в **2 server-блока** (`/etc/nginx/sites-available/liderra`, симлинк в `sites-enabled`):
- `:80` → редирект на https, **кроме** `/.well-known/acme-challenge/` (certbot) и `^~ /api/webhook/` (вебхуки поставщика могут не следовать за 301 на POST → оставлены доступными по http).
- `:443` → приложение + защитные заголовки.
Заголовки на `:443`:
```nginx
add_header Strict-Transport-Security "max-age=604800" always; # 1 неделя — умеренно/обратимо
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```
**NB про Basic-Auth «дверь»:** ранее перед сайтом стоял Basic-Auth барьер; **убран 22.05.2026 по явной информированной просьбе заказчика** (данные остаются за app-логином). Восстановить: вернуть `auth_basic "Liderra test"; auth_basic_user_file /etc/nginx/.htpasswd;` в `location /` блока `:443` из бэкапа `liderra.bak-*`.
Связка с приложением: `APP_URL=https://liderra.ru` + `SANCTUM_STATEFUL_DOMAINS=liderra.ru,www.liderra.ru` в `/var/www/liderra/app/.env` (cookie-логин на apex+www). После правки `.env` обязателен `php artisan config:cache` (запускать **от `ubuntu`** — владелец `.env` + `bootstrap/cache`; php-fpm = www-data читает по правам).
**CSP** — см. отдельную секцию ниже (SEC-6 CSP).
---
## SEC-2 — анти-перебор паролей ✅
Прикладной слой **уже был** (`AuthController` RateLimiter, `LOGIN_MAX_ATTEMPTS=5`, лок по email+IP).
Добавлен **fail2ban** (`/etc/fail2ban/jail.local`): jails `sshd` (maxretry 4) + `nginx-http-auth` (порты http,https, лог `/var/log/nginx/error.log`), `bantime 1h`, `findtime 10m`, `ignoreip 127.0.0.1/8 ::1`, backend systemd. Активен + enabled.
Фон атак реальный: отчёт показал **~1408 неудачных SSH-попыток/сутки**. SSH — только по ключу (пароль отключён), поэтому свой доступ fail2ban не банит.
Управление: `sudo fail2ban-client status`, `sudo fail2ban-client status sshd`.
---
## SEC-1 — WAF (ModSecurity + OWASP CRS) ✅ боевой режим
Пакеты `libnginx-mod-http-modsecurity` 1.0.3 + `modsecurity-crs` 3.3.5. Движок `/etc/modsecurity/modsecurity.conf` (создан вручную — пакет CRS не несёт движковый конфиг): `SecRuleEngine On`, `SecResponseBodyAccess Off` (ради памяти), audit `/var/log/modsec_audit.log` RelevantOnly. Порог блокировки CRS — дефолт 5 (inbound anomaly). Загружено **1830 правил**.
**Подключение** `/etc/nginx/modsec/main.conf` + `modsecurity on; modsecurity_rules_file ...` в обоих server-блоках.
**ВАЖНО:** НЕ использовать `/usr/share/modsecurity-crs/owasp-crs.load` — там Apache-директива `IncludeOptional`, которую nginx-коннектор (libmodsecurity v3) не понимает (`nginx -t` падает). Вместо неё в `main.conf` явный порядок `Include`:
```
modsecurity.conf → crs-setup.conf → liderra-exclusions.conf →
REQUEST-900-EXCLUSION-BEFORE → /usr/share/modsecurity-crs/rules/*.conf →
RESPONSE-999-EXCLUSION-AFTER
```
### Исключение вебхука поставщика
Новый файл `/etc/nginx/modsec/liderra-exclusions.conf` (вне пакета CRS → переживает обновления `modsecurity-crs`):
```
SecRule REQUEST_URI "@beginsWith /api/webhook/" \
"id:1900100,phase:1,pass,nolog,ctl:ruleEngine=DetectionOnly"
```
Приём лидов — деньги бизнеса, и он уже защищён на уровне приложения (HMAC + rate-limit + SSRF-guard), поэтому WAF на нём только наблюдает: ложное срабатывание = потерянный лид. URI-based (а не per-location nginx) — надёжно при `try_files``/index.php`.
### Фикс: WAF разрешил REST-методы (важно)
После включения боевого режима правило CRS **911100 «Method is not allowed by policy»** блокировало `PATCH`/`DELETE`/`PUT` (CRS-дефолт разрешает только GET/HEAD/POST/OPTIONS) → молча ломало редактирование/удаление в портале. Фикс — в `/etc/modsecurity/crs/crs-setup.conf` (бэкап `crs-setup.conf.bak-*`):
```
SecAction "id:900200,phase:1,nolog,pass,t:none,\
setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'"
```
Грузится **до** 901-init (который ставит дефолт условно `&TX:allowed_methods @eq 0`). NB: попытка через `liderra-exclusions.conf` (id:1900200) НЕ сработала — фикс работает только в `crs-setup.conf`.
### Проверка боевого режима
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://liderra.ru/.env # → 403 (WAF блок)
curl -s -o /dev/null -w "%{http_code}\n" "https://liderra.ru/?x=<script>" # → 403
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE https://liderra.ru/api/projects/2 # → 419/405 (app, НЕ 403)
sudo grep "Access denied" /var/log/modsec_audit.log # периодически: не режет ли WAF реальное
```
**Future cleanup (не срочно):** поставщик шлёт вебхуки на IP `111.88.246.137`, а не на домен `liderra.ru` (отсюда вечный сигнал 920350 «Host = числовой IP»). Попросить поставщика сменить URL на домен — чище, но не критично (исключение покрывает).
---
## SEC-6 (CSP) — Content-Security-Policy ✅ боевой режим
Сначала был `Content-Security-Policy-Report-Only`, затем переведён в боевой `Content-Security-Policy` (бэкапы `liderra.bak-*`). Политика в `:443`:
```nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'" always;
```
Обоснование директив: инлайн-скриптов в blade нет (только `@vite`, Vite-prod их не инжектит — verified) → `script-src 'self'`; шрифты Inter/JetBrains Mono грузятся с Google Fonts через `@import` в build CSS → `style-src ...fonts.googleapis.com` + `font-src ...fonts.gstatic.com`; `img-src ... https:` — запас под внешние картинки на authed-страницах.
**Проверка:** статически (build CSS `@import` googleapis + woff с gstatic) + браузерная (Playwright на живом `/login` под боевым CSP → 0 CSP-ошибок, шрифты 200, SPA грузится).
**Усилить позже:** убрать `'unsafe-inline'` из `style-src` (нужны nonce для Vuetify — нетривиально); сузить `img-src` после аудита authed-страниц.
---
## SEC-4 — мониторинг + алертинг ✅ (лёгкий)
`/usr/local/bin/liderra-security-report.sh` + cron `/etc/cron.d/liderra-security-report` (root, **ежедневно 07:00** → лог `/var/log/liderra-security-report.log`, self-trim 3000 строк): диск/память, срок TLS-сертификата (дни), баны fail2ban (ssh+web), неудачные SSH/24ч, nginx 5xx/401, БД up, счётчик WAF-блоков (`[waf-blocks]`), счётчик pgaudit-строк.
**Email-алертинг:** `/usr/local/bin/liderra-mail.py` (python3 smtplib, читает `MAIL_*` из `/var/www/liderra/app/.env`; SMTP_SSL smtp.yandex.ru:465; пароль не печатает). Отчёт 07:00 шлётся на **`kdv1@bk.ru`**. (Первые письма могут попасть в «Спам».)
**Счётчик 5xx:** в отчёте используется `grep -c '" 5[0-9][0-9] '` (якорь-кавычка = реальный статус сразу после строки запроса). Без кавычки (`' 5[0-9][0-9] '`) ловило размеры ответов в байтах — давало ложные «5xx».
**Sentry — ⏸ DEFERRED** (2 ГБ RAM мало для self-host ~4 ГБ+; pending Б-1 / сервер помощнее).
---
## SEC-7 — бэкапы ✅ + off-site (через почту)
`/usr/local/bin/liderra-backup.sh` + cron `/etc/cron.d/liderra-backup` (root, **ежедневно 03:30**, лог `/var/log/liderra-backup.log`):
1. `pg_dump -Fc` БД `liderra``/home/ubuntu/backups/liderra-daily-<TS>.dump`, **retention 14 дней**.
2. **Off-site (промежуточный):** шифрует копию (`gzip | openssl enc -aes-256-cbc -salt -pbkdf2 -pass file:/root/liderra-backup-crypt.key`) и шлёт вложением на `kdv1@bk.ru` — копия переживёт потерю VM, ПДн зашифрованы. Шаг best-effort (не валит бэкап).
Локальные бэкапы на той же VM защищают от порчи данных/миграций/app-ransomware, **но НЕ от потери VM** — для этого и off-site-копия на почту.
**Расшифровать emailed-бэкап:**
```bash
openssl enc -d -aes-256-cbc -pbkdf2 -pass file:<key> -in <file> | gunzip > liderra.dump
```
**⚠️ Ключ `/root/liderra-backup-crypt.key`** (root, 600) создан один раз и переиспользуется. Заказчику — **сохранить ключ ВНЕ сервера** (`sudo cat /root/liderra-backup-crypt.key` → менеджер паролей), иначе emailed-бэкапы не расшифровать.
**Полный off-site → YC Object Storage** — отложен (на VM нет `yc`/сервис-аккаунта).
**IR-runbook (регламент реагирования)** — отдельным документом не формализован; реюз `operations:runbook` #51 при необходимости.
---
## SEC-5 — хранилище секретов (Lockbox) 🟦 частично
Через `yc` на сервере (значения секретов читались файл→payload→облако, **не печатались**): создан **KMS-ключ** `liderra-secrets-key` (AES-256, ротация год) + **Lockbox-секрет** `liderra-secrets` (KMS-encrypted, ACTIVE) с **8 entry** (роли БД + basic_auth + 2× supplier_webhook_secret). Источник — `/home/ubuntu/liderra-secrets.txt`. Цена Lockbox+KMS ~2550 ₽/мес.
**App-интеграция — ⏸ БЛОКИРОВАНА.** Приложение всё ещё читает секреты из файла + `.env`. Чтобы достроить, нужно:
1. YC **сервис-аккаунт** (роль `lockbox.payloadViewer`), привязанный к VM — требует доступа к YC-консоли (его нет).
2. Код-провайдер чтения секретов из Lockbox **с fallback на `.env`** (риск: если чтение Lockbox упадёт на старте — приложение без пароля БД ляжет).
3. Деплой копированием.
**Не делать без сервис-аккаунта и без fallback.** Сейчас секрет лежит в ДВУХ местах (файл + Lockbox) — выигрыш будет только после интеграции.
---
## SEC-3 — DDoS-защита ⏸ отложено (решение заказчика по цене)
Разведка через `yc` CLI: внешний IP `111.88.246.137` уже **статический** (reserved), но **без DDoS-провайдера** — продвинутую YC DDoS на существующий IP не добавить, нужен новый защищённый IP → смена DNS. Цена: платная подписка (тариф Professional+) + 976 ₽/Мбит/с свыше 10 Мбит/с — дорого/избыточно для портала.
**Базовая сетевая DDoS (L3/L4) уже бесплатно активна.** Решение заказчика 22.05: платный YC-DDoS не брать.
**Альтернатива на будущее** — бесплатный **Cloudflare** перед сайтом (DDoS + WAF + CDN, DNS на CF).
---
## Доступ к Yandex Cloud + ручные действия заказчика
**Доступ YC (22.05):** заказчик дал OAuth-токен (сервисный аккаунт создать не вышло — навигация консоли глючила). Токен **засветился в скриншоте переписки** → подлежит **отзыву** (Яндекс ID → отключить «Yandex Cloud CLI»). Для будущей YC-работы (напр. app-интеграция Lockbox) — завести **сервисный аккаунт** со scoped-ролями (vpc/compute/lockbox.admin), не OAuth.
**Ручные действия заказчика (вне сервера):**
1. **Отозвать засветившийся OAuth-токен** Яндекс-облака (Яндекс ID → «Yandex Cloud CLI»).
2. **Удалить** `C:\yc-oauth.txt` + папку `C:\yc\` (харнесс не дал удалить — защита корня диска C:).
3. **Сохранить ключ шифрования бэкапов вне сервера** (`/root/liderra-backup-crypt.key`), иначе emailed-бэкапы не расшифровать.
---
## Что ещё осталось (security follow-ups)
- **Усилить CSP** — убрать `'unsafe-inline'` из `style-src` (nonce для Vuetify).
- **Cloudflare** перед сайтом (бесплатная альтернатива SEC-3 DDoS).
- **Lockbox app-интеграция** + **off-site → YC Object Storage** — после получения YC сервис-аккаунта.
- **Sentry** — после перехода на сервер помощнее (Б-1).
- **Прогон сканера уязвимостей** (Nuclei #69 / ZAP #68 / Ward #70) по боевому порталу.
Связано: `memory/project_server_hardening.md`, `memory/project_a8_infosec.md`, ADR-014, `docs/security/pgaudit-anonymizer-setup.md`.
-65
View File
@@ -1,65 +0,0 @@
# Ward (#70) — установка и использование
**Узел A8:** #70 — безопасность настроек Laravel (.env / config / заголовки / cookie / secrets / deps).
**Источник (IS9-вет принят):** `Eljakani/ward` (MIT, Go), **заменил** Enlightn (abandoned + без поддержки Laravel 13 — см. `infosec-vet.md` §ПЕРЕСМОТР #70).
**Тип:** CLI-сканер (Go-бинарь) — **не MCP-сервер, не Composer dev-dep** (как Nuclei #69 / gitleaks #8). Go-бинарь → **не зависит от версии Laravel** (проблема Enlightn снята).
---
## Установка (native-Windows, портативно, без choco)
Готовых бинарей в релизе Ward нет — только `go install`. Go ставится **портативно** (zip, без choco), всё под `bin/` (gitignored).
```powershell
# 1. Portable Go (официальный zip, проверка SHA256)
$ProgressPreference='SilentlyContinue'
Invoke-WebRequest -Uri 'https://go.dev/dl/go1.26.3.windows-amd64.zip' -OutFile 'bin\_dl\go.zip' -UseBasicParsing
# ожидаемый SHA256: 20d2ceafb4ed41b96b879010927b28bc92a5be57a7c1801ce365a9ca51d3224a
Expand-Archive 'bin\_dl\go.zip' -DestinationPath 'bin\_runtimes' -Force # → bin\_runtimes\go\
# 2. Собрать Ward (локальные GOPATH/GOCACHE — всё остаётся под bin/)
$root=(Get-Location).Path
$env:GOROOT="$root\bin\_runtimes\go"; $env:GOPATH="$root\bin\_runtimes\gopath"; $env:GOCACHE="$root\bin\_runtimes\gocache"
$env:PATH="$env:GOROOT\bin;$env:PATH"
& "$env:GOROOT\bin\go.exe" install github.com/eljakani/ward@v0.4.1
# 3. Положить бинарь рядом с прочими security-CLI
Copy-Item "$env:GOPATH\bin\ward.exe" 'bin\ward.exe'
```
- **Расположение:** `bin/ward.exe` (рядом с nuclei/gitleaks/lychee/squawk; `bin/*` в `.gitignore` → бинарь машинно-локальный, в репозиторий не коммитится).
- **Go SDK** (`bin/_runtimes/go`, ~256 МБ) сохранён для обновлений (`go install ...@latest`); можно удалить — `ward.exe` статичный и работает без Go.
- **Verified (2026-05-21):** `bin\ward.exe version` → v0.4.1.
## Smoke (verified 2026-05-21)
```powershell
bin\ward.exe scan app -o json --no-color
```
Результат: 2 находки в Laravel-приложении `app/`**[High] APP_DEBUG включён**, **[Medium] APP_ENV = 'local'** (env-scanner: 2, config-scanner: 0, dependency-scanner: 0). Это ожидаемые dev-настройки, и одновременно — те самые go-live-проблемы, которые Ward и должен ловить (перед публикацией нужны `APP_DEBUG=false` + `APP_ENV=production`). Доказывает: Ward устанавливается и реально сканирует проект.
## Использование
```powershell
# несколько форматов сразу; report-файл(ы) пишутся в текущую папку
bin\ward.exe scan app -o json,sarif,html --no-color
# гейт по severity (exit 1 при находках ≥ уровня) — для CI/go-live
bin\ward.exe scan app --fail-on high --no-color
# подавить известные находки baseline-файлом
bin\ward.exe scan app --baseline docs/security/ward-baseline.json --no-color
```
- **TUI по умолчанию** (`-o tui`) — в неинтерактивной оболочке зависнет; всегда задавать `-o json`/`sarif`/`html`/`markdown`.
- **Артефакт:** `ward scan ... -o json` пишет `ward-report.json` в CWD — это временный отчёт, не коммитить.
- **Сеть:** локальный анализ кода/конфигов; единственный outbound — OSV.dev для проверки CVE в зависимостях (как Enlightn security-checker — функциональный запрос, не телеметрия).
## Границы (ADR-014)
IS3 — Ward (misconfig/secrets/deps Laravel) ≠ Larastan #12 (типы) ≠ Semgrep #25 (generic-паттерны кода). Dep-скан Ward ↔ Trivy #26 / Dependabot #27 — информационно, не дублирующий гейт.
## Caveat
Молодой проект (фев 2026), single-maintainer → bus-factor. Митигация: pin версии (`@v0.4.1`); MIT → форкабелен при забрасывании.
-57
View File
@@ -1,57 +0,0 @@
# OWASP ZAP (#68) — установка и использование
**Узел A8:** #68 — глубокая боевая DAST работающего портала (spider + active scan: обход входа, инъекции, XSS, сессии/CSRF).
**Источник (IS9-вет принят):** официальный ZAP «MCP Integration» add-on (`zaproxy/zap-extensions`, `addOns/mcp/`, Apache-2.0; провенанс OWASP/Checkmarx).
**Тип:** Java-приложение (ZAP) + **MCP-аддон** (единственный настоящий MCP в наборе A8). Управляется через MCP при запущенном ZAP-демоне.
---
## Установка (native-Windows, портативно, без choco)
ZAP — Java-приложение, требует Java 17+. И Java, и ZAP ставятся **портативно** (zip, без choco), всё под `bin/` (gitignored).
```powershell
$ProgressPreference='SilentlyContinue'
# 1. Portable Temurin JRE 17 (официальный zip, проверка SHA256)
Invoke-WebRequest -Uri 'https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jre_x64_windows_hotspot_17.0.19_10.zip' -OutFile 'bin\_dl\jre17.zip' -UseBasicParsing
# ожидаемый SHA256: 79a598e1fbb4e16582d92c4ee22280a3c4d72fd52606e1e46b1223c0fe53b0da
tar.exe -xf 'bin\_dl\jre17.zip' -C 'bin\_runtimes' # → bin\_runtimes\jdk-17.0.19+10-jre\
# 2. ZAP cross-platform 2.17.0 (официальный GitHub-релиз; размер 286 652 857 Б)
Invoke-WebRequest -Uri 'https://github.com/zaproxy/zaproxy/releases/download/v2.17.0/ZAP_2.17.0_Crossplatform.zip' -OutFile 'bin\_dl\zap.zip' -UseBasicParsing
tar.exe -xf 'bin\_dl\zap.zip' -C 'bin' # → bin\ZAP_2.17.0\
# 3. MCP-аддон (+ зависимости) из маркетплейса ZAP
$env:JAVA_HOME="$((Get-Location).Path)\bin\_runtimes\jdk-17.0.19+10-jre"
& "$env:JAVA_HOME\bin\java.exe" -jar 'bin\ZAP_2.17.0\zap-2.17.0.jar' -cmd -dir 'bin\ZAP_2.17.0\_home' -addoninstall mcp
```
- **Расположение:** `bin/ZAP_2.17.0/` (движок + аддоны в `_home/plugin/`), JRE — `bin/_runtimes/jdk-17.0.19+10-jre/`. `bin/*` в `.gitignore` → машинно-локально, не коммитится.
- **Java — портативная**, системная не устанавливается (`JAVA_HOME` задаётся при запуске ZAP).
- **Verified (2026-05-21):** `java -jar zap-2.17.0.jar -cmd -version``2.17.0`; daemon API `/JSON/core/view/version/``2.17.0`; аддон `mcp-alpha-0.0.1.zap` в `_home/plugin/`.
## Квирки native-Windows (важно)
1. **`Start-Process -ArgumentList` калечит путь к jar** с пробелами/кириллицей (`Error: Unable to access jarfile`). Запускать через оператор `&` (корректно кавычит) **или** задавать `-WorkingDirectory bin\ZAP_2.17.0` + относительное имя `zap-2.17.0.jar`.
2. **Первый daemon-старт тянет полный штатный набор аддонов** (~817 МБ: active/passive scan rules, spider, ajax, openapi, soap, graphql, selenium/webdrivers) — это нормально.
3. **Цель сканирования — `127.0.0.1`** (как у Nuclei), не `localhost`.
## Запуск daemon (для MCP-режима)
```powershell
$root=(Get-Location).Path; $env:JAVA_HOME="$root\bin\_runtimes\jdk-17.0.19+10-jre"
Start-Process -FilePath "$env:JAVA_HOME\bin\java.exe" -WorkingDirectory "$root\bin\ZAP_2.17.0" `
-ArgumentList @('-jar','zap-2.17.0.jar','-daemon','-dir','_home','-host','127.0.0.1','-port','8092','-config','api.disablekey=true')
# проверка готовности: GET http://127.0.0.1:8092/JSON/core/view/version/ → {"version":"2.17.0"}
```
**MCP-интеграция:** при запущенном демоне MCP-аддон отдаёт MCP-эндпоинт; зарегистрировать его SSE-адрес в `.mcp.json` (блок `zap`), затем доступны 15 MCP-инструментов (`ZapStartSpiderTool`, `ZapStartActiveScanTool`, `ZapGetActiveScanStatusTool`, `ZapGenerateReportTool` и т.д.) — все обращаются только к локальному ZAP API. Аддон **alpha** (`mcp-alpha-0.0.1`) — API может меняться.
## Гард IS8
Цель по умолчанию — **локальная/тестовая копия** (127.0.0.1). Боевой портал — **только по явной команде** заказчика. Active scan тяжёлый — в smoke не запускать (только spider + passive / проверка связности). READ-only постура.
## Границы (ADR-014)
IS1 — ZAP (динамика, бьёт работающий портал) ≠ Semgrep #25 (статика, читает код). IS2 — ZAP (глубина: логика приложения) ≠ Nuclei #69 (широта: известные дыры) — комплементарны.
@@ -1,641 +0,0 @@
# A8 infosec-tooling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Project wrapper: `.claude/skills/subagent-driven-development/` (git-safety per Pravila §15.1).
**Goal:** Наполнить раздел A8 «Информационная безопасность» шестью узлами (#68 OWASP ZAP MCP, #69 Nuclei MCP, #70 Enlightn — внешние; #71 скил ПДн/152-ФЗ, #72 скил моделирование угроз, #73 скил прогон перед публикацией — self-authored) с полным footprint роутера, наблюдателя, карты и серверным слоем как открытыми вопросами.
**Architecture:** Off-phase tooling integration в изолированном worktree (паттерн A1/A11/C10/finance). ZAP/Nuclei — MCP-серверы (`.mcp.json`, READ-only сканеры, таргет по умолчанию локальный); Enlightn — Composer dev-dep + конфиг (on-demand/CI, не блокирующий); три self-authored project-скила. Каждый внешний инструмент проходит провенанс-вет (IS9) ДО установки. Нормативка bump-ится атомарным набором (cross-ref-checker C2 STRICT).
**Tech Stack:** PHP 8.3 / Laravel 13 / Composer / Node MCP / Java (ZAP) / Go (Nuclei) / lefthook / PostgreSQL 16 / Markdown-нормативка / vis.js карта.
**Spec:** `docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md`
> **ПОПРАВКА 2026-05-21 (узел #70):** Enlightn → **Ward** (`Eljakani/ward`, Go-бинарь, MIT) после Task 1 IS9-вета (Enlightn abandoned + не поддерживает Laravel 13). Task 4 переписан под Ward: скачать `ward` Go-бинарь (pin по commit SHA — релизов нет), `ward scan` по корню `app/`, документировать в `docs/security/ward-setup.md`, постура on-demand (не lefthook). Ward — CLI-бинарь (как Nuclei/gitleaks), НЕ Composer dev-dep и НЕ MCP-сервер → `.mcp.json`/l1-watcher alias для #70 не нужны. Обоснование — `docs/security/infosec-vet.md` §«ПЕРЕСМОТР #70». «Enlightn» в слоте #70 ниже читать как «Ward».
---
## Pre-flight (исполнитель — перед Task 1)
- Worktree от свежего `origin/main` через `superpowers:using-git-worktrees`. После создания скопировать gitignored-файлы (учёт Sprint 4): `app/.env`, `app/storage/`, `app/vendor/`, `app/node_modules/`, `bin/*.exe`, `лендинг/` — иначе composer/тесты/lefthook не запустятся.
- `git fetch && git log HEAD..origin/main --oneline` — pre-flight sync 8 нормативных файлов (Pravila §15.2).
- Закоммитить уже написанные spec (`docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md`) + этот план первым коммитом.
- Создать home-директорию раздела: `docs/security/` (отчёты ПДн/угроз/go-live + вет-документ).
---
## File Structure
| Файл | Ответственность | Задача |
|---|---|---|
| `docs/security/infosec-vet.md` | провенанс-вет 3 внешних (IS9) + выбор источников | 1 |
| `.mcp.json` | блоки `zap` + `nuclei` (READ-only сканеры) | 2, 3 |
| `tools/.l1-watcher-aliases.txt` | alias MCP-имён ZAP/Nuclei → имена в Tooling | 2, 3 |
| `docs/security/zap-setup.md` | запуск ZAP на native-Windows + локальный таргет (IS8) | 2 |
| `docs/security/nuclei-setup.md` | запуск Nuclei + шаблоны | 3 |
| `app/composer.json` | dev-dep `enlightn/enlightn` | 4 |
| `app/config/enlightn.php` | конфиг Enlightn (60 OSS-проверок) | 4 |
| `.claude/skills/pdn-152fz-audit/SKILL.md` + `references/` + `evals/` | аудит ПДн + чек-лист 152-ФЗ | 5 |
| `.claude/skills/threat-model/SKILL.md` + `references/` + `evals/` | STRIDE под портал, going-public | 6 |
| `.claude/skills/security-go-live/SKILL.md` + `references/` + `evals/` | go-live security-gate (оркестратор) | 7 |
| `docs/adr/ADR-014-infosec-tooling.md` | границы узлов + IS1–IS9 | 8 |
| `docs/routing-off-phase.md` | +6 строк routing + связка L15 | 9 |
| `docs/router-procedure.md` | bump cross-ref | 9 |
| `docs/Tooling_v8_3.md` | §4.43–4.48 (9-атрибутные блоки) + §0 счётчик + header | 10 |
| `docs/Plugin_stack_rules_v1.md` | R10.1 +6 строк + header | 10 |
| `docs/Pravila_raboty_Claude_v1_1.md` | §13.2 +абзац + header | 10 |
| `CLAUDE.md` | §3.3 +#68–73, §6 +абзац, §9 +запись, header | 10 |
| `docs/automation-graph-data.js` | +6 узлов NODE_SECTION (A8) + рёбра + версии-метки | 11 |
| `docs/Открытые_вопросы_v8_3.md` | +7 записей серверного слоя (привязка Б-1) | 12 |
---
## Phase 0 — Провенанс-вет (IS9, ДО установки)
### Task 1: Вет 3 внешних инструментов + выбор источников
**Files:**
- Create: `docs/security/infosec-vet.md`
- [ ] **Step 1: Прочитать процедуру attack-surface**
Прочитать `docs/audit/` (ручная процедура attack-surface тулчейна, ADR-003) — расширяем её на 3 новых внешних.
- [ ] **Step 2: Вет OWASP ZAP MCP-кандидата**
Для каждого кандидата собрать: владелец/провенанс, лицензия, звёзды/активность, последний релиз, что исполняет (читать README + ключевые исходники через WebFetch / `gh`).
- Кандидат A: официальный ZAP «MCP Integration» add-on (`zaproxy.org/blog/2026-04-02-zap-mcp-server`) — провенанс OWASP (предпочтительно).
- Кандидат B: `dtkmn/mcp-zap-server` (Apache-2.0, Spring Boot).
Записать выбор + обоснование в `infosec-vet.md`.
- [ ] **Step 3: Вет Nuclei MCP-wrapper**
Движок `projectdiscovery/nuclei` (MIT, провенанс ProjectDiscovery — чистый). Wrapper-кандидаты: `cyproxio/mcp-for-security` (nuclei-mcp), `addcontent/nuclei-mcp`. Выбрать wrapper с лучшим провенансом ИЛИ решить запускать `nuclei.exe` через тонкую собственную обвязку (без чужого wrapper'а — минимизация surface). Записать.
- [ ] **Step 4: Вет Enlightn**
`enlightn/enlightn` (LGPL; security-checker MIT) — подтвердить OSS-уровень (60 проверок), отсутствие телеметрии наружу, активность. Записать.
- [ ] **Step 5: Зафиксировать verdict по каждому**
В `infosec-vet.md`: таблица «инструмент / источник / лицензия / вердикт (принят/отклонён) / pin-версия». Любой кандидат с непрозрачным провенансом — отклонить (ADR-003, риск ToxicSkills). Минимум один принятый на каждый из трёх слотов #68/#69/#70.
- [ ] **Step 6: Commit**
```bash
git add docs/security/infosec-vet.md
git commit -m "docs(security): provenance vet of ZAP/Nuclei/Enlightn (IS9)"
```
---
## Phase 1 — Внешние движки (#6870)
### Task 2: OWASP ZAP MCP — установка + native-Windows + локальный таргет smoke
**Files:**
- Modify: `.mcp.json`
- Modify: `tools/.l1-watcher-aliases.txt`
- Create: `docs/security/zap-setup.md`
- [ ] **Step 1: Установить ZAP + MCP-сервер (по вердикту Task 1)**
Установить ZAP (Java-приложение) на native-Windows + выбранный MCP-вариант. Зафиксировать команды в `docs/security/zap-setup.md`. Проверить наличие Java-рантайма (`java -version`); если нет — записать как пред-требование.
- [ ] **Step 2: Зарегистрировать MCP-сервер (READ-only сканер, локальный таргет по умолчанию)**
В `.mcp.json` добавить блок `zap`. В конфиге/обвязке зафиксировать дефолтный таргет = локальная копия портала (`http://localhost:<dev-port>`) — гард IS8 (бой только осознанно).
- [ ] **Step 3: Alias для l1-watcher (C1 STRICT)**
В `tools/.l1-watcher-aliases.txt` добавить alias MCP-имени `zap` → имя узла в Tooling Прил. Н (#68), иначе C1 заблокирует коммит нормативки.
- [ ] **Step 4: Smoke — пассивный скан локального портала**
Запустить локальный портал (dev). Через MCP запустить ZAP spider + passive scan по `http://localhost:<dev-port>`. Expected: ZAP отвечает, возвращает список endpoint'ов/alert'ов без падения. Скриншот/лог в `zap-setup.md`. **Active scan не запускать в smoke** (тяжёлый) — только проверка связности.
- [ ] **Step 5: Commit**
```bash
git add .mcp.json tools/.l1-watcher-aliases.txt docs/security/zap-setup.md
git commit -m "feat(security): OWASP ZAP MCP — setup + local-target guard + smoke (#68)"
```
---
### Task 3: Nuclei MCP — установка + smoke
**Files:**
- Modify: `.mcp.json`
- Modify: `tools/.l1-watcher-aliases.txt`
- Create: `docs/security/nuclei-setup.md`
- [ ] **Step 1: Установить nuclei + (опц.) wrapper (по вердикту Task 1)**
Установить `nuclei.exe` (Go-бинарь) на native-Windows + выбранный MCP-вариант (wrapper или собственная обвязка). Обновить шаблоны (`nuclei -update-templates`). Зафиксировать в `docs/security/nuclei-setup.md`.
- [ ] **Step 2: Зарегистрировать MCP-сервер (локальный таргет по умолчанию)**
В `.mcp.json` добавить блок `nuclei` (READ-only). Дефолтный таргет — локальная копия (гард IS8).
- [ ] **Step 3: Alias для l1-watcher (C1 STRICT)**
В `tools/.l1-watcher-aliases.txt` alias `nuclei` → имя узла Tooling (#69).
- [ ] **Step 4: Smoke — прогон шаблонов по локальному таргету**
Через MCP запустить nuclei с лёгким набором тегов (напр. `-tags tech,exposure`) по `http://localhost:<dev-port>`. Expected: nuclei отвечает, возвращает findings (или «no results») без падения. Лог в `nuclei-setup.md`.
- [ ] **Step 5: Commit**
```bash
git add .mcp.json tools/.l1-watcher-aliases.txt docs/security/nuclei-setup.md
git commit -m "feat(security): Nuclei MCP — setup + local-target guard + smoke (#69)"
```
---
### Task 4: Enlightn — установка + baseline + конфиг
**Files:**
- Modify: `app/composer.json` (require-dev)
- Create: `app/config/enlightn.php`
- [ ] **Step 1: Установить Enlightn (OSS)**
Run (root `app/`):
```bash
composer require enlightn/enlightn --dev
```
Expected: `enlightn/enlightn` в `require-dev`.
- [ ] **Step 2: Опубликовать конфиг**
Run (root `app/`):
```bash
php artisan vendor:publish --tag=enlightn
```
Expected: создан `config/enlightn.php`.
- [ ] **Step 3: Baseline-прогон**
Run (root `app/`):
```bash
php artisan enlightn --no-interaction
```
Expected: отчёт по 60 OSS-проверкам (Security / Performance / Reliability). **Записать число fail/warn по Security-категории** в коммит-сообщение.
- [ ] **Step 4: Настроить конфиг под проект**
В `config/enlightn.php`: ограничить анализаторы Security-категорией (либо оставить все, но в #73-скиле приоритезировать Security); исключить ложноположительные под native-Windows стек (если baseline покажет, напр. проверки, ожидающие конкретный веб-сервер). Не маскировать реальные находки — только явные FP.
- [ ] **Step 5: Зафиксировать постуру (не блокирующий lefthook)**
Enlightn — on-demand/CI (`php artisan enlightn`), **НЕ в lefthook** (паттерн Rector/PHP Insights, IS3). Зафиксировать в коммит-сообщении.
- [ ] **Step 6: Commit**
```bash
git add app/composer.json app/composer.lock app/config/enlightn.php
git commit -m "feat(security): Enlightn OSS setup + baseline (Security fails=<N>); on-demand posture (#70)"
```
---
## Phase 2 — Self-authored скилы (#7173)
### Task 5: Скил «ПДн / 152-ФЗ» (#71)
**Files:**
- Create: `.claude/skills/pdn-152fz-audit/SKILL.md`
- Create: `.claude/skills/pdn-152fz-audit/references/checklist.md`
- Create: `.claude/skills/pdn-152fz-audit/evals/evals.json`
- [ ] **Step 1: Написать SKILL.md (frontmatter + тело)**
```markdown
---
name: pdn-152fz-audit
description: Аудит защиты персональных данных Лидерры и соответствие 152-ФЗ. Режим 1 — техника (где лежат ПДн в схеме/коде, RLS, маскирование pg_anonymizer, утечки в логах/Sentry/CSV-экспортах, шифрование). Режим 2 — закон (хранение в РФ, согласия, сроки/удаление, реестр обработки, уведомление РКН, права субъекта pd_subject_request). Используй при «проверь ПДн», «утекают ли персональные данные», «соответствие 152-ФЗ», «где хранятся телефоны лидов», «маскируются ли данные в дампах». НЕ для денежной корректности (billing-audit), security-аудита кода (D3/Semgrep), юридического оформления договоров/политик (D2 право), generic-угроз (threat-model #72).
---
# ПДн / 152-ФЗ аудит — Лидерра
[Тело: 2 режима, для каждого — шаги проверки со ссылками на reference и реальные артефакты проекта.]
```
- [ ] **Step 2: Написать references/checklist.md — заземлено в схему и код**
Прочитать `db/schema.sql` (таблицы с ПДн), `db/CHANGELOG_schema.md`, найти: pg_anonymizer #29 правила маскирования, RLS-политики, функцию `set_pd_subject_request_deadline` + таблицу `pd_subject_request`. Записать чек-лист:
- *Техника:* перечень таблиц/колонок с ПДн (телефоны лидов, данные клиентов); под RLS ли; маскируются ли в дампах; не пишутся ли в `import_log`/логи/Sentry/CSV-экспорты в открытом виде; шифрование at-rest.
- *152-ФЗ:* хранение в РФ (Yandex Cloud `ru-central1` ✓), согласия, сроки хранения и удаление, реестр обработки, уведомление РКН, реализация прав субъекта (выгрузка/удаление через `pd_subject_request`).
Каждый пункт — со ссылкой на конкретный файл/таблицу проекта.
- [ ] **Step 3: Написать evals/evals.json (trigger + near-miss)**
```json
{
"skill": "pdn-152fz-audit",
"cases": [
{"prompt": "проверь, не утекают ли телефоны лидов в логи", "should_trigger": true},
{"prompt": "соответствует ли портал 152-ФЗ перед запуском", "should_trigger": true},
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": false, "expected": "threat-model"},
{"prompt": "составь договор обработки персональных данных", "should_trigger": false, "expected": "D2 право"}
]
}
```
- [ ] **Step 4: Прогнать классификацию + lint**
Прогнать евал-кейсы (skill-creator eval-режим или ручная проверка `description`). Expected: trigger → pdn-152fz-audit; near-miss → корректный сосед. Если перетягивает — уточнить границу в `description`.
```bash
npx markdownlint-cli2 ".claude/skills/pdn-152fz-audit/**/*.md"
```
Expected: 0 ошибок.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/pdn-152fz-audit/
git commit -m "feat(security): pdn-152fz-audit skill — ПДн + 152-ФЗ checklist (#71)"
```
---
### Task 6: Скил «Моделирование угроз» (#72)
**Files:**
- Create: `.claude/skills/threat-model/SKILL.md`
- Create: `.claude/skills/threat-model/references/stride-portal.md`
- Create: `.claude/skills/threat-model/evals/evals.json`
- [ ] **Step 1: Написать SKILL.md (frontmatter + тело)**
```markdown
---
name: threat-model
description: Моделирование угроз портала Лидерра по STRIDE — карта точек входа, что меняется при выходе в интернет, приоритизация защиты. Используй при «смоделируй угрозы», «откуда могут атаковать», «что защищать в первую очередь перед публикацией», «карта точек входа», «threat model / STRIDE». НЕ для аудита ПДн/152-ФЗ (pdn-152fz-audit #71), статического security-аудита кода (D3/Semgrep/Trail of Bits), generic архитектурных паттернов (architecture-patterns), go-live прогона (security-go-live #73).
---
# Моделирование угроз — Лидерра (STRIDE)
[Тело: процедура STRIDE под портал, со ссылкой на reference карты точек входа.]
```
- [ ] **Step 2: Написать references/stride-portal.md — заземлено в реальные точки входа**
Прочитать `app/routes/` (web.php/api.php) + контроллеры, выявить точки входа: форма входа, регистрация/2FA/recovery, вебхуки поставщика лидов (HMAC), deals API, админка, impersonation, импорт CSV. Для каждой — STRIDE-разбор (Spoofing/Tampering/Repudiation/Information disclosure/DoS/Elevation) + что меняется при публичной экспозиции (раньше контур своих → теперь произвольный внешний актор). Приоритизация по риску.
- [ ] **Step 3: Написать evals/evals.json**
```json
{
"skill": "threat-model",
"cases": [
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": true},
{"prompt": "что защищать в первую очередь перед публикацией", "should_trigger": true},
{"prompt": "проверь соответствие 152-ФЗ", "should_trigger": false, "expected": "pdn-152fz-audit"},
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": false, "expected": "security-go-live"},
{"prompt": "просканируй код на уязвимости семгрепом", "should_trigger": false, "expected": "D3/Semgrep"}
]
}
```
- [ ] **Step 4: Прогнать классификацию + lint**
Как Task 5 Step 4 (для `.claude/skills/threat-model/`).
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/threat-model/
git commit -m "feat(security): threat-model skill — STRIDE going-public (#72)"
```
---
### Task 7: Скил «Прогон перед публикацией» (#73, оркестратор)
**Files:**
- Create: `.claude/skills/security-go-live/SKILL.md`
- Create: `.claude/skills/security-go-live/references/gate.md`
- Create: `.claude/skills/security-go-live/evals/evals.json`
- [ ] **Step 1: Написать SKILL.md (frontmatter + тело)**
```markdown
---
name: security-go-live
description: Единый go-live security-gate Лидерры перед публикацией в интернете — один воспроизводимый прогон всех проверок безопасности и вердикт «можно/нельзя в прод». Оркеструет ZAP (#68), Nuclei (#69), Enlightn (#70), pdn-152fz-audit (#71), threat-model (#72) + Semgrep #25 / Trivy #26 / gitleaks #8 / Trail of Bits #39. Используй при «прогон безопасности перед релизом», «можно ли выкатывать», «go-live security check», «финальная проверка безопасности». НЕ для полного 14-фазного аудита портала (audit-portal), отдельной проверки ПДн (pdn-152fz-audit #71) или угроз (threat-model #72).
---
# Security go-live gate — Лидерра
[Тело: порядок прогона инструментов, сбор findings, формат вердикта.]
```
- [ ] **Step 2: Написать references/gate.md — порядок и вердикт**
Описать порядок: статика (Semgrep/gitleaks/Enlightn/Trail of Bits) → ПДн (#71) → угрозы (#72) → динамика (Nuclei breadth → ZAP depth, **только локальный таргет по умолчанию**, IS8) → сбор findings по серьёзности → вердикт GO / NO-GO с перечнем блокеров. Гард IS8 явно: бой только по явной команде заказчика. Граница IS7: это security-only gate, не подменяет audit-portal.
- [ ] **Step 3: Написать evals/evals.json**
```json
{
"skill": "security-go-live",
"cases": [
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": true},
{"prompt": "можно ли выкатывать портал в прод по безопасности", "should_trigger": true},
{"prompt": "проведи полный аудит портала", "should_trigger": false, "expected": "audit-portal"},
{"prompt": "проверь только персональные данные", "should_trigger": false, "expected": "pdn-152fz-audit"},
{"prompt": "смоделируй угрозы", "should_trigger": false, "expected": "threat-model"}
]
}
```
- [ ] **Step 4: Прогнать классификацию + lint**
Как Task 5 Step 4 (для `.claude/skills/security-go-live/`). Особое внимание near-miss с `audit-portal` (IS7).
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/security-go-live/
git commit -m "feat(security): security-go-live skill — go-live gate orchestrator (#73)"
```
---
## Phase 3 — ADR + роутер
### Task 8: ADR-014
**Files:**
- Create: `docs/adr/ADR-014-infosec-tooling.md`
- [ ] **Step 1: Прочитать шаблон ADR**
Прочитать `docs/adr/013-backend-tooling.md` (последний) как шаблон структуры (Status / Context / Decision / Alternatives / Consequences / Related / References).
- [ ] **Step 2: Написать ADR-014**
Содержание: Decision — 6 узлов infosec-tooling + границы; Alternatives — отброшенные готовые threat-model/compliance-скилы (ToxicSkills) + платные tiers + dedicated dependency-tool (дубль); Consequences — IS1IS9 (§8 спеки) + bus-factor/supply-chain (мит. вет IS9 + pin) + DAST-safety IS8; Related — ADR-002 (RLS, драйвер ПДн-скила), ADR-003 (D3 граница). Включить серверный слой как «out of scope, отдельные открытые вопросы».
- [ ] **Step 3: Проверить adr-judge не падает**
Run (root):
```bash
git diff --cached --unified=0 | python -X utf8 tools/adr-judge.py --diff - --adr-dir docs/adr/
```
Expected: нет нарушений на собственном диффе.
- [ ] **Step 4: Commit**
```bash
git add docs/adr/ADR-014-infosec-tooling.md
git commit -m "docs(adr): ADR-014 infosec-tooling boundaries (IS1-IS9)"
```
---
### Task 9: routing-off-phase.md + router-procedure.md
**Files:**
- Modify: `docs/routing-off-phase.md`
- Modify: `docs/router-procedure.md`
- [ ] **Step 1: Прочитать текущие файлы**
Прочитать `docs/routing-off-phase.md` (формат routing-таблицы + связки L1–L14, версия v1.3) и `docs/router-procedure.md` (header v1.2).
- [ ] **Step 2: Добавить 6 строк routing-таблицы**
Для #68–73 — строки «триггер → узел» (значения routing-trigger из спеки §3 / атрибутов Task 10). Bump version routing-off-phase v1.3 → **v1.4**.
- [ ] **Step 3: Добавить каноническую связку L15**
L15 «security go-live chain»: #73 (оркестратор) → #68 ZAP / #69 Nuclei / #70 Enlightn / #71 ПДн / #72 угрозы + D3 (#39/#25/#26/#8/#40). Anti-pattern: не запускать ZAP/Nuclei в pre-commit хук (тяжёлые, требуют таргета); не путать #73 (security-only go-live) с `audit-portal` (полный аудит).
- [ ] **Step 4: Bump router-procedure.md**
router-procedure v1.2 → **v1.3**: процедура не меняется, обновить cross-ref-строку/счётчик узлов под новый набор.
- [ ] **Step 5: Verify lychee + commit**
```bash
./bin/lychee.exe --config .lychee.toml "docs/routing-off-phase.md" "docs/router-procedure.md"
git add docs/routing-off-phase.md docs/router-procedure.md
git commit -m "docs(router): +6 infosec nodes routing + L15 chain (routing-off-phase v1.4, router-procedure v1.3)"
```
---
## Phase 4 — Нормативка (АТОМАРНЫЙ набор — C2 STRICT)
### Task 10: Tooling + PSR_v1 + Pravila + CLAUDE.md — один атомарный коммит
> **Критично:** cross-ref-checker (C2, lefthook job 12) STRICT — все §0/header cross-refs между этими 4 файлами должны совпасть. Поэтому **все 4 файла редактируются и коммитятся ОДНИМ коммитом.** l1-watcher (C1, job 11) проверит формализацию MCP-серверов ZAP/Nuclei (см. alias Task 2/3).
**Files:**
- Modify: `docs/Tooling_v8_3.md`
- Modify: `docs/Plugin_stack_rules_v1.md`
- Modify: `docs/Pravila_raboty_Claude_v1_1.md`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Tooling Прил. Н — §4.43–4.48 (9-атрибутные блоки)**
Прочитать §4.36 (finance plugin) как канонический шаблон 9-attribute блока, реплицировать для 6 узлов:
- **§4.43 #68 OWASP ZAP** — name@source per Task 1 вердикт; category: infosec-tooling (off-phase, 17-я); install: ZAP + MCP per `docs/security/zap-setup.md` + `.mcp.json` блок `zap`; activation: on-demand, READ-only сканер, локальный таргет (IS8); conflicts: IS1/IS2 (ADR-014); dormant: false; routing-trigger: «боевая проверка работающего портала», обход входа/инъекции/XSS; cost: 0 LLM.
- **§4.44 #69 Nuclei** — @ projectdiscovery/nuclei + wrapper per Task 1; infosec-tooling; install: `docs/security/nuclei-setup.md` + `.mcp.json` блок `nuclei`; activation: on-demand, локальный таргет (IS8); conflicts: IS2 (ADR-014); dormant: false; routing-trigger: «известные дыры/открытые двери/слабый TLS снаружи»; cost: 0 LLM.
- **§4.45 #70 Enlightn** — @ enlightn/enlightn (Composer dev-dep); infosec-tooling; install: `composer require enlightn/enlightn --dev` + `config/enlightn.php`; activation: on-demand/CI (`php artisan enlightn`), НЕ lefthook (IS3); conflicts: IS3 (ADR-014); dormant: false; routing-trigger: «безопасность настроек Laravel», заголовки/режим отладки/cookie; cost: 0 LLM.
- **§4.46 #71 pdn-152fz-audit** — @ self-authored (`.claude/skills/`); infosec-tooling; install: project skill auto-discovered; activation: trigger-based; conflicts: IS4/IS5 (ADR-014); dormant: false; routing-trigger: «проверь ПДн», «соответствие 152-ФЗ», утечки персональных данных; cost: skill inference.
- **§4.47 #72 threat-model** — @ self-authored; infosec-tooling; activation: trigger-based; conflicts: IS6 (ADR-014); dormant: false; routing-trigger: «смоделируй угрозы», «откуда атакуют», STRIDE going-public; cost: skill inference.
- **§4.48 #73 security-go-live** — @ self-authored; infosec-tooling; activation: trigger-based (оркеструет #6872 + D3); conflicts: IS7 (ADR-014); dormant: false; routing-trigger: «прогон безопасности перед релизом», go/no-go; cost: skill inference.
§0 счётчик: 67 → **73**; добавить 17-ю off-phase подкатегорию «infosec-tooling». Header Прил. Н: v2.19 → **v2.20** + наследие-строка.
- [ ] **Step 2: PSR_v1 — R10.1 +6 строк + header**
R10.1 Блок 1 (project-скилы #71/#72/#73) + Блок 3 (MCP-серверы #68/#69) + строка Enlightn (#70, Composer dev-dep, не marketplace) с категорией infosec-tooling (не UI → вне R6/R14). Header v3.19 → **v3.20** + наследие.
- [ ] **Step 3: Pravila — §13.2 +абзац + header**
§13.2: +абзац «Off-phase infosec-tooling» (#68 ZAP / #69 Nuclei / #70 Enlightn / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live — 17-я подкатегория; счётчики — пин на Tooling Прил. Н §0; провенанс-вет IS9 обязателен для внешних). Header v1.35 → **v1.36** + §10 changelog.
- [ ] **Step 4: CLAUDE.md — §3.3 + §6 + §9 + header**
- §3.3: +6 строк #68–73 (однострочный индекс, пин на Tooling §4.434.48).
- §6: +абзац «2026-05-21 A8 infosec-tooling integration» сверху (+серверный слой → открытые вопросы).
- §9: +запись v2.23.
- §0 cross-refs: Pravila v1.36 / PSR_v1 v3.20 / Tooling Прил.Н v2.20.
- Header: v2.22 → **v2.23**.
(Прямой Edit — worktree-эксцепшн §5 п.10.)
- [ ] **Step 5: Verify cross-refs локально перед коммитом**
Run (root):
```bash
node tools/cross-ref-checker.mjs
node tools/l1-watcher.mjs
```
Expected: оба чисто (нет version drift; MCP ZAP/Nuclei формализованы/aliased).
- [ ] **Step 6: Атомарный commit (все 4 файла вместе)**
```bash
git add docs/Tooling_v8_3.md docs/Plugin_stack_rules_v1.md docs/Pravila_raboty_Claude_v1_1.md CLAUDE.md
git commit -m "docs(normative): A8 infosec-tooling #68-73 — Tooling v2.20/PSR v3.20/Pravila v1.36/CLAUDE v2.23"
```
---
## Phase 5 — Карта
### Task 11: automation-graph-data.js +6 узлов + рёбра + версии
**Files:**
- Modify: `docs/automation-graph-data.js`
- [ ] **Step 1: Прочитать текущее состояние карты**
Прочитать блок finance (`finance_plugin`/`billing_audit`/`ru_tax`) + backend (`rector`/`php_insights`/...) как образцы. **Зафиксировать текущий счётчик узлов/рёбер** из шапки/`NODES` (для commit-сообщения N→N+6).
- [ ] **Step 2: Добавить 6 узлов в NODES + NODE_SECTION (все A8)**
Узлы `mcp_zap`, `mcp_nuclei`, `enlightn`, `sk_pdn_152fz`, `sk_threat_model`, `sk_security_golive` в `NODES` (group: mcp / lefthook-нет / skills_proj) + в `NODE_SECTION` все 6 → `'A8'`. Reuse через `NODE_SECTION_SECONDARY` — нет (оставить только A8).
- [ ] **Step 3: Добавить рёбра**
Рёбра L15-цепочки: `sk_security_golive``mcp_zap`/`mcp_nuclei`/`enlightn`/`sk_pdn_152fz`/`sk_threat_model` + reuse-связи (`sk_security_golive``mcp_semgrep`/`lh_gitleaks`/`trivy`(если есть узел)/Trail of Bits; `sk_pdn_152fz` ↔ pg_anonymizer-узел если есть). Обновить версии-метки шапки карты (v1.36/v2.23/v3.20/v2.20) + счётчики узлов/рёбер.
- [ ] **Step 4: Browser-smoke карты**
Открыть `docs/automation-graph.html` через Playwright MCP, проверить: 6 новых узлов рендерятся в секторе A8, рёбра присутствуют, нет JS-ошибок в консоли. Скриншот.
- [ ] **Step 5: Commit**
```bash
git add docs/automation-graph-data.js
git commit -m "feat(map): +6 A8 infosec-tooling nodes + L15 chain (N→N+6 nodes)"
```
---
## Phase 6 — Серверный слой (открытые вопросы)
### Task 12: Открытые_вопросы — +7 записей серверной защиты
**Files:**
- Modify: `docs/Открытые_вопросы_v8_3.md`
- [ ] **Step 1: Прочитать формат реестра**
Прочитать `docs/Открытые_вопросы_v8_3.md` (формат записи, префиксы, сводка §0). Выбрать префикс для серверной безопасности (новый `SEC-` или существующий `DO-` DevOps — по факту формата).
- [ ] **Step 2: Добавить 7 записей (только ДОБАВИТЬ — ничего не закрывать)**
7 записей серверного слоя (§9 спеки), каждая со статусом «открыт» и привязкой к Б-1 где уместно:
1. WAF (Yandex Smart Web Security / Coraza/ModSecurity).
2. Anti-brute-force / rate-limit (Laravel throttle + серверный).
3. DDoS-защита (Yandex Cloud DDoS Protection).
4. Мониторинг вторжений (Sentry #34 pending Б-1 + алерты).
5. Хранилище секретов (Yandex Lockbox).
6. TLS/HSTS/CSP на бою.
7. Бэкапы + IR-runbook (реюз operations:runbook #51).
Обновить сводку §0 (счётчики открытых вопросов). **Не закрывать никаких существующих вопросов** (правило §2.2 / economy).
- [ ] **Step 3: Verify lychee + commit**
```bash
./bin/lychee.exe --config .lychee.toml "docs/Открытые_вопросы_v8_3.md"
git add docs/Открытые_вопросы_v8_3.md
git commit -m "docs(open-questions): +7 server-side security items (A8 server layer, Б-1)"
```
---
## Phase 7 — Финал
### Task 13: Полная регрессия + finishing
**Files:** (нет правок)
- [ ] **Step 1: Полная регрессия**
Run (root `app/`):
```bash
composer pint -- --test
composer stan
php vendor/bin/pest --parallel --recreate-databases
```
Запустить Vitest, если затронут frontend (не затронут — пропустить). Expected: Pint 0, Larastan 0 above baseline, Pest GREEN (выписать точные числа passed/failed с file:line при падении).
- [ ] **Step 2: Pre-push проверки**
Run (root):
```bash
./bin/gitleaks.exe detect --source . --no-banner --config .gitleaks.toml --redact
./bin/lychee.exe --config .lychee.toml "docs/**/*.md" "*.md"
```
Expected: gitleaks 0; lychee 0 broken (untracked setup-доки `docs/security/*` — если ломают, добавить в exclude или закоммичены ранее).
- [ ] **Step 3: finishing-a-development-branch**
Использовать `superpowers:finishing-a-development-branch` — представить заказчику опции (push в main / PR / cleanup). Push паттерн `git push origin <ветка>:main` (memory reference_github).
---
## Self-Review (исполнено при написании плана)
**Spec coverage:**
- Спека §2 (6 узлов + out-of-scope) → Tasks 2/3/4 (внешние), 5/6/7 (скилы); out-of-scope зафиксирован в ADR Task 8 + серверный слой Task 12. ✅
- Спека §3 (детали узлов + границы) → Tasks 27 + ADR Task 8. ✅
- Спека §4 (роутер) → Task 9. ✅
- Спека §5 (наблюдатель: 9-атрибуты + C1/C2) → Task 10 (9-атрибуты §4.4348, C1/C2 verify Step 5). ✅
- Спека §6 (нормативка атомарно) → Task 10. ✅
- Спека §7 (карта) → Task 11. ✅
- Спека §8 (IS1IS9) → Task 8 ADR + заземление IS9 в Task 1, IS8 в Task 2/3/7. ✅
- Спека §9 (серверный слой) → Task 12. ✅
- Спека §10 (spikes) → Task 1 (IS9-вет) + smoke Task 2/3 + baseline Task 4. ✅
- Спека §11 (worktree subagent-driven) → Pre-flight + execution handoff. ✅
- Спека §12 (решения заказчика) → отражены в ADR Task 8 + гарды IS8/IS9. ✅
**Placeholder scan:** `<N>`/`<dev-port>` — намеренные spike/runtime-выходы (заполняются в Task 2/4), не placeholder-долги. Условные элементы (выбор источника per Task 1 вердикт) явно помечены ветвлением. ✅
**Type consistency:** имена узлов карты (`mcp_zap`/`mcp_nuclei`/`enlightn`/`sk_pdn_152fz`/`sk_threat_model`/`sk_security_golive`), номера (#6873), §4.434.48, версии (v1.36/v3.20/v2.20/v2.23), коды IS1IS9, связка L15, имена скилов (pdn-152fz-audit/threat-model/security-go-live) — единообразны across задач. ✅
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,631 @@
# Тестовый деплой портала Лидерра в Yandex Cloud — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task (inline — план содержит интерактивные шаги заказчика: создание VM, DNS, deploy-key). Steps use checkbox (`- [ ]`) syntax.
**Goal:** Поднять рабочую копию портала в интернете на одной Linux-VM в Yandex Cloud по адресу `https://<поддомен>` с HTTPS, доступом только для заказчика+Claude, для ручного теста.
**Architecture:** Одна Ubuntu 24.04 VM: nginx (HTTPS + Basic Auth) → PHP-FPM 8.3 → портал (Laravel 13 + собранный Vue) → PostgreSQL 16 + Redis 7 на той же машине; queue worker + scheduler как systemd-службы. Фронтенд собирается на dev-машине и заливается. Настоящие роли БД (RLS включён). Спека: `docs/superpowers/specs/2026-05-21-test-deploy-yandex-cloud-design.md`.
**Tech Stack:** Yandex Cloud Compute, Ubuntu 24.04 LTS, nginx, PHP 8.3-FPM, PostgreSQL 16, Redis 7, Certbot/Let's Encrypt, systemd, OpenSSH.
**Условные обозначения:** 🧑 = шаг заказчика (веб-интерфейс/решение), 🤖 = шаг Claude (Bash/SSH). Плейсхолдеры: `<SERVER_IP>`, `<DOMAIN>` (например `test.example.ru`), `<BASIC_USER>`/`<BASIC_PASS>` (дверь сайта) — заполняются по ходу.
---
## Фаза 0 — Подготовка на dev-машине (🤖, до создания сервера)
### Task 0.1: Проверить SSH-клиент и сгенерировать ключ деплоя
**Files:** `~/.ssh/liderra_deploy`, `~/.ssh/liderra_deploy.pub` (на dev-машине)
- [ ] **Step 1: Проверить наличие OpenSSH**
Run: `ssh -V; ssh-keygen --help 2>&1 | Select-Object -First 1`
Expected: версия OpenSSH (например `OpenSSH_for_Windows_9.x`). Если нет — поставить «OpenSSH Client» через Settings → Optional Features.
- [ ] **Step 2: Сгенерировать ключ-пару (без пароля, ed25519)**
Run (PowerShell):
```powershell
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\liderra_deploy" -C "liderra-test-deploy" -N '""'
```
Expected: созданы `liderra_deploy` (приватный) и `liderra_deploy.pub` (публичный).
- [ ] **Step 3: Показать публичный ключ заказчику**
Run: `Get-Content "$env:USERPROFILE\.ssh\liderra_deploy.pub"`
Expected: строка `ssh-ed25519 AAAA... liderra-test-deploy`. Отдать заказчику для вставки при создании VM (Task 1.2).
### Task 0.2: Код-правка — временный флаг доступа к админке (TDD)
**Files:**
- Modify: `app/config/app.php` (добавить ключ `saas_admin_test_bypass`)
- Modify: `app/app/Http/Middleware/EnsureSaasAdmin.php`
- Test: `app/tests/Feature/Middleware/EnsureSaasAdminTest.php` (создать или дополнить)
- [ ] **Step 1: Написать падающий тест**
Создать `app/tests/Feature/Middleware/EnsureSaasAdminTest.php`:
```php
<?php
declare(strict_types=1);
use function Pest\Laravel\get;
it('blocks admin area in production by default', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => false]);
// любой admin-маршрут под EnsureSaasAdmin; подставить реальный из routes
$response = get('/api/admin/tenants');
expect($response->status())->toBe(503);
});
it('allows admin area in production when test bypass flag is on', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => true]);
$response = get('/api/admin/tenants');
expect($response->status())->not->toBe(503);
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app; C:\tools\php83\php.exe artisan test --filter=EnsureSaasAdmin`
Expected: второй тест FAIL (сейчас middleware всегда 503 вне local/testing).
- [ ] **Step 3: Добавить ключ конфига**
В `app/config/app.php` добавить (рядом с другими ключами):
```php
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
```
- [ ] **Step 4: Поправить middleware**
В `app/app/Http/Middleware/EnsureSaasAdmin.php` заменить тело `handle`:
```php
public function handle(Request $request, Closure $next): Response
{
if (app()->environment('local', 'testing')) {
return $next($request);
}
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
if (config('app.saas_admin_test_bypass') === true) {
return $next($request);
}
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
```
- [ ] **Step 5: Запустить тест — зелёный**
Run: `cd app; C:\tools\php83\php.exe artisan test --filter=EnsureSaasAdmin`
Expected: оба PASS.
- [ ] **Step 6: Линт + commit**
Run: `cd app; composer pint; composer stan`
Expected: 0 ошибок.
```bash
git add app/config/app.php app/app/Http/Middleware/EnsureSaasAdmin.php app/tests/Feature/Middleware/EnsureSaasAdminTest.php
git commit -m "feat(deploy): temporary SAAS_ADMIN_TEST_BYPASS flag for test server (off by default)"
```
> NB: маршрут `/api/admin/tenants` в тесте — подставить реальный admin-маршрут из `app/routes/`. Уточнить на Step 1 (grep по `EnsureSaasAdmin`).
### Task 0.3: Собрать фронтенд для прода
- [ ] **Step 1: Прод-сборка**
Run: `npm --prefix app run build`
Expected: создан `app/public/build/` с манифестом и ассетами, ошибок нет.
- [ ] **Step 2: Зафиксировать факт сборки**
Сборка не коммитится (build в .gitignore) — будет залита на сервер в Task 3.3 через scp. Проверить: `Test-Path app/public/build/manifest.json` → True.
---
## Фаза 1 — Создание сервера (🧑 заказчик в консоли YC, по инструкции Claude)
### Task 1.1: Зарезервировать статический публичный IP
- [ ] **Step 1:** YC Console → Virtual Private Cloud → IP-адреса → «Зарезервировать адрес» → зона `ru-central1-a`.
- [ ] **Step 2:** Записать выданный IP → это `<SERVER_IP>` (нужен для DNS; статический, чтобы адрес не менялся при перезагрузке).
### Task 1.2: Создать виртуальную машину
- [ ] **Step 1:** Compute Cloud → «Создать ВМ».
- [ ] **Step 2:** Параметры:
- Имя: `liderra-test`; зона `ru-central1-a`.
- Образ: **Ubuntu 24.04 LTS**.
- vCPU 2, RAM 2 ГБ, **гарантированная доля vCPU 20%** (дёшево; сборки идут на dev-машине).
- Диск: SSD 20 ГБ.
- Публичный адрес: выбрать **зарезервированный** из Task 1.1.
- Доступ: логин `deploy`; SSH-ключ — вставить публичный ключ из Task 0.1 Step 3.
- [ ] **Step 3:** Создать. Дождаться статуса RUNNING.
### Task 1.3: Открыть порты (группа безопасности)
- [ ] **Step 1:** VPC → Группы безопасности → группа сети ВМ → правила входящего трафика.
- [ ] **Step 2:** Разрешить TCP **22, 80, 443** (источник `0.0.0.0/0`; 22 можно сузить до IP заказчика/dev — но для простоты теста оставить открытым).
- [ ] **Step 3:** Сообщить Claude `<SERVER_IP>` → переходим к Фазе 2.
---
## Фаза 2 — Базовая настройка сервера (🤖 по SSH)
### Task 2.1: Первое подключение
- [ ] **Step 1: Подключиться**
Run: `ssh -i "$env:USERPROFILE\.ssh\liderra_deploy" -o StrictHostKeyChecking=accept-new deploy@<SERVER_IP> "echo OK; lsb_release -d"`
Expected: `OK` + `Ubuntu 24.04`.
- [ ] **Step 2: Обновить пакеты**
Run: `ssh ... deploy@<SERVER_IP> "sudo apt-get update && sudo apt-get -y upgrade"`
Expected: завершается без ошибок.
### Task 2.2: Установить стек
- [ ] **Step 1: Установить пакеты**
Run одной командой по SSH:
```bash
sudo apt-get install -y nginx \
php8.3-fpm php8.3-cli php8.3-pgsql php8.3-redis php8.3-mbstring \
php8.3-xml php8.3-curl php8.3-bcmath php8.3-zip php8.3-gd php8.3-intl \
postgresql postgresql-contrib redis-server git unzip certbot python3-certbot-nginx \
apache2-utils
```
Expected: установлено без ошибок (`apache2-utils` даёт `htpasswd`).
- [ ] **Step 2: Установить Composer**
```bash
php -r "copy('https://getcomposer.org/installer','/tmp/ci.php');" \
&& sudo php /tmp/ci.php --install-dir=/usr/local/bin --filename=composer
```
Run: `ssh ... "composer --version; php -v | head -1"`
Expected: Composer 2.x; PHP 8.3.
- [ ] **Step 3: Проверить службы**
Run: `ssh ... "systemctl is-active nginx php8.3-fpm postgresql redis-server"`
Expected: `active` × 4.
---
## Фаза 3 — База, код, конфиг (🤖 по SSH)
> **Порядок исполнения внутри фазы:** 3.2 (код на сервере — db/-скрипты приезжают с репо) → 3.1 (БД и роли) → 3.3 (фронтенд) → 3.4 (.env) → 3.5 (схема через migrate + grants + seed). Здесь нумерация по смыслу, но db-скрипты есть только после clone.
>
> **DB-роли (из `db/00_create_roles.sql` v1.1 + `app/config/database.php`):** пароли передаются psql через `-v` (НЕ `ALTER ROLE`). Схема грузится миграцией `load_initial_schema` (она делает `DB::unprepared(schema.sql)`) под ролью `crm_migrator` (BYPASSRLS+CREATEDB). Гранты — `db/02_grants.sql`. Рантайм — `crm_app_user` (RLS). Supplier-джобы — `crm_supplier_worker` (BYPASSRLS) через connection `pgsql_supplier`. Connection `pgsql_migrator` в конфиге НЕТ → для миграций временно подменяем `DB_USERNAME` на `crm_migrator` (default-connection `pgsql`), потом возвращаем на `crm_app_user`.
### Task 3.1: Создать БД и роли
**Files (на сервере):** `db/00_create_roles.sql` (после clone в 3.2).
- [ ] **Step 1: Сгенерировать пароли ролей (на dev или сервере)**
Run: `ssh ... "for r in app admin migrator audit supplier; do echo \$r=\$(openssl rand -hex 16); done"`
Expected: 5 строк вида `app=...`. Сохранить как `<APP_DB_PASS>` / `<ADMIN_DB_PASS>` / `<MIGRATOR_DB_PASS>` / `<AUDIT_DB_PASS>` / `<WORKER_DB_PASS>` (в безопасное место, не в git).
- [ ] **Step 2: Создать БД**
```bash
ssh ... "sudo -u postgres createdb liderra"
```
Expected: без ошибок.
- [ ] **Step 3: Создать роли с паролями (через -v)**
```bash
ssh ... "sudo -u postgres psql -d liderra \
-v crm_app_password='<APP_DB_PASS>' \
-v crm_admin_password='<ADMIN_DB_PASS>' \
-v crm_migrator_password='<MIGRATOR_DB_PASS>' \
-v crm_audit_writer_password='<AUDIT_DB_PASS>' \
-v crm_supplier_worker_password='<WORKER_DB_PASS>' \
-f /var/www/liderra/db/00_create_roles.sql"
```
Run: `ssh ... "sudo -u postgres psql -d liderra -c '\du' | grep -E 'crm_(app|migrator|supplier)'"`
Expected: 5 ролей созданы (`crm_app_user`, `crm_admin_user`, `crm_migrator`, `crm_audit_writer`, `crm_supplier_worker`).
- [ ] **Step 4: Разрешить TCP-вход ролям (pg_hba)**
> Роли ходят через 127.0.0.1 (scram). Убедиться, что `pg_hba.conf` имеет строку `host all all 127.0.0.1/32 scram-sha-256` (на Ubuntu по умолчанию есть). Если нет — добавить и `sudo systemctl reload postgresql`.
Run: `ssh ... "sudo grep -E '127.0.0.1/32' /etc/postgresql/16/main/pg_hba.conf"`
Expected: строка с `scram-sha-256` (или `md5`).
### Task 3.2: Выложить код (deploy-key + clone)
- [ ] **Step 1: Сгенерировать deploy-key на сервере**
```bash
ssh ... "ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N '' -C 'liderra-server'; cat ~/.ssh/github_deploy.pub"
```
Expected: публичный ключ сервера.
- [ ] **Step 2 (🧑): Добавить ключ в GitHub**
Заказчик: GitHub → репо `CoralMinister/lidpotok` → Settings → Deploy keys → Add → вставить ключ (read-only, без write).
- [ ] **Step 3: Настроить SSH для GitHub + clone**
```bash
ssh ... 'cat >> ~/.ssh/config <<EOF
Host github.com
IdentityFile ~/.ssh/github_deploy
StrictHostKeyChecking accept-new
EOF
sudo mkdir -p /var/www && sudo chown deploy:deploy /var/www
git clone git@github.com:CoralMinister/lidpotok.git /var/www/liderra
cd /var/www/liderra && git checkout main && git log -1 --oneline'
```
Expected: репозиторий склонирован, HEAD на нужном коммите (с флагом из Task 0.2 — убедиться, что коммит влит в `main`; иначе `git checkout <ветка>`).
- [ ] **Step 4: composer install**
```bash
ssh ... "cd /var/www/liderra/app && composer install --no-dev --optimize-autoloader --no-interaction"
```
Expected: зависимости установлены, 0 ошибок.
### Task 3.3: Залить собранный фронтенд
- [ ] **Step 1: Скопировать build на сервер**
Run (с dev-машины):
```powershell
scp -i "$env:USERPROFILE\.ssh\liderra_deploy" -r app/public/build deploy@<SERVER_IP>:/var/www/liderra/app/public/
```
Expected: `manifest.json` + ассеты на сервере.
### Task 3.4: Production .env
- [ ] **Step 1: Создать .env на сервере**
```bash
ssh ... 'cat > /var/www/liderra/app/.env <<EOF
APP_NAME=Liderra
APP_ENV=production
APP_DEBUG=false
APP_URL=https://<DOMAIN>
APP_LOCALE=ru
APP_FALLBACK_LOCALE=ru
APP_TIMEZONE=Europe/Moscow
LOG_CHANNEL=stack
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=liderra
DB_USERNAME=crm_app_user
DB_PASSWORD=<APP_DB_PASS>
DB_SUPPLIER_USERNAME=crm_supplier_worker
DB_SUPPLIER_PASSWORD=<WORKER_DB_PASS>
SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@<DOMAIN>"
MAIL_FROM_NAME=Liderra
SAAS_ADMIN_TEST_BYPASS=true
AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets
EOF'
```
- [ ] **Step 2: APP_KEY**
```bash
ssh ... "cd /var/www/liderra/app && php artisan key:generate --force && php artisan about | head -20"
```
Expected: ключ сгенерирован; `Environment: production`, `Debug Mode: OFF`.
### Task 3.5: Схема (migrate), гранты, демо-данные, кэши
> Схему и сиды грузим под BYPASSRLS-ролью `crm_migrator`, потом возвращаем рантайм на `crm_app_user`. Подмена — временно правим `DB_USERNAME`/`DB_PASSWORD` в `.env` (это значения для default-connection `pgsql`, через которую идёт migrate/seed).
- [ ] **Step 1: Временно переключить .env на crm_migrator**
```bash
ssh ... "cd /var/www/liderra/app && \
sed -i 's/^DB_USERNAME=.*/DB_USERNAME=crm_migrator/; s/^DB_PASSWORD=.*/DB_PASSWORD=<MIGRATOR_DB_PASS>/' .env && \
grep -E '^DB_(USERNAME|PASSWORD)=' .env"
```
Expected: `DB_USERNAME=crm_migrator`.
- [ ] **Step 2: Накатить схему (миграция load_initial_schema грузит schema.sql)**
```bash
ssh ... "cd /var/www/liderra/app && php artisan migrate --force"
```
Run: `ssh ... "sudo -u postgres psql -d liderra -c '\dt' | tail -3"`
Expected: миграция `load_initial_schema` отработала; десятки таблиц (схема v8.27).
- [ ] **Step 3: Создать партиции (как на dev — ручной cron вместо pg_partman)**
```bash
ssh ... "cd /var/www/liderra/app && php artisan partitions:create-months"
```
Expected: партиции созданы (команда из ЭТАЛОН/project_phase1_strategy; если имя иное — `php artisan list | grep partition`).
- [ ] **Step 4: Применить гранты**
```bash
ssh ... "sudo -u postgres psql -d liderra -f /var/www/liderra/db/02_grants.sql"
```
Expected: гранты применены без ошибок (запуск под postgres-суперюзером — владелец/superuser, см. 00_create_roles doc вариант с crm_admin_user тоже подходит).
- [ ] **Step 5: Демо-данные (под crm_migrator, BYPASSRLS — cross-tenant сид проходит)**
```bash
# залить нужные демо-скрипты на сервер
scp -i "$env:USERPROFILE\.ssh\liderra_deploy" app/storage/_demo_5users.php app/storage/_demo_split_tenants.php deploy@<SERVER_IP>:/var/www/liderra/app/storage/
ssh ... "cd /var/www/liderra/app && php artisan db:seed --force && php artisan tinker storage/_demo_5users.php && php artisan tinker storage/_demo_split_tenants.php"
```
Expected: 5 компаний + учётки `admin@demo.local` / `manager1..4@demo.local` (пароль `password`).
> NB: точный набор демо-скриптов сверить с ЭТАЛОН §4 (там же команда восстановления). Залить только нужные `_demo_*.php`.
- [ ] **Step 6: Вернуть рантайм-роль crm_app_user**
```bash
ssh ... "cd /var/www/liderra/app && \
sed -i 's/^DB_USERNAME=.*/DB_USERNAME=crm_app_user/; s/^DB_PASSWORD=.*/DB_PASSWORD=<APP_DB_PASS>/' .env && \
grep -E '^DB_USERNAME=' .env"
```
Expected: `DB_USERNAME=crm_app_user` (RLS будет enforce'иться в рантайме).
- [ ] **Step 7: Права и кэши**
```bash
ssh ... 'cd /var/www/liderra/app \
&& sudo chown -R deploy:www-data storage bootstrap/cache \
&& sudo chmod -R 775 storage bootstrap/cache \
&& php artisan config:cache && php artisan route:cache && php artisan view:cache'
```
Expected: кэши собраны, прав хватает.
---
## Фаза 4 — Веб, HTTPS, дверь (🤖 + 🧑 DNS)
### Task 4.1: DNS A-запись (🧑)
- [ ] **Step 1:** В панели домена создать запись `A` для `<DOMAIN>``<SERVER_IP>`.
- [ ] **Step 2 (🤖): Проверить распространение**
Run: `ssh ... "getent hosts <DOMAIN> || nslookup <DOMAIN>"`
Expected: резолвится в `<SERVER_IP>` (может занять до 30–60 мин).
### Task 4.2: nginx vhost (HTTP)
- [ ] **Step 1: Конфиг сайта**
```bash
ssh ... 'sudo tee /etc/nginx/sites-available/liderra <<EOF
server {
listen 80;
server_name <DOMAIN>;
root /var/www/liderra/app/public;
index index.php;
# дверь на весь сайт (Basic Auth), кроме webhook поставщика
location / {
auth_basic "Liderra test";
auth_basic_user_file /etc/nginx/.htpasswd;
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ^~ /api/webhook/ {
auth_basic off;
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/liderra /etc/nginx/sites-enabled/liderra
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx'
```
Expected: `nginx -t` syntax ok; reload без ошибок.
> NB: точный префикс webhook (`/api/webhook/`) сверить с `app/routes/api.php` (grep `webhook`). Если иной — поправить `location ^~`.
- [ ] **Step 2: Создать пароль двери**
```bash
ssh ... "sudo htpasswd -bc /etc/nginx/.htpasswd <BASIC_USER> <BASIC_PASS>"
```
Expected: `.htpasswd` создан.
- [ ] **Step 3: Проверка по HTTP**
Run: `ssh ... "curl -s -o /dev/null -w '%{http_code}' -u <BASIC_USER>:<BASIC_PASS> http://<DOMAIN>/"`
Expected: `200` (или `302` на /login). Без креда → `401`.
### Task 4.3: HTTPS (Let's Encrypt)
- [ ] **Step 1: Выпустить сертификат**
```bash
ssh ... "sudo certbot --nginx -d <DOMAIN> --non-interactive --agree-tos -m <EMAIL> --redirect"
```
Expected: сертификат выпущен, nginx переписан на 443 + редирект с 80.
- [ ] **Step 2: Проверить HTTPS + авто-продление**
Run: `ssh ... "curl -sI -u <BASIC_USER>:<BASIC_PASS> https://<DOMAIN>/ | head -1; sudo certbot renew --dry-run 2>&1 | tail -1"`
Expected: `HTTP/2 200|302`; dry-run `Congratulations` / success.
---
## Фаза 5 — Фоновые службы (🤖)
### Task 5.1: queue worker как systemd-служба
- [ ] **Step 1: Юнит**
```bash
ssh ... 'sudo tee /etc/systemd/system/liderra-queue.service <<EOF
[Unit]
Description=Liderra queue worker
After=redis-server.service postgresql.service
[Service]
User=deploy
Restart=always
WorkingDirectory=/var/www/liderra/app
ExecStart=/usr/bin/php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now liderra-queue'
```
Run: `ssh ... "systemctl is-active liderra-queue"`
Expected: `active`.
### Task 5.2: scheduler (cron)
- [ ] **Step 1: Cron-запись**
```bash
ssh ... '( crontab -l 2>/dev/null; echo "* * * * * cd /var/www/liderra/app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1" ) | crontab -'
```
Run: `ssh ... "crontab -l | grep schedule:run"`
Expected: строка присутствует.
---
## Фаза 6 — Приёмка и сопровождение (🤖)
### Task 6.1: Проверка критериев готовности (DoD)
- [ ] **Step 1: HTTPS + замочек**
Открыть `https://<DOMAIN>` в браузере (с логином двери) → валидный сертификат, портал грузится.
- [ ] **Step 2: Дверь работает**
Run: `ssh ... "curl -s -o /dev/null -w '%{http_code}' https://<DOMAIN>/"``401` (без креда).
- [ ] **Step 3: Вход + данные**
В браузере: `admin@demo.local` / `password` → видно 4 демо-проекта.
- [ ] **Step 4: Изоляция компаний (RLS)**
Войти `manager1@demo.local` / `password` → видна только своя компания (чужих проектов нет). Если падает SQL — зафиксировать, чинить (риск из спеки §5.4).
- [ ] **Step 5: Админка**
Открыть `/admin/...` под админом → не 503 (флаг bypass работает).
- [ ] **Step 6: Службы переживают перезагрузку**
```bash
ssh ... "sudo reboot" # подождать ~40с
ssh ... "systemctl is-active nginx php8.3-fpm postgresql redis-server liderra-queue"
```
Expected: все `active`; сайт снова открывается.
### Task 6.2: Скрипт обновления + инструкция
**Files:** `/var/www/liderra/deploy.sh` (на сервере), `docs/deploy/test-server-runbook.md` (в репо)
- [ ] **Step 1: deploy.sh**
```bash
ssh ... 'cat > /var/www/liderra/deploy.sh <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/liderra
git pull
cd app
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo systemctl restart php8.3-fpm liderra-queue
echo "Deployed: \$(git -C /var/www/liderra log -1 --oneline)"
EOF
chmod +x /var/www/liderra/deploy.sh'
```
> Фронтенд при обновлении: пересобрать на dev (`npm --prefix app run build`) и `scp` build на сервер ПЕРЕД запуском deploy.sh.
- [ ] **Step 2: Runbook**
Создать `docs/deploy/test-server-runbook.md`: адрес, доступы (где лежат пароли), команда обновления, как остановить/удалить VM (прекратить оплату), напоминание убрать `SAAS_ADMIN_TEST_BYPASS` при переходе к настоящему SSO.
- [ ] **Step 3: Commit runbook**
```bash
git add docs/deploy/test-server-runbook.md
git commit -m "docs(deploy): test-server runbook"
```
---
## Открытые вопросы (заполнить при исполнении)
- `<DOMAIN>` и панель управления доменом — от заказчика.
- Точный admin-маршрут для теста (Task 0.2) и префикс webhook (Task 4.2) — grep по коду.
- Точные seed-шаги демо-учёток (Task 3.5) — по ЭТАЛОН §4.
- Пароли БД-ролей (`<APP_DB_PASS>`, `<ADMIN_DB_PASS>`, `<MIGRATOR_DB_PASS>`, `<AUDIT_DB_PASS>`, `<WORKER_DB_PASS>`) + дверь сайта (`<BASIC_PASS>`) — сгенерировать (Task 3.1 Step 1), сохранить в безопасном месте (не в git; занести в runbook-ссылку на хранилище).
- `pg_hba.conf` путь зависит от версии PG (`/etc/postgresql/16/main/`) — сверить на сервере.

Some files were not shown because too many files have changed in this diff Show More