Files
portal/app/app/Http/Middleware/SetTenantContext.php
T
Дмитрий 56195c8a59 fix(backend): tenant middleware на auth-routes + HasPasswordRules trait + test password (audit P0-01 + O-refactor-03 + P2-01)
Закрытие аудита 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>
2026-05-09 18:22:30 +03:00

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;
}
}