fix(impersonation): SaaS-admin запросы через pgsql_supplier (BYPASSRLS) — лечит RLS 42704 на проде

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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-22 08:40:16 +03:00
parent 3657e18e16
commit b32dfbcdc1
3 changed files with 44 additions and 15 deletions
@@ -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);
}
+1 -1
View File
@@ -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
-
+20 -1
View File
@@ -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,