Files
portal/docs/superpowers/plans/2026-05-16-sprint3f-api-middleware.md
T
2026-05-16 14:56:11 +03:00

30 KiB
Raw Blame History

Sprint 3F — API middleware (J1/J2) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Закрыть аудит-находки J1 (auth+tenant middleware на /api/deals*) и J2 (стаб-гейт SaaS-admin зоны /api/admin/*) — убрать незащищённые API-эндпоинты, где tenant подставляется параметром запроса.

Architecture: J1 — на 8 роутов /api/deals* навешивается ['auth:sanctum','tenant']; три контроллера (DealController, DealBulkActionController, DealExportController) перестают читать tenant_id из запроса и берут его из auth()->user()->tenant_id; 8 Pest-файлов мигрируют с ?tenant_id= на actingAs($user). J2 — новый middleware EnsureSaasAdmin (стаб: dev/testing пропускает, production fail-closed 503) вешается на весь блок /api/admin/*; реальная Yandex 360 SSO-авторизация — TODO под Б-1+DO-4.

Tech Stack: PHP 8.3 + Laravel 13, Sanctum SPA session auth, PostgreSQL 16 (RLS), Pest 4.


Контекст (audit J1/J2)

Аудит портала (docs/superpowers/specs/2026-05-15-portal-audit-design.md), раздел J:

  • J1 — «CTO-18 — auth+tenant middleware на /api/deals (требует Б-1 для prod)». Сейчас 8 роутов /api/deals* идут без middleware: tenant_id берётся параметром. Любой клиент читает/пишет сделки чужого тенанта, подставив tenant_id. auth:sanctum+tenant уже используются на /api/reminders, /api/reports/*, /api/billing/*, /api/projects — на dev работают, Б-1 их не блокирует (Б-1 блокирует только production-deploy). J1 = применить тот же middleware к /api/deals*.
  • J2 — «/api/admin/* — auth:saas-admin middleware (требует Б-1 + DO-4)». Гварда saas-admin в config/auth.php нет (только web); реальный гвард = Yandex 360 SSO, аудит явно пишет «после Б-1+DO-4» — оба registry-блокера открыты. Полноценный J2 невозможен. Решение заказчика (2026-05-16): заготовка-стаб — middleware-гейт, на dev пропускает, на production fail-closed; production SSO — TODO.

Scope J1 — backend + Pest. Фронтенд (app/resources/js/api/deals.ts и 3 view) НЕ трогаем: после рефактора backend игнорирует клиентский tenant_id (Laravel validate() молча отбрасывает лишние ключи; лишний query-параметр игнорируется), фронт продолжает слать сессионную cookie и работает без изменений. Клиентский tenant_id становится вестигиальным безвредным параметром — его удаление косметическое, в аудите J1 не значится, вне scope Sprint 3F. Это устраняет дублирующий риск (8 frontend-файлов + 11 Vitest-спеков) при нулевом выигрыше для безопасности: backend, игнорируя клиентский tenant_id, уже закрывает кросс-tenant утечку.

Регистровые items. J1 связан с CTO-18, J2 — с Б-1+DO-4 (все открыты). Sprint 3F реализует код находок J1/J2 (что заказчик авторизовал командой «делай 3f»), но не закрывает CTO-18/Б-1/DO-4 в реестре Открытые_вопросы — закрытие требует явного «закрываем» от заказчика. Реестр в этом спринте не трогаем.


File Structure

Task 1 (J2):

  • Create: app/app/Http/Middleware/EnsureSaasAdmin.php — стаб-гейт SaaS-admin зоны.
  • Modify: app/bootstrap/app.php — alias 'saas-admin' в $middleware->alias([...]).
  • Modify: app/routes/web.php — обернуть блок /api/admin/* (impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) в Route::middleware('saas-admin')->group(...).
  • Test: app/tests/Feature/SaasAdminMiddlewareTest.php — passthrough на testing + fail-closed 503 на production.

Task 2 (J1):

  • Modify: app/routes/web.php — 8 роутов /api/deals* в Route::middleware(['auth:sanctum','tenant'])->group(...).
  • Modify: app/app/Http/Controllers/Api/DealController.phpindex/show/store/update: tenant из auth()->user().
  • Modify: app/app/Http/Controllers/Api/DealBulkActionController.phptransition/destroy/restore: то же.
  • Modify: app/app/Http/Controllers/Api/DealExportController.phpexport: то же.
  • Test (migrate): app/tests/Feature/DealIndexTest.php, DealShowTest.php, DealCreateTest.php, DealUpdateTest.php, DealTransitionTest.php, DealDestroyTest.php, DealRestoreTest.php, LookupsTest.php (только 3 /api/deals-теста).

НЕ трогать: app/dev-indices.json (авто-генерируемый, pre-existing M — не стейджить); фронтенд deals.ts и deal-views (см. Scope выше); DealModelTest.php (модельный unit-тест, HTTP не вызывает); lookup-эндпоинты /api/managers и /api/lead-statuses (в аудит-находке J1 не значатся — остаются без middleware; /api/managers-тесты в LookupsTest не трогать).


Task 1: J2 — стаб-гейт EnsureSaasAdmin на /api/admin/*

Files:

  • Create: app/app/Http/Middleware/EnsureSaasAdmin.php

  • Modify: app/bootstrap/app.php

  • Modify: app/routes/web.php

  • Test: app/tests/Feature/SaasAdminMiddlewareTest.php

  • Step 1: Написать failing-тест SaasAdminMiddlewareTest.php

Создать app/tests/Feature/SaasAdminMiddlewareTest.php:

<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;

/**
 * J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
 *
 * EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
 * работает на dev), прочие окружения — fail-closed 503 до подключения
 * реального Yandex 360 SSO (TODO под Б-1+DO-4).
 */
uses(DatabaseTransactions::class);

test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
    // Дефолтное тестовое окружение = testing → middleware пропускает.
    $this->getJson('/api/admin/tenants')->assertStatus(200);
});

