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:
@@ -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).");
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user