Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion resources/lang/en/incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/Cachet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
6 changes: 6 additions & 0 deletions src/Data/Requests/Incident/CreateIncidentRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/Data/Requests/Incident/UpdateIncidentRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'],
Expand Down
16 changes: 14 additions & 2 deletions src/Http/Controllers/Api/IncidentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
*/
Expand Down
4 changes: 4 additions & 0 deletions src/Http/Resources/Incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/Mcp/Tools/Incidents/CreateIncident.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);

Expand Down
124 changes: 124 additions & 0 deletions tests/Feature/Api/IncidentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
});
13 changes: 13 additions & 0 deletions tests/Feature/Mcp/Tools/IncidentToolsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
25 changes: 23 additions & 2 deletions tests/Feature/StatusPage/IncidentPageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
}
Expand All @@ -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']);
Expand Down
Loading