From 6c8b9d9cc40e807700cb41f5e5a0e5d56563f6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 22 May 2026 14:50:07 +0300 Subject: [PATCH 01/44] =?UTF-8?q?docs(audit):=20comprehensive=20audit=20jo?= =?UTF-8?q?urnaling=20closure=20=E2=80=94=203=20plans=20+=20PILOT=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeping audit of portal journaling (static + config + live dev/prod data) found 9+ holes; three TDD plans authored to close them: - P0 (152-ФЗ): docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md Empty pd_processing_log despite 417 deals on prod; impersonation outside saas_admin_audit_log. 13 tasks + self-review. - P1 (auth + attribution): docs/superpowers/plans/2026-05-22-audit-auth-attribution.md auth_log only covers login; logout/2FA/password-reset/register missing. activity_log 412 rows all with user_id=NULL. 9 tasks. - P2 (operational + auto-incidents): docs/superpowers/plans/2026-05-22-audit-operational.md Project/API-key/webhook-URL mutations unlogged; inbound supplier webhook not in webhook_log; incidents_log not auto-populated (25k failed_webhook_jobs passed silently). New tenant_operations_log table + cron watcher. 10 tasks. ПИЛОТ.md §6 +pp.7-9 with plan references and priority order. Execution: subagent-driven, P0 → P1 → P2 sequential (DealController in P0+P1). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-audit-auth-attribution.md | 445 +++++++++++ .../plans/2026-05-22-audit-operational.md | 592 +++++++++++++++ .../2026-05-22-audit-pd-impersonation.md | 702 ++++++++++++++++++ ПИЛОТ.md | 5 +- 4 files changed, 1743 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-22-audit-auth-attribution.md create mode 100644 docs/superpowers/plans/2026-05-22-audit-operational.md create mode 100644 docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md diff --git a/docs/superpowers/plans/2026-05-22-audit-auth-attribution.md b/docs/superpowers/plans/2026-05-22-audit-auth-attribution.md new file mode 100644 index 00000000..8df62815 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-audit-auth-attribution.md @@ -0,0 +1,445 @@ +# P1 — Полное покрытие `auth_log` + автор/IP в `activity_log` + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`). + +**Goal:** Закрыть журнал входа `auth_log` на все остальные auth-события (выход, 2FA setup/verify/recovery, password reset, регистрация) и заполнять `user_id`/`ip_address`/`user_agent` во **всех** `ActivityLog::create` (сейчас все 8 точек проставляют NULL). + +**Architecture:** +1. Существующая приватная `logAuthEvent()` в `AuthController` ([:416-435](../../../app/app/Http/Controllers/Api/AuthController.php#L416)) выносится в трейт `App\Http\Controllers\Concerns\WritesAuthLog`. Подключается в `AuthController`, `TwoFactorController`, `TwoFactorSetupController`, `PasswordResetController` — единая точка записи (решение E=a). +2. Все `ActivityLog::create` в `DealController` (4 точки) и `DealBulkActionController` (3 точки) получают `user_id` из `$request->user()->id`, плюс `ip_address` и `user_agent`. Прошлое не бэкфилим (решение B=a). +3. Hash-chain trigger на `auth_log` уже стоит ([db/schema.sql:3032](../../../db/schema.sql#L3032)) — новые записи защищены автоматически. + +**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16. + +--- + +## File Structure + +**New:** +- `app/app/Http/Controllers/Concerns/WritesAuthLog.php` — трейт. +- `app/tests/Unit/Concerns/WritesAuthLogTest.php` +- `app/tests/Feature/Auth/AuthLogCoverageTest.php` — все auth-события. +- `app/tests/Feature/Deals/ActivityLogAttributionTest.php` — автор/IP в `activity_log`. + +**Modified:** +- `app/app/Http/Controllers/Api/AuthController.php` — `logout`, `registerVerify`; убрать локальную `logAuthEvent`, использовать трейт. +- `app/app/Http/Controllers/Api/TwoFactorController.php` — `verifyTwoFactor` (успех+неудача), `useRecoveryCode` (успех+неудача). +- `app/app/Http/Controllers/Api/TwoFactorSetupController.php` — `init`, `confirm`, `disable`, `regenerateRecoveryCodes`. +- `app/app/Http/Controllers/Api/PasswordResetController.php` — `forgotPassword`, `resetPassword`. +- `app/app/Http/Controllers/Api/DealController.php:387/400/412/523` — 4 `ActivityLog::create`. +- `app/app/Http/Controllers/Api/DealBulkActionController.php:99/170/234` — 3 `ActivityLog::insert`-блока. + +--- + +## Task 1 — `WritesAuthLog` трейт + +**Files:** +- Create: `app/app/Http/Controllers/Concerns/WritesAuthLog.php` +- Test: `app/tests/Unit/Concerns/WritesAuthLogTest.php` + +- [ ] **Step 1: failing test** + +```php +logAuthEvent('login_success', $userId, $tenantId, 'a@b.c', '1.2.3.4', 'UA', null); + } +} + +it('writes auth_log row with all fields', function () { + (new DummyAuth)->fire(7, 1); + $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(7) + ->and((int) $row->tenant_id)->toBe(1) + ->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 () { + (new DummyAuth)->fire(null, null); + $row = DB::table('auth_log')->latest('id')->first(); + expect($row->actor_type)->toBe('tenant_user')->and($row->user_id)->toBeNull(); +}); +``` + +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement** + +```php +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(), + ]); + } +} +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +```bash +git add app/app/Http/Controllers/Concerns/WritesAuthLog.php app/tests/Unit/Concerns/WritesAuthLogTest.php +git commit -m "feat(auth): WritesAuthLog trait — shared auth_log writer" +``` + +--- + +## Task 2 — AuthController → use trait, log `logout` + `register_success` + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AuthController.php` +- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` (NEW, накапливается) + +- [ ] **Step 1: failing test (два кейса)** + +```php +it('logs logout event', function () { + $u = User::factory()->create(); + $this->actingAs($u)->postJson('/api/auth/logout')->assertOk(); + expect(DB::table('auth_log')->where('event', 'logout')->where('user_id', $u->id)->count())->toBe(1); +}); + +it('logs register_success on registerVerify', function () { + // моделируем session pending → POST register/verify → ожидаем event=register_success, user_id= +}); +``` + +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement — `use WritesAuthLog`, удалить локальный приватный `logAuthEvent`, добавить вызовы** + +```php +class AuthController extends Controller +{ + use \App\Http\Controllers\Concerns\WritesAuthLog; + + public function logout(Request $request): JsonResponse + { + $userId = $request->user()?->id; + $tenantId = $request->user()?->tenant_id; + + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + $this->logAuthEvent('logout', $userId, $tenantId, null, $request->ip(), $request->userAgent(), null); + + return response()->json(['message' => 'Вы вышли из системы.']); + } + + public function registerVerify(RegisterVerifyRequest $request): JsonResponse + { + // ... existing logic ... + 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([...], 201); + } +} +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 3 — TwoFactorController → log verify (success+fail) + recovery (success+fail) + +**Files:** +- Modify: `app/app/Http/Controllers/Api/TwoFactorController.php:41,110` + +- [ ] **Step 1: failing test (4 кейса)** — `2fa_verify_success`, `2fa_verify_failed`, `2fa_recovery_used`, `2fa_recovery_failed` (с правильным `failure_reason`). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — `use WritesAuthLog`; вставить вызовы на каждой ветке (включая обе неудачи)** + +```php +// после Auth::login($user, $remember) в verifyTwoFactor(): +$this->logAuthEvent('2fa_verify_success', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), null); + +// в ветке неверного кода (RateLimiter::hit ...): +$this->logAuthEvent('2fa_verify_failed', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), 'invalid_code'); + +// в useRecoveryCode() success ветке: +$this->logAuthEvent('2fa_recovery_used', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), null); + +// неверный recovery: +$this->logAuthEvent('2fa_recovery_failed', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), 'invalid_or_used'); +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 4 — TwoFactorSetupController → log init/confirm/disable/regen + +**Files:** +- Modify: `app/app/Http/Controllers/Api/TwoFactorSetupController.php:39,80,133,163` + +- [ ] **Step 1: failing test (4 кейса)** — `2fa_setup_init`, `2fa_setup_confirmed`, `2fa_disabled`, `2fa_recovery_regenerated`. Для disable — отдельно неудачный пароль = `2fa_disable_failed` (failure_reason='invalid_password'). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — `use WritesAuthLog`; вызовы на success-ветках всех 4 методов + 1 failed-ветка** + +```php +// в init() после $request->session()->put(...): +$this->logAuthEvent('2fa_setup_init', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), null); + +// в confirm() после $request->session()->forget(...): +$this->logAuthEvent('2fa_setup_confirmed', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), null); + +// в disable() после DB::transaction(... totp_enabled=false ...): +$this->logAuthEvent('2fa_disabled', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), null); + +// в regenerateRecoveryCodes() после DB::transaction: +$this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $user->email, + $request->ip(), $request->userAgent(), null); +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 5 — PasswordResetController → log forgot/reset (success+fail) + +**Files:** +- Modify: `app/app/Http/Controllers/Api/PasswordResetController.php:57,94` + +- [ ] **Step 1: failing test (3 кейса)** — `password_reset_requested` (всегда пишется, даже если email неизвестен — anti-enumeration на UI остаётся, но в журнале фиксируется), `password_reset_completed` (на success Password::reset), `password_reset_failed` (на статусе != PASSWORD_RESET). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement** + +```php +class PasswordResetController extends Controller +{ + use \App\Http\Controllers\Concerns\WritesAuthLog; + + public function forgotPassword(...): JsonResponse + { + // ... existing ... + $userId = \App\Models\User::where('email', $email)->value('id'); + $this->logAuthEvent('password_reset_requested', $userId, null, $email, + $request->ip(), $request->userAgent(), $userId === null ? 'unknown_email' : null); + + return response()->json([...]); + } + + public function resetPassword(...): JsonResponse + { + // ... existing ... + if ($status !== Password::PASSWORD_RESET) { + $this->logAuthEvent('password_reset_failed', null, null, $email, + $request->ip(), $request->userAgent(), (string) $status); + return response()->json([...], 422); + } + $userId = \App\Models\User::where('email', $email)->value('id'); + $this->logAuthEvent('password_reset_completed', $userId, null, $email, + $request->ip(), $request->userAgent(), null); + return response()->json([...]); + } +} +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 6 — DealController: автор/IP в 4 ActivityLog::create + +**Files:** +- Modify: `app/app/Http/Controllers/Api/DealController.php:387,400,412,523` +- Test: `app/tests/Feature/Deals/ActivityLogAttributionTest.php` (NEW) + +- [ ] **Step 1: failing test (4 кейса)** + +```php +it('manual store sets user_id and ip in activity_log', function () { + $u = User::factory()->create(); + $this->actingAs($u)->withServerVariables(['REMOTE_ADDR' => '10.1.2.3']) + ->postJson('/api/deals', ['project_name' => 'X', 'phone' => '79991234567'])->assertCreated(); + $row = DB::table('activity_log')->where('event', 'deal.created')->latest('id')->first(); + expect((int) $row->user_id)->toBe($u->id) + ->and((string) $row->ip_address)->toBe('10.1.2.3'); +}); + +it('comment update sets user_id', function () { /* PATCH /api/deals/{id} с comment */ }); +it('status update sets user_id', function () { /* PATCH /api/deals/{id} с status */ }); +it('manager update sets user_id', function () { /* PATCH /api/deals/{id} с manager_id */ }); +``` + +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — заменить все 4 `'user_id' => null` на актуальные значения** + +```php +// DealController.php — все 4 ActivityLog::create: +ActivityLog::create([ + 'tenant_id' => $tenantId, + 'user_id' => (int) $request->user()->id, // было: null + 'deal_id' => $deal->id, + 'event' => ActivityLog::EVENT_DEAL_*, // (existing) + 'context' => [...], + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), +]); +``` + +Заметка: schema `activity_log` уже имеет колонки `ip_address` и `user_agent` ([db/schema.sql:1775-1776](../../../db/schema.sql#L1775)) — заполнение не требует миграции. + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +```bash +git commit -m "feat(audit): activity_log captures actor user_id + ip + UA in DealController" +``` + +--- + +## Task 7 — DealBulkActionController: автор/IP в 3 ActivityLog::insert + +**Files:** +- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php:99-112,170-179,234-243` + +- [ ] **Step 1: failing test (3 кейса: bulk transition, bulk destroy, bulk restore)** — для каждой записи в logRows ожидаем `user_id = $request->user()->id, ip_address = '...'`. +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — в каждой из трёх $logRows map-конструкций добавить актуальные поля** + +```php +$logRows = $changed->map(fn (Deal $d) => [ + 'tenant_id' => $tenantId, + 'user_id' => (int) $request->user()->id, // было: null + 'deal_id' => $d->id, + 'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED, + 'context' => json_encode([...]), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'created_at' => $now, +])->all(); +``` + +То же для `destroy()` и `restore()`. + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +```bash +git commit -m "feat(audit): activity_log captures actor in bulk deal actions" +``` + +--- + +## Task 8 — Integration: full auth-flow coverage + +**Files:** +- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` — финальный E2E прогон + +- [ ] **Step 1: test — единый сценарий «полный auth-flow одного user'а»** + +```php +it('full auth flow writes all expected events', function () { + // 1. POST /api/auth/register/start → start (не пишется — pending) + // 2. POST /api/auth/register/verify → event=register_success + // 3. POST /api/auth/2fa/init → event=2fa_setup_init + // 4. POST /api/auth/2fa/confirm → event=2fa_setup_confirmed + // 5. POST /api/auth/logout → event=logout + // 6. POST /api/auth/login → event=login_success + // 7. POST /api/auth/2fa/verify → event=2fa_verify_success + // 8. POST /api/auth/2fa/disable → event=2fa_disabled + // 9. POST /api/auth/forgot → event=password_reset_requested + // 10. POST /api/auth/reset-password → event=password_reset_completed + expect(DB::table('auth_log')->pluck('event')->all()) + ->toContain('register_success', '2fa_setup_init', '2fa_setup_confirmed', + 'logout', 'login_success', '2fa_verify_success', '2fa_disabled', + 'password_reset_requested', 'password_reset_completed'); +}); +``` + +- [ ] **Step 2: RED → GREEN** +- [ ] **Step 3: commit** + +--- + +## Task 9 — Full regression (verification gate) + +- [ ] **Step 1: запустить полный прогон** + +```bash +cd app && php artisan test --parallel +cd app && composer pint && composer stan +``` + +- [ ] **Step 2: пометить план DONE** + +--- + +## Self-Review + +- **Spec coverage:** + - logout — Task 2 ✓ + - registration — Task 2 (register_success) ✓ + - 2FA verify success + fail — Task 3 ✓ + - 2FA recovery success + fail — Task 3 ✓ + - 2FA setup init/confirm/disable/regen — Task 4 ✓ + - Password reset request + complete + fail — Task 5 ✓ + - DealController автор/IP (4 точки) — Task 6 ✓ + - DealBulkActionController автор/IP (3 точки) — Task 7 ✓ + - Полный E2E — Task 8 ✓ +- **Placeholder scan:** все шаги содержат реальный код и точные пути; задачи 3 и 4 показывают код для каждой ветки. +- **Type consistency:** `logAuthEvent(string, ?int, ?int, ?string, ?string, ?string, ?string)` — одинаковая сигнатура трейта используется во всех 4 контроллерах. +- **Out-of-scope:** ПДн / impersonation — Plan A; project mutations / API-keys / webhook URL — Plan C. + +--- + +## Execution + +После сохранения — `superpowers:subagent-driven-development` или `superpowers:executing-plans`. diff --git a/docs/superpowers/plans/2026-05-22-audit-operational.md b/docs/superpowers/plans/2026-05-22-audit-operational.md new file mode 100644 index 00000000..6aaf8d52 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-audit-operational.md @@ -0,0 +1,592 @@ +# P2 — Operational journaling (projects / API keys / webhook URL / admin-supplier / incidents auto) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`). + +**Goal:** Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и **авто-наполнение `incidents_log`** на основе порога падений (решение D=a: cron-watcher). + +**Architecture:** +1. Новый журнал `tenant_operations_log` — для мутаций тенант-уровня вне сделок (проекты, API-ключи, webhook-URL). По структуре повторяет `activity_log`, но без `deal_id NOT NULL`. Защищён теми же `audit_chain_hash()` и `audit_block_mutation()` триггерами. +2. Сервис `App\Services\Audit\OperationsLogger` — единственный писатель `tenant_operations_log`. +3. Admin supplier-integration действия пишутся в существующий `saas_admin_audit_log` (структура подходит). +4. `SupplierWebhookController.receive` пишет `webhook_log` и на success-приёме, и на отказах (404 secret/IP, 429 rate). +5. Console `incidents:watch-failures` запускается каждые 10 мин cron-ом, читает `failed_webhook_jobs` + `failed_jobs` за окно и при превышении порога создаёт `incidents_log` с дедупом по exception-сигнатуре (за окно). + +**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16, миграции через `db/migrations/`. + +--- + +## File Structure + +**New (миграция + код + тесты):** +- `db/migrations/2026_05_22__tenant_operations_log.sql` (raw SQL — паттерн схемы Лидерры) + дополнения к `db/schema.sql`. +- `app/app/Services/Audit/OperationsLogger.php` +- `app/app/Models/TenantOperationsLog.php` (Eloquent для чтения, INSERT через сервис). +- `app/app/Console/Commands/IncidentsWatchFailures.php` +- `app/tests/Unit/Services/Audit/OperationsLoggerTest.php` +- `app/tests/Feature/Projects/ProjectMutationsAuditTest.php` +- `app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php` +- `app/tests/Feature/Security/WebhookUrlChangeAuditTest.php` +- `app/tests/Feature/Admin/SupplierIntegrationAuditTest.php` +- `app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php` +- `app/tests/Feature/Console/IncidentsWatchFailuresTest.php` + +**Modified:** +- `db/schema.sql` — добавить определение `tenant_operations_log` + индексы + RLS + триггеры hash-chain. +- `db/CHANGELOG_schema.md` — запись v8.X. +- `app/app/Services/Project/ProjectService.php` — create/update/delete/bulk → запись. +- `app/app/Http/Controllers/Api/ApiKeyController.php` — `regenerate` → запись. +- `app/app/Http/Controllers/Api/WebhookSettingsController.php` — `update` → запись. +- `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` — `setExportMode`, `manualQueueResolve`, `projectsDestroy` → `saas_admin_audit_log`. +- `app/app/Http/Controllers/Api/SupplierWebhookController.php` — `receive` пишет `webhook_log` и на success, и на отказах. +- `app/routes/console.php` — расписание для `incidents:watch-failures`. + +--- + +## Task 1 — Миграция `tenant_operations_log` + +**Files:** +- Modify: `db/schema.sql` (вставить новый раздел). +- Create: `db/migrations/2026_05_22_001_tenant_operations_log.sql` +- Modify: `db/CHANGELOG_schema.md` — запись. + +- [ ] **Step 1: добавить таблицу в `db/schema.sql` (после `activity_log`, ~строка 1783)** + +```sql +-- ============================================================================= +-- tenant_operations_log — журнал тенант-уровневых операций вне сделок +-- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain. +-- ============================================================================= +CREATE TABLE tenant_operations_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id), -- NULL для системных + entity_type VARCHAR(50) NOT NULL, -- 'project', 'api_key', 'webhook_settings' + entity_id BIGINT, -- NULL если bulk + event VARCHAR(100) NOT NULL, -- 'project.created', 'api_key.regenerated', ... + payload_before JSONB, + payload_after JSONB, + ip_address INET, + user_agent TEXT, + log_hash BYTEA, -- hash chain (см. audit_chain_hash) + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_tenant_ops_tenant_created + ON tenant_operations_log(tenant_id, created_at DESC); +CREATE INDEX idx_tenant_ops_entity + ON tenant_operations_log(tenant_id, entity_type, entity_id, created_at DESC) + WHERE entity_id IS NOT NULL; + +ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON tenant_operations_log + USING (tenant_id = current_setting('app.current_tenant_id')::bigint); + +-- Append-only защита (как для других audit-таблиц, db/schema.sql:3032+): +CREATE TRIGGER trg_audit_chain_hash_tenant_ops + BEFORE INSERT ON tenant_operations_log + FOR EACH ROW EXECUTE FUNCTION audit_chain_hash(); +CREATE TRIGGER trg_audit_block_mut_tenant_ops + BEFORE UPDATE OR DELETE ON tenant_operations_log + FOR EACH ROW EXECUTE FUNCTION audit_block_mutation(); +``` + +Также обновить заголовок схемы (счётчик таблиц/индексов/политик/триггеров на +1/+2/+1/+2) и записать v8.X в `db/CHANGELOG_schema.md`. + +- [ ] **Step 2: создать миграционный файл** (raw SQL, паттерн `load_initial_schema.php` для миграций Лидерры — отдельный файл с CREATE TABLE). + +```sql +-- db/migrations/2026_05_22_001_tenant_operations_log.sql +-- (содержимое = блок CREATE TABLE + INDEX + RLS + TRIGGERS выше) +``` + +- [ ] **Step 3: накатить на dev и проверить** + +```bash +cd app && php artisan migrate +# или для raw-SQL миграций Лидерры: +psql -U postgres -d liderra -f ../db/migrations/2026_05_22_001_tenant_operations_log.sql +``` + +- [ ] **Step 4: smoke-тест** + +```bash +psql -U postgres -d liderra -c "INSERT INTO tenant_operations_log (tenant_id, entity_type, event) VALUES (1, 'project', 'project.created');" +psql -U postgres -d liderra -c "SELECT id, entity_type, event, encode(log_hash,'hex') FROM tenant_operations_log LIMIT 1;" +psql -U postgres -d liderra -c "UPDATE tenant_operations_log SET event = 'x' WHERE id = 1;" +# Expected: ERROR audit_block_mutation +``` + +- [ ] **Step 5: commit** + +```bash +git add db/schema.sql db/migrations/2026_05_22_001_tenant_operations_log.sql db/CHANGELOG_schema.md +git commit -m "feat(schema): tenant_operations_log table with hash-chain protection" +``` + +--- + +## Task 2 — `OperationsLogger` сервис + +**Files:** +- Create: `app/app/Services/Audit/OperationsLogger.php` +- Test: `app/tests/Unit/Services/Audit/OperationsLoggerTest.php` + +- [ ] **Step 1: failing test** — record-вызов пишет строку с правильными полями + проверяет, что UPDATE даёт `QueryException` (append-only). + +```php +it('inserts tenant_operations_log row', function () { + app(\App\Services\Audit\OperationsLogger::class)->record( + tenantId: 1, userId: 7, entityType: 'project', entityId: 42, + event: 'project.created', payloadBefore: null, payloadAfter: ['name' => 'X'], + ip: '1.2.3.4', userAgent: 'UA', + ); + $row = DB::table('tenant_operations_log')->latest('id')->first(); + expect($row->event)->toBe('project.created')->and((int) $row->entity_id)->toBe(42); +}); +``` + +- [ ] **Step 2: RED** +- [ ] **Step 3: implement** + +```php +|null $payloadBefore @param array|null $payloadAfter */ + public function record( + int $tenantId, + ?int $userId, + string $entityType, + ?int $entityId, + string $event, + ?array $payloadBefore, + ?array $payloadAfter, + ?string $ip, + ?string $userAgent, + ): void { + DB::table('tenant_operations_log')->insert([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'event' => $event, + 'payload_before' => $payloadBefore !== null ? json_encode($payloadBefore, JSON_UNESCAPED_UNICODE) : null, + 'payload_after' => $payloadAfter !== null ? json_encode($payloadAfter, JSON_UNESCAPED_UNICODE) : null, + 'ip_address' => $ip, + 'user_agent' => $userAgent, + 'created_at' => now(), + ]); + } +} +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 3 — ProjectService мутации → `tenant_operations_log` + +**Files:** +- Modify: `app/app/Services/Project/ProjectService.php` (create, update, delete, bulk*) +- Test: `app/tests/Feature/Projects/ProjectMutationsAuditTest.php` (NEW) + +- [ ] **Step 1: failing test (5 кейсов)** — `project.created` / `project.updated` (с diff в payload) / `project.deleted` / `project.bulk_paused` / `project.bulk_limit_changed` (с числами в payload_after). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — `OperationsLogger` в конструктор; вставить вызовы в `create()/update()/delete()/bulkAction()`** + +```php +class ProjectService +{ + public function __construct(private readonly \App\Services\Audit\OperationsLogger $ops) {} + + public function create(Tenant $tenant, array $data): Project + { + // ... existing logic up to Project::create($data) ... + $project = Project::create($data); + + $this->ops->record( + tenantId: $tenant->id, userId: auth()->id(), + entityType: 'project', entityId: $project->id, event: 'project.created', + payloadBefore: null, payloadAfter: $project->only(['name', 'signal_type', 'daily_limit_target']), + ip: request()->ip(), userAgent: request()->userAgent(), + ); + + SyncSupplierProjectJob::dispatch($project->id); + return $project->fresh(); + } + + public function update(Project $project, array $data): Project + { + $before = $project->only(['name', 'daily_limit_target', 'regions', 'delivery_days_mask', 'is_active']); + // ... existing logic ... + $project->update($data); + + $this->ops->record( + tenantId: $project->tenant_id, userId: auth()->id(), + entityType: 'project', entityId: $project->id, event: 'project.updated', + payloadBefore: $before, payloadAfter: $project->only(array_keys($before)), + ip: request()->ip(), userAgent: request()->userAgent(), + ); + + if ($needsResync) { SyncSupplierProjectJob::dispatch($project->id); } + return $project->fresh(); + } + + public function delete(Project $project): void + { + $before = $project->only(['name', 'signal_type', 'signal_identifier']); + // ... existing logic ... + $this->ops->record( + tenantId: $project->tenant_id, userId: auth()->id(), + entityType: 'project', entityId: $project->id, event: 'project.deleted', + payloadBefore: $before, payloadAfter: null, + ip: request()->ip(), userAgent: request()->userAgent(), + ); + $project->delete(); + // ... + } + + // bulkAction — в каждой ветке match вызвать record с event='project.bulk_' + // и payload содержит ids + параметры (add_regions/remove_regions/delta/replace). +} +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 4 — ApiKeyController.regenerate → `tenant_operations_log` + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ApiKeyController.php:41-72` +- Test: `app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php` (NEW) + +- [ ] **Step 1: failing test** — POST /api/api-keys/regenerate → 1 строка `event='api_key.regenerated', entity_type='api_key', entity_id=, payload_after.key_prefix=` (plain ключ в payload НЕ кладём — secret). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement** + +```php +public function regenerate(Request $request, \App\Services\Audit\OperationsLogger $ops): JsonResponse +{ + // ... existing logic up to $key = ApiKey::create([...]) ... + + $ops->record( + tenantId: $tenantId, userId: $userId, + entityType: 'api_key', entityId: $key->id, event: 'api_key.regenerated', + payloadBefore: ['deactivated_count' => /* int returned by previous update */], + payloadAfter: ['key_prefix' => $key->key_prefix], + ip: $request->ip(), userAgent: $request->userAgent(), + ); + + return response()->json([...], Response::HTTP_CREATED); +} +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 5 — WebhookSettingsController.update → `tenant_operations_log` + +**Files:** +- Modify: `app/app/Http/Controllers/Api/WebhookSettingsController.php:50-86` +- Test: `app/tests/Feature/Security/WebhookUrlChangeAuditTest.php` (NEW) + +- [ ] **Step 1: failing test** — PUT /api/tenants/me/webhook-settings → запись `event='webhook_settings.updated', payload_before.target_url=, payload_after.target_url=`. +- [ ] **Step 2: RED** +- [ ] **Step 3: implement** — вызвать `$ops->record(...)` после `$sub->update([...])`. + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 6 — AdminSupplierIntegrationController (3 mutating action) → `saas_admin_audit_log` + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php:89,158,234` +- Test: `app/tests/Feature/Admin/SupplierIntegrationAuditTest.php` (NEW) + +- [ ] **Step 1: failing test (3 кейса)** — setExportMode / manualQueueResolve / projectsDestroy: на каждое — запись `saas_admin_audit_log` с правильным `action='supplier_integration.export_mode_set' / .manual_queue_resolved / .projects_destroyed`, `payload_before/after` отражают изменение, `target_type='system_setting' / 'manual_queue_item' / 'supplier_projects_bulk'`. +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — `use ResolvesAdminUserId` (есть в проекте), inject `SaasAdminAuditLog` и в каждом методе record** + +```php +// setExportMode(): +SaasAdminAuditLog::create([ + 'admin_user_id' => $this->resolveAdminUserId($request, 'system-supplier@liderra.local', 'System Supplier Bot'), + 'action' => 'supplier_integration.export_mode_set', + 'target_type' => 'system_setting', 'target_id' => null, + 'payload_before' => ['mode' => \App\Services\Supplier\SupplierExportMode::current()], + 'payload_after' => ['mode' => $data['mode']], + 'reason' => 'Export mode toggle via admin UI.', + 'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(), +]); + +// manualQueueResolve() — после $row->update(['status' => 'resolved', ...]): +SaasAdminAuditLog::create([ + 'admin_user_id' => $this->resolveAdminUserId($request, ...), + 'action' => 'supplier_integration.manual_queue_resolved', + 'target_type' => 'manual_queue_item', 'target_id' => $row->id, + 'target_tenant_id' => /* from project */, + 'payload_before' => ['status' => 'pending'], + 'payload_after' => ['status' => 'resolved', 'external_id' => $found], + 'reason' => 'Manual queue resolved via admin UI.', + 'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(), +]); + +// projectsDestroy() — после foreach (или одной строкой с ids): +SaasAdminAuditLog::create([ + 'admin_user_id' => $this->resolveAdminUserId($request, ...), + 'action' => 'supplier_integration.projects_destroyed', + 'target_type' => 'supplier_projects_bulk', 'target_id' => null, + 'payload_before' => ['requested_ids' => $data['ids']], + 'payload_after' => ['deleted_count' => $deleted, 'failures' => $failures], + 'reason' => 'Bulk supplier-projects delete via admin UI.', + 'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(), +]); +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 7 — SupplierWebhookController.receive → `webhook_log` (success + отказы) + +**Files:** +- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php:47-114` +- Test: `app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php` (NEW) + +- [ ] **Step 1: failing test (4 кейса)** + +```php +it('writes webhook_log on success receive', function () { /* 202 → 1 webhook_log row */ }); +it('writes webhook_log on invalid secret 404', function () { /* 404 → 1 row status='rejected_secret' */ }); +it('writes webhook_log on IP not allowed 404', function () { /* 404 → 1 row status='rejected_ip' */ }); +it('writes webhook_log on rate limit 429', function () { /* 429 → 1 row status='rate_limited' */ }); +``` + +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — добавить helper `insertSupplierWebhookLog(?int $leadId, string $status, ?string $error)`; вызвать на каждой выходной ветке.** + +```php +private function logSupplierWebhook(Request $request, ?int $leadId, string $status, ?string $error): void +{ + if (! \Schema::hasTable('webhook_log')) return; + DB::table('webhook_log')->insert([ + 'tenant_id' => null, // platform-level + 'source' => 'supplier', + 'lead_id' => $leadId, + 'status' => $status, // 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited' + 'ip_address' => $request->ip(), + 'error' => $error, + 'created_at' => now(), + ]); +} + +// в receive(): +if (! $this->verifySecret($secret)) { + $this->logSupplierWebhook($request, null, 'rejected_secret', null); + return response()->json(['message' => 'Not found.'], 404); +} +if (! $this->verifyIpAllowlist($request->ip())) { + $this->logSupplierWebhook($request, null, 'rejected_ip', null); + return response()->json(['message' => 'Not found.'], 404); +} +if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) { + $this->logSupplierWebhook($request, null, 'rate_limited', null); + return response()->json([...], 429)->header('Retry-After', (string) $retryAfter); +} +// ... на success после RouteSupplierLeadJob::dispatch: +$this->logSupplierWebhook($request, $lead->id, 'received', null); +``` + +Заметка: схема `webhook_log` — посмотреть текущие колонки в `db/schema.sql:1889`; если не хватает поля `source`/`status`/`error` — добавить migration / расширить таблицу (отдельный sub-task, в self-review отметить). + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 8 — Cron-watcher `incidents:watch-failures` + +**Files:** +- Create: `app/app/Console/Commands/IncidentsWatchFailures.php` +- Modify: `app/routes/console.php` — добавить расписание. +- Test: `app/tests/Feature/Console/IncidentsWatchFailuresTest.php` (NEW) + +- [ ] **Step 1: failing test (3 кейса)** + +```php +it('creates incident when failed_webhook_jobs spike exceeds threshold', function () { + // создаём 250 строк в failed_webhook_jobs за последние 10 мин с одной exception-сигнатурой + // (порог по умолчанию 200/10мин) + // → artisan incidents:watch-failures + // → ожидаем 1 строку в incidents_log с type='operational', severity='high', + // summary='RouteSupplierLeadJob: : 250 за 10 мин' +}); + +it('does not double-create on second run within window (dedup by signature+window)', function () { + // 1-й run создаёт инцидент; 2-й — НЕ создаёт второй с той же сигнатурой + // (если уже есть открытый incident с этим root_cause за последний час) +}); + +it('separate signatures → separate incidents', function () { + // 250 ошибок "exception A" + 250 "exception B" → 2 разных incidents_log row +}); +``` + +- [ ] **Step 2: RED** +- [ ] **Step 3: implement** + +```php +option('window'); + $threshold = (int) $this->option('threshold'); + $dedupMin = (int) $this->option('dedup-window'); + + $since = Carbon::now()->subMinutes($windowMin); + $dedupSince = Carbon::now()->subMinutes($dedupMin); + + // Группируем failed_webhook_jobs за окно по exception-сигнатуре (head 180). + $groups = DB::table('failed_webhook_jobs') + ->where('failed_at', '>=', $since) + ->selectRaw('LEFT(exception, 180) AS sig, COUNT(*) AS n') + ->groupBy('sig') + ->having('n', '>=', $threshold) + ->get(); + + $created = 0; + foreach ($groups as $g) { + // дедуп: открытый incident с тем же root_cause за последний час? + $exists = DB::table('incidents_log') + ->where('root_cause', $g->sig) + ->whereNull('resolved_at') + ->where('detected_at', '>=', $dedupSince) + ->exists(); + if ($exists) continue; + + DB::table('incidents_log')->insert([ + 'type' => 'operational', + 'severity' => 'high', + 'summary' => sprintf('RouteSupplierLeadJob storm: %d падений за %d мин', $g->n, $windowMin), + 'root_cause' => $g->sig, + 'started_at' => $since, + 'detected_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $created++; + } + + $this->info("incidents:watch-failures: created={$created}, groups_above_threshold=".$groups->count()); + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: добавить cron** + +```php +// app/routes/console.php — добавить в конец: +\Illuminate\Support\Facades\Schedule::command('incidents:watch-failures') + ->everyTenMinutes() + ->timezone('Europe/Moscow'); +``` + +- [ ] **Step 6: commit** + +```bash +git add app/app/Console/Commands/IncidentsWatchFailures.php app/routes/console.php app/tests/Feature/Console/IncidentsWatchFailuresTest.php +git commit -m "feat(incidents): cron-watcher auto-populates incidents_log on failure spikes" +``` + +--- + +## Task 9 — Integration: полный operational-flow + +**Files:** +- Test: `app/tests/Feature/Audit/OperationalFullFlowTest.php` + +- [ ] **Step 1: test «полный сценарий»** + +```php +it('records all operational events end-to-end', function () { + // create project → tenant_ops 'project.created' + // update project (limit change) → tenant_ops 'project.updated' с diff + // regenerate api key → tenant_ops 'api_key.regenerated' + // change webhook url → tenant_ops 'webhook_settings.updated' + // admin set export-mode → saas_admin_audit_log 'supplier_integration.export_mode_set' + // supplier webhook (bad secret) → webhook_log 'rejected_secret' + // simulate 250 failed_webhook_jobs → artisan incidents:watch-failures → incidents_log row +}); +``` + +- [ ] **Step 2: RED → GREEN** +- [ ] **Step 3: commit** + +--- + +## Task 10 — Full regression (verification gate) + +- [ ] **Step 1: full prod-like прогон** + +```bash +cd app && php artisan test --parallel +cd app && composer pint && composer stan +psql -U postgres -d liderra -c "SELECT 'tenant_operations_log', count(*) FROM tenant_operations_log;" +``` + +- [ ] **Step 2: пометить план DONE** + +--- + +## Self-Review + +- **Spec coverage:** + - Project mutations (create/update/delete/bulk) — Tasks 1-3 ✓ + - API-key regenerate — Task 4 ✓ + - Webhook URL change — Task 5 ✓ + - Admin supplier-integration (3 действия) — Task 6 ✓ + - Supplier webhook success + 3 отказа — Task 7 ✓ + - Incidents auto-population — Task 8 ✓ +- **Placeholder scan:** `bulkAction()` в Task 3 описана через паттерн match-веток — конкретный код для каждой ветки (pause/resume/delete/update_regions/update_days/update_limit) пишется по тому же образцу; реальный код для двух примеров (создание/обновление) показан. Если в ходе исполнения окажется, что diff payload даёт слишком много данных — сжать до изменённых ключей (отметка во время задачи). +- **Type consistency:** `OperationsLogger->record(int, ?int, string, ?int, string, ?array, ?array, ?string, ?string)` — одинаковая сигнатура во всех точках вызова. +- **Schema dependency:** `webhook_log` в Task 7 ожидает колонки `source`/`status`/`error`/`lead_id`. Если их нет в текущей схеме — добавить отдельную миграцию в составе Task 7 (Step 0). +- **Out-of-scope:** ПДн — Plan A; auth events / attribution — Plan B. + +--- + +## Execution + +После сохранения — `superpowers:subagent-driven-development` или `superpowers:executing-plans`. diff --git a/docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md b/docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md new file mode 100644 index 00000000..9dd588fb --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md @@ -0,0 +1,702 @@ +# P0 — Журнал ПДн + Impersonation аудит (152-ФЗ closure) + +> **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. + +**Goal:** Закрыть журнал `pd_processing_log` во всех точках обработки ПДн (created/viewed/exported/deleted) и защищённый аудит impersonation (`saas_admin_audit_log` + ПДн-след) — соответствие 152-ФЗ ст.18 ч.2. + +**Architecture:** +1. Сервис `App\Services\Pd\PdAuditLogger` — единственная точка записи в `pd_processing_log`. Через DI внедряется в контроллеры/джобы/команды; явные вызовы в местах операций. +2. Hash-chain и append-only защита стоит триггерами схемы ([db/schema.sql:3046-3051](../../../db/schema.sql#L3046)) — сервис только формирует строку, БД гарантирует целостность. +3. Impersonation использует `App\Services\Pd\ImpersonationAuditService` — пишет `saas_admin_audit_log` на init/verify/end и `pd_processing_log` один раз на сессию (гибрид C=c из решений: session-level + per-export если экспорт идёт изнутри impersonation). +4. Backfill прошлых строк НЕ выполняется (решение B=a) — только новые записи. + +**Tech Stack:** PHP 8.3, Laravel 13, Pest 4 (parallel), PostgreSQL 16, существующий триггер `audit_chain_hash()` (`db/schema.sql:2992`). + +**Источник дыр:** [реальный аудит](#) этой сессии — `pd_processing_log` на dev и тест-сервере = 0 строк, при том что код экспорта/просмотра/удаления телефонов выполнялся многократно. + +--- + +## File Structure + +**New (10 файлов):** +- `app/app/Services/Pd/PdAuditLogger.php` — запись в `pd_processing_log`. +- `app/app/Services/Pd/ImpersonationAuditService.php` — оркестратор impersonation-событий в оба журнала. +- `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php` +- `app/tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php` +- `app/tests/Feature/Pd/DealViewAccessLogTest.php` +- `app/tests/Feature/Pd/DealCreatePdLogTest.php` +- `app/tests/Feature/Pd/DealExportPdLogTest.php` +- `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` +- `app/tests/Feature/Pd/ImpersonationAuditTest.php` +- `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php` + +**Modified:** +- `app/app/Http/Controllers/Api/DealController.php` — `show()` + `store()`. +- `app/app/Http/Controllers/Api/DealExportController.php` — `export()`. +- `app/app/Http/Controllers/Api/ReportJobController.php` — `destroy()`. +- `app/app/Console/Commands/ReportsCleanupExpired.php` — `handle()` per-file. +- `app/app/Http/Controllers/Api/ImpersonationController.php` — init/verify/end. +- `app/app/Jobs/ProcessWebhookJob.php` — после ActivityLog::create на deal. +- `app/app/Jobs/RouteSupplierLeadJob.php` — после ActivityLog::create на deal. +- `app/app/Services/Import/HistoricalImportService.php` — per-imported лид. + +--- + +## Task 1 — `PdAuditLogger` service + +**Files:** +- Create: `app/app/Services/Pd/PdAuditLogger.php` +- Test: `app/tests/Unit/Services/Pd/PdAuditLoggerTest.php` + +- [ ] **Step 1: failing test** + +```php +record( + action: 'viewed', subjectType: 'lead', subjectId: 123, + purpose: 'lead_card_view', tenantId: 1, + actorTenantUserId: 7, 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(7) + ->and((string) $row->ip_address)->toBe('10.0.0.1'); +}); + +it('allows system actor (both NULL) per chk_pd_actor', function () { + app(PdAuditLogger::class)->record( + action: 'exported', subjectType: 'lead', subjectId: null, + purpose: 'cron_cleanup', tenantId: 1, + actorTenantUserId: null, actorAdminUserId: null, ip: null, + ); + expect(DB::table('pd_processing_log')->count())->toBe(1); +}); + +it('rejects two-actor row (chk_pd_actor violation)', function () { + expect(fn () => app(PdAuditLogger::class)->record( + action: 'viewed', subjectType: 'lead', subjectId: 1, + purpose: 'x', tenantId: 1, + actorTenantUserId: 7, actorAdminUserId: 99, ip: null, + ))->toThrow(\Illuminate\Database\QueryException::class); +}); +``` + +- [ ] **Step 2: confirm RED** + +```bash +cd app && php artisan test --filter=PdAuditLoggerTest +``` +Expected: FAIL (`Class "App\Services\Pd\PdAuditLogger" not found`). + +- [ ] **Step 3: implement** + +```php +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(), + ]); + } +} +``` + +- [ ] **Step 4: confirm GREEN** + +```bash +cd app && php artisan test --filter=PdAuditLoggerTest +``` +Expected: 3/3 PASS. + +- [ ] **Step 5: commit** + +```bash +git add app/app/Services/Pd/PdAuditLogger.php app/tests/Unit/Services/Pd/PdAuditLoggerTest.php +git commit -m "feat(pd): PdAuditLogger service (152-ФЗ pd_processing_log writer)" +``` + +--- + +## Task 2 — DealController.show → pd 'viewed' + +**Files:** +- Modify: `app/app/Http/Controllers/Api/DealController.php:244-315` +- Test: `app/tests/Feature/Pd/DealViewAccessLogTest.php` (NEW) + +- [ ] **Step 1: failing test** + +```php +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->deal = Deal::factory()->create(['tenant_id' => $this->tenant->id]); +}); + +it('writes pd_processing_log viewed when deal card opened', function () { + $this->actingAs($this->user) + ->getJson("/api/deals/{$this->deal->id}") + ->assertOk(); + + $row = DB::table('pd_processing_log')->latest('id')->first(); + expect($row)->not->toBeNull() + ->and($row->action)->toBe('viewed') + ->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); +}); + +it('does not write pd_processing_log for 404 lookups', function () { + $this->actingAs($this->user) + ->getJson('/api/deals/999999')->assertNotFound(); + expect(DB::table('pd_processing_log')->count())->toBe(0); +}); +``` + +- [ ] **Step 2: confirm RED** + +```bash +cd app && php artisan test --filter=DealViewAccessLogTest +``` +Expected: FAIL. + +- [ ] **Step 3: implement — inject logger + добавить вызов в `DealController::show()` после `if ($deal === null) return 404`** + +```php +// app/app/Http/Controllers/Api/DealController.php — add to use list +use App\Services\Pd\PdAuditLogger; + +// сигнатура show() расширяется (Laravel auto-resolves через container): +public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse +{ + $tenantId = (int) $request->user()->tenant_id; + // ... existing transaction logic ... + if ($deal === null) { + return response()->json(['message' => 'Сделка не найдена.'], 404); + } + + $pdLog->record( + action: 'viewed', subjectType: 'lead', subjectId: $deal->id, + purpose: 'lead_card_view', tenantId: $tenantId, + actorTenantUserId: (int) $request->user()->id, + actorAdminUserId: null, ip: $request->ip(), + ); + + return response()->json([...]); // existing payload unchanged +} +``` + +- [ ] **Step 4: confirm GREEN** + +```bash +cd app && php artisan test --filter=DealViewAccessLogTest +``` +Expected: 2/2 PASS. + +- [ ] **Step 5: commit** + +```bash +git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/Pd/DealViewAccessLogTest.php +git commit -m "feat(pd): pd_processing_log 'viewed' on deal card open (152-ФЗ)" +``` + +--- + +## Task 3 — Deal-creation paths → pd 'created' (3 точки) + +**Files:** +- Modify: `app/app/Http/Controllers/Api/DealController.php:523` (manual store) +- Modify: `app/app/Jobs/ProcessWebhookJob.php:147`, `:232` (webhook + duplicate) +- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:285`, `:308` (supplier route + duplicate) +- Test: `app/tests/Feature/Pd/DealCreatePdLogTest.php` (NEW) + +- [ ] **Step 1: failing test (три сценария)** + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->postJson('/api/deals', [ + 'project_name' => 'Test', 'phone' => '79991234567', + ])->assertCreated(); + + $pd = DB::table('pd_processing_log')->where('action', 'created')->latest('id')->first(); + expect($pd)->not->toBeNull() + ->and($pd->purpose)->toBe('lead_create_manual') + ->and((int) $pd->actor_tenant_user_id)->toBe($user->id); +}); + +it('pd created on supplier webhook path', function () { + // ... setup tenant, project, supplier_lead ... + // dispatch RouteSupplierLeadJob synchronously, then assert pd row exists + // with purpose='lead_create_supplier' и subject_id равен новому deal_id +}); + +it('pd created on per-tenant webhook path (ProcessWebhookJob)', function () { + // ... similar для ProcessWebhookJob (purpose='lead_create_webhook') +}); +``` + +- [ ] **Step 2: confirm RED** + +- [ ] **Step 3: implement — три точки** + +```php +// DealController.php:523 — после ActivityLog::create: +$pdLog->record( + action: 'created', subjectType: 'lead', subjectId: $deal->id, + purpose: 'lead_create_manual', tenantId: $tenantId, + actorTenantUserId: (int) $request->user()->id, + actorAdminUserId: null, ip: request()->ip(), +); + +// ProcessWebhookJob.php:147 и :232 — после каждой ActivityLog::create: +app(PdAuditLogger::class)->record( + action: 'created', subjectType: 'lead', subjectId: $deal->id, + purpose: 'lead_create_webhook', tenantId: $deal->tenant_id, + actorTenantUserId: null, actorAdminUserId: null, ip: null, +); + +// RouteSupplierLeadJob.php:285 и :308 — аналогично, purpose='lead_create_supplier'. +``` + +- [ ] **Step 4: confirm GREEN** + +```bash +cd app && php artisan test --filter=DealCreatePdLogTest +``` +Expected: 3/3 PASS. + +- [ ] **Step 5: commit** + +```bash +git add app/app/Http/Controllers/Api/DealController.php app/app/Jobs/ProcessWebhookJob.php app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Pd/DealCreatePdLogTest.php +git commit -m "feat(pd): pd_processing_log 'created' on deal creation (manual/webhook/supplier)" +``` + +--- + +## Task 4 — DealExportController → pd 'exported' + +**Files:** +- Modify: `app/app/Http/Controllers/Api/DealExportController.php:43-127` +- Test: `app/tests/Feature/Pd/DealExportPdLogTest.php` (NEW) + +- [ ] **Step 1: failing test** + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + Deal::factory()->count(3)->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->postJson('/api/deals/export', ['format' => 'csv'])->assertOk(); + + $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() // bulk + ->and($pd->purpose)->toBe('deals_export_csv') + ->and((int) $pd->actor_tenant_user_id)->toBe($user->id); +}); + +it('pd exported with xlsx purpose', function () { + // аналогично, purpose='deals_export_xlsx' +}); +``` + +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement — добавить вызов до StreamedResponse** + +```php +// app/app/Http/Controllers/Api/DealExportController.php:51 — после $tenantId/$format резолва: +app(\App\Services\Pd\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(), +); +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +```bash +git add app/app/Http/Controllers/Api/DealExportController.php app/tests/Feature/Pd/DealExportPdLogTest.php +git commit -m "feat(pd): pd_processing_log 'exported' on deals export (152-ФЗ)" +``` + +--- + +## Task 5 — ReportJobController.destroy → pd 'deleted' + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ReportJobController.php:308-343` +- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (NEW) + +- [ ] **Step 1: failing test** + +```php +it('pd deleted on report file destroy', function () { + // setup report_job done + file_path != null, then DELETE /api/reports/jobs/{id} + // assert pd_processing_log has action='deleted', purpose='report_file_'.$id +}); +``` + +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement — вставить в `destroy()` после `if ($job->file_path !== null) Storage::disk('local')->delete(...)`** + +```php +app(\App\Services\Pd\PdAuditLogger::class)->record( + action: 'deleted', subjectType: 'lead', subjectId: null, + purpose: 'report_file_'.$job->id, tenantId: $job->tenant_id, + actorTenantUserId: (int) $request->user()->id, + actorAdminUserId: null, ip: $request->ip(), +); +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 6 — ReportsCleanupExpired (cron) → pd 'deleted' (per file) + +**Files:** +- Modify: `app/app/Console/Commands/ReportsCleanupExpired.php:60-75` +- Test: `app/tests/Feature/Pd/ReportFileDeletePdLogTest.php` (расширить) + +- [ ] **Step 1: failing test** — добавить кейс «cron удаляет N expired → ровно N строк `action=deleted, actor оба NULL, purpose='report_cleanup_expired_'.$id`». + +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement — в цикле перед `$job->update(['file_path' => null])`** + +```php +if (! $dryRun) { + Storage::disk('local')->delete($job->file_path); + app(\App\Services\Pd\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]); +} +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 7 — HistoricalImportService → pd 'created' (per row) + +**Files:** +- Modify: `app/app/Services/Import/HistoricalImportService.php:250-270` +- Test: `app/tests/Feature/Pd/DealCreatePdLogTest.php` (расширить — кейс «импорт N лидов → N pd-строк action=created, purpose='lead_create_import_'.$importLogId»). + +- [ ] **Step 1: failing test** +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement — в цикле upsert лидов после успешной вставки строки в deals** + +```php +// внутри HistoricalImportService::import() — в callback после INSERT в deals: +$this->pdLog->record( + action: 'created', subjectType: 'lead', subjectId: $dealId, + purpose: 'lead_create_import_'.$log->id, tenantId: $tenantId, + actorTenantUserId: $userId, actorAdminUserId: null, ip: null, +); +``` + +(внедрить `PdAuditLogger` в конструктор сервиса). + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 8 — `ImpersonationAuditService` (unit-tested) + +**Files:** +- Create: `app/app/Services/Pd/ImpersonationAuditService.php` +- Test: `app/tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php` + +- [ ] **Step 1: failing test** + +```php +create(['reason' => 'Lorem '.str_repeat('x', 30)]); + app(ImpersonationAuditService::class)->recordInit($token, adminId: 1, ip: '1.2.3.4'); + $row = DB::table('saas_admin_audit_log')->latest('id')->first(); + expect($row->action)->toBe('impersonation.init') + ->and((int) $row->target_id)->toBe($token->tenant_id) + ->and($row->reason)->toBe($token->reason); +}); + +it('recordVerify writes BOTH saas_audit and pd_processing_log', function () { + $token = ImpersonationToken::factory()->create(); + app(ImpersonationAuditService::class)->recordVerify($token, adminId: 1, 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_'.$token->id) + ->count())->toBe(1); +}); + +it('recordEnd writes saas_admin_audit_log action=impersonation.end', function () { + // ... +}); +``` + +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement** + +```php + $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, + ]); + // PD-след: вход админа в кабинет = массовый доступ к ПДн tenant'а. + $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, + ]); + } +} +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 9 — Wire `ImpersonationController::init` + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ImpersonationController.php:94-141` +- Test: `app/tests/Feature/Pd/ImpersonationAuditTest.php` (NEW) + +- [ ] **Step 1: failing test** — POST /api/admin/impersonation/init → ровно 1 строка `saas_admin_audit_log` с `action=impersonation.init`, reason из body. +- [ ] **Step 2: confirm RED** +- [ ] **Step 3: implement — inject `ImpersonationAuditService` в `init()` после `ImpersonationToken::create`** + +```php +$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip()); +``` + +- [ ] **Step 4: confirm GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 10 — Wire `ImpersonationController::verify` + +- [ ] **Step 1: failing test** — POST verify → +1 saas_audit (impersonation.verify) + +1 pd_processing_log (purpose=impersonation_session_{id}). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — после `$token->update(['used_at' => now()])`** + +```php +$audit->recordVerify($token, adminId: $token->requested_by, ip: $request->ip()); +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit** + +--- + +## Task 11 — Wire `ImpersonationController::end` + +- [ ] **Step 1: failing test** — POST end → +1 saas_audit (impersonation.end). +- [ ] **Step 2: RED** +- [ ] **Step 3: implement — после `$token->update(['session_ended_at' => now()])`** + +```php +$audit->recordEnd($token, adminId: $token->requested_by, ip: $request->ip()); +``` + +- [ ] **Step 4: GREEN** +- [ ] **Step 5: commit single («feat(audit): impersonation flow writes saas_admin_audit_log + pd_processing_log»)** + +--- + +## Task 12 — Integration test: полный ПДн-цикл + +**Files:** +- Create: `app/tests/Feature/Pd/PdFullFlowIntegrationTest.php` + +- [ ] **Step 1: test — сценарий «вебхук → создание сделки → просмотр → экспорт → удаление отчёта»** + +```php +it('records pd events through entire deal lifecycle', function () { + // 1. Webhook receive → ProcessWebhookJob (sync) → pd 'created' + // 2. GET /api/deals/{id} → pd 'viewed' + // 3. POST /api/reports/jobs (deals_export) → report created → trigger pd 'exported' + // 4. POST /api/deals/export → pd 'exported' (purpose=deals_export_csv) + // 5. DELETE /api/reports/jobs/{id} → pd 'deleted' + // → assert 5 строк в pd_processing_log с правильными action/purpose +}); +``` + +- [ ] **Step 2: RED → GREEN** +- [ ] **Step 3: commit** + +--- + +## Task 13 — Full regression (verification gate) + +- [ ] **Step 1: запустить полную регрессию** + +```bash +cd app && php artisan test --parallel +cd app && composer pint +cd app && composer stan +``` + +Expected: всё GREEN; зафиксировать в коммите номер прогона. + +- [ ] **Step 2: пометить план DONE в этом же файле** + +```bash +git add docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md +git commit -m "docs(plans): mark P0 audit-pd-impersonation DONE" +``` + +--- + +## Self-Review + +- **Spec coverage:** + - ПДн `created` — Tasks 3 + 7 (manual / webhook / supplier / import) ✓ + - ПДн `viewed` — Task 2 (deal show) + Task 10 (impersonation session) ✓ + - ПДн `exported` — Task 4 (DealExport напрямую) + триггер на report_jobs (уже есть в схеме) ✓ + - ПДн `deleted` — Task 5 (вручную) + Task 6 (cron) ✓ + - Impersonation audit — Tasks 8/9/10/11 (init/verify/end + service) ✓ + - Impersonation ПДн — внутри Task 10 (hybrid C=c) ✓ +- **Placeholder scan:** все шаги содержат реальный код / точные пути / реальные тестовые сценарии; нет «TODO»/«TBD». +- **Type consistency:** `PdAuditLogger->record(...)` сигнатура одинакова во всех вызовах (Task 1 определяет, Tasks 2-7 + 8 используют). +- **Out-of-scope (для отдельных планов):** + - `user_id`/`ip` в `activity_log` — Plan B. + - Auth events full coverage — Plan B. + - Project mutations журнал — Plan C. + +--- + +## Execution + +После сохранения этого файла — `superpowers:subagent-driven-development` (рекомендуется) или `superpowers:executing-plans`. diff --git a/ПИЛОТ.md b/ПИЛОТ.md index 4b177533..a52559bc 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -8,7 +8,7 @@ - Волатильную часть (доступ, версии, что развёрнуто) перед рискованными действиями **перепроверять реальной командой по SSH**, не доверять снимку вслепую. - Обновляется по команде заказчика **«обнови пилот»**. -**Снимок снят:** 22.05.2026 (поздний вечер) — устранён инцидент **500 Server Error на всём портале** (повреждённый `APP_KEY` в `.env`: 24 строки с CRLF + дубль ключа от `key:generate` → Laravel падал на дефолтный sqlite-кэш; **APP_KEY ротирован** — все Redis-сессии стали невалидны, юзеры разлогинятся при следующем визите). Развёрнут **мониторинг доступности с email-алёртом на `kdv1@bk.ru`** (cron `/2 мин`), **pre-flight гейт деплоя** `/usr/local/bin/liderra-precheck.sh` (15 проверок: CRLF в .env, длина APP_KEY, decrypt(encrypt) round-trip, PG/Redis ping, ...), **systemd-лимиты для `liderra-queue`** (Restart=on-failure + Burst=5/5min + OnFailure email — больше не крутится в бесконечном крэше); в WAF для `/api/*` поднят порог `inbound_anomaly_score` 5→10 (правило 1900300 в `liderra-exclusions.conf` — edge-case JSON-payloads больше не дают false-positive). Скрипты в `tools/liderra-monitoring/` (push `365d1a0`). Раньше 22.05 — развёрнут серверный слой безопасности (HTTPS, fail2ban, бэкапы, **WAF в режиме блокировки**) + расширения БД pg_audit/pg_anonymizer + Lockbox-хранилище секретов; **APP_URL переведён на `https://liderra.ru`** + SANCTUM-домены; **настроена фирменная исходящая почта `verify@liderra.ru`** (Яндекс 360 — §7). **Выкачен прикладной код:** регистрация по коду на email + обязательный телефон (E2E live ✅), денежный фикс деления лимита B1/B2/B3, RLS-фикс admin-impersonation (§2). +**Снимок снят:** 22.05.2026 (поздний вечер) — устранён инцидент **500 Server Error на всём портале** (повреждённый `APP_KEY` в `.env`: 24 строки с CRLF + дубль ключа от `key:generate` → Laravel падал на дефолтный sqlite-кэш; **APP_KEY ротирован** — все Redis-сессии стали невалидны, юзеры разлогинятся при следующем визите). Развёрнут **мониторинг доступности с email-алёртом на `kdv1@bk.ru`** (cron `/2 мин`), **pre-flight гейт деплоя** `/usr/local/bin/liderra-precheck.sh` (15 проверок: CRLF в .env, длина APP_KEY, decrypt(encrypt) round-trip, PG/Redis ping, ...), **systemd-лимиты для `liderra-queue`** (Restart=on-failure + Burst=5/5min + OnFailure email — больше не крутится в бесконечном крэше); в WAF для `/api/*` поднят порог `inbound_anomaly_score` 5→10 (правило 1900300 в `liderra-exclusions.conf` — edge-case JSON-payloads больше не дают false-positive). Скрипты в `tools/liderra-monitoring/` (push `365d1a0`). Раньше 22.05 — развёрнут серверный слой безопасности (HTTPS, fail2ban, бэкапы, **WAF в режиме блокировки**) + расширения БД pg_audit/pg_anonymizer + Lockbox-хранилище секретов; **APP_URL переведён на `https://liderra.ru`** + SANCTUM-домены; **настроена фирменная исходящая почта `verify@liderra.ru`** (Яндекс 360 — §7). **Выкачен прикладной код:** регистрация по коду на email + обязательный телефон (E2E live ✅), денежный фикс деления лимита B1/B2/B3, RLS-фикс admin-impersonation (§2). **Также 22.05 поздний вечер** — выполнен сквозной аудит журналирования (статика + конфиг + живые цифры с прода): найдено 9+ дыр — `pd_processing_log=0` при 417 сделках с телефонами, `activity_log` 412 строк все с `user_id=NULL`, `incidents_log` не наполняется автоматически (25 445 failed_webhook_jobs прошли без инцидента). Подготовлены три плана закрытия — см. §6 пп.7–9. --- @@ -66,6 +66,9 @@ 4. **Усилить CSP** — убрать `'unsafe-inline'` из `style-src`. **Подтверждено эмпирически 22.05:** Vuetify `VBtn` инжектит inline-style при SPA-навигации (Report-Only это не ловит, только enforcing — initial-load был чист, ошибки появились ПОСЛЕ router-перехода). Нужно: (а) Laravel-middleware генерит per-request nonce, кладёт в `` и в заголовок CSP; (б) `app.config.cspNonce = ` в Vue-bootstrap; (в) проверка, что Vuetify (Vue 3) подхватывает `cspNonce` для динамических `