feat(supplier): Plan 3 Task 3 — switch supplier-flow на pgsql_supplier (BYPASSRLS)

Закрывает 3 backlog-айтема Plan 2.6 одной правкой:
- BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL теперь проходит
  (BYPASSRLS обходит RLS-политику отвергавшую NULL под обычной ролью)
- WARN #2: LeadRouter::matchEligibleProjects видит projects всех tenant'ов
  через Project::on('pgsql_supplier') без SET LOCAL app.current_tenant_id
- WARN #3: ResetDeliveredTodayCommand обновляет projects всех tenant'ов
  через DB::connection('pgsql_supplier')

Архитектура: crm_supplier_worker BYPASSRLS-роль (создана Plan 2.6 #iv 7899071)
+ новый pgsql_supplier connection в config/database.php. WHERE(tenant_id=)
фильтры сохраняются как defense-in-depth.

Уточнение по Job's $connection: оригинальный план предполагал public $connection
= 'pgsql_supplier' на RouteSupplierLeadJob, но в Laravel Job's $connection
управляет очередью (sync/database/redis), не БД. Заменено на константу
RouteSupplierLeadJob::DB_CONNECTION + явный DB::connection(self::DB_CONNECTION)
в failed() callback'е. Это:
1) не ломает queue resolution (без этой правки тесты падают
   'pgsql_supplier queue connection has not been configured')
2) явно документирует intent — failed_webhook_jobs INSERT идёт через BYPASSRLS
3) handle()'s tenant-scoped транзакции остаются на default pgsql + SET LOCAL,
   где RLS нужна для defense-in-depth.

Также добавлено в tests/TestCase.php разделение PDO между pgsql и
pgsql_supplier connection'ами через setPdo/setReadPdo — иначе DatabaseTransactions
не откатывал бы supplier-side данные (две PDO-сессии = две независимые транзакции,
supplier не видит uncommitted INSERTs default-side).

Brainstorm decision: вариант C из 3 опций (A=schema bump, B=отдельная таблица,
C=BYPASSRLS-role). См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.

