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:
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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 () {
|
||||
// наполняется в Фазах 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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user