56195c8a59
Закрытие аудита 2026-05-09 (b6ae8dd):
- P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам:
/api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63).
/api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F.
- O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register.
- P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest (≥8 chars).
- bonus-fix: SetTenantContext::resolveTenantId — property_exists() заменён на isset() для
Eloquent magic-attributes (auth-путь резолюции tenant_id никогда не работал из-за этого
бага; тесты-смоки middleware покрывали только X-Tenant-Id header / subdomain). Без фикса
P0-01 ломает 58 тестов в /api/notifications + /api/reminders + /api/reports/jobs.
Pest: 416/416 PASS.
Larastan: 0 errors.
Pint: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
79 lines
2.6 KiB
PHP
79 lines
2.6 KiB
PHP
<?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();
|
|
// Eloquent-атрибуты через __get не видны property_exists() — нужен isset()
|
|
// (вызывает __isset, который проверяет наличие атрибута через getAttribute).
|
|
if ($user !== null && isset($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;
|
|
}
|
|
}
|