phase1(eloquent): Tenant/User/Project models + SetTenantContext middleware

Первый Eloquent-слой над schema v8.6 + middleware для RLS-фильтрации
HTTP-запросов. Pest 19/19 passed (1672 ms): 4 RLS smoke + 8 model
smoke + 5 middleware + 2 default.

app/app/Models/ (NEW Tenant.php, Project.php; MODIFIED User.php):

- Tenant — saas-уровневая модель (БЕЗ RLS, тенант-родитель). Soft Deletes.
  hasMany Users, Projects.
- User — переписан под нашу схему: password_hash вместо password,
  first_name/last_name вместо name, override getAuthPassword/Name для
  Laravel auth-интеграции. Soft Deletes. belongsTo Tenant.
- Project — tenant-aware с RLS. belongsTo Tenant.

app/database/factories/ (NEW TenantFactory, ProjectFactory; MODIFIED
UserFactory):

- TenantFactory: уникальный subdomain через Str::random + дефолты
  (timezone Europe/Moscow, locale ru, is_trial true, api_key_limit 5).
- UserFactory: tenant_id через Tenant::factory() chain, email unique
  через faker, password_hash через Hash::make.
- ProjectFactory: tenant_id через factory chain, дефолты под schema.sql
  (region_mask 255, delivery_days_mask 127, assignment_strategy manual).

app/app/Http/Middleware/SetTenantContext.php (NEW, alias `tenant`):

- Резолюция tenant_id (приоритет): auth()->user() → subdomain (3+ parts
  HTTP_HOST) → X-Tenant-Id header (только dev/testing).
- Без контекста → 403 Forbidden с явным сообщением.
- SET LOCAL app.current_tenant_id внутри транзакции (DB::beginTransaction
  + SET LOCAL + next() + commit/rollback). PgBouncer-safe (Прил. И Г.1
  кейс 2).
- Зарегистрирован в bootstrap/app.php через $middleware->alias().

app/tests/Feature/ (NEW TenantModelsTest, SetTenantContextTest):

- TenantModelsTest (8 тестов): factories + связи (Tenant→users/projects,
  User→tenant, Project→tenant) + проверка User::getAuthPassword override.
- SetTenantContextTest (5 тестов): 403 без контекста, X-Tenant-Id header,
  игнор не-числового header, subdomain резолюция через absolute URL,
  middleware устанавливает app.current_tenant_id для last query.

Сопутствующие правки:

- app/.gitignore: + _ide_helper_models.php (сгенерированный, как и
  _ide_helper.php — не в репо)
- app/phpstan-baseline.neon: regenerated — Pest dynamic methods
  ($this->get(), withHeaders()) и factory-return-type mismatch (Larastan
  v3 не понимает array<string, mixed> vs array<model property of T>) +
  Request::user/header (Larastan тип hint мисс) + console.php $this в
  Closure — все в baseline до миграции на typed properties / pest extension
- CLAUDE.md §6: Pest 6/6 → 19/19, добавлены модели + middleware
- memory project_state.md, MEMORY.md: обновлены под новый этап

Не сделано в этой сессии (отложено):

- Deal model — composite primary key (id, received_at) + partitioned
  parent. Эта модель сложнее по архитектуре Eloquent.
- ide-helper:models -W -M -N запускался — добавил @mixin IdeHelperX
  и сгенерил _ide_helper_models.php; но т.к. этот файл gitignored,
  @mixin строки удалены из моделей (PHPStan не нашёл бы класс на CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-08 14:29:50 +03:00
parent c6f9c62da0
commit 5560ebbdfd
13 changed files with 634 additions and 27 deletions
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Устанавливает app.current_tenant_id в текущей транзакции PG для RLS-фильтрации.
*
* Стратегия: оборачивает HTTP-запрос в транзакцию (DB::beginTransaction),
* выполняет `SET LOCAL app.current_tenant_id = X`, после next() commit/rollback.
* SET LOCAL действует только в пределах транзакции это PgBouncer-safe (Прил. И Г.1).
*
* Резолюция tenant_id (приоритет):
* 1. authenticated user `auth()->user()->tenant_id`
* 2. subdomain (production) tenants.subdomain
* 3. header `X-Tenant-Id` integer (только dev/testing)
* Если ни одна стратегия не сработала 403 Forbidden.
*/
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
$tenantId = $this->resolveTenantId($request);
if ($tenantId === null) {
abort(403, 'No tenant context (no auth, subdomain, or X-Tenant-Id header).');
}
DB::beginTransaction();
try {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$response = $next($request);
DB::commit();
return $response;
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
}
private function resolveTenantId(Request $request): ?int
{
$user = $request->user();
if ($user !== null && property_exists($user, 'tenant_id') && $user->tenant_id !== null) {
return (int) $user->tenant_id;
}
$host = $request->getHost();
$parts = explode('.', $host);
if (count($parts) >= 3) {
$tenant = Tenant::query()->where('subdomain', $parts[0])->first();
if ($tenant !== null) {
return (int) $tenant->id;
}
}
if ($request->hasHeader('X-Tenant-Id')) {
$headerValue = $request->header('X-Tenant-Id');
if (is_string($headerValue) && ctype_digit($headerValue)) {
return (int) $headerValue;
}
}
return null;
}
}