test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
    $this->app->detectEnvironment(fn () => 'production');

    $this->getJson('/api/admin/tenants')->assertStatus(503);
});
  • Step 2: Прогнать тест — убедиться, что падает

Run: cd app && composer test -- --filter=SaasAdminMiddlewareTest Expected: FAIL — middleware EnsureSaasAdmin ещё не существует, alias saas-admin не зарегистрирован, на роуты не навешан; тест «503 вне dev/testing» получит 200.

  • Step 3: Создать middleware EnsureSaasAdmin.php

Создать app/app/Http/Middleware/EnsureSaasAdmin.php:

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
 *
 * СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
 * SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
 * реального механизма аутентификации нет.
 *
 * Поведение стаба:
 *   - dev / testing (local, testing) → пропускаем. Admin-панель работает на
 *     dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
 *   - прочие окружения (production / staging) → fail-closed 503: зона
 *     закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
 *     открытый /api/admin/* в проде.
 *
 * TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
 * saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
 */
class EnsureSaasAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! app()->environment('local', 'testing')) {
            abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
        }

        return $next($request);
    }
}
  • Step 4: Зарегистрировать alias saas-admin в bootstrap/app.php

В app/bootstrap/app.php добавить импорт и расширить $middleware->alias([...]):

Импорт (после use App\Http\Middleware\SetTenantContext;):

use App\Http\Middleware\EnsureSaasAdmin;

