php-tenant/Controllers/Api/EntitlementWebhookController.php
Snider 8a521d4f3e security: fix P1 items for rate limiting, auth, SSRF and workspace validation
P1-010: Rate limiting (60 req/min) on EntitlementApiController
P1-011: API authentication documentation and middleware
P1-014: SSRF protection for webhook endpoints (PreventsSSRF trait)
P1-015: Workspace access validation in middleware (breaking change)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:19:27 +00:00

285 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Controllers\Api;
use Core\Rules\SafeWebhookUrl;
use Core\Tenant\Exceptions\InvalidWebhookUrlException;
use Core\Tenant\Models\EntitlementWebhook;
use Core\Tenant\Models\EntitlementWebhookDelivery;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementWebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
/**
* API controller for entitlement webhook management.
*
* Provides CRUD operations for webhooks and delivery history.
*
* SECURITY: All webhook URLs are validated against SSRF attacks.
* The test endpoint cannot be used to probe internal networks.
*/
class EntitlementWebhookController extends Controller
{
public function __construct(
protected EntitlementWebhookService $webhookService
) {}
/**
* List webhooks for the current workspace.
*/
public function index(Request $request): JsonResponse
{
$workspace = $this->resolveWorkspace($request);
$webhooks = EntitlementWebhook::query()
->forWorkspace($workspace)
->withCount('deliveries')
->latest()
->paginate($request->integer('per_page', 25));
return response()->json($webhooks);
}
/**
* Create a new webhook.
*/
public function store(Request $request): JsonResponse
{
$workspace = $this->resolveWorkspace($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'url' => ['required', 'url', 'max:2048', new SafeWebhookUrl],
'events' => ['required', 'array', 'min:1'],
'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)],
'secret' => ['nullable', 'string', 'min:32'],
'metadata' => ['nullable', 'array'],
]);
try {
$webhook = $this->webhookService->register(
workspace: $workspace,
name: $validated['name'],
url: $validated['url'],
events: $validated['events'],
secret: $validated['secret'] ?? null,
metadata: $validated['metadata'] ?? []
);
return response()->json([
'message' => __('Webhook created successfully'),
'webhook' => $webhook,
'secret' => $webhook->secret, // Return secret on creation only
], 201);
} catch (InvalidWebhookUrlException $e) {
return response()->json([
'message' => $e->getMessage(),
'error' => 'invalid_webhook_url',
], 422);
}
}
/**
* Get a specific webhook.
*/
public function show(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
$webhook->loadCount('deliveries');
$webhook->load(['deliveries' => fn ($q) => $q->latest('created_at')->limit(10)]);
return response()->json([
'webhook' => $webhook,
'available_events' => $this->webhookService->getAvailableEvents(),
]);
}
/**
* Update a webhook.
*/
public function update(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'url' => ['sometimes', 'url', 'max:2048', new SafeWebhookUrl],
'events' => ['sometimes', 'array', 'min:1'],
'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)],
'is_active' => ['sometimes', 'boolean'],
'max_attempts' => ['sometimes', 'integer', 'min:1', 'max:10'],
'metadata' => ['sometimes', 'array'],
]);
try {
$webhook = $this->webhookService->update($webhook, $validated);
return response()->json([
'message' => __('Webhook updated successfully'),
'webhook' => $webhook,
]);
} catch (InvalidWebhookUrlException $e) {
return response()->json([
'message' => $e->getMessage(),
'error' => 'invalid_webhook_url',
], 422);
}
}
/**
* Delete a webhook.
*/
public function destroy(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
$this->webhookService->unregister($webhook);
return response()->json([
'message' => __('Webhook deleted successfully'),
]);
}
/**
* Regenerate webhook secret.
*/
public function regenerateSecret(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
$secret = $webhook->regenerateSecret();
return response()->json([
'message' => __('Secret regenerated successfully'),
'secret' => $secret,
]);
}
/**
* Send a test webhook.
*
* SECURITY: This endpoint validates the webhook URL against SSRF before
* making any outbound request. Requests to internal networks are blocked.
*/
public function test(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
try {
$delivery = $this->webhookService->testWebhook($webhook);
return response()->json([
'message' => $delivery->isSucceeded()
? __('Test webhook sent successfully')
: __('Test webhook failed'),
'delivery' => $delivery,
]);
} catch (InvalidWebhookUrlException $e) {
return response()->json([
'message' => $e->getMessage(),
'error' => 'invalid_webhook_url',
'reason' => $e->reason,
], 422);
}
}
/**
* Reset circuit breaker for a webhook.
*/
public function resetCircuitBreaker(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
$this->webhookService->resetCircuitBreaker($webhook);
return response()->json([
'message' => __('Webhook re-enabled successfully'),
'webhook' => $webhook->refresh(),
]);
}
/**
* Get delivery history for a webhook.
*/
public function deliveries(Request $request, EntitlementWebhook $webhook): JsonResponse
{
$this->authorizeWebhook($request, $webhook);
$deliveries = $webhook->deliveries()
->latest('created_at')
->paginate($request->integer('per_page', 50));
return response()->json($deliveries);
}
/**
* Retry a failed delivery.
*/
public function retryDelivery(Request $request, EntitlementWebhookDelivery $delivery): JsonResponse
{
$this->authorizeWebhook($request, $delivery->webhook);
if ($delivery->isSucceeded()) {
return response()->json([
'message' => __('Cannot retry a successful delivery'),
], 422);
}
$delivery = $this->webhookService->retryDelivery($delivery);
return response()->json([
'message' => $delivery->isSucceeded()
? __('Delivery retried successfully')
: __('Delivery retry failed'),
'delivery' => $delivery,
]);
}
/**
* Get available event types.
*/
public function events(): JsonResponse
{
return response()->json([
'events' => $this->webhookService->getAvailableEvents(),
]);
}
/**
* Resolve the workspace from the request.
*/
protected function resolveWorkspace(Request $request): Workspace
{
// First try explicit workspace_id parameter
if ($request->has('workspace_id')) {
$workspace = Workspace::findOrFail($request->integer('workspace_id'));
// Verify user has access
if (! $request->user()->workspaces->contains($workspace)) {
abort(403, 'You do not have access to this workspace');
}
return $workspace;
}
// Fall back to user's default workspace
return $request->user()->defaultHostWorkspace()
?? abort(400, 'No workspace specified and user has no default workspace');
}
/**
* Authorize that the user can access this webhook.
*/
protected function authorizeWebhook(Request $request, EntitlementWebhook $webhook): void
{
if (! $request->user()->workspaces->contains($webhook->workspace)) {
abort(403, 'You do not have access to this webhook');
}
}
}