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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user