Files
portal/app/app/Http/Middleware/SetTenantContext.php
T
Дмитрий 1fd6f7f597 fix(security): harden impersonation/webhook/tenant — audit A2/A3/B3/C2
- A2: impersonation _dev_plain_code в ответе init только в local/testing
- A3: X-Tenant-Id принимается только в local/testing (anti-spoof тенанта)
- B3: WebhookReceiveController isHmacRequired() default false→true (fail-secure)
- C2: SupplierWebhookController per-IP rate-limit 600/min (DoS-guard)
- WebhookReceiveTest обновлён под B3 (отсутствие настройки → 401)

Tests: 70/70 passed (323 assertions) — Webhook/Impersonation/Tenant suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:16:13 +03:00

82 lines
3.0 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;
}
}
// Audit-fix A3: X-Tenant-Id принимается ТОЛЬКО на dev/testing. На prod
// заголовок игнорируется — иначе на любом роуте с `tenant`, но без
// auth-middleware возможен спуфинг тенанта произвольным значением.
if (app()->environment('local', 'testing') && $request->hasHeader('X-Tenant-Id')) {
$headerValue = $request->header('X-Tenant-Id');
if (is_string($headerValue) && ctype_digit($headerValue)) {
return (int) $headerValue;
}
}
return null;
}
}