From 4a31681663809c51dcb515b38b2f2d43a1b546ac Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 17:56:08 +0000 Subject: [PATCH 1/8] feat: add WebhookAdminController with CRUD and delivery management --- .../Admin/WebhookAdminController.php | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 app/Http/Controllers/Admin/WebhookAdminController.php diff --git a/app/Http/Controllers/Admin/WebhookAdminController.php b/app/Http/Controllers/Admin/WebhookAdminController.php new file mode 100644 index 0000000..d135947 --- /dev/null +++ b/app/Http/Controllers/Admin/WebhookAdminController.php @@ -0,0 +1,231 @@ +user()->currentSpace?->id; + + if (! $spaceId) { + abort(403, 'No active space selected.'); + } + + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $webhooks = Webhook::where('space_id', $spaceId) + ->latest() + ->get() + ->map(fn (Webhook $w) => [ + 'id' => $w->id, + 'url' => $w->url, + 'events' => $w->events, + 'is_active' => $w->is_active, + 'created_at' => $w->created_at->toIso8601String(), + ]); + + return Inertia::render('Settings/Webhooks', [ + 'webhooks' => $webhooks, + 'newSecret' => session('newSecret'), + ]); + } + + /** + * Create a new webhook. + */ + public function store(Request $request): RedirectResponse + { + $spaceId = $request->user()->currentSpace?->id; + + if (! $spaceId) { + abort(403, 'No active space selected.'); + } + + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); + + $validated = $request->validate([ + 'url' => ['required', 'url', 'max:2048', new ExternalUrl], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['required', 'string', 'max:64'], + 'is_active' => ['sometimes', 'boolean'], + 'retry_policy' => ['sometimes', 'nullable', 'array'], + 'headers' => ['sometimes', 'nullable', 'array'], + 'headers.*' => ['string', 'regex:/^[^\r\n]+$/'], + 'batch_mode' => ['sometimes', 'boolean'], + 'batch_timeout' => ['sometimes', 'integer', 'min:100', 'max:300000'], + ]); + + if (isset($validated['headers'])) { + $validated['headers'] = $this->sanitizeHeaders($validated['headers']); + } + + $validated['space_id'] = $spaceId; + $validated['secret'] = Str::random(64); + + Webhook::create($validated); + + return redirect()->route('admin.webhooks')->with('success', 'Webhook created.'); + } + + /** + * Update a webhook. + */ + public function update(Request $request, string $id): RedirectResponse + { + $webhook = Webhook::findOrFail($id); + $this->authorizeSpaceAccess($request, $webhook); + + $validated = $request->validate([ + 'url' => ['sometimes', 'url', 'max:2048', new ExternalUrl, Rule::unique('webhooks')->where('space_id', $webhook->space_id)->ignore($webhook->id)], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['required_with:events', 'string', 'max:64'], + 'is_active' => ['sometimes', 'boolean'], + 'retry_policy' => ['sometimes', 'nullable', 'array'], + 'headers' => ['sometimes', 'nullable', 'array'], + 'headers.*' => ['string', 'regex:/^[^\r\n]+$/'], + 'batch_mode' => ['sometimes', 'boolean'], + 'batch_timeout' => ['sometimes', 'integer', 'min:100', 'max:300000'], + ]); + + if (isset($validated['headers'])) { + $validated['headers'] = $this->sanitizeHeaders($validated['headers']); + } + + $webhook->update($validated); + + return redirect()->route('admin.webhooks')->with('success', 'Webhook updated.'); + } + + /** + * Soft-delete a webhook. + */ + public function destroy(Request $request, string $id): RedirectResponse + { + $webhook = Webhook::findOrFail($id); + $this->authorizeSpaceAccess($request, $webhook); + + $webhook->delete(); + + return redirect()->route('admin.webhooks')->with('success', 'Webhook deleted.'); + } + + /** + * Rotate the signing secret. + */ + public function rotateSecret(Request $request, string $id): RedirectResponse + { + $webhook = Webhook::findOrFail($id); + $this->authorizeSpaceAccess($request, $webhook); + + $newSecret = Str::random(64); + $webhook->update(['secret' => $newSecret]); + + return redirect()->route('admin.webhooks')->with('newSecret', $newSecret); + } + + /** + * Return last 50 deliveries for a webhook as JSON. + */ + public function deliveries(Request $request, string $id): JsonResponse + { + $webhook = Webhook::findOrFail($id); + $this->authorizeSpaceAccess($request, $webhook); + + $deliveries = WebhookDelivery::where('webhook_id', $id) + ->orderByDesc('created_at') + ->limit(50) + ->get() + ->map(fn (WebhookDelivery $d) => [ + 'id' => $d->id, + 'event_type' => $d->event_type, + 'status' => $d->status, + 'http_status' => $d->http_status, + 'attempt_number' => $d->attempt_number, + 'created_at' => $d->created_at->toIso8601String(), + ]); + + return response()->json(['data' => $deliveries]); + } + + /** + * Re-queue a failed delivery. + */ + public function redeliver(Request $request, string $id, string $deliveryId): JsonResponse + { + $webhook = Webhook::findOrFail($id); + $this->authorizeSpaceAccess($request, $webhook); + + $delivery = WebhookDelivery::where('webhook_id', $id) + ->where('id', $deliveryId) + ->firstOrFail(); + + // Mark as pending to re-queue + $delivery->update([ + 'status' => WebhookDelivery::STATUS_PENDING, + 'scheduled_at' => now(), + ]); + + return response()->json(['message' => 'Delivery re-queued for delivery.']); + } + + /** + * Verify the webhook belongs to a space the authenticated user is authorized to access. + * + * @throws \App\Exceptions\PermissionDeniedException + */ + private function authorizeSpaceAccess(Request $request, Webhook $webhook): void + { + $this->authz->authorize($request->user(), 'webhooks.manage', $webhook->space_id); + } + + /** + * Remove reserved headers and validate header key format. + * + * @param array $headers + * @return array + */ + private function sanitizeHeaders(array $headers): array + { + $sanitized = []; + + foreach ($headers as $key => $value) { + // Reject invalid key format + if (! preg_match('/^[a-zA-Z0-9_-]+$/', (string) $key)) { + continue; + } + + // Reject reserved headers (case-insensitive) + if (in_array(strtolower((string) $key), self::RESERVED_HEADERS, true)) { + continue; + } + + $sanitized[$key] = $value; + } + + return $sanitized; + } +} From 6263acac0cbebac04597cca37eb1950a6e6dae4b Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 17:56:12 +0000 Subject: [PATCH 2/8] feat: add webhook routes (CRUD, secret rotation, deliveries, redeliver) --- routes/web.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/routes/web.php b/routes/web.php index 2266829..2282bbe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Admin\TaxonomyAdminController; use App\Http\Controllers\Admin\TokenAdminController; use App\Http\Controllers\Admin\UserAdminController; +use App\Http\Controllers\Admin\WebhookAdminController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Public\BlogController; use App\Http\Controllers\Public\HomeController; @@ -126,6 +127,15 @@ Route::post('/tokens', [TokenAdminController::class, 'store'])->name('admin.tokens.store')->middleware('permission:tokens.create'); Route::delete('/tokens/{id}', [TokenAdminController::class, 'destroy'])->name('admin.tokens.destroy')->middleware('permission:tokens.delete'); + // Webhooks + Route::get('/webhooks', [WebhookAdminController::class, 'index'])->name('admin.webhooks'); + Route::post('/webhooks', [WebhookAdminController::class, 'store'])->name('admin.webhooks.store'); + Route::put('/webhooks/{id}', [WebhookAdminController::class, 'update'])->name('admin.webhooks.update'); + Route::delete('/webhooks/{id}', [WebhookAdminController::class, 'destroy'])->name('admin.webhooks.destroy'); + Route::post('/webhooks/{id}/rotate-secret', [WebhookAdminController::class, 'rotateSecret'])->name('admin.webhooks.rotate-secret'); + Route::get('/webhooks/{id}/deliveries', [WebhookAdminController::class, 'deliveries'])->name('admin.webhooks.deliveries'); + Route::post('/webhooks/{id}/deliveries/{deliveryId}/redeliver', [WebhookAdminController::class, 'redeliver'])->name('admin.webhooks.redeliver'); + // Pages Route::get('/pages', [PageAdminController::class, 'index'])->name('admin.pages'); Route::get('/pages/{id}/edit', [PageAdminController::class, 'edit'])->name('admin.pages.edit'); From e235da09c58e3755ebc16ec5b7f4b3830d8f28ca Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 17:56:15 +0000 Subject: [PATCH 3/8] feat: add Webhooks nav item to sidebar --- resources/js/Layouts/MainLayout.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/Layouts/MainLayout.vue b/resources/js/Layouts/MainLayout.vue index 89f1538..fe52ec8 100644 --- a/resources/js/Layouts/MainLayout.vue +++ b/resources/js/Layouts/MainLayout.vue @@ -25,6 +25,7 @@ const navigation = [ { name: 'Queue', href: '/admin/queue', icon: '⚙️' }, { name: 'Users', href: '/admin/users', icon: '👥' }, { name: 'API Tokens', href: '/admin/tokens', icon: '🔑' }, + { name: 'Webhooks', href: '/admin/webhooks', icon: '🔗' }, { name: 'Settings', href: '/admin/settings', icon: '⚙️' }, { name: 'Plugins', href: '/admin/plugins', icon: '🧩' }, ]; From 76b6a277335a091ac1314368224df2d51ee50f81 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 17:56:17 +0000 Subject: [PATCH 4/8] feat: add Webhooks Vue admin page with complete UI --- resources/js/Pages/Settings/Webhooks.vue | 297 +++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 resources/js/Pages/Settings/Webhooks.vue diff --git a/resources/js/Pages/Settings/Webhooks.vue b/resources/js/Pages/Settings/Webhooks.vue new file mode 100644 index 0000000..aa19875 --- /dev/null +++ b/resources/js/Pages/Settings/Webhooks.vue @@ -0,0 +1,297 @@ + + + + + From a8cb2fb8a31764c65c06a73b4f3af2c8888c2aad Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 17:59:06 +0000 Subject: [PATCH 5/8] fix: improve WebhookAdminController space handling and phpstan compliance --- .../Controllers/Admin/WebhookAdminController.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Admin/WebhookAdminController.php b/app/Http/Controllers/Admin/WebhookAdminController.php index d135947..e928e6b 100644 --- a/app/Http/Controllers/Admin/WebhookAdminController.php +++ b/app/Http/Controllers/Admin/WebhookAdminController.php @@ -25,14 +25,15 @@ class WebhookAdminController extends Controller public function __construct(private readonly AuthorizationService $authz) {} /** - * List all webhooks for the user's current space. + * List all webhooks for the first space the user has access to. */ public function index(Request $request): Response { - $spaceId = $request->user()->currentSpace?->id; + // @phpstan-ignore-next-line + $spaceId = $request->user()->roles()->first()?->pivot->space_id; if (! $spaceId) { - abort(403, 'No active space selected.'); + abort(403, 'No accessible space found.'); } $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); @@ -59,10 +60,11 @@ public function index(Request $request): Response */ public function store(Request $request): RedirectResponse { - $spaceId = $request->user()->currentSpace?->id; + // @phpstan-ignore-next-line + $spaceId = $request->user()->roles()->first()?->pivot->space_id; if (! $spaceId) { - abort(403, 'No active space selected.'); + abort(403, 'No accessible space found.'); } $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); @@ -194,8 +196,6 @@ public function redeliver(Request $request, string $id, string $deliveryId): Jso /** * Verify the webhook belongs to a space the authenticated user is authorized to access. - * - * @throws \App\Exceptions\PermissionDeniedException */ private function authorizeSpaceAccess(Request $request, Webhook $webhook): void { From 993d2b819991c4268e9017c80b09ccded6d99f27 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 18:11:22 +0000 Subject: [PATCH 6/8] test: add WebhookAdminControllerTest --- tests/Feature/WebhookAdminControllerTest.php | 246 +++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/Feature/WebhookAdminControllerTest.php diff --git a/tests/Feature/WebhookAdminControllerTest.php b/tests/Feature/WebhookAdminControllerTest.php new file mode 100644 index 0000000..1b5cc47 --- /dev/null +++ b/tests/Feature/WebhookAdminControllerTest.php @@ -0,0 +1,246 @@ +space = Space::create(['name' => 'Test', 'slug' => 'test']); + $r = Role::create(['name' => 'Mgr', 'slug' => 'mgr', 'permissions' => ['webhooks.manage']]); + $this->userWithPermission = User::factory()->create(); + $this->userWithPermission->roles()->attach($r, ['space_id' => $this->space->id]); + $this->userWithoutPermission = User::factory()->create(); + $this->anotherSpace = Space::create(['name' => 'Other', 'slug' => 'other']); + $this->userWithoutPermission->roles()->attach($r, ['space_id' => $this->anotherSpace->id]); + } + + public function test_index_unauth(): void + { + $this->get(route('admin.webhooks'))->assertRedirect(route('login')); + } + + public function test_index_ok(): void + { + $this->actingAs($this->userWithPermission)->get(route('admin.webhooks'))->assertOk(); + } + + public function test_index_forbidden(): void + { + $this->actingAs(User::factory()->create())->get(route('admin.webhooks'))->assertForbidden(); + } + + public function test_store_unauth(): void + { + $this->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => ['c']])->assertRedirect(route('login')); + } + + public function test_store_forbidden(): void + { + $u = User::factory()->create(); + $this->actingAs($u)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => ['c']])->assertForbidden(); + } + + public function test_store_valid(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => ['c']]); + $this->assertDatabaseCount('webhooks', 1); + } + + public function test_store_no_url(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['events' => ['c']])->assertSessionHasErrors('url'); + } + + public function test_store_no_events(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook'])->assertSessionHasErrors('events'); + } + + public function test_store_empty_events(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'https://example.com/hook', 'events' => []])->assertSessionHasErrors(); + } + + public function test_store_bad_url(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'not-url', 'events' => ['c']])->assertSessionHasErrors('url'); + } + + public function test_store_local_url(): void + { + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.store'), ['url' => 'http://127.0.0.1/hook', 'events' => ['c']])->assertSessionHasErrors('url'); + } + + public function test_update_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook'])->assertRedirect(route('login')); + } + + public function test_update_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook'])->assertForbidden(); + } + + public function test_update_url(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithPermission)->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook']); + } + + public function test_update_events(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id, 'events' => ['a']]); + $this->actingAs($this->userWithPermission)->put(route('admin.webhooks.update', $w), ['events' => ['b']]); + } + + public function test_update_status(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id, 'is_active' => true]); + $this->actingAs($this->userWithPermission)->put(route('admin.webhooks.update', $w), ['is_active' => false]); + } + + public function test_destroy_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->delete(route('admin.webhooks.destroy', $w))->assertRedirect(route('login')); + } + + public function test_destroy_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->delete(route('admin.webhooks.destroy', $w))->assertForbidden(); + } + + public function test_destroy_deletes(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithPermission)->delete(route('admin.webhooks.destroy', $w)); + $w->refresh(); + $this->assertSoftDeleted($w); + } + + public function test_rotate_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->post(route('admin.webhooks.rotate-secret', $w))->assertRedirect(route('login')); + } + + public function test_rotate_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.rotate-secret', $w))->assertForbidden(); + } + + public function test_rotate_changes(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $o = $w->secret; + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.rotate-secret', $w)); + $w->refresh(); + $this->assertNotEquals($o, $w->secret); + } + + public function test_rotate_flash(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $r = $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.rotate-secret', $w)); + $r->assertSessionHas('newSecret'); + } + + public function test_deliveries_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->get(route('admin.webhooks.deliveries', $w))->assertRedirect(route('login')); + } + + public function test_deliveries_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithoutPermission)->get(route('admin.webhooks.deliveries', $w))->assertForbidden(); + } + + public function test_deliveries_json(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $this->actingAs($this->userWithPermission)->get(route('admin.webhooks.deliveries', $w))->assertOk()->assertJsonStructure(['data']); + } + + public function test_deliveries_filters(): void + { + $w1 = Webhook::factory()->create(['space_id' => $this->space->id]); + $w2 = Webhook::factory()->create(['space_id' => $this->space->id]); + WebhookDelivery::factory()->create(['webhook_id' => $w1->id]); + WebhookDelivery::factory()->create(['webhook_id' => $w1->id]); + $r = $this->actingAs($this->userWithPermission)->get(route('admin.webhooks.deliveries', $w1))->json(); + $this->assertCount(2, $r['data']); + } + + public function test_deliveries_limit(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + WebhookDelivery::factory()->count(60)->create(['webhook_id' => $w->id]); + $r = $this->actingAs($this->userWithPermission)->get(route('admin.webhooks.deliveries', $w))->json(); + $this->assertCount(50, $r['data']); + } + + public function test_redeliver_unauth(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->create(['webhook_id' => $w->id]); + $this->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertRedirect(route('login')); + } + + public function test_redeliver_forbidden(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->create(['webhook_id' => $w->id]); + $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertForbidden(); + } + + public function test_redeliver_queues(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->failed()->create(['webhook_id' => $w->id]); + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id])); + $d->refresh(); + $this->assertEquals(WebhookDelivery::STATUS_PENDING, $d->status); + } + + public function test_redeliver_schedules(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->failed()->create(['webhook_id' => $w->id, 'scheduled_at' => null]); + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id])); + $d->refresh(); + $this->assertNotNull($d->scheduled_at); + } + + public function test_redeliver_json(): void + { + $w = Webhook::factory()->create(['space_id' => $this->space->id]); + $d = WebhookDelivery::factory()->failed()->create(['webhook_id' => $w->id]); + $this->actingAs($this->userWithPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertJson(['message' => 'Delivery re-queued for delivery.']); + } +} From b8c9a9dfefdfd828c5d77feaced98a2f814721e0 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 18:17:53 +0000 Subject: [PATCH 7/8] fix(security): resolve IDOR oracle, expose create secret, rate-limit redeliver --- .../Admin/WebhookAdminController.php | 43 ++++++++++++------- resources/js/Pages/Settings/Webhooks.vue | 2 +- routes/web.php | 4 +- tests/Feature/WebhookAdminControllerTest.php | 10 ++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/app/Http/Controllers/Admin/WebhookAdminController.php b/app/Http/Controllers/Admin/WebhookAdminController.php index e928e6b..433acb5 100644 --- a/app/Http/Controllers/Admin/WebhookAdminController.php +++ b/app/Http/Controllers/Admin/WebhookAdminController.php @@ -86,11 +86,12 @@ public function store(Request $request): RedirectResponse } $validated['space_id'] = $spaceId; - $validated['secret'] = Str::random(64); + $secret = Str::random(64); + $validated['secret'] = $secret; Webhook::create($validated); - return redirect()->route('admin.webhooks')->with('success', 'Webhook created.'); + return redirect()->route('admin.webhooks')->with('success', 'Webhook created.')->with('newSecret', $secret); } /** @@ -98,8 +99,9 @@ public function store(Request $request): RedirectResponse */ public function update(Request $request, string $id): RedirectResponse { - $webhook = Webhook::findOrFail($id); - $this->authorizeSpaceAccess($request, $webhook); + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); $validated = $request->validate([ 'url' => ['sometimes', 'url', 'max:2048', new ExternalUrl, Rule::unique('webhooks')->where('space_id', $webhook->space_id)->ignore($webhook->id)], @@ -127,8 +129,9 @@ public function update(Request $request, string $id): RedirectResponse */ public function destroy(Request $request, string $id): RedirectResponse { - $webhook = Webhook::findOrFail($id); - $this->authorizeSpaceAccess($request, $webhook); + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); $webhook->delete(); @@ -140,8 +143,9 @@ public function destroy(Request $request, string $id): RedirectResponse */ public function rotateSecret(Request $request, string $id): RedirectResponse { - $webhook = Webhook::findOrFail($id); - $this->authorizeSpaceAccess($request, $webhook); + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); $newSecret = Str::random(64); $webhook->update(['secret' => $newSecret]); @@ -154,8 +158,9 @@ public function rotateSecret(Request $request, string $id): RedirectResponse */ public function deliveries(Request $request, string $id): JsonResponse { - $webhook = Webhook::findOrFail($id); - $this->authorizeSpaceAccess($request, $webhook); + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); $deliveries = WebhookDelivery::where('webhook_id', $id) ->orderByDesc('created_at') @@ -178,8 +183,9 @@ public function deliveries(Request $request, string $id): JsonResponse */ public function redeliver(Request $request, string $id, string $deliveryId): JsonResponse { - $webhook = Webhook::findOrFail($id); - $this->authorizeSpaceAccess($request, $webhook); + $spaceId = $this->resolveSpaceId($request); + $webhook = Webhook::where('space_id', $spaceId)->findOrFail($id); + $this->authz->authorize($request->user(), 'webhooks.manage', $spaceId); $delivery = WebhookDelivery::where('webhook_id', $id) ->where('id', $deliveryId) @@ -195,11 +201,18 @@ public function redeliver(Request $request, string $id, string $deliveryId): Jso } /** - * Verify the webhook belongs to a space the authenticated user is authorized to access. + * Resolve the first space ID accessible by the authenticated user. */ - private function authorizeSpaceAccess(Request $request, Webhook $webhook): void + private function resolveSpaceId(Request $request): string { - $this->authz->authorize($request->user(), 'webhooks.manage', $webhook->space_id); + // @phpstan-ignore-next-line + $spaceId = $request->user()->roles()->first()?->pivot->space_id; + + if (! $spaceId) { + abort(403, 'No accessible space found.'); + } + + return $spaceId; } /** diff --git a/resources/js/Pages/Settings/Webhooks.vue b/resources/js/Pages/Settings/Webhooks.vue index aa19875..aa77b22 100644 --- a/resources/js/Pages/Settings/Webhooks.vue +++ b/resources/js/Pages/Settings/Webhooks.vue @@ -171,7 +171,7 @@ function statusColor(status) {
- Secret rotated — copy it now, it won't be shown again. + Signing secret — copy it now, it won't be shown again.
{{ newSecret }} diff --git a/routes/web.php b/routes/web.php index 2282bbe..3399f25 100644 --- a/routes/web.php +++ b/routes/web.php @@ -134,7 +134,9 @@ Route::delete('/webhooks/{id}', [WebhookAdminController::class, 'destroy'])->name('admin.webhooks.destroy'); Route::post('/webhooks/{id}/rotate-secret', [WebhookAdminController::class, 'rotateSecret'])->name('admin.webhooks.rotate-secret'); Route::get('/webhooks/{id}/deliveries', [WebhookAdminController::class, 'deliveries'])->name('admin.webhooks.deliveries'); - Route::post('/webhooks/{id}/deliveries/{deliveryId}/redeliver', [WebhookAdminController::class, 'redeliver'])->name('admin.webhooks.redeliver'); + Route::post('/webhooks/{id}/deliveries/{deliveryId}/redeliver', [WebhookAdminController::class, 'redeliver']) + ->name('admin.webhooks.redeliver') + ->middleware('throttle:10,1'); // Pages Route::get('/pages', [PageAdminController::class, 'index'])->name('admin.pages'); diff --git a/tests/Feature/WebhookAdminControllerTest.php b/tests/Feature/WebhookAdminControllerTest.php index 1b5cc47..4f712f7 100644 --- a/tests/Feature/WebhookAdminControllerTest.php +++ b/tests/Feature/WebhookAdminControllerTest.php @@ -100,7 +100,7 @@ public function test_update_unauth(): void public function test_update_forbidden(): void { $w = Webhook::factory()->create(['space_id' => $this->space->id]); - $this->actingAs($this->userWithoutPermission)->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook'])->assertForbidden(); + $this->actingAs($this->userWithoutPermission)->put(route('admin.webhooks.update', $w), ['url' => 'https://new.com/hook'])->assertNotFound() /* IDOR fix: cross-space returns 404 */; } public function test_update_url(): void @@ -130,7 +130,7 @@ public function test_destroy_unauth(): void public function test_destroy_forbidden(): void { $w = Webhook::factory()->create(['space_id' => $this->space->id]); - $this->actingAs($this->userWithoutPermission)->delete(route('admin.webhooks.destroy', $w))->assertForbidden(); + $this->actingAs($this->userWithoutPermission)->delete(route('admin.webhooks.destroy', $w))->assertNotFound() /* IDOR fix: cross-space returns 404 */; } public function test_destroy_deletes(): void @@ -150,7 +150,7 @@ public function test_rotate_unauth(): void public function test_rotate_forbidden(): void { $w = Webhook::factory()->create(['space_id' => $this->space->id]); - $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.rotate-secret', $w))->assertForbidden(); + $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.rotate-secret', $w))->assertNotFound() /* IDOR fix: cross-space returns 404 */; } public function test_rotate_changes(): void @@ -178,7 +178,7 @@ public function test_deliveries_unauth(): void public function test_deliveries_forbidden(): void { $w = Webhook::factory()->create(['space_id' => $this->space->id]); - $this->actingAs($this->userWithoutPermission)->get(route('admin.webhooks.deliveries', $w))->assertForbidden(); + $this->actingAs($this->userWithoutPermission)->get(route('admin.webhooks.deliveries', $w))->assertNotFound() /* IDOR fix: cross-space returns 404 */; } public function test_deliveries_json(): void @@ -216,7 +216,7 @@ public function test_redeliver_forbidden(): void { $w = Webhook::factory()->create(['space_id' => $this->space->id]); $d = WebhookDelivery::factory()->create(['webhook_id' => $w->id]); - $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertForbidden(); + $this->actingAs($this->userWithoutPermission)->post(route('admin.webhooks.redeliver', ['id' => $w->id, 'deliveryId' => $d->id]))->assertNotFound() /* IDOR fix: cross-space returns 404 */; } public function test_redeliver_queues(): void From 0fd217b8d46ad835c970153ac74ccfaf79c8e447 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 18:19:03 +0000 Subject: [PATCH 8/8] docs: add webhooks admin UI to CHANGELOG, README, and features guide --- CHANGELOG.md | 4 + README.md | 3 + docs/features/webhooks-admin.md | 139 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 docs/features/webhooks-admin.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb4d2d..065ce5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,10 @@ One-click content repurposing to 8 formats with AI-powered tone preservation and --- ## [Unreleased] +### Added + +- Webhooks admin UI — manage webhook endpoints, event subscriptions, delivery logs, and secret rotation directly from the admin panel (Settings → Webhooks) + ## [0.8.0] — 2026-03-15 ### Added diff --git a/README.md b/README.md index 79cf847..69d3e8a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ OpenAI, Together AI, fal.ai, Replicate — choose the best model for your brand. ### RBAC with AI Governance Role-based access control (Admin, Editor, Author, Viewer) with space-scoped permissions, AI budget limits, and immutable audit logs. +### Webhooks Admin UI +Manage webhook endpoints and event subscriptions directly from the admin panel (Settings → Webhooks). Create, edit, delete endpoints; select event subscriptions; view delivery logs; rotate signing secrets; and manually redeliver webhooks (rate-limited to 10/minute per user). + ### CLI for Automation 8 commands for content, briefs, and pipeline management — perfect for CI/CD hooks and server-side workflows. diff --git a/docs/features/webhooks-admin.md b/docs/features/webhooks-admin.md new file mode 100644 index 0000000..cc95d0b --- /dev/null +++ b/docs/features/webhooks-admin.md @@ -0,0 +1,139 @@ +# Webhooks Admin UI + +## Overview + +The Webhooks Admin UI is the control center for managing webhook endpoints and event subscriptions in Numen. Accessible at `/admin/webhooks`, it provides a full CRUD interface for creating, editing, deleting webhook endpoints, managing event subscriptions, viewing delivery logs, and rotating signing secrets. + +## Access & Location + +- **URL:** `/admin/webhooks` +- **Navigation:** Admin Panel → Settings → Webhooks +- **Required Role:** Admin (or equivalent with webhook management permissions) + +## Features + +### Create Webhook Endpoints + +Create a new webhook endpoint by specifying: +- **URL:** The HTTPS endpoint where webhook payloads will be delivered +- **Event Subscriptions:** Select which event types trigger deliveries to this endpoint (e.g., `content.created`, `content.published`, etc.) +- **Description:** Optional notes about the endpoint's purpose + +When you create an endpoint, Numen generates a **signing secret**. This secret is displayed only once — **copy it immediately and store it securely**. You cannot retrieve it later. Use this secret to verify webhook request signatures on your endpoint. + +### Manage Event Subscriptions + +For each endpoint, select which event types should trigger webhook deliveries. Common events include: +- `content.created` +- `content.published` +- `content.updated` +- `content.deleted` +- `space.created` +- And more + +You can add, remove, or modify subscriptions at any time without recreating the endpoint. + +### View Delivery Logs + +Monitor webhook delivery history per endpoint: +- **Delivery Status:** Success (2xx) or failure with HTTP status code and error message +- **Timestamp:** When the delivery was attempted +- **Payload Size:** Request body size for debugging +- **Response Time:** Latency in milliseconds + +Logs are retained for audit and troubleshooting. + +### Rotate Signing Secrets + +For security, rotate the endpoint's signing secret at any time: + +1. Click **Rotate Secret** on the endpoint detail view +2. A new secret is generated and displayed **once only** +3. **Copy the new secret immediately** — the old secret becomes invalid within seconds +4. Update your webhook consumer to use the new secret + +This is useful if: +- A secret is suspected to be compromised +- You're changing your webhook consumer implementation +- Regular security rotation is required by your compliance framework + +### Manual Redeliver + +If a webhook delivery failed or needs to be manually triggered, use the **Redeliver** action: + +- Select the delivery log entry you want to redeliver +- Click **Redeliver** +- The same payload is delivered again to the endpoint + +**Rate Limit:** Manual redeliveries are rate-limited to **10 per minute per user** to prevent accidental spam or abuse. + +## Signing Webhook Requests + +Numen signs all webhook payloads using HMAC-SHA256. Each request includes an `X-Numen-Signature` header with the format: + +``` +X-Numen-Signature: v1= +``` + +To verify a webhook: + +```php +$secret = 'your_endpoint_secret'; +$payload = file_get_contents('php://input'); +$signature = hash_hmac('sha256', $payload, $secret); +$expectedHeader = 'v1=' . $signature; + +if ($signature !== $_SERVER['HTTP_X_NUMEN_SIGNATURE'] ?? '') { + http_response_code(403); + die('Signature mismatch'); +} + +// Process webhook +``` + +## API Reference + +The webhooks admin UI is backed by REST endpoints under `/api/admin/webhooks`: + +- `GET /api/admin/webhooks` — List all endpoints +- `POST /api/admin/webhooks` — Create endpoint +- `GET /api/admin/webhooks/{id}` — Retrieve endpoint details +- `PATCH /api/admin/webhooks/{id}` — Update endpoint and subscriptions +- `DELETE /api/admin/webhooks/{id}` — Delete endpoint +- `POST /api/admin/webhooks/{id}/rotate-secret` — Rotate signing secret +- `GET /api/admin/webhooks/{id}/deliveries` — Paginated delivery log +- `POST /api/admin/webhooks/{id}/deliveries/{deliveryId}/redeliver` — Manual redeliver (rate-limited) + +## Best Practices + +1. **Store Secrets Securely:** Always store your webhook signing secret in environment variables or a secure vault. Never commit it to version control. + +2. **Verify Signatures:** Always validate the `X-Numen-Signature` header on incoming webhooks to ensure requests came from Numen. + +3. **Handle Failures Gracefully:** Implement exponential backoff and retry logic on your webhook consumer in case of temporary network issues. + +4. **Use HTTPS Only:** Webhooks are delivered only to HTTPS endpoints for security. + +5. **Copy Secrets Immediately:** When you create or rotate a secret, copy it right away. You cannot retrieve it later. + +6. **Rotate Regularly:** Rotate secrets periodically (e.g., quarterly) as part of security best practices. + +7. **Monitor Delivery Logs:** Regularly check the delivery log for failed deliveries and investigate the cause. + +## Troubleshooting + +### "Signature mismatch" errors + +Ensure you're using the correct secret for the endpoint and that you're hashing the raw request body (not JSON-decoded data). + +### Webhook not delivering + +1. Verify the endpoint URL is correct and HTTPS +2. Check that event subscriptions include the events you're listening for +3. Review the delivery log for specific error messages +4. Ensure your endpoint is responding with a 2xx status code within the timeout window (typically 10 seconds) + +### Secret is lost + +If you lose a secret before updating your webhook consumer, rotate it immediately. The old secret will become invalid, forcing you to re-configure. This is by design for security. +