diff --git a/app/app/Http/Controllers/Api/WebhookSettingsController.php b/app/app/Http/Controllers/Api/WebhookSettingsController.php index ebf67620..b4d97140 100644 --- a/app/app/Http/Controllers/Api/WebhookSettingsController.php +++ b/app/app/Http/Controllers/Api/WebhookSettingsController.php @@ -6,6 +6,7 @@ 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; @@ -49,7 +50,7 @@ class WebhookSettingsController extends Controller ]]); } - public function update(Request $request): JsonResponse + public function update(Request $request, OperationsLogger $ops): JsonResponse { $validated = $request->validate([ 'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'], @@ -65,13 +66,19 @@ class WebhookSettingsController extends Controller 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' => (int) $request->user()->tenant_id, + 'tenant_id' => $tenantId, 'user_id' => (int) $request->user()->id, 'name' => 'Webhook', 'target_url' => $validated['target_url'], @@ -84,6 +91,19 @@ class WebhookSettingsController extends Controller $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, diff --git a/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php b/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php new file mode 100644 index 00000000..70cec2b1 --- /dev/null +++ b/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php @@ -0,0 +1,102 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $this->actingAs($user); + DB::statement('SET app.current_tenant_id = ' . $tenant->id); + + $oldUrl = 'https://8.8.8.8/hook'; + OutboundWebhookSubscription::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'target_url' => $oldUrl, + ]); + + $newUrl = 'https://1.1.1.1/hook'; + $r = $this->putJson('/api/tenants/me/webhook-settings', [ + 'target_url' => $newUrl, + ]); + $r->assertStatus(200); + + $row = DB::table('tenant_operations_log') + ->where('event', 'webhook_settings.updated') + ->where('tenant_id', $tenant->id) + ->latest('id') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->entity_type)->toBe('webhook_settings') + ->and((int) $row->user_id)->toBe($user->id); + + $after = json_decode($row->payload_after, true); + expect($after['target_url'] ?? null)->toBe($newUrl); + + $before = json_decode($row->payload_before, true); + expect($before['target_url'] ?? null)->toBe($oldUrl); +}); + +it('webhook_settings.updated logged when subscription created for first time', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $this->actingAs($user); + DB::statement('SET app.current_tenant_id = ' . $tenant->id); + + $newUrl = 'https://93.184.216.34/hook'; + $r = $this->putJson('/api/tenants/me/webhook-settings', [ + 'target_url' => $newUrl, + ]); + $r->assertStatus(200); + + $row = DB::table('tenant_operations_log') + ->where('event', 'webhook_settings.updated') + ->where('tenant_id', $tenant->id) + ->latest('id') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->entity_type)->toBe('webhook_settings') + ->and((int) $row->user_id)->toBe($user->id); + + $after = json_decode($row->payload_after, true); + expect($after['target_url'] ?? null)->toBe($newUrl); + + expect($row->payload_before)->toBeNull(); +}); + +it('audit log does not contain webhook secret', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $this->actingAs($user); + DB::statement('SET app.current_tenant_id = ' . $tenant->id); + + $r = $this->putJson('/api/tenants/me/webhook-settings', [ + 'target_url' => 'https://93.184.216.34/hook', + ]); + $r->assertStatus(200); + + $row = DB::table('tenant_operations_log') + ->where('event', 'webhook_settings.updated') + ->where('tenant_id', $tenant->id) + ->latest('id') + ->first(); + + expect($row)->not->toBeNull(); + + // Neither payload should contain secret_hash or the plaintext secret (whsec_...) + $payloadAfterRaw = $row->payload_after ?? ''; + expect($payloadAfterRaw)->not->toContain('secret_hash') + ->and($payloadAfterRaw)->not->toContain('whsec_'); + + $payloadBeforeRaw = $row->payload_before ?? ''; + expect($payloadBeforeRaw)->not->toContain('secret_hash') + ->and($payloadBeforeRaw)->not->toContain('whsec_'); +});