From b32dfbcdc1c70f6aff76ee4ebf660fd84d091b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 22 May 2026 08:40:16 +0300 Subject: [PATCH] =?UTF-8?q?fix(impersonation):=20SaaS-admin=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20pgsql=5Fsupplier=20(BYPASSRLS)=20=E2=80=94=20=D0=BB=D0=B5?= =?UTF-8?q?=D1=87=D0=B8=D1=82=20RLS=2042704=20=D0=BD=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImpersonationController читал/писал impersonation_tokens+tenants через дефолтное подключение (crm_app_user, RLS on). У saas-admin нет tenant-контекста (middleware 'tenant' на /api/admin/* не висит) -> app.current_tenant_id не задан -> SELECT падал SQLSTATE 42704. На dev маскировалось postgres-superuser'ом. Фикс: запросы к impersonation_tokens/tenants через BYPASSRLS pgsql_supplier (как AdminSupplierIntegrationController; модель уже документирует BYPASSRLS-доступ). Транзакция в verify() убрана — increment атомарен, isUsable() гейтит attempts<5. Тест: +SharesSupplierPdo + regression на подключение; baseline getJson 2->3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/ImpersonationController.php | 36 ++++++++++++------- app/phpstan-baseline.neon | 2 +- app/tests/Feature/ImpersonationTest.php | 21 ++++++++++- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/app/Http/Controllers/Api/ImpersonationController.php b/app/app/Http/Controllers/Api/ImpersonationController.php index 8361f2de..9cd487a5 100644 --- a/app/app/Http/Controllers/Api/ImpersonationController.php +++ b/app/app/Http/Controllers/Api/ImpersonationController.php @@ -9,7 +9,6 @@ use App\Models\ImpersonationToken; use App\Models\Tenant; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; /** @@ -39,10 +38,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 +76,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']) @@ -105,7 +114,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 +122,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), @@ -146,7 +155,7 @@ class ImpersonationController extends Controller $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 +173,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' => 'Неверный код.', @@ -196,7 +206,7 @@ class ImpersonationController extends Controller { $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); } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index e93cb016..c92a26ba 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 - diff --git a/app/tests/Feature/ImpersonationTest.php b/app/tests/Feature/ImpersonationTest.php index cdd489ac..87279041 100644 --- a/app/tests/Feature/ImpersonationTest.php +++ b/app/tests/Feature/ImpersonationTest.php @@ -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,