feat(sales): guard sales (sanctum) + provider + таблица токенов

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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 12:36:13 +03:00
parent 9b91016f46
commit bdcb82f8f7
6 changed files with 191 additions and 7 deletions
+14
View File
@@ -1,5 +1,6 @@
<?php
use App\Models\SalesUser;
use App\Models\User;
return [
@@ -49,6 +50,13 @@ return [
'impersonation' => [
'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,
],
],
/*
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Таблица Sanctum personal_access_tokens для Bearer-токенов портала продаж.
*
* Проект использует SPA cookie-auth для основного кабинета (таблица раньше не
* создавалась), но портал отдела продаж (guard «sales») использует Sanctum
* API-токены: SalesAuthController->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');
}
};
+13 -1
View File
@@ -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
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
/**
* TDD: guard «sales» (Sanctum driver, provider sales_users).
*
* Проверяет, что:
* 1. Bearer-токен sales_user открывает доступ к маршруту auth:sales.
* 2. Запрос без токена получает 401.
*
* Примечание по кросс-модельной проверке:
* Стандартный tenant User (App\Models\User) в этом проекте НЕ использует
* HasApiTokens (SPA cookie-auth), поэтому createToken() на User недоступен.
* Кросс-модельная изоляция guard'а sales гарантируется архитектурно:
* Sanctum привязывает tokenable_type к модели в personal_access_tokens;
* guard с provider sales_users резолвит только записи, где tokenable_type =
* App\Models\SalesUser. Тест на «чужой токен» возможен между двумя
* разными SalesUser разные пользователи корректно разрешаются (auth OK
* для обоих), но привязка к конкретному пользователю верна.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.3)
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
// Стаб-маршрут, защищённый guard'ом sales.
Route::middleware('auth:sales')->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();
});
+23 -1
View File
@@ -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 системных таблиц
@@ -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 () {
// наполняется в Фазах 17 (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`.