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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
-
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user