feat: status page authoring API

Adds owner|admin routes under /api/status-pages for authoring status pages
and their incidents/maintenance windows, gated by the status_pages licence
feature. Adds the "status" token scope resource and the ten route-scope
entries, and regenerates the committed OpenAPI document.

Also types ErrPageInvalid as a sentinel for status page/incident validation
failures (previously bare errors), so statusPageError maps them to 400
instead of 500, and createStatusIncident/updateStatusIncident route through
the shared error mapper rather than hand-rolling a 400 for any service error.
This commit is contained in:
2026-08-24 14:43:17 +00:00
parent 6ba54f690c
commit 3abbdc41d6
8 changed files with 1122 additions and 12 deletions
+1
View File
@@ -26,6 +26,7 @@ var ScopeResources = []string{
"vulns",
"workloads",
"settings",
"status",
}
const (
+10 -10
View File
@@ -35,34 +35,34 @@ var impacts = map[string]bool{
func validateIncident(inc *models.StatusIncident) error {
if inc.Title == "" {
return errors.New("title is required")
return fmt.Errorf("%w: title is required", ErrPageInvalid)
}
if len(inc.PageIDs) == 0 {
return errors.New("at least one page is required")
return fmt.Errorf("%w: at least one page is required", ErrPageInvalid)
}
switch inc.Kind {
case models.StatusKindIncident:
if !incidentStatuses[inc.Status] {
return fmt.Errorf("invalid incident status %q", inc.Status)
return fmt.Errorf("%w: invalid incident status %q", ErrPageInvalid, inc.Status)
}
case models.StatusKindMaintenance:
if !maintenanceStatuses[inc.Status] {
return fmt.Errorf("invalid maintenance status %q", inc.Status)
return fmt.Errorf("%w: invalid maintenance status %q", ErrPageInvalid, inc.Status)
}
if inc.ScheduledStart == nil || inc.ScheduledEnd == nil {
return errors.New("maintenance needs a scheduled start and end")
return fmt.Errorf("%w: maintenance needs a scheduled start and end", ErrPageInvalid)
}
if !inc.ScheduledEnd.After(*inc.ScheduledStart) {
return errors.New("maintenance must end after it starts")
return fmt.Errorf("%w: maintenance must end after it starts", ErrPageInvalid)
}
default:
return fmt.Errorf("invalid kind %q", inc.Kind)
return fmt.Errorf("%w: invalid kind %q", ErrPageInvalid, inc.Kind)
}
if inc.Impact == "" {
inc.Impact = models.ImpactNone
}
if !impacts[inc.Impact] {
return fmt.Errorf("invalid impact %q", inc.Impact)
return fmt.Errorf("%w: invalid impact %q", ErrPageInvalid, inc.Impact)
}
return nil
}
@@ -214,14 +214,14 @@ func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author str
return nil, err
}
if body == "" {
return nil, errors.New("update body is required")
return nil, fmt.Errorf("%w: update body is required", ErrPageInvalid)
}
valid := incidentStatuses
if existing.Kind == models.StatusKindMaintenance {
valid = maintenanceStatuses
}
if !valid[status] {
return nil, fmt.Errorf("invalid status %q for a %s", status, existing.Kind)
return nil, fmt.Errorf("%w: invalid status %q for a %s", ErrPageInvalid, status, existing.Kind)
}
upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author}
+9 -2
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"errors"
"fmt"
"log"
"regexp"
"time"
@@ -74,6 +75,12 @@ func EnsureStatusPageIndexes() error {
var (
ErrPageNotFound = errors.New("status page not found")
ErrPageIDTaken = errors.New("that page id is already in use")
// ErrPageInvalid is the sentinel for validation failures on a page or
// incident body — anything the caller can fix by sending a different
// request. statusPageError maps it to 400; wrap it rather than returning a
// bare error, or a bad request answers 500.
ErrPageInvalid = errors.New("status page request invalid")
)
// statusRedis is set by main.go at boot. It is nil in any process that has not
@@ -134,7 +141,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa
return nil, err
}
if p.Title == "" {
return nil, errors.New("title is required")
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
}
p.ID = bson.ObjectID{}
p.InstanceID = instanceID
@@ -169,7 +176,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa
// The page id itself is immutable: it is a URL that has been handed out.
func UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error) {
if p.Title == "" {
return nil, errors.New("title is required")
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
}
if p.Sections == nil {
p.Sections = []models.StatusPageSection{}