1fd6f7f597
- 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>
82 lines
3.0 KiB
PHP
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;
|
|
}
|
|
}
|