Files
portal/app/app/Http/Controllers/Api/WebhookSettingsController.php
T

181 lines
7.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OutboundWebhookSubscription;
use App\Services\Audit\OperationsLogger;
use App\Support\WebhookUrlGuard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
/**
* Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
* Endpoints под auth:sanctum + tenant.
*
* Одна подписка-ряд на тенанта. Секрет генерируется при создании и
* показывается ОДИН раз (в БД — bcrypt secret_hash + secret_prefix).
*
* test(): MVP делает unsigned connectivity-проверку (реальный POST на
* target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий —
* отдельный пост-MVP эпик (outbound-pipeline пока не построен).
*/
class WebhookSettingsController extends Controller
{
private const SECRET_PREFIX = 'whsec_';
/** @var list<string> События по умолчанию для новой подписки. */
private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed'];
public function show(Request $request): JsonResponse
{
$sub = $this->currentSubscription($request);
if ($sub === null) {
return response()->json(['data' => null]);
}
return response()->json(['data' => [
'target_url' => $sub->target_url,
'secret_prefix' => $sub->secret_prefix,
'events' => $sub->events,
'is_active' => $sub->is_active,
]]);
}
public function update(Request $request, OperationsLogger $ops): JsonResponse
{
$validated = $request->validate([
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
]);
// SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную
// сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка
// событий) читает из БД только безопасные адреса. NB: будущая доставка
// обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от
// DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный).
$blockReason = WebhookUrlGuard::blockReason($validated['target_url']);
if ($blockReason !== null) {
throw ValidationException::withMessages(['target_url' => [$blockReason]]);
}
$tenantId = (int) $request->user()->tenant_id;
$sub = $this->currentSubscription($request);
$plainSecret = null;
// Capture before-state (null on first-time creation)
$payloadBefore = $sub !== null
? ['target_url' => $sub->target_url, 'is_active' => (bool) $sub->is_active]
: null;
if ($sub === null) {
$plainSecret = self::SECRET_PREFIX.Str::random(40);
$sub = OutboundWebhookSubscription::query()->create([
'tenant_id' => $tenantId,
'user_id' => (int) $request->user()->id,
'name' => 'Webhook',
'target_url' => $validated['target_url'],
'secret_hash' => Hash::make($plainSecret),
'secret_prefix' => substr($plainSecret, 0, 10),
'events' => self::DEFAULT_EVENTS,
'is_active' => true,
]);
} else {
$sub->update(['target_url' => $validated['target_url']]);
}
// Audit: log target_url change (no secret_hash or plaintext secret)
$ops->record(
tenantId: $tenantId,
userId: (int) $request->user()->id,
entityType: 'webhook_settings',
entityId: $sub->id,
event: 'webhook_settings.updated',
payloadBefore: $payloadBefore,
payloadAfter: ['target_url' => $sub->target_url, 'is_active' => (bool) $sub->is_active],
ip: $request->ip(),
userAgent: $request->userAgent(),
);
$payload = [
'target_url' => $sub->target_url,
'secret_prefix' => $sub->secret_prefix,
'events' => $sub->events,
'is_active' => $sub->is_active,
];
if ($plainSecret !== null) {
$payload['secret'] = $plainSecret;
}
return response()->json(['data' => $payload]);
}
public function test(Request $request): JsonResponse
{
$sub = $this->currentSubscription($request);
if ($sub === null) {
return response()->json([
'message' => 'Сначала сохраните URL webhook.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
if ($blockReason !== null) {
return response()->json([
'ok' => false,
'status' => null,
'message' => $blockReason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$testPayload = [
'event' => 'webhook.test',
'sent_at' => now()->toIso8601String(),
'message' => 'Тестовая доставка webhook от Лидерра.',
];
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
try {
$response = Http::timeout(10)
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
->post($sub->target_url, $testPayload);
return response()->json([
'ok' => $response->successful(),
'status' => $response->status(),
'message' => $response->successful()
? "Тестовый запрос доставлен (HTTP {$response->status()})."
: "Endpoint ответил HTTP {$response->status()}.",
]);
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'status' => null,
'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(),
]);
}
}
private function currentSubscription(Request $request): ?OutboundWebhookSubscription
{
$tenantId = (int) $request->user()->tenant_id;
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
return OutboundWebhookSubscription::query()
->where('tenant_id', $tenantId)
->orderByDesc('id')
->first();
}
}