Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f1ccbf47 | |||
| 3f7c1e4069 | |||
| 9fa187780b | |||
| cf9c082af1 | |||
| b9f4f73311 | |||
| 9e749ef24b | |||
| f64c70501d | |||
| b7f65865b1 | |||
| 06df563ddf | |||
| c1e7384437 | |||
| d19842afb3 | |||
| ccd2419432 | |||
| b55faf79d2 | |||
| 8e910d024c | |||
| 640ee51520 | |||
| a575d55e9a | |||
| bc09186299 | |||
| 8e732fa855 | |||
| 79309c7595 | |||
| c4e6691b28 | |||
| 791bc1bfae | |||
| 25790f3f9d | |||
| 5d7d7af00c | |||
| d3b3a4f436 | |||
| e2b2bc7487 | |||
| 20e5752c68 | |||
| 38914fc779 | |||
| 09fa3b6a40 | |||
| c3e6ddbe22 | |||
| 9bf97efb0b | |||
| 4d37402bc7 | |||
| e605303e02 | |||
| ce65df27e2 | |||
| 218a6738fa | |||
| 61ee04d3e6 | |||
| c5d360fc59 | |||
| 365d1a0a93 | |||
| 000822d687 | |||
| 01bd9977b4 | |||
| 2f14169360 | |||
| 1cc1fc292a | |||
| b1ce3d1f36 | |||
| ded8e3758d | |||
| 391607cadd | |||
| d5b3406860 | |||
| 9fd8f35ca4 | |||
| ede7b97a4f | |||
| 9cabe8ded4 | |||
| 16edd922ed | |||
| 4772ae78ad | |||
| 9ae505b490 | |||
| 0374612444 | |||
| eeb76712eb | |||
| 0c9357af7a | |||
| 4c80a5823f | |||
| 029b19a091 | |||
| 4ff3d3ed1e | |||
| db287d19a8 | |||
| b32dfbcdc1 | |||
| 3657e18e16 | |||
| a1296707e0 | |||
| 8a8b860c61 | |||
| 351186cee9 | |||
| 438c066b8e | |||
| bce8789951 | |||
| 527a779d9b |
@@ -4,3 +4,23 @@
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?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,6 +5,7 @@ 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;
|
||||
@@ -69,6 +70,16 @@ 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++;
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -47,6 +48,8 @@ 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;
|
||||
|
||||
@@ -78,7 +81,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, $credentials['email'], $ip, $request->userAgent(),
|
||||
$this->logAuthEvent('login_failed', $user?->id, $user?->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
$user ? 'invalid_password' : 'unknown_email');
|
||||
$this->maybeNotifySuspiciousLogin($user, $ip);
|
||||
|
||||
@@ -90,7 +93,7 @@ class AuthController extends Controller
|
||||
|
||||
if (! $user->is_active) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
|
||||
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
'account_locked');
|
||||
|
||||
return response()->json([
|
||||
@@ -120,7 +123,7 @@ class AuthController extends Controller
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent('login_success', $user, $user->email, $ip, $request->userAgent(), null);
|
||||
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
@@ -152,6 +155,8 @@ 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,
|
||||
@@ -170,11 +175,17 @@ 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' => 'Вы вышли из системы.']);
|
||||
}
|
||||
|
||||
@@ -311,34 +322,6 @@ 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
|
||||
{
|
||||
|
||||
@@ -64,7 +64,7 @@ class DealBulkActionController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId) {
|
||||
$updated = DB::transaction(function () use ($validated, $tenantId, $request) {
|
||||
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' => null,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'deal_id' => $d->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => json_encode([
|
||||
@@ -106,6 +106,8 @@ class DealBulkActionController extends Controller
|
||||
'to' => $validated['status'],
|
||||
'source' => 'bulk',
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'created_at' => $now,
|
||||
])->all();
|
||||
|
||||
@@ -140,7 +142,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId) {
|
||||
$deleted = DB::transaction(function () use ($validated, $tenantId, $request) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
|
||||
@@ -169,10 +171,12 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'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);
|
||||
|
||||
@@ -202,7 +206,7 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId) {
|
||||
$restored = DB::transaction(function () use ($validated, $tenantId, $request) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// withTrashed обходит SoftDeletes global scope; whereNotNull —
|
||||
@@ -233,10 +237,12 @@ class DealBulkActionController extends Controller
|
||||
|
||||
$logRows = array_map(fn (int $id) => [
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'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,6 +10,7 @@ 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;
|
||||
@@ -241,7 +242,7 @@ class DealController extends Controller
|
||||
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
|
||||
* принадлежит tenant'у (или не существует) — 404.
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
@@ -274,6 +275,17 @@ 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,
|
||||
@@ -386,10 +398,12 @@ class DealController extends Controller
|
||||
$deal->comment = $validated['comment'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => request()->user()?->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => 'deal.commented',
|
||||
'context' => ['text' => $validated['comment'] ?? ''],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -399,10 +413,12 @@ class DealController extends Controller
|
||||
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => request()->user()?->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
|
||||
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -411,10 +427,12 @@ class DealController extends Controller
|
||||
$deal->status = $validated['status'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null,
|
||||
'user_id' => request()->user()?->id,
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -448,7 +466,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/deals — manual create */
|
||||
public function store(Request $request): JsonResponse
|
||||
public function store(Request $request, PdAuditLogger $pdLog): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_name' => 'required|string|max:255',
|
||||
@@ -522,15 +540,24 @@ class DealController extends Controller
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => null, // на prod — request()->user()->id
|
||||
'user_id' => 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,6 +6,7 @@ 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;
|
||||
@@ -55,6 +56,17 @@ 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,10 +39,20 @@ 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::query()
|
||||
$rows = ImpersonationToken::on(self::DB_CONNECTION)
|
||||
->whereNotNull('used_at')
|
||||
->whereNull('session_ended_at')
|
||||
->with(['tenant'])
|
||||
@@ -67,7 +77,7 @@ class ImpersonationController extends Controller
|
||||
/** GET /api/admin/impersonation/recent — последние 20 завершённых */
|
||||
public function recent(): JsonResponse
|
||||
{
|
||||
$rows = ImpersonationToken::query()
|
||||
$rows = ImpersonationToken::on(self::DB_CONNECTION)
|
||||
->whereNotNull('used_at')
|
||||
->whereNotNull('session_ended_at')
|
||||
->with(['tenant'])
|
||||
@@ -92,7 +102,7 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/init */
|
||||
public function init(Request $request): JsonResponse
|
||||
public function init(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->input('tenant_id');
|
||||
$requestedBy = (int) $request->input('requested_by'); // TODO: $request->user()->id когда saas-admin auth готов
|
||||
@@ -105,7 +115,7 @@ class ImpersonationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
$tenant = Tenant::on(self::DB_CONNECTION)->find($tenantId);
|
||||
if (! $tenant) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
@@ -113,7 +123,7 @@ class ImpersonationController extends Controller
|
||||
// 6-значный код. Числа от 100000 до 999999.
|
||||
$plainCode = (string) random_int(100_000, 999_999);
|
||||
|
||||
$token = ImpersonationToken::create([
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'requested_by' => $requestedBy,
|
||||
'code_hash' => Hash::make($plainCode),
|
||||
@@ -122,6 +132,8 @@ 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,
|
||||
@@ -141,12 +153,12 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/verify */
|
||||
public function verify(Request $request): JsonResponse
|
||||
public function verify(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$tokenId = (int) $request->input('token_id');
|
||||
$code = $request->string('code')->toString();
|
||||
|
||||
$token = ImpersonationToken::find($tokenId);
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||||
}
|
||||
@@ -164,12 +176,13 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
if (! Hash::check($code, $token->code_hash)) {
|
||||
DB::transaction(function () use ($token) {
|
||||
$token->increment('failed_attempts');
|
||||
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
|
||||
$token->update(['invalidated_at' => now()]);
|
||||
}
|
||||
});
|
||||
// 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()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Неверный код.',
|
||||
@@ -183,6 +196,8 @@ 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,
|
||||
@@ -192,11 +207,11 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/end */
|
||||
public function end(Request $request): JsonResponse
|
||||
public function end(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$tokenId = (int) $request->input('token_id');
|
||||
|
||||
$token = ImpersonationToken::find($tokenId);
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||||
}
|
||||
@@ -215,6 +230,8 @@ 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([
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -29,6 +30,8 @@ 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;
|
||||
|
||||
@@ -69,6 +72,17 @@ 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 зарегистрирован — мы отправили ссылку для сброса пароля.',
|
||||
@@ -120,12 +134,33 @@ 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,6 +8,7 @@ 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;
|
||||
@@ -305,12 +306,12 @@ class ReportJobController extends Controller
|
||||
/**
|
||||
* DELETE /api/reports/jobs/{id} — удалить terminal job + файл.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
public function destroy(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
return DB::transaction(function () use ($user, $id, $request, $pdLog): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$job = ReportJob::query()
|
||||
@@ -335,6 +336,16 @@ 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,6 +4,7 @@ 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;
|
||||
@@ -32,6 +33,8 @@ 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;
|
||||
|
||||
@@ -70,6 +73,16 @@ 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' => ['Неверный код.']],
|
||||
@@ -85,6 +98,16 @@ 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,
|
||||
@@ -151,6 +174,16 @@ 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' => ['Резервный код недействителен или уже использован.']],
|
||||
@@ -168,6 +201,16 @@ 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,6 +4,7 @@ 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;
|
||||
@@ -26,6 +27,8 @@ use PragmaRX\Google2FA\Google2FA;
|
||||
*/
|
||||
class TwoFactorSetupController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
private const RECOVERY_CODES_COUNT = 8;
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,9 @@ 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(
|
||||
@@ -121,6 +127,9 @@ 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 включена. Сохраните резервные коды — они показываются один раз.',
|
||||
@@ -139,6 +148,9 @@ 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' => ['Неверный пароль.']],
|
||||
@@ -154,6 +166,9 @@ 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 отключена.']);
|
||||
}
|
||||
|
||||
@@ -187,6 +202,9 @@ 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' => 'Резервные коды перегенерированы.',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -155,6 +156,12 @@ 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
|
||||
@@ -238,6 +245,12 @@ 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 и логирует —
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -91,7 +92,20 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
$lead = SupplierLead::findOrFail($this->supplierLeadId);
|
||||
$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;
|
||||
}
|
||||
|
||||
// Idempotency guard для retry-сценария ($tries = 3).
|
||||
// Если лид уже обработан — выходим, не создаём ghost duplicate'ы deal'ов.
|
||||
@@ -282,6 +296,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -304,6 +324,12 @@ 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);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -26,6 +27,7 @@ final class HistoricalImportService
|
||||
public function __construct(
|
||||
private readonly MonthlyPartitionManager $partitions,
|
||||
private readonly StatusRuToSlugMapper $statusMapper,
|
||||
private readonly PdAuditLogger $pdLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -68,7 +70,7 @@ final class HistoricalImportService
|
||||
}
|
||||
|
||||
try {
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug, $log->id);
|
||||
$wasCreated ? $added++ : $updated++;
|
||||
} catch (Throwable $e) {
|
||||
$skipped++;
|
||||
@@ -132,9 +134,9 @@ final class HistoricalImportService
|
||||
* Идемпотентный upsert одной строки в собственной транзакции.
|
||||
* Возвращает true — создана новая сделка, false — обновлена существующая.
|
||||
*/
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug, int $importLogId): bool
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug, $importLogId): bool {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
@@ -188,6 +190,17 @@ 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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -159,4 +159,42 @@ final class SupplierRegions
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1347,7 +1347,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
count: 3
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
<?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();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?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');
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
<?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');
|
||||
}
|
||||
});
|
||||
@@ -7,8 +7,13 @@ use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
// 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);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
@@ -67,6 +72,20 @@ 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,6 +48,21 @@ 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',
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?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');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
<?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');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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]);
|
||||
});
|
||||
@@ -47,3 +47,23 @@ it('every map entry points to a distinct supplier code (no collisions)', functio
|
||||
$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([]);
|
||||
});
|
||||
|
||||
@@ -1589,3 +1589,43 @@ lemed
|
||||
ретраит
|
||||
шеринге
|
||||
unactivated
|
||||
|
||||
# Серверный слой защиты SEC-1..7 (2026-05-22)
|
||||
бэкапа
|
||||
баны
|
||||
алертинг
|
||||
алертингом
|
||||
htpasswd
|
||||
ignoreip
|
||||
libnginx
|
||||
crs
|
||||
coraza
|
||||
usr
|
||||
|
||||
# ПИЛОТ.md эксплуатационные термины (2026-05-22)
|
||||
ротирован
|
||||
разлогинятся
|
||||
крэше
|
||||
стектрейсы
|
||||
закэширован
|
||||
scp
|
||||
крашей
|
||||
PGDG
|
||||
лок
|
||||
SMTPS
|
||||
юните
|
||||
бакет
|
||||
MTA
|
||||
алиас
|
||||
прода
|
||||
попап
|
||||
COEP
|
||||
Самобана
|
||||
CDP
|
||||
волатилен
|
||||
синке
|
||||
субдомен
|
||||
субдомена
|
||||
субдомены
|
||||
артизан
|
||||
Артизан
|
||||
|
||||
+10
-6
File diff suppressed because one or more lines are too long
@@ -21,10 +21,10 @@ function pos(ring, angleDeg) {
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.37', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.24', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.20', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.20', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ 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) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
@@ -96,9 +96,9 @@ const NODES = [
|
||||
{ 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, pending install)', group: 'mcp', size: 18, ring: 5, ...pos(5, 360) },
|
||||
{ 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 безопасность, pending)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 380) },
|
||||
{ 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) },
|
||||
|
||||
@@ -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.15', cond: 'ссылается как на реестр инструментов' },
|
||||
{ name: 'Tooling v2.22', 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(
|
||||
'Реестр 80 позиций — 60 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
|
||||
'Реестр 93 позиций — 73 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
|
||||
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
|
||||
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
|
||||
[
|
||||
@@ -1503,6 +1503,83 @@ 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' }],
|
||||
[]
|
||||
),
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1663,10 +1740,10 @@ const META_WINDOW = '09–20.05.2026'; // окно подсчёта исп
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | 'интеграция' | 'DEFERRED' | '—'
|
||||
const NODE_META = {
|
||||
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
|
||||
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: '—' },
|
||||
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: '—' },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ──
|
||||
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -1860,6 +1937,14 @@ 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) — попадают в кнопку «⧉ Дубли».
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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 искажает счётчики).
|
||||
@@ -1,21 +1,21 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-21T06:54:27.698Z
|
||||
Last updated: 2026-05-22T11:27:52.849Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| C2 Cross-ref consistency | 🔴 | Update cross-refs in offending files. |
|
||||
| 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 | ⚠️ | 39 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 16 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync |
|
||||
| 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 |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 39 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Observer evidence: 41 episodes this month, 0 observer_error markers, 5 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 5
|
||||
- Last /brain-retro: 2 day(s) ago
|
||||
- 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).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Серверный слой защиты боевого сервера (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 ~25–50 ₽/мес.
|
||||
|
||||
**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`.
|
||||
@@ -0,0 +1,447 @@
|
||||
# P1 — Полное покрытие `auth_log` + автор/IP в `activity_log`
|
||||
|
||||
> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p1-auth` (от P0-baseline `a575d55`). 10 commits (Tasks 1-8 + gate). Auth-suite **131/131 passing (537 assertions)**; touched-area regression GREEN: AuthControllerTest 13/13, TwoFactor 16/16, TwoFactorSetup 10/10, ForgotPassword|ResetPassword 12/12, DealCreate/Update/Transition 26/26, DealBulkAction 14/14. Pint **clean** на изменённых файлах; Larastan **production code clean** (один `nullsafe.neverNull` в `AuthController.php:96` исправлен — `$user` уже non-null в `if (! $user->is_active)`; line 84 `?->` оставлен корректно — `$user` там может быть null). Plan-level deviations: (1) Task 5 implementer добавил 4 теста вместо 3 (bonus password_reset_failed на невалидном токене); (2) Task 6 — `request()` helper вместо `$request` (closures без `$request` в `use()`); (3) Task 7 — `$request` добавлен в 3 closure `use()` lists (transition/destroy/restore); (4) Task 8 integration test задокументировал Sanctum/SessionGuard interaction в shared test app (`Auth::shouldUse('sanctum')` от auth:sanctum middleware ломает следующие `Auth::login()` в той же тестовой функции — fix `app('auth')->forgetGuards(); setDefaultDriver('web')` перед `Auth::login`). NB: `--parallel` full-suite не запущен (shared DB + concurrent worktrees) — заменён targeted sequential regression.
|
||||
|
||||
> **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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DummyAuth { use \App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
public function fire(?int $userId, ?int $tenantId): void {
|
||||
$this->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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Запись в auth_log (защищён hash-chain тригером, см. db/schema.sql:3032).
|
||||
* Используется в AuthController, TwoFactorController, TwoFactorSetupController,
|
||||
* PasswordResetController — единственная точка записи auth-событий.
|
||||
*
|
||||
* Канонические event-strings (расширяемо):
|
||||
* login_success, login_failed, logout, register_success,
|
||||
* 2fa_verify_success, 2fa_verify_failed, 2fa_recovery_used,
|
||||
* 2fa_setup_init, 2fa_setup_confirmed, 2fa_disabled, 2fa_recovery_regenerated,
|
||||
* password_reset_requested, password_reset_completed
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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=<new>
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **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`.
|
||||
@@ -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_<seq>_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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class OperationsLogger
|
||||
{
|
||||
/** @param array<string,mixed>|null $payloadBefore @param array<string,mixed>|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_<action>'
|
||||
// и 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=<new key id>, payload_after.key_prefix=<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=<old>, payload_after.target_url=<new>`.
|
||||
- [ ] **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: <exc head>: 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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IncidentsWatchFailures extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно в минутах}
|
||||
{--threshold=200 : Порог числа падений за окно}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Создаёт incidents_log на основе шторма failed_webhook_jobs / failed_jobs';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$windowMin = (int) $this->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`.
|
||||
@@ -0,0 +1,704 @@
|
||||
# P0 — Журнал ПДн + Impersonation аудит (152-ФЗ closure)
|
||||
|
||||
> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p0-pd` (от `9bf97ef`). 13 commits (Tasks 1-12 + gate b9e4e03). Pd-suite **22/22 passing (100 assertions)**; touched-area regression **149/150** — единственное падение `RouteSupplierLeadJobTest::it_is_terminal` подтверждено как pre-existing (фейлит и на parent `f6d83f6` до правок Task 3; корень — deal-leak через `pgsql_supplier` BYPASSRLS connection вне `DatabaseTransactions`, не связан с PD-эпиком). Pint **clean** на изменённых файлах; Larastan **production code clean** (один реальный `nullsafe.neverNull` в `ImpersonationAuditService` исправлен; остаточные 120 ошибок — Pest false-positives в новых тестовых файлах + ide-helper baseline drift, известный worktree-квирк per memory). Plan-level deviations: (1) Tasks 9-11 объединены в один commit per plan hint «commit single»; (2) Task 5 implementer изначально написал `subject_type='report_file'` / `purpose='report_file_delete'` — поправлено fixup'ом до spec'овых `subject_type='lead'` / `purpose='report_file_'.$job->id` (consistency с Task 6 + Task 12 integration test); (3) Task 7 потребовал thread `$importLogId` через `upsertRow()` — план предполагал `$log->id` в scope, но он там не был. NB: `--parallel` full-suite не запущен (shared `liderra_testing` DB + 13 active worktrees → noise), вместо него targeted sequential regression — гейт-equivalent для аддитивного эпика.
|
||||
|
||||
> **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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('inserts pd_processing_log row with all fields', function () {
|
||||
app(PdAuditLogger::class)->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
|
||||
<?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() (db/schema.sql:3046) автоматически заполняет log_hash.
|
||||
* Append-only: UPDATE/DELETE заблокированы audit_block_mutation.
|
||||
*
|
||||
* chk_pd_actor (db/schema.sql:2461): ровно один актор из 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('pd created on manual store via /api/deals', function () {
|
||||
$tenant = Tenant::factory()->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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('pd exported on deals CSV export', function () {
|
||||
$tenant = Tenant::factory()->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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('recordInit writes saas_admin_audit_log action=impersonation.init', function () {
|
||||
$token = ImpersonationToken::factory()->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
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
|
||||
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,
|
||||
]);
|
||||
// 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`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
# Дизайн: разовый импорт активных проектов поставщика в тенант info@lkomega.ru
|
||||
|
||||
**Дата:** 2026-05-22
|
||||
**Статус:** утверждён заказчиком (brainstorming), готов к плану
|
||||
**Ветка:** `feat/supplier-import-lkomega` (worktree от origin/main `4c80a58`)
|
||||
**Среда выполнения:** боевой пилот liderra.ru = `111.88.246.137` (там тенант info@lkomega.ru и живая supplier-сессия)
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Заказчик ведёт проекты вручную на портале поставщика crm.bp-gr.ru (логин `lkomega.ru`). Нужно один раз завести их как проекты в Лидерре под тенантом **info@lkomega.ru** («Компания 1»), **полностью по правилам Лидерры**: три площадки B1/B2/B3 одного источника = один проект Лидерры; лимит лидов и прочие настройки переносятся корректно.
|
||||
|
||||
Проекты **уже существуют** на портале и собирают лиды. Поэтому «перенести» = **усыновить** существующие записи портала (связать с ними проекты Лидерры), **не создавая дублей** на портале и **не меняя** его настройки.
|
||||
|
||||
## 2. Решения заказчика (brainstorming)
|
||||
|
||||
| Вопрос | Решение |
|
||||
|---|---|
|
||||
| Охват | Все активные проекты (`status` = включён; у всех `lim` > 0) |
|
||||
| Сторона поставщика | **Не трогать** портал — только усыновить (никаких save/update/delete на портал) |
|
||||
| Лимит в Лидерре | **Сумма** `lim` активных площадок группы (B1+B2+B3) |
|
||||
| Способ | Артизан-команда с режимом «примерки» (dry-run по умолчанию) |
|
||||
|
||||
## 3. Исходные данные (recon read-only 2026-05-22)
|
||||
|
||||
`SupplierPortalClient::listProjects()` на пилоте: **472** проекта аккаунта lkomega.
|
||||
|
||||
- По типу: `calls`=322, `hosts`=135, `sms`=15.
|
||||
- По источнику (`src`): `rt`=152, `bl`=160, `mt`=159, `dop2`=1.
|
||||
- По статусу: активных (`status=true`)=375, выключенных=97. У всех `lim`>0.
|
||||
- Группировка активных по `(content, type, tag)` → **128 групп**: 120 троек B1/B2/B3, 6 пар, 2 одиночных.
|
||||
|
||||
Форма строки портала (ключевые поля): `id` (строка, внешний id), `tag`, `src` (rt/bl/mt/…), `type` (calls/hosts/sms), `content` (идентификатор — телефон/домен), `name` (`B<n>_<content>`), `lim` (строка, лимит на площадку), `workdays` (строки `["1".."7"]`, 1=Пн..7=Вс ISO), `regions` (строка кодов ГИБДД, через запятую; пусто = вся РФ), `regions_reverse` (bool), `status` (bool).
|
||||
|
||||
NB: на портале лимиты активных проектов **уже поделены** на B1/B2/B3 (re-split форсом, ПИЛОТ.md `029b19a`) — значит сумма площадок = корректный целевой total для Лидерры.
|
||||
|
||||
## 4. Маппинг портал → Лидерра
|
||||
|
||||
| Портал | Лидерра |
|
||||
|---|---|
|
||||
| `src` rt / bl / mt | platform B1 / B2 / B3 |
|
||||
| `src` = `dop2` и любые иные | **пропуск** + строка в отчёт (вне модели B1/B2/B3) |
|
||||
| `type` calls / hosts / sms | `signal_type` call / site / sms |
|
||||
| `content` (для site/call) | `signal_identifier` |
|
||||
| группа = (`content`, `type`, `tag`) | один `Project` Лидерры |
|
||||
| Σ `lim` активных площадок группы | `daily_limit_target` |
|
||||
| `regions` (коды ГИБДД, union по площадкам группы) | `Project.regions` INT[] (коды Лидерры, обратная карта `SupplierRegions`); пусто = вся РФ → `[]` |
|
||||
| `regions_reverse=false` (include) | поддерживаем; `regions_reverse=true` (exclude) → **пропуск группы** + отчёт (модель Лидерры импорта — include) |
|
||||
| union `workdays` строк | `delivery_days_mask` (бит 0=Пн..6=Вс; bit=`1<<(d-1)`) |
|
||||
| `tag` | `Project.tag` (как есть); `Project.name` = производное от `tag` (+ суффикс идентификатора при коллизии имён) |
|
||||
| `status=true` | `is_active=true` |
|
||||
|
||||
**Обратная карта регионов:** существующий `SupplierRegions::mapToSupplier()` — Лидерра→ГИБДД (биекция 79 субъектов). Импорту нужна инверсия `mapFromSupplier()` (ГИБДД→Лидерра); непереводимый код → лог-warning + пропуск кода (регион не добавляется).
|
||||
|
||||
**SMS (15 строк, особый случай):** модель Лидерры для sms: `sms_senders` + `sms_keyword`, площадки B2 (sender+keyword) / B3 (sender), `unique_key` по `SupplierProjectGrouping::buildUniqueKey`. На портале `content` sms-строки кодирует sender(+keyword). План: best-effort разбор `content` → `sms_senders[0]`/`sms_keyword`; группы sms, которые не разбираются однозначно, **выводятся в отчёт и пропускаются** для ручного решения (объём мал).
|
||||
|
||||
## 5. Группировка и идемпотентность
|
||||
|
||||
- **Группа** строится только из **активных** строк (`status=true`), у которых `src` ∈ {rt,bl,mt}. Группы с одной/двумя площадками — валидны (создаём проект с теми платформами, что есть).
|
||||
- `unique_key` для `supplier_projects` вычисляется через `SupplierProjectGrouping::buildUniqueKey($project, $platform)` (консистентность с ночным джобом: будущие синки матчатся).
|
||||
- **Идемпотентность Project:** если под тенантом info@lkomega.ru уже есть `Project` с тем же (`signal_type`, `signal_identifier`) [для sms — (`signal_type`, `sms_senders[0]`, `sms_keyword`)] → **пропуск** (в отчёт «уже существует»), не дубль.
|
||||
- **Идемпотентность supplier_projects:** строка матчится по `supplier_external_id` (id портала) либо по unique-индексу `(platform, unique_key, subject_code=null)`. Есть → переиспользуем; нет → `forceCreate` с `sync_status='ok'`, `last_synced_at=now()`.
|
||||
- Повторный запуск команды безопасен: создаёт только недостающее.
|
||||
|
||||
## 6. Архитектура / компоненты
|
||||
|
||||
1. **`App\Services\Supplier\SupplierProjectImporter`** — чистая логика, без побочных эффектов записи:
|
||||
- `buildPlan(): ImportPlan` — читает `listProjects()`, фильтрует активные, группирует, реверс-маппит, считает суммы лимитов, помечает пропуски (dop2 / regions_reverse / нераспознанный sms / уже существующие). Возвращает структуру плана (список запланированных проектов + список пропусков). Зависимость `SupplierPortalClient` инжектится → тестируется на моках.
|
||||
2. **`App\Console\Commands\ImportSupplierProjectsCommand`** (`supplier:import-projects {--tenant=} {--commit}`):
|
||||
- Резолвит тенант по email (`User::where('email', …)->tenant_id`).
|
||||
- Печатает план таблицей (имя, тип, регионы, лимит, площадки + external_id) + счётчики + список пропусков.
|
||||
- Без `--commit` (по умолчанию) — только печать (dry-run), 0 записей.
|
||||
- С `--commit` — пишет в транзакции (см. §7).
|
||||
3. **`SupplierRegions::mapFromSupplier(array<int> $gibddCodes): array<int>`** — инверсия существующей карты.
|
||||
|
||||
Граница: importer НЕ знает про вывод в консоль; команда НЕ знает про парсинг портала. План — простая DTO-структура.
|
||||
|
||||
## 7. Путь записи (только при `--commit`)
|
||||
|
||||
Зеркалит create-ветку `SyncSupplierProjectsJob::syncGroup`, но **без HTTP на портал** — `supplier_external_id` берётся из уже прочитанного `listProjects` (`id` строки).
|
||||
|
||||
Соединение: **`pgsql_supplier`** (BYPASSRLS, роль `crm_supplier_worker`) для всех записей — это паттерн supplier-джобов; `Project` пишется с **явным `tenant_id`** (BYPASSRLS обходит RLS, поэтому tenant_id задаётся в коде, не из GUC). `supplier_projects` и `project_supplier_links` — SaaS-level (без RLS).
|
||||
|
||||
На каждую группу в транзакции:
|
||||
1. `Project::on('pgsql_supplier')->create([tenant_id, name, tag, signal_type, signal_identifier|sms_*, regions, delivery_days_mask, daily_limit_target=Σ, is_active=true, region_mode='include'])`.
|
||||
2. На каждую активную площадку: upsert `supplier_projects` (`platform`, `signal_type`, `unique_key`, `subject_code=null`, `supplier_external_id`=id портала, `current_limit`=`lim` площадки, `current_workdays`, `current_regions`, `sync_status='ok'`, `last_synced_at=now()`).
|
||||
3. `project_supplier_links` insertOrIgnore (`project_id`, `supplier_project_id`, `platform`, `subject_code=null`).
|
||||
4. `SupplierSyncLog` action='create' (audit).
|
||||
|
||||
Легаси-FK `supplier_b{1,2,3}_project_id` **не заполняем** — текущий `LeadRouter` ходит через pivot `project_supplier_links` (Plan 2 redesign); консистентно с актуальным джобом.
|
||||
|
||||
## 8. Безопасность
|
||||
|
||||
- **dry-run по умолчанию** — реальная запись только с явным `--commit`.
|
||||
- Перед `--commit` на пилоте — показ плана заказчику и его «ок».
|
||||
- Запись в **одной транзакции** на пилоте; при ошибке — откат.
|
||||
- Портал не трогаем (никаких save/update/delete) — нулевой риск дублей и переплаты.
|
||||
- Телефоны/ПДн в выводе/логах команды маскируются (152-ФЗ): идентификаторы и имена с цифровыми хвостами усекаются в отчёте.
|
||||
|
||||
## 9. Тестирование (TDD)
|
||||
|
||||
`SupplierProjectImporterTest` на моках `SupplierPortalClient`:
|
||||
- группировка троек B1/B2/B3 в один план-проект;
|
||||
- сумма лимитов площадок → `daily_limit_target`;
|
||||
- обратная карта регионов (ГИБДД→Лидерра), union, пусто=вся РФ;
|
||||
- фильтр статуса (выключенные не попадают);
|
||||
- пропуск `dop2` / `regions_reverse=true` / нераспознанного sms — с записью в отчёт;
|
||||
- идемпотентность (существующий Project → skip; существующий supplier_project → reuse).
|
||||
|
||||
`ImportSupplierProjectsCommandTest` — smoke: dry-run ничего не пишет; `--commit` создаёт Project+supplier_projects+pivot (на тестовой БД, мок listProjects).
|
||||
|
||||
`SupplierRegions::mapFromSupplier` — unit: биекция-инверсия, непереводимый код.
|
||||
|
||||
## 10. Выполнение (порядок)
|
||||
|
||||
1. Команда + сервис + тесты в worktree `feat/supplier-import-lkomega` (от origin/main).
|
||||
2. Зелёная регрессия (Pest целевой + relevant supplier suite, Pint, Larastan).
|
||||
3. Деплой на пилот (scp файлов; команда — не воркер, restart очереди не нужен).
|
||||
4. **Dry-run на пилоте** → показываю план заказчику → его «ок».
|
||||
5. Реальный прогон `--commit` на пилоте.
|
||||
6. Пост-проверка: число созданных Project под тенантом, выборочная сверка лимитов/регионов, отсутствие записей на портале (портал не дёргался).
|
||||
7. Push ветки → main; merge по решению заказчика.
|
||||
|
||||
## 11. Уточнить при планировании (не блокеры)
|
||||
|
||||
- **Ключ группировки.** Ночной `SyncSupplierProjectsJob` группирует по `(signal_type, identifier)` **без тега**; recon считал по `(content, type, tag)` → 128. Если у одного `(content,type)` несколько тегов — счёт групп изменится, и будущий ночной синк слил бы их в одну. **Рекомендация:** группировать как ночной джоб — по `(signal_type, identifier)` без тега (консистентно с live-синком); при нескольких тегах на одном идентификаторе — взять один (первый/наиболее частый) + отчёт. Финальный счёт уточнить на реальных данных при планировании.
|
||||
- **Семантика `tag`.** На портале у Дмитрия `tag` = кампания (напр. «Сфера Займов https://…»). Ночной синк Лидерры, наоборот, **пишет в портальный `tag` имя региона** (или «РФ»). Для импорта `Project.tag` = **сохранить кампанию из портала** (это данные заказчика); портал не трогаем, поэтому подмены тега не происходит. NB: если позже сделать resync этого проекта — штатный синк перезапишет портальный `tag` на регион (известный побочный эффект штатного поведения, вне scope импорта).
|
||||
- Точные fillable/типы `SupplierProject` (платформенный CHECK uppercase B1/B2/B3; колонка `supplier_external_id` строка).
|
||||
- Имя `Project.name`: формат из `tag` (тег бывает с URL — обрезать/нормализовать).
|
||||
- SMS-разбор `content` → sender/keyword (15 строк) — формат подтвердить на реальных sms-строках портала (read-only).
|
||||
- Резолв тенанта: `User` vs отдельная `Tenant` запись по email.
|
||||
|
||||
## 12. Вне scope (YAGNI)
|
||||
|
||||
- Импорт исторических лидов/сделок (отдельный CSV-эпик, уже частично сделан).
|
||||
- Импорт выключенных проектов (`status=false`).
|
||||
- Изменение чего-либо на портале crm.bp-gr.ru.
|
||||
- UI для импорта (разовая операция — команда).
|
||||
- Двусторонняя синхронизация (это разовый импорт; дальше работает штатный sync).
|
||||
|
||||
## 13. Риски
|
||||
|
||||
- **Несовпадение группировки портал↔Лидерра для sms** — митигируется пропуском нераспознанных + отчётом (объём 15).
|
||||
- **Регионы exclude (`regions_reverse=true`)** — пропуск + отчёт (импорт only-include).
|
||||
- **Параллельная сессия / §15** — worktree изолирован; коммиты явными путями; pre-flight sync перед нормативкой (нормативка тут не правится).
|
||||
- **Расхождение кода ветки vs пилота** — строим от origin/main = код пилота; перед `--commit` проверяем, что версия команды на пилоте = собранная.
|
||||
File diff suppressed because one or more lines are too long
@@ -61,6 +61,12 @@
|
||||
"#65": false,
|
||||
"#66": false,
|
||||
"#67": true,
|
||||
"#68": false,
|
||||
"#69": false,
|
||||
"#70": false,
|
||||
"#71": false,
|
||||
"#72": false,
|
||||
"#73": false,
|
||||
"#25": false,
|
||||
"#26": false,
|
||||
"#27": false,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Эти файлы заливаются на Linux-сервер через scp.
|
||||
# CRLF здесь = повтор инцидента 22.05.2026 (битый .env).
|
||||
* text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
*.template text eol=lf
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# liderra-healthcheck.sh — проверка здоровья портала + email-алёрт на kdv1@bk.ru.
|
||||
# Cron: каждые 2 минуты.
|
||||
# Логика: 2 подряд провала (~4 мин даунтайма) → DOWN-алёрт; первый успех после DOWN → UP-алёрт.
|
||||
set -u
|
||||
|
||||
ALERT_TO="kdv1@bk.ru"
|
||||
URL="https://127.0.0.1/"
|
||||
HOST_HDR="liderra.ru"
|
||||
STATE_DIR="/var/lib/liderra"
|
||||
STATE_FILE="$STATE_DIR/healthcheck.state"
|
||||
LOG="/var/log/liderra-healthcheck.log"
|
||||
|
||||
DOWN_THRESHOLD=2 # подряд провалов до DOWN-алёрта
|
||||
TIMEOUT=10
|
||||
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
touch "$STATE_FILE" "$LOG" 2>/dev/null || true
|
||||
|
||||
# Текущее состояние: DOWN_COUNT и LAST_STATE (up|down)
|
||||
DOWN_COUNT=$(awk -F= '/^down_count=/{print $2}' "$STATE_FILE" 2>/dev/null)
|
||||
DOWN_COUNT="${DOWN_COUNT:-0}"
|
||||
LAST_STATE=$(awk -F= '/^last_state=/{print $2}' "$STATE_FILE" 2>/dev/null)
|
||||
LAST_STATE="${LAST_STATE:-up}"
|
||||
|
||||
HTTP_CODE=$(curl -sS -k --max-time "$TIMEOUT" -H "Host: $HOST_HDR" -o /dev/null -w "%{http_code}" "$URL" 2>/dev/null || echo "000")
|
||||
NOW=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
write_state() {
|
||||
cat > "$STATE_FILE" <<EOF
|
||||
down_count=$1
|
||||
last_state=$2
|
||||
last_check=$NOW
|
||||
last_code=$HTTP_CODE
|
||||
EOF
|
||||
}
|
||||
|
||||
send_mail() {
|
||||
local subj="$1"
|
||||
local body="$2"
|
||||
{
|
||||
printf 'Subject: %s\n' "$subj"
|
||||
printf 'From: verify@liderra.ru\n'
|
||||
printf 'To: %s\n' "$ALERT_TO"
|
||||
printf 'MIME-Version: 1.0\n'
|
||||
printf 'Content-Type: text/plain; charset=utf-8\n\n'
|
||||
printf '%s\n' "$body"
|
||||
} | msmtp -a yandex "$ALERT_TO" >> "$LOG" 2>&1
|
||||
}
|
||||
|
||||
# Считаем 5xx, 000 (timeout/refused) и любые сетевые ошибки как «провал».
|
||||
# 200/301/302/401/403 — портал отвечает (даже 401 — это «жив»).
|
||||
if [[ "$HTTP_CODE" =~ ^[23] ]] || [[ "$HTTP_CODE" =~ ^(301|302|401|403)$ ]]; then
|
||||
STATE="up"
|
||||
else
|
||||
STATE="down"
|
||||
fi
|
||||
|
||||
if [[ "$STATE" == "down" ]]; then
|
||||
DOWN_COUNT=$((DOWN_COUNT + 1))
|
||||
echo "[$NOW] DOWN code=$HTTP_CODE count=$DOWN_COUNT" >> "$LOG"
|
||||
if [[ "$DOWN_COUNT" -ge "$DOWN_THRESHOLD" ]] && [[ "$LAST_STATE" == "up" ]]; then
|
||||
TAIL_ERR=$(grep -E 'production\.ERROR' /var/www/liderra/app/storage/logs/laravel.log 2>/dev/null | tail -3 | cut -c1-400)
|
||||
NGX=$(tail -5 /var/log/nginx/error.log 2>/dev/null)
|
||||
send_mail "[Лидерра ПАДЕНИЕ] liderra.ru недоступен — HTTP $HTTP_CODE" "Портал liderra.ru недоступен.
|
||||
|
||||
Время: $NOW
|
||||
HTTP-код: $HTTP_CODE
|
||||
Провалов подряд: $DOWN_COUNT (порог $DOWN_THRESHOLD)
|
||||
|
||||
Последние ошибки Laravel:
|
||||
$TAIL_ERR
|
||||
|
||||
Последние строки nginx error.log:
|
||||
$NGX
|
||||
|
||||
Сервер: ssh ubuntu@111.88.246.137
|
||||
"
|
||||
write_state "$DOWN_COUNT" "down"
|
||||
else
|
||||
write_state "$DOWN_COUNT" "$LAST_STATE"
|
||||
fi
|
||||
else
|
||||
if [[ "$LAST_STATE" == "down" ]]; then
|
||||
echo "[$NOW] RECOVERED code=$HTTP_CODE" >> "$LOG"
|
||||
send_mail "[Лидерра восстановлен] liderra.ru снова работает — HTTP $HTTP_CODE" "Портал liderra.ru снова отвечает.
|
||||
|
||||
Время восстановления: $NOW
|
||||
HTTP-код: $HTTP_CODE
|
||||
"
|
||||
fi
|
||||
write_state 0 "up"
|
||||
fi
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# liderra-precheck.sh — pre-flight гейт перед/после деплоя.
|
||||
# Проверяет .env, ключи, БД, Redis, шифрование. exit 1 при любом провале.
|
||||
# Запускать руками после scp файлов / git pull, до systemctl restart.
|
||||
set -e
|
||||
|
||||
APP_DIR="/var/www/liderra/app"
|
||||
ENV="$APP_DIR/.env"
|
||||
FAIL=0
|
||||
|
||||
red() { printf '\033[31m✗ %s\033[0m\n' "$1"; FAIL=1; }
|
||||
green() { printf '\033[32m✓ %s\033[0m\n' "$1"; }
|
||||
yellow(){ printf '\033[33m! %s\033[0m\n' "$1"; }
|
||||
|
||||
echo "=== liderra pre-flight check ==="
|
||||
|
||||
# 1. .env existence + perms
|
||||
if [[ ! -f "$ENV" ]]; then red "$ENV отсутствует"; exit 1; fi
|
||||
if [[ "$(stat -c '%U' "$ENV")" != "www-data" ]]; then yellow ".env owner $(stat -c '%U:%G %a' "$ENV") — должен быть www-data:www-data 640"; fi
|
||||
green ".env существует"
|
||||
|
||||
# 2. CRLF check
|
||||
CRLF=$(grep -c $'\r' "$ENV" || true)
|
||||
if [[ "$CRLF" -gt 0 ]]; then red ".env содержит $CRLF строк с CRLF — Laravel сломает значения. Запусти: sudo sed -i 's/\\r\$//' $ENV"; else green ".env без CRLF"; fi
|
||||
|
||||
# 3. APP_KEY
|
||||
KEY=$(grep '^APP_KEY=' "$ENV" | head -1 | cut -d= -f2-)
|
||||
if [[ -z "$KEY" ]]; then red "APP_KEY пустой";
|
||||
elif [[ "$KEY" != base64:* ]]; then red "APP_KEY без префикса base64: → '$KEY'";
|
||||
elif [[ "${#KEY}" -ne 51 ]]; then red "APP_KEY длина ${#KEY}, должна быть 51 (base64: + 44 символа base64)";
|
||||
else green "APP_KEY корректный (${#KEY} символов)"; fi
|
||||
|
||||
# 4. Дубль APP_KEY
|
||||
DUP=$(grep -c '^APP_KEY=' "$ENV" || true)
|
||||
if [[ "$DUP" -gt 1 ]]; then red "В .env $DUP строк APP_KEY= — должна быть одна"; fi
|
||||
|
||||
# 5. Critical drivers
|
||||
for VAR in APP_ENV DB_CONNECTION SESSION_DRIVER CACHE_STORE QUEUE_CONNECTION REDIS_HOST; do
|
||||
V=$(grep "^${VAR}=" "$ENV" | head -1 | cut -d= -f2-)
|
||||
if [[ -z "$V" ]]; then yellow "$VAR не задан (берётся дефолт)"; else green "$VAR=$V"; fi
|
||||
done
|
||||
|
||||
# 6. PostgreSQL connection
|
||||
cd "$APP_DIR"
|
||||
if sudo -u www-data php -r "require 'vendor/autoload.php'; \$app=require 'bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); try { DB::select('SELECT 1'); echo 'PG_OK'; } catch (Throwable \$e) { echo 'PG_FAIL: '.\$e->getMessage(); }" 2>/dev/null | grep -q "PG_OK"; then
|
||||
green "PostgreSQL доступен"
|
||||
else
|
||||
red "PostgreSQL недоступен — проверь DB_HOST/DB_PASSWORD"
|
||||
fi
|
||||
|
||||
# 7. Redis ping
|
||||
if sudo -u www-data php -r "require 'vendor/autoload.php'; \$app=require 'bootstrap/app.php'; \$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); try { \$r=Illuminate\Support\Facades\Redis::ping(); echo strpos(strtolower((string)\$r ?: 'pong'),'pong')!==false || \$r===true ? 'REDIS_OK' : 'REDIS_FAIL'; } catch (Throwable \$e) { echo 'REDIS_FAIL: '.\$e->getMessage(); }" 2>/dev/null | grep -q "REDIS_OK"; then
|
||||
green "Redis доступен"
|
||||
else
|
||||
red "Redis недоступен — проверь REDIS_HOST"
|
||||
fi
|
||||
|
||||
# 8. Encryption round-trip (главная защита от инцидента 22.05.2026)
|
||||
if [[ "$(sudo -u www-data php artisan tinker --execute "echo decrypt(encrypt('ping'));" 2>/dev/null | tail -1)" == "ping" ]]; then
|
||||
green "Шифрование работает (decrypt(encrypt) round-trip)"
|
||||
else
|
||||
red "Шифрование сломано — APP_KEY невалидный, портал упадёт на 500"
|
||||
fi
|
||||
|
||||
# 9. config-cache не stale
|
||||
if [[ -f "$APP_DIR/bootstrap/cache/config.php" ]]; then
|
||||
CACHE_AGE=$(( $(date +%s) - $(stat -c %Y "$APP_DIR/bootstrap/cache/config.php") ))
|
||||
ENV_AGE=$(( $(date +%s) - $(stat -c %Y "$ENV") ))
|
||||
if [[ "$ENV_AGE" -lt "$CACHE_AGE" ]]; then
|
||||
red "config.php кэш СТАРШЕ чем .env — запусти: sudo -u www-data php artisan config:cache"
|
||||
else
|
||||
green "config-кэш свежее .env"
|
||||
fi
|
||||
else
|
||||
yellow "config-кэш отсутствует (норма для dev, но в prod лучше иметь)"
|
||||
fi
|
||||
|
||||
# 10. Pending migrations
|
||||
if cd "$APP_DIR" && sudo -u www-data php artisan migrate:status 2>/dev/null | grep -q "Pending"; then
|
||||
yellow "Есть pending миграции — запусти: sudo -u www-data php artisan migrate --force"
|
||||
else
|
||||
green "Миграции применены"
|
||||
fi
|
||||
|
||||
# 11. HTTP smoke
|
||||
HTTP=$(curl -sS -k --max-time 5 -H "Host: liderra.ru" -o /dev/null -w "%{http_code}" https://127.0.0.1/ 2>/dev/null || echo "000")
|
||||
case "$HTTP" in
|
||||
200|301|302|401|403) green "HTTP smoke: $HTTP" ;;
|
||||
*) red "HTTP smoke: $HTTP — портал не отвечает" ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
if [[ "$FAIL" -eq 0 ]]; then
|
||||
green "ВСЁ ОК — можно перезапускать сервисы (sudo systemctl reload php8.3-fpm; sudo systemctl restart liderra-queue)"
|
||||
exit 0
|
||||
else
|
||||
red "ЕСТЬ ПРОВАЛЫ — НЕ ЗАПУСКАЙ systemctl restart, сначала почини выше"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
Description=Liderra queue alert (sends email on liderra-queue failure)
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/liderra-systemd-alert.sh liderra-queue
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Liderra queue worker
|
||||
After=redis-server.service postgresql.service network.target
|
||||
# Перейти в failed после 5 крашей за 5 минут — иначе systemd крутит бесконечно
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
# При окончательном fail — запустить алёрт-юнит
|
||||
OnFailure=liderra-queue-alert.service
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
WorkingDirectory=/var/www/liderra/app
|
||||
# --timeout=300: Laravel default 60s убивал worker до завершения долгих supplier-задач
|
||||
# (PlaywrightBridge cold-start Chromium на 2GB VM ~65s, см. фикс 22.05.2026 утро).
|
||||
# 300s = PlaywrightBridge TIMEOUT_SECONDS=180 + 120s запас.
|
||||
ExecStart=/usr/bin/php /var/www/liderra/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --timeout=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# liderra-systemd-alert.sh — отправка email-алёрта при упавшем systemd-юните.
|
||||
# Используется как OnFailure= для liderra-queue.service.
|
||||
set -u
|
||||
|
||||
UNIT="${1:-unknown}"
|
||||
ALERT_TO="kdv1@bk.ru"
|
||||
NOW=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
STATUS=$(systemctl status "$UNIT" --no-pager -l 2>&1 | head -30)
|
||||
JOURNAL=$(journalctl -u "$UNIT" -n 30 --no-pager 2>&1)
|
||||
|
||||
{
|
||||
printf 'Subject: [Лидерра-мониторинг] systemd-юнит упал: %s\n' "$UNIT"
|
||||
printf 'From: verify@liderra.ru\n'
|
||||
printf 'To: %s\n' "$ALERT_TO"
|
||||
printf 'MIME-Version: 1.0\n'
|
||||
printf 'Content-Type: text/plain; charset=utf-8\n\n'
|
||||
printf 'Юнит %s окончательно упал (5 крашей за 5 минут — превышен лимит).\n\n' "$UNIT"
|
||||
printf 'Время: %s\n' "$NOW"
|
||||
printf 'Сервер: ssh ubuntu@111.88.246.137\n\n'
|
||||
printf '=== systemctl status ===\n%s\n\n' "$STATUS"
|
||||
printf '=== journalctl (последние 30 строк) ===\n%s\n' "$JOURNAL"
|
||||
} | msmtp -a yandex "$ALERT_TO" 2>>/var/log/msmtp.log
|
||||
@@ -0,0 +1,15 @@
|
||||
defaults
|
||||
auth on
|
||||
tls on
|
||||
tls_starttls off
|
||||
tls_trust_file /etc/ssl/certs/ca-certificates.crt
|
||||
logfile /var/log/msmtp.log
|
||||
|
||||
account yandex
|
||||
host smtp.yandex.ru
|
||||
port 465
|
||||
from verify@liderra.ru
|
||||
user verify@liderra.ru
|
||||
password __MAIL_PASSWORD__
|
||||
|
||||
account default : yandex
|
||||
@@ -37,5 +37,14 @@
|
||||
"claude-md-management:revise-claude-md": ["L12"],
|
||||
"billing-audit": ["L13"],
|
||||
"pest": ["L13"],
|
||||
"ru-tax-accounting": ["L13"]
|
||||
"ru-tax-accounting": ["L13"],
|
||||
"security-go-live": ["L15"],
|
||||
"pdn-152fz-audit": ["L15"],
|
||||
"threat-model": ["L15"],
|
||||
"nuclei": ["L15"],
|
||||
"ward": ["L15"],
|
||||
"owasp-zap": ["L15"],
|
||||
"gitleaks": ["L15"],
|
||||
"semgrep": ["L15"],
|
||||
"trailofbits": ["L15"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema_version": 1,
|
||||
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime.",
|
||||
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime. Classifier vocabulary is Claude's free judgment when writing the episode (no hardcoded enum) — adding a key here makes it 'blessed'. 'security' added 22.05.2026 (A8 follow-up): use when the PURPOSE of the task is verifying or improving security (scans, hardening, audits, threat modeling, go-live gates); NOT for bug-fixes that happen to be in security-relevant code (those stay 'bugfix').",
|
||||
"map": {
|
||||
"refactor": ["#11", "#12", "#43", "#64", "#65"],
|
||||
"bugfix": ["#18", "#34"],
|
||||
@@ -9,6 +9,7 @@
|
||||
"memory-sync": ["#33"],
|
||||
"monitoring": ["#34", "#35"],
|
||||
"analysis": ["#25", "#39", "#53"],
|
||||
"security": ["#73", "#69", "#68", "#70", "#71", "#72"],
|
||||
"cleanup": ["#11", "#12"],
|
||||
"question": ["#60"],
|
||||
"other": []
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# ПИЛОТ — боевая интернет-версия Лидерры (liderra.ru)
|
||||
|
||||
Снимок **истинного состояния опубликованной в интернете (пилотной) версии** портала. Парный к [ЭТАЛОН.md](ЭТАЛОН.md): **ЭТАЛОН = локальная dev-версия**, **ПИЛОТ = боевой сервер liderra.ru**.
|
||||
|
||||
**Правило использования:**
|
||||
|
||||
- При работе с боевым сервером — сверяться с этим файлом.
|
||||
- Волатильную часть (доступ, версии, что развёрнуто) перед рискованными действиями **перепроверять реальной командой по 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 поздний вечер** — выполнен сквозной аудит журналирования (статика + конфиг + живые цифры с прода): найдено 9+ дыр — `pd_processing_log=0` при 417 сделках с телефонами, `activity_log` 412 строк все с `user_id=NULL`, `incidents_log` не наполняется автоматически (25 445 failed_webhook_jobs прошли без инцидента). Подготовлены три плана закрытия — см. §6 пп.7–9.
|
||||
|
||||
---
|
||||
|
||||
## 1. Доступ
|
||||
|
||||
- **Домен:** `liderra.ru` + `www.liderra.ru` → A-запись на `111.88.246.137` (резолвятся). `test.liderra.ru` — НЕ резолвится (легаси в старом конфиге).
|
||||
- **Сайт:** теперь по **HTTPS** (`https://liderra.ru`), http редиректит на https.
|
||||
- **«Дверь» (HTTP Basic Auth)** перед сайтом: логин `liderra`, пароль в `/home/ubuntu/liderra-secrets.txt` (ключ `basic_auth`). Эндпоинт `/api/webhook/` — без двери (для поставщика).
|
||||
- **Демо-вход в портал:** `admin@demo.local` / `password` (tenant demo).
|
||||
- **SSH:** `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137` (ключ на dev-машине; вход по паролю отключён). sudo без пароля.
|
||||
|
||||
## 2. Сервер и стек
|
||||
|
||||
- **VM** `liderra-test` (id `fhmu5kbgbui60r773qic`), Yandex Cloud, зона `ru-central1-a`, **Ubuntu 24.04**, 1.9 ГБ RAM / 2 vCPU / 19 ГБ диск (~12 ГБ свободно). Ресурсы тесные — тяжёлые сервисы (self-host Sentry ~4 ГБ+) не помещаются.
|
||||
- **Стек одной VM:** nginx 1.24 + php8.3-fpm + PostgreSQL 16.14 + Redis. Приложение в `/var/www/liderra/app`.
|
||||
- **`APP_ENV=production`, `APP_DEBUG=false`** ✅ (стектрейсы наружу не светят).
|
||||
- ✅ **`APP_URL=https://liderra.ru`** (исправлено 22.05; было `http://111.88.246.137`) + `SANCTUM_STATEFUL_DOMAINS=liderra.ru,www.liderra.ru` (cookie-логин на apex+www). **Конфиг закэширован** (`php artisan config:cache`) → будущие правки `.env` применять `php artisan config:cache`. ✅ `SESSION_SECURE_COOKIE=true` (22.05 — куки сессии/CSRF только по HTTPS; verified `secure; httponly; samesite=lax`).
|
||||
- **Деплой кода** — копированием (НЕ git-checkout; `/var/www/liderra/app` не под git). Соответствует test-deploy эпику (ветка `feat/test-deploy`). Точную версию кода сверять при деплое (`git hash-object` файла vs нужный коммит). Фронт собирается **локально** (`npm run build`) и `public/build` копируется — на VM `node_modules` нет, RAM мало. После правки PHP в очереди — `systemctl restart liderra-queue`; после правки `.env` или контроллеров — `php artisan config:cache` + `systemctl reload php8.3-fpm` (opcache). Часть файлов на сервере принадлежит `www-data` (правка через `sudo cp`). **⚠️ scp с Windows кладёт CRLF в `.env`** (инцидент 22.05 вечер: Laravel молча fallback на дефолтный sqlite-кэш, 500 на каждой странице) — после копирования любых текстовых файлов с Windows-машины обязательно `sudo sed -i 's/\r$//' /var/www/liderra/app/.env` (или копировать `git archive`/`tar`). **Pre-flight гейт** `sudo /usr/local/bin/liderra-precheck.sh` — 15 проверок (CRLF, длина APP_KEY, decrypt(encrypt) round-trip, PG/Redis ping, config-cache свежее .env, pending migrations, HTTP smoke), exit 1 при провале — **запускать после любой правки `.env` и до `systemctl restart`**. Источник скриптов: `tools/liderra-monitoring/` в репо (`365d1a0`).
|
||||
- **Очередь (`liderra-queue.service`):** ✅ `Restart=on-failure` + `StartLimitBurst=5/5min` + `OnFailure=liderra-queue-alert.service` (22.05 вечер). Раньше было `Restart=always` — крутилось бесконечно при crash'е, забивая `laravel.log` (26 МБ за сутки). Теперь после 5 крашей за 5 минут systemd останавливает + шлёт email на `kdv1@bk.ru`. Источник unit'ов: `tools/liderra-monitoring/liderra-queue*.service`. ✅ **`--timeout=300` в `ExecStart`** (22.05 утро, инцидент `signal=9/KILL` каждые 60с): Laravel queue worker дефолтным `--timeout=60` убивал сам себя через `pcntl_alarm`+`posix_kill` раньше, чем `RefreshSupplierSessionJob` успевал завершить PlaywrightBridge (cold-start Chromium на 2GB VM ~65с — HOTPATCH `PlaywrightBridge.php:TIMEOUT_SECONDS=180`, но воркера про это «забыли»). Поймано через `bpftrace tracepoint:signal:signal_generate` — sender pid == target pid, comm=php. На сервере также drop-in `/etc/systemd/system/liderra-queue.service.d/timeout.conf` как safety-net до синка из репо. Проверено вживую: `RefreshSupplierSessionJob 1 мин. 5 сек. DONE` (раньше `1 мин. FAIL → KILL`).
|
||||
- **Развёрнутый прикладной код (22.05):** ✅ регистрация по коду на email + обязательный телефон (E2E live: `POST /api/auth/register/start` → 200 + реальная отправка); ✅ деление лимита лидов B1/B2/B3 (денежный фикс `distributeForPlatform`, on main `e6beff6`; **re-split существующих проектов ВЫПОЛНЕН 22.05 форсом** — все активные поделены: 50→17/17/16, 30→10/10/10, 24→8/8/8, 18→6/6/6, 15→5/5/5; переплата ×N остановлена. NB: уже-`ok` проекты со старыми ×N батч сам не перечинивает → нужен force per-project online-синк); ✅ RLS-фикс admin-impersonation (`pgsql_supplier` BYPASSRLS, on main `b32dfbc`); ✅ **автолинковка субдомен→корень** (ветка `feat/root-domain-auto-link`, 22.05 вечер): `SyncSupplierProjectJob`/`SyncSupplierProjectsJob` при синке проекта-субдомена (`krasnoyarsk.carmoney.ru`) автоматически добавляют ещё один линк к корневому supplier_project (`carmoney.ru`), если он есть в `supplier_projects`. Закрывает класс «поставщик шлёт корень `carmoney.ru` — подписчики на субдомены не получают». Утилита `App\Support\SupplierIdentifier::extractRootDomain` + артизан-команда `supplier:backfill-root-links` (идемпотентна, --dry-run); ✅ **пагинация «Проектов»** (`<v-pagination>` внизу страницы — клиент с >20 проектов теперь видит все, листая по страницам); ✅ **backfill 348 пред-проектных лидов выполнен 22.05 вечер**: они приходили от поставщика 21.05 (до создания проектов tenant 2) → молча сохранились в `supplier_leads` без сделок. После деплоя кода: balance Клиент 1 поднят 194→1 000 000 (страховка от исчерпания при массовой конверсии), `failed_webhook_jobs` 25 445→0 (исторический шторм зачищен, backup `/home/ubuntu/deploy-backups/failed_webhook_jobs_pre_truncate_*.csv.gz`), `supplier:backfill-root-links` добавил 9 root-связок к 32 site-проектам, потом `processed_at` 348 лидов сброшен в NULL и `RouteSupplierLeadJob` re-enqueue'нут — результат: **deals 6→412 у Клиента 1** (275 лидов сделали по 1 сделке, 90 silent-no-deal — поставщик шлёт на бренды без подписчиков; 73 разошлись cap=3 распределением), balance: 1 000 000→999 731 (269 lead-credits списано). Все проверены на проде. Бэкапы выкаток — `/home/ubuntu/deploy-backups/`.
|
||||
|
||||
## 3. База данных
|
||||
|
||||
- **PostgreSQL 16.14** (кластер `16/main`, порт 5432), БД `liderra`. **Версия закреплена** (`apt-mark hold postgresql-16 postgresql-client-16` + PGDG-репа отключена) — самопроизвольных апгрейдов не будет. Для патча — `apt-mark unhold` осознанно.
|
||||
- **Расширения:** `pgaudit` (журнал аудита: `ddl, role, write`, `log_parameter=off` — ПДн не логируются; журнал `/var/log/postgresql/postgresql-16-main.log`), `anon` 3.0.13 (pg_anonymizer, маскирование ПДн в выгрузках, грузится по требованию `LOAD 'anon'`), `pgcrypto`, `btree_gin`, `pg_trgm`. RLS активна (роли + политики).
|
||||
- **Бэкапы по расписанию:** `/usr/local/bin/liderra-backup.sh` через cron `/etc/cron.d/liderra-backup` — **ежедневно 03:30**, `pg_dump -Fc` в `/home/ubuntu/backups/`, **хранение 14 дней**, лог `/var/log/liderra-backup.log`. Off-site (YC Object Storage) — ещё нет.
|
||||
|
||||
## 4. Безопасность (серверный слой, SEC-1..SEC-7)
|
||||
|
||||
- **SEC-6 HTTPS** ✅ — Let's Encrypt `liderra.ru`+`www` (истекает 2026-08-20, авто-обновление certbot). nginx 2 блока: :80 редиректит на https **кроме** `/.well-known/acme-challenge/` и `/api/webhook/`; :443 — приложение. Заголовки: `HSTS max-age=31536000` (1 год, обновлено 22.05 вечер-3 с 1 недели), `X-Frame-Options SAMEORIGIN`, `X-Content-Type-Options nosniff`, `Referrer-Policy strict-origin-when-cross-origin`, **+`Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"`**, **+`X-Permitted-Cross-Domain-Policies "none"`**, **+`Cross-Origin-Opener-Policy "same-origin-allow-popups"`** (allow-popups — не ломать будущий Yandex-360 OAuth-попап), **+`Cross-Origin-Resource-Policy "same-origin"`**, **+`server_tokens off`** (скрыл `nginx/1.24.0` → `Server: nginx`). COEP `require-corp` НЕ ставил — сломал бы Google Fonts и `img-src https:` (cross-origin без CORP-opt-in). Бэкап усиления `liderra.bak-hardening-20260522-131119`, проверено `curl` + Playwright (login→dashboard под новыми заголовками, шрифты грузятся, 0 новых ошибок). **CSP** ✅ **боевой режим** — `Content-Security-Policy` (блокирует внедрение чужого кода: `script-src 'self'`, `object-src 'none'`; `style-src +'unsafe-inline'` для Vuetify + `https://fonts.googleapis.com`; `font-src` + `https://fonts.gstatic.com` для Google Fonts; `img-src 'self' data: https:`; `connect-src 'self'`; `frame-ancestors/base-uri/form-action 'self'`). Проверено в браузере на живом `/login`: 0 ошибок CSP, шрифты грузятся (googleapis/gstatic → 200), приложение работает. Бэкап `liderra.bak-20260522-054524`, reload без простоя. **22.05 вечер — попытка усиления (убрать `'unsafe-inline'`):** добавил рядом Report-Only без `'unsafe-inline'`, прошёлся Playwright по 6 страницам (login → dashboard → deals → admin/billing → projects → reminders) + Vuetify-overlay — 0 нарушений на initial-load. Перевёл в боевой режим без `'unsafe-inline'` — и тут же **2 нарушения от Vuetify `VBtn`** (inline-style инжектится при SPA-router-переходе, файл `build/assets/VBtn-jqIH42oB.js:4`, sha256 двух разных стилей). Откатил за минуту (бэкап `liderra.bak-strict-attempt-*`). Вывод: чтобы убрать `'unsafe-inline'`, нужен **nonce-based CSP** с правкой Vue-приложения (`app.config.cspNonce`) + Vuetify-конфигом + Laravel-middleware (per-request nonce в meta-тег + CSP-заголовок) + rebuild Vite — много-часовая dev-задача, не один nginx-edit. См. §6 п.4.
|
||||
- **SEC-2 анти-перебор** ✅ — прикладной throttle логина (5 попыток, лок по email+IP) + **fail2ban** (`/etc/fail2ban/jail.local`: jails `sshd` + `nginx-http-auth`, bantime 1h). NB: ~1400 неудачных SSH-попыток/сутки — fail2ban банит.
|
||||
- **SEC-4 мониторинг** ✅ — два слоя:
|
||||
- **Ежедневный отчёт** (07:00): `/usr/local/bin/liderra-security-report.sh` cron → `/var/log/liderra-security-report.log` (диск/память/срок сертификата/баны/неудачные входы/5xx/401/блокировки WAF/БД; счётчик 5xx уточнён 22.05 — считает только реальные статусы, не размеры ответов). Отчёт ежедневно шлётся на `kdv1@bk.ru` (`/usr/local/bin/liderra-mail.py`, SMTP из §7).
|
||||
- **Healthcheck доступности** (каждые 2 мин, 22.05 вечер): `/usr/local/bin/liderra-healthcheck.sh` cron `*/2 * * * *` (файл `/etc/cron.d/liderra-healthcheck`). Дёргает `https://127.0.0.1/` с `Host: liderra.ru`; 2 подряд провала (~4 мин downtime) → email **«[Лидерра ПАДЕНИЕ]»** на `kdv1@bk.ru` (с tail Laravel + nginx error log); первый 200 после DOWN → email **«[Лидерра восстановлен]»**. State в `/var/lib/liderra/healthcheck.state`, лог `/var/log/liderra-healthcheck.log`. Защита от спама: одно письмо на инцидент. Транспорт — `msmtp` (`/etc/msmtprc`, account `yandex` → smtp.yandex.ru:465 SMTPS, креды из MAIL_PASSWORD).
|
||||
- **Email при упавшем юните**: `liderra-queue-alert.service` (`OnFailure=` для `liderra-queue`) → `/usr/local/bin/liderra-systemd-alert.sh` → email с `systemctl status` + `journalctl -n 30`.
|
||||
- **Sentry — отложен** (2 ГБ RAM мало); Telegram — нет.
|
||||
- **SEC-1 WAF** ✅ **боевой режим** (`SecRuleEngine On`, с 22.05) — ModSecurity + OWASP CRS 3.3.5 (**1830 правил**), **блокирует атаки** (HTTP 403). Конфиг `/etc/modsecurity/modsecurity.conf` + `/etc/nginx/modsec/main.conf` (не `owasp-crs.load` — Apache-`IncludeOptional` несовместим с nginx-коннектором). Audit-лог `/var/log/modsec_audit.log`. **Приём лидов защищён от ложных блокировок:** вебхук `/api/webhook/` выведен в наблюдение отдельным правилом `id:1900100` в `/etc/nginx/modsec/liderra-exclusions.conf` (вне пакета CRS → переживает обновления) — endpoint и так под HMAC+rate-limit+SSRF-guard, ложное срабатывание ≠ потерянный лид. Проверено: обычная страница → 401 (не WAF), сканер `/.env` и XSS → 403 (блок), атака на пути вебхука → не блокируется. Бэкапы конфигов `*.bak-*`, reload без простоя. **Контроль FP:** периодически `sudo grep "Access denied" /var/log/modsec_audit.log`. **REST-методы разрешены (важный фикс 22.05):** CRS по умолчанию резал `PATCH`/`DELETE`/`PUT` (правило 911100) — после включения WAF молча ломалось редактирование/удаление в портале; методы разрешены в `crs-setup.conf` (`tx.allowed_methods`, бэкап `crs-setup.conf.bak-*`) **и** дублирующим правилом `id:1900200` в `liderra-exclusions.conf` (переживёт обновления CRS). Проверено: edit/delete проходят (419/405 от приложения, не 403), атаки/`/.env` режутся. **Доп. правило `id:1900300` (22.05 вечер)**: для `REQUEST_URI @beginsWith /api/` порог `tx.inbound_anomaly_score_threshold` поднят с 5 до 10 — edge-case JSON-payloads больше не дают false-positive (CRS-методы PATCH/DELETE сами по себе ранее давали +5 → 0 запаса).
|
||||
- **SEC-5 Lockbox** ✅ (хранилище заведено) — см. §5. **App-интеграция не сделана** (приложение читает секреты из файла + `.env`; реальная польза — после доработки).
|
||||
- **SEC-3 DDoS** ⏸ отложен — базовая сетевая защита YC бесплатна и активна; продвинутая платная (подписка + 976 ₽/Мбит/с + смена IP/DNS) — избыточна. Альтернатива: бесплатный Cloudflare перед сайтом.
|
||||
- **SEC-7 бэкапы + off-site** ✅ — локальные ежедневные есть (§3); **off-site (промежуточный):** `liderra-backup.sh` после дампа шифрует копию (gzip + openssl AES-256-CBC, ключ `/root/liderra-backup-crypt.key` root-600, создан однократно) и шлёт вложением на `kdv1@bk.ru` — копия переживёт потерю VM, ПДн зашифрованы. **⚠️ Ключ сохранить ВНЕ сервера** (`sudo cat /root/liderra-backup-crypt.key` → менеджер паролей), иначе emailed-бэкапы не расшифровать. Полноценный путь (YC Object Storage) — после сервис-аккаунта. IR-runbook — позже.
|
||||
- **Скан уязвимостей боевого** ✅ (22.05 вечер-3, **Nuclei v3.8.0** + 13 060 шаблонов, безопасный детект-режим). 16 217 запросов за 18 мин при rate-limit 15 RPS (щадящий темп, сайт жив 200/0.4с весь скан). **Вердикт: 32 находки — ВСЕ `info`, 0 critical/high/medium = GO ✅.** Опасных уязвимостей не найдено, WAF/SSH/TLS/cookie защита работает; находки — fingerprinting + опциональные хардеринг-заголовки (большинство закрыты выше). Артефакты `/tmp/nuclei-prod-2026-05-22.{txt,jsonl}` (не в репо). Полный отчёт — memory `project_server_hardening`. Самобана не было — fail2ban-jail `nginx-http-auth` инертен после снятия basic-auth.
|
||||
|
||||
## 5. Yandex Cloud
|
||||
|
||||
- Облако `cloud-sasha261185` = **`b1gkjq80utmr51cr929n`**, каталог `default` = **`b1gkcguod0994m1dhoam`**. Аккаунт-владелец: Sasha261185@yandex.ru.
|
||||
- **Внешний IP** `111.88.246.137` — статический (reserved), **без DDoS-провайдера** (продвинутую DDoS на него не добавить — нужен новый IP).
|
||||
- **KMS-ключ** `liderra-secrets-key` (id `abjd9hsp5dodqctpna74`, AES-256) — шифрует Lockbox.
|
||||
- **Lockbox-секрет** `liderra-secrets` (id `e6qg3l35hmemftdc6gpe`, KMS-encrypted, ACTIVE) — **8 записей**: пароли ролей БД (crm_app_user/admin/migrator/audit_writer/supplier_worker), basic_auth, 2× supplier_webhook_secret.
|
||||
- **Доступ для Claude:** только SSH к VM; YC-консоли/CLI **нет** постоянно. Разовый OAuth-токен 22.05 отозван (засветился в скриншоте). Для будущей YC-работы — заводить **сервисный аккаунт** со scoped-ролями (vpc/compute/lockbox.admin).
|
||||
|
||||
## 6. Отложено / следующие шаги
|
||||
|
||||
1. **Lockbox app-интеграция** ⏸ БЛОКИРОВАНА — нужен YC сервис-аккаунт (роль `lockbox.payloadViewer`), привязанный к VM (требует доступа к YC-консоли), + код-провайдер чтения секретов из Lockbox с fallback на `.env` + деплой. Без этого: риск падения приложения (нет пароля БД). Сейчас секреты в двух местах (файл + `.env`). Делать только с сервис-аккаунтом и fallback.
|
||||
2. **DDoS** — только если появится реальная угроза (Cloudflare free предпочтительнее платного YC).
|
||||
3. **Sentry** — при апгрейде RAM или отдельным инстансом (Б-1).
|
||||
4. **Усилить CSP** — убрать `'unsafe-inline'` из `style-src`. **Подтверждено эмпирически 22.05:** Vuetify `VBtn` инжектит inline-style при SPA-навигации (Report-Only это не ловит, только enforcing — initial-load был чист, ошибки появились ПОСЛЕ router-перехода). Нужно: (а) Laravel-middleware генерит per-request nonce, кладёт в `<meta name="csp-nonce">` и в заголовок CSP; (б) `app.config.cspNonce = <meta>` в Vue-bootstrap; (в) проверка, что Vuetify (Vue 3) подхватывает `cspNonce` для динамических `<style>`; (г) Vite-rebuild + копир-деплой; (д) перевод nginx CSP в strict с `'nonce-...'` (или CSP полностью отдаёт Laravel вместо nginx). Тестировать обязательно с router-переходами, не только initial-load. Сузить `img-src` после аудита authed-страниц — параллельно.
|
||||
5. **Off-site → YC Object Storage** — заменить промежуточный email-бэкап на бакет (после сервис-аккаунта).
|
||||
6. Sync runbook `docs/deploy/test-server-runbook.md` (ветка feat/test-deploy) с этим состоянием.
|
||||
7. ✅ **Аудит журналирования — P0 (152-ФЗ ст.18 ч.2) — DONE+DEPLOYED 22.05.2026 (`a575d55`, на origin/main → liderra.ru):** [`docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md`](docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md). 12 коммитов subagent-driven. Журнал ПДн закрыт: создание сделки (manual/webhook/supplier/import), просмотр карточки, экспорт CSV/XLSX, удаление файла отчёта (вручную + cron) — все пишут `pd_processing_log`. Impersonation: init/verify/end → `saas_admin_audit_log`; verify дополнительно → `pd_processing_log`. Pd-suite 22/22 ✅; regression 149/150 (pre-existing flake). **Выкачен 22.05 вечер:** 16 файлов scp (P0+P1 единым деплоем), backup `/home/ubuntu/deploy-backups/app-pre-p0p1-20260522-175125`, precheck ✅, `config:cache` + `systemctl reload php8.3-fpm` + `systemctl restart liderra-queue` — все service `active`.
|
||||
8. ✅ **Аудит журналирования — P1 (security + attribution) — DONE+DEPLOYED 22.05.2026 (`9fa18778`, на origin/main → liderra.ru):** [`docs/superpowers/plans/2026-05-22-audit-auth-attribution.md`](docs/superpowers/plans/2026-05-22-audit-auth-attribution.md). 10 коммитов subagent-driven. Создан общий `WritesAuthLog` трейт; `auth_log` теперь пишется на logout / register_success / 2fa verify (успех+неудача) / 2fa recovery (успех+неудача) / 2fa setup (init/confirm/disable/regen) / password reset (requested/completed/failed). В `activity_log` (DealController 4 точки + DealBulkAction 3 точки) теперь заполняются `user_id`/`ip_address`/`user_agent`. Auth-suite 131/131 ✅. **Выкачен совместно с P0 22.05 вечер; live-smoke:** `POST /api/auth/forgot` с bogus email → HTTP 200 + `auth_log` строка `event=password_reset_requested email=deploy-smoke-…@nowhere.test failure_reason=unknown_email ip_address=111.88.246.137` ✅.
|
||||
9. **Аудит журналирования — P2 (operational + авто-инциденты):** [`docs/superpowers/plans/2026-05-22-audit-operational.md`](docs/superpowers/plans/2026-05-22-audit-operational.md) — мутации проектов / API-ключей / webhook URL / admin-supplier-integration в журналы; `SupplierWebhookController` в `webhook_log` + лог отказов 404/429; cron-watcher `incidents:watch-failures` для авто-наполнения `incidents_log` (закрывает класс «25k failed без инцидента»). 10 задач, миграция новой таблицы `tenant_operations_log`. **Порядок исполнения:** P0 → P1 → P2 последовательно через subagent-driven (DealController фигурирует в P0+P1 → параллельность даст merge-конфликты).
|
||||
|
||||
## 7. Почта (исходящая, фирменная)
|
||||
|
||||
- ✅ **Настроена 22.05** — портал может слать письма (коды подтверждения регистрации и т.п.) с фирменного адреса **`verify@liderra.ru`** (имя «Лидерра»). Раньше MTA не было.
|
||||
- **Через Яндекс 360 для бизнеса** (организация id `8491092`, аккаунт-владелец Sasha261185@yandex.ru). `verify@liderra.ru` — отдельный ящик-сотрудник (НЕ алиас личного аккаунта).
|
||||
- **SMTP:** `smtp.yandex.ru:465` (SSL, `MAIL_SCHEME=smtps`), логин `verify@liderra.ru`, пароль ящика. Пароль приложения НЕ нужен (у орг-ящика 2FA off + протоколы IMAP/SMTP включены). **NB:** новый орг-ящик требует первого входа на mail.yandex.ru + принятия соглашения, иначе SMTP отдаёт `535 accept EULA first`.
|
||||
- **DNS почты** (reg.ru, домен liderra.ru): MX `→ mx.yandex.net.` (10), SPF `v=spf1 redirect=_spf.yandex.net`, DKIM `mail._domainkey`, TXT-подтверждение домена. Все подтверждены в Яндекс 360 → письма не в спам.
|
||||
- **Конфиг:** ✅ `MAIL_*` прописаны на боевом сервере 22.05 (`MAIL_MAILER=smtp`, host/port `465`/scheme `smtps`/username/password/`from=verify@liderra.ru`/`from_name=Лидерра`); было `MAIL_MAILER=log` (письма не уходили). Бэкап `.env.bak-*`. Применено через `config:cache`. **Подтверждено живой отправкой** (`SENT_OK` + E2E register/start → 200). Пароль ящика — секрет, в git нет; кандидат в Lockbox.
|
||||
- ✅ **Фича «регистрация по коду + обязательный телефон»** — **выкачена на боевой сервер 22.05** (backend 9 файлов + фронт пересобран `public/build` + `MAIL_*` + config/route cache + queue restart). E2E live: `POST /api/auth/register/start` → 200, код реально уходит на email. Код в ветке `feat/test-deploy` (`0e31783`).
|
||||
|
||||
> ✅ Закрыто 22.05: APP_URL → https://liderra.ru + SANCTUM-домены (см. §2); фирменная исходящая почта (см. §7); WAF переведён в боевой режим блокировки (см. §4); CSP в боевом режиме (блокировка) + email-алертинг отчёта + off-site зашифрованный бэкап на почту (см. §4); **выкачен прикладной код — регистрация по коду+телефон, денежный фикс лимита B1/B2/B3, RLS-фикс admin-impersonation (см. §2)**; устранён retry-шторм supplier-задачи по удалённому лиду №1 (`RouteSupplierLeadJob` `findOrFail`→`find`+terminal, фикс `0c9357a` задеплоен; очередь повторов + failed_jobs почищены, ~25k записей); **устранён 500-инцидент на всём портале (повреждённый APP_KEY, CRLF в .env, APP_KEY ротирован — все Redis-сессии невалидны); добавлен healthcheck/2 мин + email-алёрт; pre-flight гейт 15 проверок; systemd-лимиты очереди + OnFailure email; WAF threshold для /api/* 5→10 — см. §2 и §4 SEC-1/SEC-4**; **устранён цикл SIGKILL `liderra-queue` каждые 60с — добавлен `--timeout=300` в systemd ExecStart, см. §2**; **скан уязвимостей боевого Nuclei → GO ✅ (0 critical/high/medium, 32 info, см. §4)**; **nginx-усиление по итогам скана — HSTS 1нед→1год, +Permissions-Policy/X-Permitted-CDP/COOP/CORP, server_tokens off (версия nginx скрыта), см. §4 SEC-6**.
|
||||
>
|
||||
> ⚠️ Снимок волатилен. Истина — реальные команды по SSH (`systemctl is-active …`, `nginx -T`, `yc …` при наличии доступа).
|
||||
Reference in New Issue
Block a user