This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
package incidentapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/correlation"
|
||||
"github.com/itworx/pulse/internal/incident"
|
||||
"github.com/itworx/pulse/internal/problem"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store incident.Store
|
||||
Audit audit.Store
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
|
||||
return
|
||||
}
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/incidents"), "/")
|
||||
if path == "" && r.Method == http.MethodGet {
|
||||
h.list(w, r)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodPatch {
|
||||
if !auth.Allows(principal.Role, auth.PermissionOperate) {
|
||||
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Incident ownership is not allowed for this role.")
|
||||
return
|
||||
}
|
||||
h.updateOwner(w, r, parts[0], principal.Subject)
|
||||
return
|
||||
}
|
||||
if len(parts) == 2 && parts[1] == "notes" && (r.Method == http.MethodGet || r.Method == http.MethodPost) {
|
||||
if r.Method == http.MethodPost && !auth.Allows(principal.Role, auth.PermissionOperate) {
|
||||
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Incident notes are not allowed for this role.")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
h.listNotes(w, r, parts[0])
|
||||
} else {
|
||||
h.addNote(w, r, parts[0], principal.Subject)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodGet {
|
||||
h.get(w, r, parts[0])
|
||||
return
|
||||
}
|
||||
if len(parts) == 3 && parts[1] == "alerts" && parts[2] != "" && (r.Method == http.MethodPost || r.Method == http.MethodDelete) {
|
||||
if !auth.Allows(principal.Role, auth.PermissionOperate) {
|
||||
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Incident association is not allowed for this role.")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
h.associate(w, r, parts[0], parts[2], principal.Subject)
|
||||
return
|
||||
}
|
||||
h.disassociate(w, r, parts[0], parts[2], principal.Subject)
|
||||
return
|
||||
}
|
||||
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Incident route not found.")
|
||||
}
|
||||
|
||||
func (h Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 100
|
||||
if value := r.URL.Query().Get("limit"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The incident limit must be between 1 and 100.")
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
status := incident.Status(r.URL.Query().Get("status"))
|
||||
items, err := h.Store.List(r.Context(), limit, status)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) {
|
||||
item, err := h.Store.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
notes, notesErr := h.Store.ListNotes(r.Context(), id, 100)
|
||||
if notesErr != nil {
|
||||
h.repositoryFailure(w, r, notesErr)
|
||||
return
|
||||
}
|
||||
item.Notes = notes
|
||||
write(w, http.StatusOK, map[string]any{"incident": item})
|
||||
}
|
||||
|
||||
func (h Handler) associate(w http.ResponseWriter, r *http.Request, incidentID, alertID, actor string) {
|
||||
var request struct {
|
||||
Rationale string `json:"rationale"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_ASSOCIATION", "The incident association document is invalid.")
|
||||
return
|
||||
}
|
||||
if request.Confidence == 0 {
|
||||
request.Confidence = 1
|
||||
}
|
||||
item, duplicate, err := h.Store.AssociateAlert(r.Context(), incidentID, alertID, strings.TrimSpace(request.Rationale), request.Confidence, actor)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
result := "success"
|
||||
if duplicate {
|
||||
result = "idempotent"
|
||||
}
|
||||
if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.alert.associate", ResourceType: "incident", ResourceID: incidentID, Result: result, CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"alertId": alertID, "rationale": request.Rationale, "confidence": request.Confidence, "manual": true, "duplicate": duplicate}}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"association": item, "duplicate": duplicate})
|
||||
}
|
||||
|
||||
func (h Handler) disassociate(w http.ResponseWriter, r *http.Request, incidentID, alertID, actor string) {
|
||||
if err := h.Store.DisassociateAlert(r.Context(), incidentID, alertID); err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.alert.disassociate", ResourceType: "incident", ResourceID: incidentID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"alertId": alertID, "manual": true}}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h Handler) updateOwner(w http.ResponseWriter, r *http.Request, id, actor string) {
|
||||
var request struct {
|
||||
OwnerUserID string `json:"ownerUserId"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil || request.Revision < 1 {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_INCIDENT", "A valid owner and revision document is required.")
|
||||
return
|
||||
}
|
||||
item, err := h.Store.UpdateOwner(r.Context(), id, strings.TrimSpace(request.OwnerUserID), request.Revision)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.owner.update", ResourceType: "incident", ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"ownerUserId": request.OwnerUserID, "revision": item.Revision}}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"incident": item})
|
||||
}
|
||||
|
||||
func (h Handler) listNotes(w http.ResponseWriter, r *http.Request, id string) {
|
||||
items, err := h.Store.ListNotes(r.Context(), id, 100)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (h Handler) addNote(w http.ResponseWriter, r *http.Request, id, actor string) {
|
||||
var request struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_NOTE", "The incident note is invalid.")
|
||||
return
|
||||
}
|
||||
note, err := h.Store.AddNote(r.Context(), id, actor, request.Body)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.note.create", ResourceType: "incident", ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"noteId": note.ID}}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
}
|
||||
write(w, http.StatusCreated, map[string]any{"note": note})
|
||||
}
|
||||
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, incident.ErrInvalid):
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_INCIDENT", "The incident request is invalid.")
|
||||
case errors.Is(err, incident.ErrNotFound):
|
||||
fail(w, r, http.StatusNotFound, "NOT_FOUND", "The incident was not found.")
|
||||
case errors.Is(err, incident.ErrConflict):
|
||||
fail(w, r, http.StatusConflict, "CONFLICT", "The incident changed before this operation was applied.")
|
||||
case errors.Is(err, incident.ErrUnavailable):
|
||||
fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Incidents are unavailable.")
|
||||
default:
|
||||
fail(w, r, http.StatusInternalServerError, "INCIDENT_REQUEST_FAILED", "The incident request failed.")
|
||||
}
|
||||
}
|
||||
func decode(r *http.Request, target any) error {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if len(body) > 1<<20 {
|
||||
return errors.New("request too large")
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
|
||||
problem.Write(w, r, status, code, http.StatusText(status), detail, nil)
|
||||
}
|
||||
func write(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package incidentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/incident"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
item incident.Incident
|
||||
association incident.Association
|
||||
associations int
|
||||
}
|
||||
|
||||
func (s *memoryStore) List(context.Context, int, incident.Status) ([]incident.Incident, error) {
|
||||
return []incident.Incident{s.item}, nil
|
||||
}
|
||||
func (s *memoryStore) Get(context.Context, string) (incident.Incident, error) { return s.item, nil }
|
||||
func (s *memoryStore) UpsertCandidate(context.Context, incident.Candidate) (incident.Incident, bool, error) {
|
||||
return s.item, false, nil
|
||||
}
|
||||
func (s *memoryStore) AssociateAlert(_ context.Context, _, id, rationale string, confidence float64, actor string) (incident.Association, bool, error) {
|
||||
s.associations++
|
||||
s.association = incident.Association{AlertID: id, Rationale: rationale, Confidence: confidence, CorrelationMethod: "manual", Manual: true, AddedBy: actor}
|
||||
return s.association, s.associations > 1, nil
|
||||
}
|
||||
func (s *memoryStore) DisassociateAlert(context.Context, string, string) error { return nil }
|
||||
func (s *memoryStore) UpdateStatus(context.Context, string, incident.Status, int64, time.Time) (incident.Incident, error) {
|
||||
return s.item, nil
|
||||
}
|
||||
|
||||
func requestWithPrincipal(method, path, body string, role auth.Role) *http.Request {
|
||||
return httptest.NewRequest(method, path, strings.NewReader(body)).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role}))
|
||||
}
|
||||
|
||||
func TestIncidentHandlerRBACAndManualAssociationAudit(t *testing.T) {
|
||||
store := &memoryStore{item: incident.Incident{ID: "incident-1", Title: "Host down", Status: incident.StatusOpen, Severity: incident.SeverityCritical, Revision: 1}}
|
||||
auditStore := &audit.MemoryStore{}
|
||||
handler := Handler{Store: store, Audit: auditStore}
|
||||
request := requestWithPrincipal(http.MethodPost, "/api/v1/incidents/incident-1/alerts/alert-1", `{"rationale":"confirmed dependency","confidence":1}`, auth.RoleViewer)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer status=%d", response.Code)
|
||||
}
|
||||
request = requestWithPrincipal(http.MethodPost, "/api/v1/incidents/incident-1/alerts/alert-1", `{"rationale":"confirmed dependency","confidence":1}`, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"manual":true`) {
|
||||
t.Fatalf("association status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if len(auditStore.Events) != 1 || auditStore.Events[0].Action != "incident.alert.associate" {
|
||||
t.Fatalf("audit=%+v", auditStore.Events)
|
||||
}
|
||||
request = requestWithPrincipal(http.MethodGet, "/api/v1/incidents?status=open", ``, auth.RoleViewer)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func (s *memoryStore) UpdateOwner(_ context.Context, _ string, owner string, _ int64) (incident.Incident, error) {
|
||||
s.item.OwnerUserID = owner
|
||||
s.item.Revision++
|
||||
return s.item, nil
|
||||
}
|
||||
func (s *memoryStore) AddNote(_ context.Context, id, author, body string) (incident.Note, error) {
|
||||
sanitized, err := incident.SanitizeNote(body)
|
||||
if err != nil {
|
||||
return incident.Note{}, err
|
||||
}
|
||||
return incident.Note{ID: "note-1", IncidentID: id, Author: author, Body: sanitized, CreatedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
func (s *memoryStore) ListNotes(context.Context, string, int) ([]incident.Note, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func TestIncidentHandlerNotesAndOwner(t *testing.T) {
|
||||
store := &memoryStore{item: incident.Incident{ID: "incident-1", Title: "Host down", Status: incident.StatusOpen, Severity: incident.SeverityCritical, Revision: 1}}
|
||||
handler := Handler{Store: store, Audit: &audit.MemoryStore{}}
|
||||
request := requestWithPrincipal(http.MethodPost, "/api/v1/incidents/incident-1/notes", `{"body":"<b>confirmed</b>"}`, auth.RoleViewer)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer note status=%d", response.Code)
|
||||
}
|
||||
request = requestWithPrincipal(http.MethodPost, "/api/v1/incidents/incident-1/notes", `{"body":"<b>confirmed</b>"}`, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusCreated || strings.Contains(response.Body.String(), "<b>") || !strings.Contains(response.Body.String(), "confirmed") {
|
||||
t.Fatalf("note status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
request = requestWithPrincipal(http.MethodPatch, "/api/v1/incidents/incident-1", `{"ownerUserId":"owner-1","revision":1}`, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "owner-1") {
|
||||
t.Fatalf("owner status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user