diff --git a/resources/lang/en/incident.php b/resources/lang/en/incident.php index d51d569e..4eb61668 100644 --- a/resources/lang/en/incident.php +++ b/resources/lang/en/incident.php @@ -59,7 +59,7 @@ '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.', + 'private_note_helper' => 'Only visible in the dashboard and to API tokens with the private notes permission. Never shown on the status page or included in 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/Cachet.php b/src/Cachet.php index d1f13af0..9aea6368 100644 --- a/src/Cachet.php +++ b/src/Cachet.php @@ -98,6 +98,7 @@ public static function getResourceApiAbilities(): array 'components' => ['manage', 'delete'], 'component-groups' => ['manage', 'delete'], 'incidents' => ['manage', 'delete'], + 'incident-private-notes' => ['manage'], 'incident-updates' => ['manage', 'delete'], 'incident-templates' => ['manage', 'delete'], 'metrics' => ['manage', 'delete'], diff --git a/src/Data/Requests/Incident/CreateIncidentRequestData.php b/src/Data/Requests/Incident/CreateIncidentRequestData.php index e15f52d2..a8f53aac 100644 --- a/src/Data/Requests/Incident/CreateIncidentRequestData.php +++ b/src/Data/Requests/Incident/CreateIncidentRequestData.php @@ -28,6 +28,7 @@ public function __construct( public readonly ?string $message = null, #[RequiredWithout('message')] public readonly ?string $template = null, + public readonly ?string $privateNote = null, public readonly bool $visible = false, public readonly bool $stickied = false, public readonly bool $notifications = false, @@ -55,6 +56,10 @@ public static function rules(ValidationContext $context): array 'message' => ['required_without:template', 'string'], 'template' => ['required_without:message', 'string', Rule::exists('incident_templates', 'slug')], 'status' => ['required', Rule::enum(IncidentStatusEnum::class)], + /** + * A dashboard-only note. Never shown on the status page, in webhooks or in subscriber notifications. Requires the incident-private-notes.manage token ability. + */ + 'private_note' => ['nullable', 'string'], 'visible' => ['boolean'], 'stickied' => ['boolean'], 'notifications' => ['boolean'], @@ -99,6 +104,7 @@ public function withMessage(string $message): self status: $this->status, message: $message, template: $this->template, + privateNote: $this->privateNote, visible: $this->visible, stickied: $this->stickied, notifications: $this->notifications, diff --git a/src/Data/Requests/Incident/UpdateIncidentRequestData.php b/src/Data/Requests/Incident/UpdateIncidentRequestData.php index d9251d3f..9b67c6fb 100644 --- a/src/Data/Requests/Incident/UpdateIncidentRequestData.php +++ b/src/Data/Requests/Incident/UpdateIncidentRequestData.php @@ -16,6 +16,7 @@ final class UpdateIncidentRequestData extends BaseData public function __construct( public readonly Optional|string $name, public readonly ?string $message = null, + public readonly ?string $privateNote = null, public readonly ?IncidentStatusEnum $status = null, public readonly ?bool $visible = null, public readonly ?bool $stickied = null, @@ -35,6 +36,10 @@ public static function rules(ValidationContext $context): array return [ 'name' => ['string', 'max:255'], 'message' => ['string'], + /** + * A dashboard-only note. Never shown on the status page, in webhooks or in subscriber notifications. Requires the incident-private-notes.manage token ability. + */ + 'private_note' => ['nullable', 'string'], 'status' => [Rule::enum(IncidentStatusEnum::class)], 'visible' => ['boolean'], 'stickied' => ['boolean'], diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index 6e8fe1d1..309a1c62 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -90,9 +90,10 @@ public function index(Request $request) /** * Create Incident */ - public function store(CreateIncidentRequestData $data, CreateIncident $createIncidentAction) + public function store(Request $request, CreateIncidentRequestData $data, CreateIncident $createIncidentAction) { $this->guard('incidents.manage'); + $this->guardPrivateNote($request); $incident = $createIncidentAction->handle($data); @@ -118,15 +119,26 @@ public function show(Incident $incident) /** * Update Incident */ - public function update(UpdateIncidentRequestData $data, Incident $incident, UpdateIncident $updateIncidentAction) + public function update(Request $request, UpdateIncidentRequestData $data, Incident $incident, UpdateIncident $updateIncidentAction) { $this->guard('incidents.manage'); + $this->guardPrivateNote($request); $updateIncidentAction->handle($incident, $data); return IncidentResource::make($incident->fresh()); } + /** + * Require the private notes ability when the request carries a private note. + */ + private function guardPrivateNote(Request $request): void + { + if ($request->has('private_note')) { + $this->guard('incident-private-notes.manage'); + } + } + /** * Delete Incident */ diff --git a/src/Http/Resources/Incident.php b/src/Http/Resources/Incident.php index 02b108be..007d17a8 100644 --- a/src/Http/Resources/Incident.php +++ b/src/Http/Resources/Incident.php @@ -17,6 +17,10 @@ public function toAttributes(Request $request): array 'guid' => $this->guid, 'name' => $this->name, 'message' => $this->message, + 'private_note' => $this->when( + (bool) $request->user('sanctum')?->tokenCan('incident-private-notes.manage'), + fn () => $this->private_note, + ), 'visible' => $this->visible, 'stickied' => $this->stickied, 'notifications' => $this->notifications, diff --git a/src/Mcp/Tools/Incidents/CreateIncident.php b/src/Mcp/Tools/Incidents/CreateIncident.php index b2f3b684..f43a8fb2 100644 --- a/src/Mcp/Tools/Incidents/CreateIncident.php +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -9,6 +9,7 @@ use Cachet\Mcp\Concerns\GuardsMcpAbilities; use Cachet\Mcp\Concerns\PresentsResources; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Arr; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -71,7 +72,7 @@ public function handle(Request $request, CreateIncidentAction $action): Response 'template' => ['required_without:message', 'nullable', 'string'], ]); - $data = CreateIncidentRequestData::validateAndCreate($request->all()); + $data = CreateIncidentRequestData::validateAndCreate(Arr::except($request->all(), 'private_note')); $incident = $action->handle($data)->load(['components', 'updates']); diff --git a/tests/Feature/Api/IncidentTest.php b/tests/Feature/Api/IncidentTest.php index 02a5868f..076e9220 100644 --- a/tests/Feature/Api/IncidentTest.php +++ b/tests/Feature/Api/IncidentTest.php @@ -7,6 +7,8 @@ use Cachet\Models\ComponentGroup; use Cachet\Models\Incident; use Cachet\Models\IncidentTemplate; +use Cachet\Models\Update; +use Illuminate\Database\Eloquent\Relations\Relation; use Laravel\Sanctum\Sanctum; use Workbench\App\User; @@ -637,3 +639,125 @@ expect(Incident::query()->where('name', 'Duplicated')->exists())->toBeFalse(); }); + +it('does not expose a private note to guests', function () { + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + + getJson('/status/api/incidents') + ->assertOk() + ->assertJsonMissingPath('data.0.attributes.private_note') + ->assertDontSee('Internal ticket SEC-482'); + + getJson('/status/api/incidents/'.$incident->id) + ->assertOk() + ->assertJsonMissingPath('data.attributes.private_note') + ->assertDontSee('Internal ticket SEC-482'); +}); + +it('does not expose a private note to a token without the private notes ability', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + + getJson('/status/api/incidents/'.$incident->id) + ->assertOk() + ->assertJsonMissingPath('data.attributes.private_note') + ->assertDontSee('Internal ticket SEC-482'); +}); + +it('exposes a private note to a token with the private notes ability', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-private-notes.manage']); + + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + + getJson('/status/api/incidents') + ->assertOk() + ->assertJsonPath('data.0.attributes.private_note', 'Internal ticket SEC-482'); + + getJson('/status/api/incidents/'.$incident->id) + ->assertOk() + ->assertJsonPath('data.attributes.private_note', 'Internal ticket SEC-482'); +}); + +it('does not expose a private note through the incident updates include without the ability', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + $update = Update::factory()->create([ + 'updateable_id' => $incident->id, + 'updateable_type' => Relation::getMorphAlias(Incident::class), + ]); + + getJson('/status/api/incidents/'.$incident->id.'/updates/'.$update->id.'?include=incident') + ->assertOk() + ->assertDontSee('Internal ticket SEC-482'); +}); + +it('can create an incident with a private note', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage', 'incident-private-notes.manage']); + + postJson('/status/api/incidents', [ + 'name' => 'Test', + 'message' => 'Something happened.', + 'status' => IncidentStatusEnum::investigating->value, + 'private_note' => 'Internal ticket SEC-482', + ]) + ->assertCreated() + ->assertJsonPath('data.attributes.private_note', 'Internal ticket SEC-482'); + + expect(Incident::query()->firstWhere('name', 'Test')->private_note)->toBe('Internal ticket SEC-482'); +}); + +it('cannot create an incident with a private note without the private notes ability', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + postJson('/status/api/incidents', [ + 'name' => 'Test', + 'message' => 'Something happened.', + 'status' => IncidentStatusEnum::investigating->value, + 'private_note' => 'Internal ticket SEC-482', + ])->assertForbidden(); + + expect(Incident::query()->where('name', 'Test')->exists())->toBeFalse(); +}); + +it('can update an incident private note', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage', 'incident-private-notes.manage']); + + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + + putJson('/status/api/incidents/'.$incident->id, [ + 'private_note' => 'Escalated to the security team', + ]) + ->assertOk() + ->assertJsonPath('data.attributes.private_note', 'Escalated to the security team'); + + expect($incident->fresh()->private_note)->toBe('Escalated to the security team'); +}); + +it('cannot update an incident private note without the private notes ability', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + + putJson('/status/api/incidents/'.$incident->id, [ + 'name' => 'Renamed', + 'private_note' => 'Escalated to the security team', + ])->assertForbidden(); + + expect($incident->fresh()) + ->name->not->toBe('Renamed') + ->private_note->toBe('Internal ticket SEC-482'); +}); + +it('can update an incident without touching its private note', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['private_note' => 'Internal ticket SEC-482']); + + putJson('/status/api/incidents/'.$incident->id, ['name' => 'Renamed'])->assertOk(); + + expect($incident->fresh()) + ->name->toBe('Renamed') + ->private_note->toBe('Internal ticket SEC-482'); +}); diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php index 7cee7bdf..549cfd32 100644 --- a/tests/Feature/Mcp/Tools/IncidentToolsTest.php +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -87,6 +87,19 @@ assertDatabaseHas('incidents', ['name' => 'API Slowdown']); }); +it('ignores a private note when creating an incident', function () { + Sanctum::actingAs(User::factory()->create(), ['*']); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'API Slowdown', + 'status' => IncidentStatusEnum::investigating->value, + 'message' => 'We are investigating API latency.', + 'private_note' => 'Internal ticket SEC-482', + ])->assertOk()->assertDontSee('Internal ticket SEC-482'); + + expect(Incident::query()->firstWhere('name', 'API Slowdown')->private_note)->toBeNull(); +}); + it('creates an incident and updates the affected components', function () { Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); diff --git a/tests/Feature/StatusPage/IncidentPageTest.php b/tests/Feature/StatusPage/IncidentPageTest.php index d00be3b2..20723df0 100644 --- a/tests/Feature/StatusPage/IncidentPageTest.php +++ b/tests/Feature/StatusPage/IncidentPageTest.php @@ -6,7 +6,7 @@ use Cachet\Models\Incident; use Workbench\App\User; -it('never exposes a public incident private note to status page or API viewers', function (bool $authenticated) { +it('never exposes a public incident private note on the status page', function (bool $authenticated) { if ($authenticated) { $this->actingAs(User::factory()->create(['is_admin' => true])); } @@ -21,13 +21,34 @@ ->assertOk() ->assertSee('Public incident message') ->assertDontSee('Internal ticket SEC-482'); +})->with([false, true]); + +it('never exposes a public incident private note to guest API viewers', function () { + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'message' => 'Public incident message', + 'private_note' => '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('exposes a private note to a first-party session on the API', function () { + $this->actingAs(User::factory()->create(['is_admin' => true])); + + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'private_note' => 'Internal ticket SEC-482', + ]); + + $this->getJson('/status/api/incidents/'.$incident->id) + ->assertOk() + ->assertJsonPath('data.attributes.private_note', 'Internal ticket SEC-482'); +}); it('shows the affected components on the incident page', function () { $component = Component::factory()->create(['name' => 'API']);