diff --git a/database/migrations/2026_10_14_000001_add_private_note_to_incidents.php b/database/migrations/2026_10_14_000001_add_private_note_to_incidents.php new file mode 100644 index 00000000..1aeb99cc --- /dev/null +++ b/database/migrations/2026_10_14_000001_add_private_note_to_incidents.php @@ -0,0 +1,22 @@ +text('private_note')->nullable()->after('message'); + }); + } + + public function down(): void + { + Schema::table('incidents', function (Blueprint $table) { + $table->dropColumn('private_note'); + }); + } +}; diff --git a/resources/lang/en/incident.php b/resources/lang/en/incident.php index 66ccd095..d51d569e 100644 --- a/resources/lang/en/incident.php +++ b/resources/lang/en/incident.php @@ -58,6 +58,8 @@ 'name_label' => 'Name', 'status_label' => 'Status', 'message_label' => 'Message', + 'private_note_label' => 'Private note', + 'private_note_helper' => 'Only visible in the dashboard. Never shown on the status page or included in API responses, webhooks, or subscriber notifications.', 'occurred_at_label' => 'Occurred at', 'occurred_at_helper' => 'The incident\'s created timestamp will be used if left empty.', 'published_at_label' => 'Published at', diff --git a/src/Events/Incidents/IncidentCreated.php b/src/Events/Incidents/IncidentCreated.php index ce7e153d..0d57736d 100644 --- a/src/Events/Incidents/IncidentCreated.php +++ b/src/Events/Incidents/IncidentCreated.php @@ -37,7 +37,7 @@ public function broadcastOn(): array public function getWebhookPayload(): array { - return $this->incident->toArray(); + return $this->incident->toWebhookArray(); } public function getWebhookEventName(): WebhookEventEnum diff --git a/src/Events/Incidents/IncidentDeleted.php b/src/Events/Incidents/IncidentDeleted.php index 94f6a742..28bb242b 100644 --- a/src/Events/Incidents/IncidentDeleted.php +++ b/src/Events/Incidents/IncidentDeleted.php @@ -25,7 +25,7 @@ public function __construct(public Incident $incident) public function getWebhookPayload(): array { - return $this->incident->toArray(); + return $this->incident->toWebhookArray(); } /** diff --git a/src/Events/Incidents/IncidentUpdated.php b/src/Events/Incidents/IncidentUpdated.php index f024ef73..53ff09e3 100644 --- a/src/Events/Incidents/IncidentUpdated.php +++ b/src/Events/Incidents/IncidentUpdated.php @@ -34,7 +34,7 @@ public function broadcastOn(): array public function getWebhookPayload(): array { - return $this->incident->toArray(); + return $this->incident->toWebhookArray(); } public function getWebhookEventName(): WebhookEventEnum diff --git a/src/Filament/Resources/Incidents/IncidentResource.php b/src/Filament/Resources/Incidents/IncidentResource.php index 3002956f..b04bbd6a 100644 --- a/src/Filament/Resources/Incidents/IncidentResource.php +++ b/src/Filament/Resources/Incidents/IncidentResource.php @@ -27,6 +27,7 @@ use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Components\ToggleButtons; @@ -71,6 +72,11 @@ public static function form(Schema $schema): Schema ->label(__('cachet::incident.form.message_label')) ->required() ->columnSpanFull(), + Textarea::make('private_note') + ->label(__('cachet::incident.form.private_note_label')) + ->helperText(__('cachet::incident.form.private_note_helper')) + ->rows(3) + ->columnSpanFull(), DateTimePicker::make('occurred_at') ->label(__('cachet::incident.form.occurred_at_label')) ->helperText(__('cachet::incident.form.occurred_at_helper')), diff --git a/src/Models/Incident.php b/src/Models/Incident.php index 1d760c1d..16da2674 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -39,6 +39,7 @@ * @property string $name * @property ?IncidentStatusEnum $status * @property string $message + * @property ?string $private_note * @property ?Carbon $created_at * @property ?Carbon $updated_at * @property ?Carbon $deleted_at @@ -111,6 +112,7 @@ class Incident extends Model implements Metable 'stickied', 'notifications', 'message', + 'private_note', 'scheduled_at', 'occurred_at', 'published_at', @@ -292,6 +294,26 @@ public function formattedMessage(): string return Cachet::markdown($this->message); } + /** + * Exclude private notes, including chaperoned incident copies in loaded relations. + */ + public function toWebhookArray(): array + { + $redact = function (array $data) use (&$redact): array { + unset($data['private_note']); + + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $redact($value); + } + } + + return $data; + }; + + return $redact($this->toArray()); + } + /** * Get the URL to the incident page within the dashboard. */ diff --git a/tests/Feature/Filament/Resources/IncidentResourceTest.php b/tests/Feature/Filament/Resources/IncidentResourceTest.php index 0412b5b4..92b5dca8 100644 --- a/tests/Feature/Filament/Resources/IncidentResourceTest.php +++ b/tests/Feature/Filament/Resources/IncidentResourceTest.php @@ -29,6 +29,40 @@ actingAs(User::factory()->create(['is_admin' => true])); }); +it('can create, reload, edit and clear a private note on a public incident', function () { + livewire(CreateIncident::class) + ->fillForm([ + 'name' => 'Public outage', + 'status' => IncidentStatusEnum::investigating, + 'message' => 'We are investigating.', + 'visible' => ResourceVisibilityEnum::guest, + 'private_note' => 'Internal ticket SEC-482', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $incident = Incident::where('name', 'Public outage')->sole(); + + expect($incident->private_note)->toBe('Internal ticket SEC-482') + ->and($incident->visible)->toBe(ResourceVisibilityEnum::guest); + + livewire(EditIncident::class, ['record' => $incident->getKey()]) + ->assertFormSet(['private_note' => 'Internal ticket SEC-482']) + ->fillForm(['private_note' => 'Escalated to the security team']) + ->call('save') + ->assertHasNoFormErrors(); + + expect($incident->fresh()->private_note)->toBe('Escalated to the security team'); + + livewire(EditIncident::class, ['record' => $incident->getKey()]) + ->assertFormSet(['private_note' => 'Escalated to the security team']) + ->fillForm(['private_note' => null]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($incident->fresh()->private_note)->toBeNull(); +}); + it('hides the notify subscribers toggle when subscriptions are not allowed', function () { $this->get(CreateIncident::getUrl()) ->assertOk() diff --git a/tests/Feature/StatusPage/IncidentPageTest.php b/tests/Feature/StatusPage/IncidentPageTest.php index c64089e9..d00be3b2 100644 --- a/tests/Feature/StatusPage/IncidentPageTest.php +++ b/tests/Feature/StatusPage/IncidentPageTest.php @@ -1,8 +1,33 @@ actingAs(User::factory()->create(['is_admin' => true])); + } + + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'message' => 'Public incident message', + 'private_note' => 'Internal ticket SEC-482', + ]); + + $this->get(route('cachet.status-page.incident', $incident)) + ->assertOk() + ->assertSee('Public incident message') + ->assertDontSee('Internal ticket SEC-482'); + + $this->getJson('/status/api/incidents/'.$incident->id) + ->assertOk() + ->assertJsonPath('data.attributes.message', 'Public incident message') + ->assertJsonMissingPath('data.attributes.private_note') + ->assertDontSee('Internal ticket SEC-482'); +})->with([false, true]); it('shows the affected components on the incident page', function () { $component = Component::factory()->create(['name' => 'API']); diff --git a/tests/Unit/Actions/Webhook/DispatchWebhooksTest.php b/tests/Unit/Actions/Webhook/DispatchWebhooksTest.php index 30ccac80..c6b6b30f 100644 --- a/tests/Unit/Actions/Webhook/DispatchWebhooksTest.php +++ b/tests/Unit/Actions/Webhook/DispatchWebhooksTest.php @@ -3,7 +3,9 @@ use Cachet\Actions\Webhook\DispatchWebhooks; use Cachet\Enums\WebhookEventEnum; use Cachet\Events\Components\ComponentCreated; +use Cachet\Filament\Resources\Incidents\IncidentResource; use Cachet\Models\Component; +use Cachet\Models\Incident; use Cachet\Models\WebhookSubscription; use Illuminate\Support\Facades\Queue; use Spatie\WebhookServer\CallWebhookJob; @@ -57,3 +59,30 @@ Queue::assertNothingPushed(); }); + +it('redacts private notes from queued dashboard incident webhooks', function () { + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + $incident->updates()->create(['message' => 'Public update']); + + WebhookSubscription::factory() + ->selectedEvents([WebhookEventEnum::incident_updated, WebhookEventEnum::incident_deleted]) + ->create(); + + $record = IncidentResource::getEloquentQuery()->findOrFail($incident->id); + $record->update(['name' => 'Updated public name']); + $record->delete(); + + Queue::assertPushed(CallWebhookJob::class, 2); + + foreach ([WebhookEventEnum::incident_updated, WebhookEventEnum::incident_deleted] as $event) { + Queue::assertPushed(CallWebhookJob::class, function (CallWebhookJob $job) use ($event): bool { + return $job->payload['event'] === $event->value + && $job->payload['body']['name'] === 'Updated public name' + && $job->payload['body']['updates'][0]['message'] === 'Public update' + && ! str_contains(json_encode($job->payload), 'private_note') + && ! str_contains(json_encode($job->payload), 'Internal ticket SEC-482'); + }); + } + + expect($record->private_note)->toBe('Internal ticket SEC-482'); +}); diff --git a/tests/Unit/Models/IncidentTest.php b/tests/Unit/Models/IncidentTest.php index d20e3e0d..9100f53d 100644 --- a/tests/Unit/Models/IncidentTest.php +++ b/tests/Unit/Models/IncidentTest.php @@ -3,10 +3,35 @@ use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Events\Incidents\IncidentCreated; +use Cachet\Events\Incidents\IncidentDeleted; +use Cachet\Events\Incidents\IncidentUpdated; use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Models\Update; +it('keeps private notes out of incident webhooks', function () { + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + $incident->updates()->create(['message' => 'Public update']); + $incident->load('updates'); + + expect($incident->fresh()->private_note)->toBe('Internal ticket SEC-482'); + + foreach ([IncidentCreated::class, IncidentUpdated::class, IncidentDeleted::class] as $event) { + $payload = (new $event($incident))->getWebhookPayload(); + + expect($payload) + ->toHaveKey('message', $incident->message) + ->not->toHaveKey('private_note') + ->and($payload['updates'][0]['message'])->toBe('Public update') + ->and($payload['updates'][0]['updateable']['message'])->toBe($incident->message) + ->and(json_encode($payload))->not->toContain('private_note', 'Internal ticket SEC-482'); + } + + expect($incident->private_note)->toBe('Internal ticket SEC-482') + ->and($incident->updates->first()->updateable->private_note)->toBe('Internal ticket SEC-482'); +}); + it('can have multiple components', function () { $incident = Incident::factory()->create();