Блок alias заменить на:

        $middleware->alias([
            'tenant' => SetTenantContext::class,
            'saas-admin' => EnsureSaasAdmin::class,
        ]);
  • Step 5: Навесить saas-admin на блок /api/admin/* в routes/web.php

В app/routes/web.php весь блок admin-роутов (от комментария // SaaS-admin impersonation flow (Ю-1)... до строки с AdminSuppliersController@update включительно — это группы impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) обернуть в Route::middleware('saas-admin')->group(...). Структура:

// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
// стаб не меняет — это отдельная зона ответственности.
Route::middleware('saas-admin')->group(function () {
    // SaaS-admin impersonation flow (Ю-1). ...
    Route::prefix('/api/admin/impersonation')->group(function () {
        // ... без изменений ...
    });

    // ... все остальные admin-роуты без изменений, только с отступом +4 ...

    Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
        ->where('id', '[0-9]+');
});

Содержимое роутов внутри — без изменений (только индентация +4; composer pint в Step 7 выровняет, но писать сразу корректно). Роуты /api/billing/charges, /api/billing/*, /api/api-keys, /api/tenants/me/webhook-settings, /api/dashboard/summary и далее — вне этой группы (это tenant-зона, не admin).

  • Step 6: Прогнать тест — убедиться, что зелёный

Run: cd app && composer test -- --filter=SaasAdminMiddlewareTest Expected: PASS — 2/2.

  • Step 7: Pint + Larastan + регрессия admin-тестов

Run: cd app && composer pint → 0 правок или авто-формат применён. Run: cd app && composer stan → 0 ошибок (новый файл middleware типизирован; тест использует только реальные методы TestCase, динамических свойств нет — baseline regen не требуется). Run: cd app && composer test -- --filter="Admin" → 0 failed. Все существующие admin-тесты (AdminBilling/AdminIncidents/AdminTenants/AdminSystemSettings/AdminPricingTiers/AdminSuppliers/Impersonation) проходят: на testing-окружении EnsureSaasAdmin прозрачен.

  • Step 8: Commit
git add app/app/Http/Middleware/EnsureSaasAdmin.php app/bootstrap/app.php app/routes/web.php app/tests/Feature/SaasAdminMiddlewareTest.php
git commit -m "feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/*"

НЕ стейджить app/dev-indices.json.


Task 2: J1 — auth:sanctum+tenant middleware на /api/deals*

Files:

  • Modify: app/routes/web.php
  • Modify: app/app/Http/Controllers/Api/DealController.php
  • Modify: app/app/Http/Controllers/Api/DealBulkActionController.php
  • Modify: app/app/Http/Controllers/Api/DealExportController.php
  • Test (migrate): DealIndexTest.php, DealShowTest.php, DealCreateTest.php, DealUpdateTest.php, DealTransitionTest.php, DealDestroyTest.php, DealRestoreTest.php, LookupsTest.php

NB про порядок шагов: миграция атомарна — добавление middleware немедленно «краснит» все 8 deal-тест-файлов (они не аутентифицируются). Поэтому routes+контроллеры+тесты мигрируют в одной задаче/одном коммите; промежуточный red — внутри задачи (Step 3 это фиксирует как TDD-red), green — в Step 6.

  • Step 1: Навесить middleware на 8 роутов /api/deals* в routes/web.php

В app/routes/web.php блок из 8 deal-роутов (GET /api/deals, GET /api/deals/{id}, POST /api/deals, POST /api/deals/export, POST /api/deals/transition, PATCH /api/deals/{id}, DELETE /api/deals, POST /api/deals/restore) обернуть в группу. Заменить docblock-комментарий и роуты на:

// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
//
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
// DealController, bulk (transition/destroy/restore) — в
// DealBulkActionController, export — в DealExportController.
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
    Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
    Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
    Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
    Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
    Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
    Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
    Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
    Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
});

Lookup-роуты /api/managers и /api/lead-statuses (идут сразу после) — вне группы, без изменений.

  • Step 2: Рефактор контроллеров — tenant из auth()->user()

Универсальное правило для всех 4 методов DealController + 3 методов DealBulkActionController + export DealExportController:

  1. Убрать чтение tenant_id из запроса: для index/show — строку $tenantId = (int) $request->query('tenant_id', '0'); и следующий за ней блок if ($tenantId < 1) { return ... 422; }. Для store/update/transition/destroy/restore/export — ключ 'tenant_id' => 'required|integer|min:1', из массива правил $request->validate([...]).
  2. Убрать резолюцию Tenant + 404: блок $tenant = Tenant::find(...); if ($tenant === null) { return ... 404; }exportabort(404, ...)).
  3. Добавить $tenantId = (int) $request->user()->tenant_id; (для index/show — на месте удалённого; для остальных — сразу после $validated = $request->validate([...]);).
  4. Заменить все $tenant->id на $tenantId, в use (...) замыканий $tenant$tenantId.
  5. Убрать use App\Models\Tenant; (станет неиспользуемым; composer pint подчистит, но убрать явно).
  6. Внутренние DB::transaction(...) + DB::statement('SET LOCAL app.current_tenant_id = ...')оставить без изменений. Для write-методов это атомарность; для DealExportController::export это обязательно — StreamedResponse-замыкание выполняется уже после commit'а транзакции tenant-middleware (см. комментарий в export() строки про «после Laravel-response pipeline»), tenant-контекст middleware streaming НЕ покрывает.
  7. Обновить docblock-и: убрать абзацы «На MVP без auth-middleware… tenant_id параметром… Production: middleware» — заменить на «J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user()

Конкретно по DealController:

  • index(Request $request): удалить строки $tenantId = (int) $request->query('tenant_id', '0'); + if ($tenantId < 1) {...422} + $tenant = Tenant::find($tenantId); + if ($tenant === null) {...404}. На их место: $tenantId = (int) $request->user()->tenant_id;. Остальное (уже использует $tenantId) — без изменений.
  • show(Request $request, int $id): то же — удалить query/422/Tenant::find/404, поставить $tenantId = (int) $request->user()->tenant_id;.
  • store(Request $request): из validate убрать 'tenant_id' => 'required|integer|min:1',; убрать $tenant = Tenant::find($validated['tenant_id']); if (...404); добавить $tenantId = (int) $request->user()->tenant_id;; заменить $tenant->id$tenantId (manager-guard, use (...) замыкания, SET LOCAL, Project::firstOrCreate, Deal::create, ActivityLog::create).
  • update(Request $request, int $id): из validate убрать 'tenant_id' => 'required|integer|min:1',; убрать $tenant = Tenant::find($validated['tenant_id']); if (...404); добавить $tenantId = (int) $request->user()->tenant_id;; заменить $tenant->id$tenantId (manager-guard, use (...), SET LOCAL, оба where('tenant_id', ...), три ActivityLog::create(['tenant_id' => ...])).

DealBulkActionControllertransition/destroy/restore идентично: убрать tenant_id из validate, убрать Tenant::find+404, $tenantId = (int) $request->user()->tenant_id;, $tenant->id$tenantId, use ($validated, $tenant)use ($validated, $tenantId).

DealExportController::export — убрать tenant_id из validate, убрать Tenant::find+abort(404), $tenantId = (int) $request->user()->tenant_id;, в use (...) StreamedResponse-замыкания $tenant$tenantId, $tenant->id$tenantId.

  • Step 3: Прогнать deal-тесты — убедиться в массовом red

Run: cd app && composer test -- --filter="Deal" Expected: FAIL — DealIndexTest/DealShowTest/DealCreateTest/DealUpdateTest/DealTransitionTest/DealDestroyTest/DealRestoreTest массово красные: запросы без actingAs теперь получают 401. Это подтверждает, что auth-гейт активен (TDD-red).

  • Step 4: Мигрировать 8 тест-файлов на actingAs

Универсальный рецепт для каждого HTTP-теста на /api/deals*:

(R1) beforeEach — после создания $this->tenant добавить:

    $this->user = User::factory()->for($this->tenant)->create();
    $this->actingAs($this->user);

(use App\Models\User; — добавить в импорты файла, если ещё нет.)

(R2) URL — убрать ?tenant_id=... / &tenant_id=...: '/api/deals?tenant_id='.$this->tenant->id'/api/deals'; '/api/deals?tenant_id='.$id.'&status_in[]=new''/api/deals?status_in[]=new' (если параметр был первым — следующий & становится ?).

(R3) Body — убрать ключ 'tenant_id' => ..., из массивов postJson/patchJson/deleteJson.

(R4) Тесты «404 unknown tenant_id»удалить целиком. После J1 tenant берётся из auth()->user()->tenant_id (FK-гарантированно валиден), пути «unknown tenant» больше нет. Удаляются: DealIndexTest «404 для unknown tenant_id», DealShowTest «404 для unknown tenant», DealUpdateTest «404 unknown tenant», DealTransitionTest «404 на unknown tenant», DealDestroyTest «404 на unknown tenant», DealRestoreTest «404 на unknown tenant», DealCreateTest «404 при unknown tenant_id» и «POST /api/deals/export 404 unknown tenant».

(R5) Тесты «422 без tenant_id» — конвертировать в «401 без auth»: тело — запрос без actingAs401. Пример (DealIndexTest):

test('GET /api/deals возвращает 401 без auth', function () {
    auth()->logout();
    $this->getJson('/api/deals')->assertStatus(401);
});

Конвертируются: DealIndexTest «422 без tenant_id», DealShowTest «422 без tenant_id», DealUpdateTest «422 без tenant_id».

(R6) Endpoints без теста «422 без tenant_id» (transition/destroy/restore/store/export) — добавить по одному новому тесту «401 без auth» (запрос без actingAs), чтобы каждый из 8 endpoint'ов имел 401-покрытие. Пример (DealTransitionTest):

test('POST /api/deals/transition возвращает 401 без auth', function () {
    auth()->logout();
    $this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
});

(R7) Тесты пустого body «422» (DealTransitionTest/DealDestroyTest/DealRestoreTest: postJson('/api/deals/transition', [])->assertStatus(422) и аналоги) — остаются 422 (поля ids/status по-прежнему required); actingAs обеспечивается через beforeEach (R1), иначе был бы 401. Если имя теста содержит «без tenant_id» — переименовать (например «422 на пустой body»).

(R8) DealCreateTest «422 без обязательных полей» — остаётся 422 (project_name/phone по-прежнему required), но assertJson-проверка ключей: toHaveKeys(['tenant_id', 'project_name', 'phone'])toHaveKeys(['project_name', 'phone']) (tenant_id больше не валидируемое поле).

(R9) Кросс-tenant RLS-тесты (DealIndexTest «не возвращает сделки чужого tenant'а», «изолирует чужие удалённые»; DealShowTest «404 чужая сделка»; DealUpdateTest «404 чужая сделка»; и т.п.) — оставить логику: чужие данные сеются через DB::statement('SET app.current_tenant_id = ...'), actingAs — пользователь $this->tenant. Применить только R2/R3 (убрать tenant_id из запроса). Изоляция продолжает проверяться: backend берёт tenant из auth-пользователя.

(R10) LookupsTest.php — содержит тесты /api/managers (НЕ трогать — endpoint без middleware) и 3 теста POST /api/deals (manager-guard). beforeEach НЕ менять (тесты /api/managers чувствительны к числу users тенанта — лишний $this->user сломал бы toHaveCount(2)). Вместо этого в каждый из 3 /api/deals-тестов («422 если manager_id не принадлежит tenant'у», «422 если manager_id не активен», «принимает manager_id из своего tenant'а») первой строкой добавить $this->actingAs(User::factory()->for($this->tenant)->create()); и применить R3 (убрать tenant_id из body). Файл использует RefreshDatabase — created user не протекает между тестами.

  • Step 5: Прогнать deal-тесты — убедиться в green

Run: cd app && composer test -- --filter="Deal" Run: cd app && composer test -- --filter="LookupsTest" Expected: PASS — 0 failed в обоих. Точное число тестов — из реального вывода (R4 удалил 8 тестов, R5/R6 добавил/конвертировал 401-тесты).

  • Step 6: Pint + Larastan (regen baseline) + полная регрессия Pest

Run: cd app && composer pint → авто-формат применён (в т.ч. удаление неиспользуемого use App\Models\Tenant;).

Run: cd app && composer stan → ожидаются НОВЫЕ ошибки от $this->user (новое динамическое свойство в тест-файлах) + сдвиг номеров строк → регенерировать baseline (quirk 25, 3 шага):

  1. В app/phpstan.neon временно закомментировать строку - phpstan-baseline.neon в includes:.
  2. Run: cd app && vendor/bin/phpstan analyse --generate-baseline
  3. Раскомментировать - phpstan-baseline.neon в app/phpstan.neon.

После — повторно cd app && composer stan → 0 ошибок.

Run: cd app && composer test → 0 failed (полная регрессия Pest). Базовый объём перед Sprint 3F (origin/main ca0c4d9) — 853 tests / 850 passed / 3 skipped / 0 failed; после Sprint 3F число изменится (J2 +2 теста; J1 −8 удалённых «404 unknown» +5..8 «401 без auth») — точное число из реального вывода, не экстраполировать.

  • Step 7: Commit
git add app/routes/web.php app/app/Http/Controllers/Api/DealController.php app/app/Http/Controllers/Api/DealBulkActionController.php app/app/Http/Controllers/Api/DealExportController.php app/app/Http/Controllers/Api/Concerns app/tests/Feature/DealIndexTest.php app/tests/Feature/DealShowTest.php app/tests/Feature/DealCreateTest.php app/tests/Feature/DealUpdateTest.php app/tests/Feature/DealTransitionTest.php app/tests/Feature/DealDestroyTest.php app/tests/Feature/DealRestoreTest.php app/tests/Feature/LookupsTest.php app/phpstan-baseline.neon
git commit -m "feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*"

(app/app/Http/Controllers/Api/Concerns в git add — на случай, если рефактор ничего там не создаст, путь просто проигнорируется; основное — 4 backend-файла + 8 тестов + baseline.)

НЕ стейджить app/dev-indices.json.


Self-Review

  • Spec coverage: J1 (auth+tenant на /api/deals* — 8 роутов, 3 контроллера, 8 тест-файлов) ; J2 (стаб-гейт /api/admin/*) . CTO-18/Б-1/DO-4 в реестре Открытые_вопросы не закрываются (нет «закрываем» от заказчика) — реализуется только код находок.
  • Placeholder scan: нет TODO/TBD в коде, кроме намеренного docblock-TODO в EnsureSaasAdmin (фиксирует, что стаб ждёт реального SSO под Б-1+DO-4 — это документация контракта, не пропуск работы). Тест-миграция задана точным рецептом R1–R10 (механическое преобразование, не placeholder).
  • Type consistency: $tenantIdint во всех 8 методах ((int) $request->user()->tenant_id); alias 'saas-admin' и класс EnsureSaasAdmin совпадают между bootstrap/app.php и routes/web.php; middleware-массив ['auth:sanctum','tenant'] — порядок как в существующих группах (/api/reminders и др.).
  • Атомарность J1: middleware и миграция тестов — один коммит (Step 1–7 одной задачи); промежуточный red зафиксирован Step 3 как TDD-проверка активности auth-гейта.
  • Регрессия admin/deal: J2 на testing прозрачен → admin-тесты зелёные без изменений; J1 мигрирует все потребители /api/deals* (8 Pest-файлов, включая частично LookupsTest) — фронтенд не потребитель backend-тестов, его tenant_id backend молча игнорирует.