+4 теста в Feature/Supplier/SupplierConnectionTest.php (DB_CONNECTION constant +
BLOCKER#6 + WARN#2 + WARN#3). 0 schema changes.

Pest: 562/560 + 2 skipped (baseline 558/556 + 4 new = 562/560, ok). PHPStan: 0 errors
(добавлен 1 baseline entry для известного Pest+PHPStan limitation на artisan()).
Pint: clean.
This commit is contained in:
Дмитрий
2026-05-11 01:00:47 +03:00
parent 989256b034
commit 6d6181b8cc
8 changed files with 245 additions and 20 deletions
+14
View File
@@ -28,6 +28,20 @@ DB_DATABASE=liderra
DB_USERNAME=postgres
DB_PASSWORD=
# Supplier Sync (Plan 3 Task 3) — crm_supplier_worker BYPASSRLS connection.
# На production указывают на роль crm_supplier_worker (создана db/00_create_roles.sql v1.1).
# Если не заданы — fallback на DB_USERNAME / DB_PASSWORD (dev: postgres superuser, BYPASSRLS implicit).
DB_SUPPLIER_USERNAME=crm_supplier_worker
DB_SUPPLIER_PASSWORD=
# Supplier Portal credentials (Playwright login — Task 5)
SUPPLIER_LOGIN=
SUPPLIER_PASSWORD=
SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
# Supplier alerts (email через Unisender Go relay)
SUPPLIER_ALERT_EMAIL=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
@@ -10,13 +10,13 @@ use Illuminate\Support\Facades\DB;
/**
* Сброс projects.delivered_today=0 для всех tenant'ов.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1.
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1 +
* docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
* Расписание: каждый день в 00:00 МСК (timezone Europe/Moscow).
*
* NB: tenant-scoped запрос без RLS UPDATE сразу на все tenant'ы. На production
* queue worker (через Scheduler) запускается под ролью crm_supplier_worker
* (BYPASSRLS) Plan 2.6 fix #iv. На dev подключение под postgres (BYPASSRLS
* implicit). См. db/00_create_roles.sql.
* Plan 3 Task 3 (WARN #3): UPDATE идёт через connection `pgsql_supplier`
* (BYPASSRLS-роль crm_supplier_worker), что позволяет одним statement'ом сбросить
* счётчики по всем tenant'ам без SET LOCAL app.current_tenant_id для каждого.
*/
class ResetDeliveredTodayCommand extends Command
{
@@ -26,7 +26,8 @@ class ResetDeliveredTodayCommand extends Command
public function handle(): int
{
$affected = DB::update('UPDATE projects SET delivered_today = 0 WHERE delivered_today <> 0');
$affected = DB::connection('pgsql_supplier')
->update('UPDATE projects SET delivered_today = 0 WHERE delivered_today <> 0');
$this->info("Reset delivered_today on {$affected} project(s).");
+18 -6
View File
@@ -61,6 +61,20 @@ class RouteSupplierLeadJob implements ShouldQueue
public int $timeout = 60;
/**
* Plan 3 Task 3: имя DB-connection (BYPASSRLS-роль crm_supplier_worker), через который
* supplier-flow обходит RLS для sharing-операций (failed_webhook_jobs с tenant_id=NULL).
*
* NB: это НЕ $this->connection из Bus\Queueable то управляет очередью, не БД.
* Job's queue connection остаётся default (sync/database), а DB-операции в failed()
* явно идут через DB::connection(self::DB_CONNECTION). Tenant-scoped транзакции в
* handle() (createDealCopyForProject) продолжают использовать default `pgsql`
* с SET LOCAL app.current_tenant_id там RLS нужна.
*
* См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
*/
public const DB_CONNECTION = 'pgsql_supplier';
public function __construct(public int $supplierLeadId) {}
public function handle(
@@ -292,15 +306,13 @@ class RouteSupplierLeadJob implements ShouldQueue
* tenant ещё не определён на момент routing'а) для ручного разбора.
* supplier_lead.error апдейтится текстом исключения.
*
* ⚠️ PRODUCTION: failed_webhook_jobs имеет RLS-policy `tenant_isolation USING
* (tenant_id = current_setting('app.current_tenant_id')::bigint)`. INSERT с
* tenant_id=NULL под non-BYPASSRLS ролью молча отклонится (NULL = ::bigint NULL).
* Queue-worker обязан подключаться к БД под elevated-ролью (postgres / service-account
* с BYPASSRLS) ИЛИ нужен INSERT-policy WITH CHECK (true) на эту таблицу Plan 3+.
* INSERT с tenant_id=NULL проходит благодаря $connection='pgsql_supplier'
* (BYPASSRLS-роль crm_supplier_worker обходит политику tenant_isolation,
* которая под обычной ролью отвергла бы NULL). Закрыто Plan 3 Task 3.
*/
public function failed(Throwable $e): void
{
DB::table('failed_webhook_jobs')->insert([
DB::connection(self::DB_CONNECTION)->table('failed_webhook_jobs')->insert([
'tenant_id' => null,
'webhook_log_id' => null,
'raw_payload' => json_encode([
+8 -7
View File
@@ -23,13 +23,14 @@ use InvalidArgumentException;
* district-bit резолвится по 3/4-значному коду в PHP-словаре).
* 7. Сортировка: created_at ASC, id ASC (детерминированно spec §6 step 4).
*
* RLS-quirk: запрос работает поверх N tenant'ов одновременно (sharing-model).
* Не использует SET LOCAL app.current_tenant_id (в sharing-flow tenant ещё не определён
* запрос подбирает кандидатов из всех tenant'ов параллельно). На production queue worker
* запускается под ролью crm_supplier_worker (BYPASSRLS) Plan 2.6 fix #iv. На dev
* подключение под postgres (BYPASSRLS implicit). См. db/00_create_roles.sql.
* Plan 3 Task 3: запрос идёт через connection `pgsql_supplier` (BYPASSRLS-роль
* crm_supplier_worker). Это закрывает WARN #2 — в sharing-flow tenant ещё не
* определён, SELECT обходит RLS-фильтрацию и видит проекты ВСЕХ tenant'ов
* параллельно. WHERE-фильтры (is_active, FK на supplier_project, workdays, лимиты,
* balance) сохраняются как defense-in-depth.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 +
* docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
*/
class LeadRouter
{
@@ -57,7 +58,7 @@ class LeadRouter
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
/** @var Collection<int, Project> $candidates */
$candidates = Project::query()
$candidates = Project::on('pgsql_supplier')
->where($fkColumn, $supplierProject->id)
->where('is_active', true)
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
+42
View File
@@ -103,6 +103,48 @@ return [
'timezone' => env('DB_TIMEZONE', 'UTC'),
],
// Plan 3 Task 3: dedicated PG connection для supplier-flow под BYPASSRLS-ролью
// crm_supplier_worker (создана Plan 2.6 #iv 7899071 в db/00_create_roles.sql v1.1).
// Закрывает 3 backlog-айтема одной правкой:
// - BLOCKER #6: INSERT в failed_webhook_jobs с tenant_id=NULL под BYPASSRLS
// проходит (политика tenant_isolation отвергает NULL под обычной ролью).
// - WARN #2: LeadRouter::matchEligibleProjects видит проекты ВСЕХ tenant'ов
// без SET LOCAL app.current_tenant_id (sharing-model §6 spec'а).
// - WARN #3: ResetDeliveredTodayCommand сбрасывает delivered_today по всем
// tenant'ам в одном UPDATE.
//
// На production env-keys DB_SUPPLIER_USERNAME=crm_supplier_worker + DB_SUPPLIER_PASSWORD
// указывают на BYPASSRLS-роль. На dev fallback на DB_USERNAME/DB_PASSWORD (postgres
// superuser — BYPASSRLS implicit), тесты работают без отдельной роли.
//
// WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth — если в будущем
// роль сменится на не-BYPASSRLS, бизнес-логика останется корректной.
//
// Brainstorm decision: вариант C (BYPASSRLS-role) из 3 опций. См.
// docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
'pgsql_supplier' => array_merge(
[
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'timezone' => env('DB_TIMEZONE', 'UTC'),
],
[
'username' => env('DB_SUPPLIER_USERNAME', env('DB_USERNAME', 'root')),
'password' => env('DB_SUPPLIER_PASSWORD', env('DB_PASSWORD', '')),
'options' => [
'_role_purpose' => 'crm_supplier_worker (BYPASSRLS) for supplier-flow jobs',
],
]
),
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
+6
View File
@@ -756,6 +756,12 @@ parameters:
count: 11
path: tests/Feature/RemindersDispatchDueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/SupplierConnectionTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* Plan 3 Task 3 supplier-flow на pgsql_supplier (BYPASSRLS) connection.
*
* 4 regression-теста, закрывающих 3 backlog-айтема Plan 2.6:
* 1. RouteSupplierLeadJob $connection = 'pgsql_supplier' (sanity-check, что свойство выставлено).
* 2. BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier
* (политика tenant_isolation отвергает NULL под обычной ролью; BYPASSRLS обходит).
* 3. WARN #2: LeadRouter::matchEligibleProjects видит проекты ВСЕХ tenant'ов
* без SET LOCAL app.current_tenant_id (sharing-model).
* 4. WARN #3: ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам.
*
* Spec: docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
*
* NB: На dev DB_USERNAME=postgres (superuser, BYPASSRLS implicit) fallback в
* config/database.php pgsql_supplier берёт DB_USERNAME, тесты работают без отдельной
* роли. На production env-keys DB_SUPPLIER_USERNAME/PASSWORD указывают на crm_supplier_worker.
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
// Симулируем условия supplier-flow на queue worker'е: tenant ещё не определён.
// set_config('app.current_tenant_id', '0', true) — session-scoped + откатывается транзакцией.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task 3)', function (): void {
// Job's $connection из Bus\Queueable управляет очередью (sync/database/redis), не БД.
// Для DB-операций используется константа DB_CONNECTION — failed() callback пишет
// в failed_webhook_jobs через DB::connection(RouteSupplierLeadJob::DB_CONNECTION).
// Закрывает BLOCKER #6: INSERT с tenant_id=NULL проходит под BYPASSRLS-роль.
expect(RouteSupplierLeadJob::DB_CONNECTION)->toBe('pgsql_supplier');
});
test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void {
// Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
// отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false).
// Под pgsql_supplier (BYPASSRLS на prod / postgres superuser на dev) INSERT проходит.
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
'tenant_id' => null,
'webhook_log_id' => null,
'raw_payload' => json_encode(['supplier_lead_id' => 42, 'project' => 'B1_test.ru']),
'exception' => 'simulated failure for BLOCKER #6 regression test',
'retry_count' => 3,
'failed_at' => now(),
]);
$exists = DB::connection('pgsql_supplier')
->table('failed_webhook_jobs')
->whereNull('tenant_id')
->where('exception', 'simulated failure for BLOCKER #6 regression test')
->exists();
expect($exists)->toBeTrue();
});
test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void {
// 3 tenant × 2 проекта = 6 проектов, все привязаны к одному supplier_project.
// БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной
// ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'plan3-task3-warn2.example.com',
]);
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
foreach ($tenants as $tenant) {
for ($i = 0; $i < 2; $i++) {
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'plan3-task3-warn2.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
}
}
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
expect($eligible)->toHaveCount(6);
});
test("ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам (WARN #3)", function (): void {
// Создаём 3 tenant'а с проектами, у каждого delivered_today=5.
// Команда должна сбросить все 3 → 0 (под pgsql_supplier BYPASSRLS — без SET LOCAL).
$tenants = Tenant::factory()->count(3)->create();
$projectIds = [];
foreach ($tenants as $tenant) {
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'delivered_today' => 5,
]);
$projectIds[] = $project->id;
}
$this->artisan('projects:reset-delivered-today')->assertExitCode(0);
$remaining = DB::connection('pgsql_supplier')
->table('projects')
->whereIn('id', $projectIds)
->where('delivered_today', '>', 0)
->count();
expect($remaining)->toBe(0);
});
+25 -1
View File
@@ -3,8 +3,32 @@
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\DB;
abstract class TestCase extends BaseTestCase
{
//
/**
* Plan 3 Task 3: share PDO between `pgsql` and `pgsql_supplier` connections в тестах,
* чтобы DatabaseTransactions corretly rollback'ил данные, созданные через дефолтный
* connection, но запрошенные через supplier. Без этого Project::on('pgsql_supplier')
* не видит свежесозданные через Project::factory() записи две PDO-сессии = две
* разные транзакции, supplier-side не видит uncommitted INSERTs default-side.
*
* На production обе роли (crm_app_user + crm_supplier_worker) две настоящие
* сессии, общая видимость через commit. В тестах достаточно одной разделяемой PDO.
*/
protected function setUp(): void
{
parent::setUp();
// После того как Laravel инициализировал dafault connection (pgsql),
// ре-используем его PDO для pgsql_supplier. Делаем только если оба
// connection'а есть в конфиге (продакшен может не иметь supplier).
if (config()->has('database.connections.pgsql_supplier')) {
$defaultPdo = DB::connection('pgsql')->getPdo();
$defaultReadPdo = DB::connection('pgsql')->getReadPdo();
DB::connection('pgsql_supplier')->setPdo($defaultPdo);
DB::connection('pgsql_supplier')->setReadPdo($defaultReadPdo);
}
}
}