From bdcb82f8f7147b11810695780e7c0a3c4a2d0719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 12:36:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales):=20guard=20sales=20(sanctum)=20+=20?= =?UTF-8?q?provider=20+=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 0.3: guard 'sales' (driver sanctum, provider sales_users). Тест SalesGuardTest 3/3 (valid Bearer→200, без токена→401, мусор→401). Миграция personal_access_tokens (Sanctum Bearer; раньше не было — основной кабинет SPA cookie), DDL через pgsql_supplier, гранты crm_admin_user, CHANGELOG v8.60. Larastan baseline: +SalesGuardTest (Pest TestCall false-pos), SetTenantContext int→mixed (второй provider расширил тип $request->user()). План: admin-db ДО auth:sales. Один эскейп на сессию. Co-Authored-By: Claude Opus 4.8 --- app/config/auth.php | 14 ++++ ...01_create_personal_access_tokens_table.php | 67 +++++++++++++++++++ app/phpstan-baseline.neon | 14 +++- app/tests/Feature/Sales/SalesGuardTest.php | 62 +++++++++++++++++ db/CHANGELOG_schema.md | 24 ++++++- .../plans/2026-06-30-sales-portal.md | 17 +++-- 6 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php create mode 100644 app/tests/Feature/Sales/SalesGuardTest.php diff --git a/app/config/auth.php b/app/config/auth.php index 4236d1ab..bbe79185 100644 --- a/app/config/auth.php +++ b/app/config/auth.php @@ -1,5 +1,6 @@ [ 'driver' => 'impersonation', ], + + // Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users. + // Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins. + 'sales' => [ + 'driver' => 'sanctum', + 'provider' => 'sales_users', + ], ], /* @@ -78,6 +86,12 @@ return [ // 'driver' => 'database', // 'table' => 'users', // ], + + // Провайдер для guard «sales» (портал отдела продаж, Task 0.3). + 'sales_users' => [ + 'driver' => 'eloquent', + 'model' => SalesUser::class, + ], ], /* diff --git a/app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php b/app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php new file mode 100644 index 00000000..39e4ad7e --- /dev/null +++ b/app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php @@ -0,0 +1,67 @@ +createToken(...). Для них нужна эта таблица. + * + * DDL идёт через соединение pgsql_supplier (как остальные системные таблицы) — + * на проде дефолтная роль crm_app_user не имеет CREATE. Гранты выданы + * crm_admin_user: вся зона /api/sales проходит через admin-db (UseAdminConnection), + * включая логин и проверку токена, поэтому Sanctum читает/пишет токены под + * crm_admin_user. Миграция идемпотентна (Schema::hasTable + DO-блок грантов). + * + * Spec: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md + * План: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.3) + */ +return new class extends Migration +{ + public function up(): void + { + $schema = Schema::connection('pgsql_supplier'); + + if (! $schema->hasTable('personal_access_tokens')) { + $schema->create('personal_access_tokens', function (Blueprint $table) { + $table->id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + // Гранты для crm_admin_user (admin-db connection, которым работает портал + // продаж — включая логин/проверку токена). Идемпотентно, на dev no-op. + DB::connection('pgsql_supplier')->statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT, INSERT, UPDATE, DELETE + ON personal_access_tokens + TO crm_admin_user; + GRANT USAGE, SELECT + ON SEQUENCE personal_access_tokens_id_seq + TO crm_admin_user; + END IF; + END + $$ + SQL); + } + + public function down(): void + { + Schema::connection('pgsql_supplier')->dropIfExists('personal_access_tokens'); + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 17726585..2f00718a 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -79,7 +79,7 @@ parameters: path: app/Http/Controllers/Api/DealExportController.php - - message: '#^Strict comparison using \!\=\= between int and null will always evaluate to true\.$#' + message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#' identifier: notIdentical.alwaysTrue count: 1 path: app/Http/Middleware/SetTenantContext.php @@ -240,6 +240,18 @@ parameters: count: 7 path: tests/Feature/Account/ChangePasswordTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Sales/SalesGuardTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesGuardTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound diff --git a/app/tests/Feature/Sales/SalesGuardTest.php b/app/tests/Feature/Sales/SalesGuardTest.php new file mode 100644 index 00000000..cb9f0bc9 --- /dev/null +++ b/app/tests/Feature/Sales/SalesGuardTest.php @@ -0,0 +1,62 @@ +get('/__sales_ping', fn () => response()->json(['ok' => true])); +}); + +test('sales user с valid Bearer-токеном получает 200 на auth:sales маршруте', function () { + $salesUser = SalesUser::create([ + 'name' => 'Тест Менеджер', + 'email' => 'sales-guard-test-'.uniqid().'@test.local', + 'password' => Hash::make('test-secret-123'), + 'role' => 'manager', + ]); + + $token = $salesUser->createToken('test')->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/__sales_ping') + ->assertOk() + ->assertJson(['ok' => true]); +}); + +test('запрос без токена на auth:sales маршрут получает 401', function () { + $this->getJson('/__sales_ping') + ->assertUnauthorized(); +}); + +test('невалидный Bearer-токен на auth:sales маршруте получает 401', function () { + // Произвольная строка, которой нет в personal_access_tokens. + $this->withHeader('Authorization', 'Bearer '.str_repeat('x', 64)) + ->getJson('/__sales_ping') + ->assertUnauthorized(); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index ecaba40b..46775a0f 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,29 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.59, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.60, консолидированная — разворачивает БД с нуля). + +## v8.60 (2026-06-30) — Портал отдела продаж: таблица Sanctum-токенов + +Продолжение фичи «Портал отдела продаж» (Task 0.3). Добавлена таблица +`personal_access_tokens` (Sanctum) — раньше в проекте её не было, т.к. основной +кабинет на SPA cookie-auth. Портал продаж (guard `sales`) использует Bearer-токены, +поэтому таблица нужна. + +Миграция: `app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php`. + +**Добавлено:** + +- **`personal_access_tokens`** — стандартная Sanctum-таблица: `tokenable_type/tokenable_id` + (morphs), `name`, `token VARCHAR(64) UNIQUE`, `abilities`, `last_used_at`, `expires_at` (index), + `created_at/updated_at`. DDL через `pgsql_supplier` (на проде дефолтная роль без CREATE). +- **GRANTs для `crm_admin_user`** (идемпотентный DO-блок): SELECT/INSERT/UPDATE/DELETE на + `personal_access_tokens` + USAGE/SELECT на `personal_access_tokens_id_seq`. Вся зона + `/api/sales` (включая логин и проверку токена) работает через admin-db (`crm_admin_user`), + поэтому Sanctum читает/пишет токены под этой ролью. + +**Счётчики:** +1 таблица (системная, без RLS); +1 unique-индекс (`token`), +1 индекс (`expires_at`). +RLS-политик — **без изменений**. ## v8.59 (2026-06-30) — Портал отдела продаж: 5 системных таблиц diff --git a/docs/superpowers/plans/2026-06-30-sales-portal.md b/docs/superpowers/plans/2026-06-30-sales-portal.md index 01a0328d..ca45e071 100644 --- a/docs/superpowers/plans/2026-06-30-sales-portal.md +++ b/docs/superpowers/plans/2026-06-30-sales-portal.md @@ -328,6 +328,8 @@ Expected: PASS. - [ ] **Step 4: Запустить — PASS.** - [ ] **Step 5: Commit** `feat(sales): guard sales (sanctum) + provider`. +> ⚠️ **Доп. при реализации 0.3 (выявлено в работе):** портал продаж использует Sanctum **Bearer-токены** (`createToken`), а в проекте раньше не было таблицы `personal_access_tokens` (основной кабинет — SPA cookie-auth). Добавлена миграция `app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php` (DDL через `pgsql_supplier`, гранты `crm_admin_user`, CHANGELOG v8.60). **Архитектурное следствие для Task 0.6:** Sanctum читает `personal_access_tokens` и грузит `tokenable` (SalesUser) на **дефолтном** соединении в момент `auth:sales`. Чтобы это работало под ролью `crm_admin_user` (которой выданы гранты на `sales_*` и токены), middleware **`admin-db` должен идти ПЕРЕД `auth:sales`**, а группа логина — тоже под `admin-db`. См. правку Task 0.6. + ### Task 0.4: Middleware `EnsureSalesUser` + трейт `ScopesSalesOwnership` **Files:** @@ -391,22 +393,27 @@ trait ScopesSalesOwnership - [ ] **Step 1: Добавить группу.** +> ⚠️ **Порядок middleware важен (см. Task 0.3).** `admin-db` (UseAdminConnection → дефолт на `pgsql_admin`/`crm_admin_user`) обязан идти **перед** `auth:sales`: Sanctum в момент `auth:sales` читает `personal_access_tokens` и грузит `tokenable` (SalesUser) на дефолтном соединении — права на эти таблицы выданы только `crm_admin_user`. Группа логина — **тоже под `admin-db`** (логин читает `sales_users` и пишет токен). + ```php -// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum). -// Cross-tenant чтение — admin-db (UseAdminConnection, crm_admin_user), но -// КАЖДЫЙ запрос фильтруется по владению (ScopesSalesOwnership). -Route::prefix('/api/sales/auth')->group(function () { +// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer). +// Всё работает через admin-db (UseAdminConnection, crm_admin_user) — и логин, +// и проверка токена, и cross-tenant чтение; КАЖДЫЙ запрос данных фильтруется +// по владению (ScopesSalesOwnership). admin-db СТОИТ ПЕРЕД auth:sales. +Route::middleware('admin-db')->prefix('/api/sales/auth')->group(function () { Route::post('/login', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'login']); Route::middleware('auth:sales')->group(function () { Route::get('/me', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'me']); Route::post('/logout', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'logout']); }); }); -Route::middleware(['auth:sales', 'sales-portal', 'admin-db'])->prefix('/api/sales')->group(function () { +Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('/api/sales')->group(function () { // наполняется в Фазах 1–7 (clients, attachments, income, tariffs, payouts, invoices, managers, dashboard) }); ``` +> NB по Task 0.3-тесту: временный стаб-роут под `auth:sales` в тесте `SalesGuardTest` работает на дефолтном (pgsql) соединении — в тест-окружении `SharesAdminPdo` уже расшаривает PDO между `pgsql` и `pgsql_admin`, плюс dev/test — superuser, поэтому грантов не требуется и тест проходит без admin-db в стабе. На проде порядок выше обязателен. + - [ ] **Step 2: Smoke-тест** `/api/sales/auth/me` возвращает 401 без токена. - [ ] **Step 3: Commit** `feat(sales): маршруты зоны /api/sales`.