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,