This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
package alertcontrolapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/alertcontrol"
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/correlation"
|
||||
"github.com/itworx/pulse/internal/problem"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store alertcontrol.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
|
||||
}
|
||||
base := "/api/v1/alert-silences"
|
||||
maintenance := strings.HasPrefix(r.URL.Path, "/api/v1/maintenance-windows")
|
||||
if maintenance {
|
||||
base = "/api/v1/maintenance-windows"
|
||||
}
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, base), "/")
|
||||
if path == "" {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.list(w, r, maintenance)
|
||||
case http.MethodPost:
|
||||
if !requireOperate(w, r, principal.Role) {
|
||||
return
|
||||
}
|
||||
h.create(w, r, principal.Subject, maintenance)
|
||||
default:
|
||||
fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
|
||||
}
|
||||
return
|
||||
}
|
||||
if path == "preview" && r.Method == http.MethodPost {
|
||||
h.preview(w, r, maintenance)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 2 && parts[1] == "revoke" && r.Method == http.MethodPost {
|
||||
if !requireOperate(w, r, principal.Role) {
|
||||
return
|
||||
}
|
||||
h.revoke(w, r, parts[0], principal.Subject, maintenance)
|
||||
return
|
||||
}
|
||||
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert-control route not found.")
|
||||
}
|
||||
|
||||
func (h Handler) list(w http.ResponseWriter, r *http.Request, maintenance bool) {
|
||||
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 limit must be between 1 and 100.")
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
if maintenance {
|
||||
items, err := h.Store.ListMaintenance(r.Context(), limit, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"items": items})
|
||||
return
|
||||
}
|
||||
items, err := h.Store.ListSilences(r.Context(), limit, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string, maintenance bool) {
|
||||
if maintenance {
|
||||
var item alertcontrol.MaintenanceWindow
|
||||
if err := decode(r, &item); err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_MAINTENANCE", "The maintenance-window document is invalid.")
|
||||
return
|
||||
}
|
||||
created, err := h.Store.CreateMaintenance(r.Context(), actor, item)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.record(r, actor, "maintenance_window.create", created.ID, "maintenance_window", nil, map[string]any{"state": created.State, "revision": created.Revision}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
write(w, http.StatusCreated, map[string]any{"maintenance": created})
|
||||
return
|
||||
}
|
||||
var item alertcontrol.Silence
|
||||
if err := decode(r, &item); err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_SILENCE", "The silence document is invalid.")
|
||||
return
|
||||
}
|
||||
item.Owner = actor
|
||||
created, err := h.Store.CreateSilence(r.Context(), actor, item)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.record(r, actor, "alert_silence.create", created.ID, "alert_silence", nil, map[string]any{"state": created.State, "revision": created.Revision}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
write(w, http.StatusCreated, map[string]any{"silence": created})
|
||||
}
|
||||
|
||||
func (h Handler) revoke(w http.ResponseWriter, r *http.Request, id, actor string, maintenance bool) {
|
||||
expected, err := revision(r)
|
||||
if err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
|
||||
return
|
||||
}
|
||||
if maintenance {
|
||||
item, err := h.Store.RevokeMaintenance(r.Context(), id, actor, expected, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.record(r, actor, "maintenance_window.revoke", id, "maintenance_window", map[string]any{"revision": expected}, map[string]any{"state": item.State, "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{"maintenance": item})
|
||||
return
|
||||
}
|
||||
item, err := h.Store.RevokeSilence(r.Context(), id, actor, expected, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.record(r, actor, "alert_silence.revoke", id, "alert_silence", map[string]any{"revision": expected}, map[string]any{"state": item.State, "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{"silence": item})
|
||||
}
|
||||
|
||||
func (h Handler) preview(w http.ResponseWriter, r *http.Request, maintenance bool) {
|
||||
var request struct {
|
||||
Matcher alertcontrol.Matcher `json:"matcher"`
|
||||
Selector alertcontrol.Matcher `json:"selector"`
|
||||
Signals []alertcontrol.Signal `json:"signals"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The matcher preview request is invalid.")
|
||||
return
|
||||
}
|
||||
matcher := request.Matcher
|
||||
if maintenance {
|
||||
matcher = request.Selector
|
||||
}
|
||||
result, err := alertcontrol.PreviewSignals(matcher, request.Signals)
|
||||
if err != nil {
|
||||
h.repositoryFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"preview": result})
|
||||
}
|
||||
|
||||
func (h Handler) record(r *http.Request, actor, action, id, resourceType string, before, after map[string]any) error {
|
||||
if h.Audit == nil {
|
||||
return nil
|
||||
}
|
||||
return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: resourceType, ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
|
||||
}
|
||||
|
||||
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, alertcontrol.ErrInvalid):
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_ALERT_CONTROL", "The alert-control document is invalid.")
|
||||
case errors.Is(err, alertcontrol.ErrConflict):
|
||||
fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert control was changed or expired.")
|
||||
case errors.Is(err, alertcontrol.ErrNotFound):
|
||||
fail(w, r, http.StatusNotFound, "NOT_FOUND", "The alert control was not found.")
|
||||
case errors.Is(err, alertcontrol.ErrUnavailable):
|
||||
fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Alert controls are unavailable.")
|
||||
default:
|
||||
fail(w, r, http.StatusInternalServerError, "ALERT_CONTROL_REQUEST_FAILED", "The alert-control request failed.")
|
||||
}
|
||||
}
|
||||
func requireOperate(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
|
||||
if auth.Allows(role, auth.PermissionOperate) {
|
||||
return true
|
||||
}
|
||||
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert-control editing is not allowed for this role.")
|
||||
return false
|
||||
}
|
||||
func revision(r *http.Request) (int64, error) {
|
||||
value := r.Header.Get("If-Match")
|
||||
if value == "" {
|
||||
value = r.URL.Query().Get("revision")
|
||||
}
|
||||
value = strings.Trim(value, "\"")
|
||||
if value == "" {
|
||||
return 0, errors.New("revision required")
|
||||
}
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
func decode(r *http.Request, target any) error {
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
|
||||
if contentType != "" && contentType != "application/json" {
|
||||
return errors.New("unsupported content type")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if len(body) > 2<<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,197 @@
|
||||
package alertcontrolapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/alertcontrol"
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
silences map[string]alertcontrol.Silence
|
||||
maintenance map[string]alertcontrol.MaintenanceWindow
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{silences: map[string]alertcontrol.Silence{}, maintenance: map[string]alertcontrol.MaintenanceWindow{}}
|
||||
}
|
||||
func (s *memoryStore) CreateSilence(_ context.Context, actor string, item alertcontrol.Silence) (alertcontrol.Silence, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if item.ID == "" {
|
||||
item.ID = alertcontrol.NewID()
|
||||
}
|
||||
if item.Owner == "" {
|
||||
item.Owner = actor
|
||||
}
|
||||
if err := item.Validate(time.Now().UTC()); err != nil {
|
||||
return alertcontrol.Silence{}, err
|
||||
}
|
||||
if _, ok := s.silences[item.ID]; ok {
|
||||
return alertcontrol.Silence{}, alertcontrol.ErrConflict
|
||||
}
|
||||
item.CreatedBy, item.CreatedAt, item.Revision = actor, time.Now().UTC(), 1
|
||||
item.State = item.StateAt(time.Now().UTC())
|
||||
s.silences[item.ID] = item
|
||||
return item, nil
|
||||
}
|
||||
func (s *memoryStore) ListSilences(_ context.Context, _ int, now time.Time) ([]alertcontrol.Silence, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := make([]alertcontrol.Silence, 0, len(s.silences))
|
||||
for _, item := range s.silences {
|
||||
item.State = item.StateAt(now)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *memoryStore) RevokeSilence(_ context.Context, id, actor string, expected int64, now time.Time) (alertcontrol.Silence, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
item, ok := s.silences[id]
|
||||
if !ok {
|
||||
return alertcontrol.Silence{}, alertcontrol.ErrNotFound
|
||||
}
|
||||
if item.Revision != expected || item.StateAt(now) != alertcontrol.StateActive {
|
||||
return alertcontrol.Silence{}, alertcontrol.ErrConflict
|
||||
}
|
||||
item.RevokedBy, item.RevokedAt, item.Revision, item.State = actor, &now, item.Revision+1, alertcontrol.StateRevoked
|
||||
s.silences[id] = item
|
||||
return item, nil
|
||||
}
|
||||
func (s *memoryStore) CreateMaintenance(_ context.Context, actor string, item alertcontrol.MaintenanceWindow) (alertcontrol.MaintenanceWindow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if item.ID == "" {
|
||||
item.ID = alertcontrol.NewID()
|
||||
}
|
||||
if err := item.Validate(time.Now().UTC()); err != nil {
|
||||
return alertcontrol.MaintenanceWindow{}, err
|
||||
}
|
||||
item.CreatedBy, item.CreatedAt, item.Revision = actor, time.Now().UTC(), 1
|
||||
item.State = item.StateAt(time.Now().UTC())
|
||||
s.maintenance[item.ID] = item
|
||||
return item, nil
|
||||
}
|
||||
func (s *memoryStore) ListMaintenance(_ context.Context, _ int, now time.Time) ([]alertcontrol.MaintenanceWindow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := make([]alertcontrol.MaintenanceWindow, 0, len(s.maintenance))
|
||||
for _, item := range s.maintenance {
|
||||
item.State = item.StateAt(now)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *memoryStore) RevokeMaintenance(_ context.Context, id, actor string, expected int64, now time.Time) (alertcontrol.MaintenanceWindow, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
item, ok := s.maintenance[id]
|
||||
if !ok {
|
||||
return alertcontrol.MaintenanceWindow{}, alertcontrol.ErrNotFound
|
||||
}
|
||||
if item.Revision != expected {
|
||||
return alertcontrol.MaintenanceWindow{}, alertcontrol.ErrConflict
|
||||
}
|
||||
item.RevokedBy, item.RevokedAt, item.Revision, item.State = actor, &now, item.Revision+1, alertcontrol.StateRevoked
|
||||
s.maintenance[id] = item
|
||||
return item, nil
|
||||
}
|
||||
func (s *memoryStore) Expire(_ context.Context, now time.Time) (alertcontrol.ExpiryResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var result alertcontrol.ExpiryResult
|
||||
for id, item := range s.silences {
|
||||
if item.State == alertcontrol.StateActive && !now.Before(item.ExpiresAt) {
|
||||
item.ExpiredAt, item.Revision = &now, item.Revision+1
|
||||
s.silences[id] = item
|
||||
result.Silences++
|
||||
}
|
||||
}
|
||||
for id, item := range s.maintenance {
|
||||
if item.State == alertcontrol.StateActive && !now.Before(item.EndsAt) {
|
||||
item.ExpiredAt, item.Revision = &now, item.Revision+1
|
||||
s.maintenance[id] = item
|
||||
result.MaintenanceWindows++
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func TestHandlerEnforcesRBACAuditPreviewAndVisibleMaintenance(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
auditStore := &audit.MemoryStore{}
|
||||
handler := Handler{Store: store, Audit: auditStore}
|
||||
now := time.Now().UTC()
|
||||
silence := alertcontrol.Silence{Name: "deploy", Reason: "planned", Owner: "operator", Matchers: alertcontrol.Matcher{Severities: []string{"critical"}}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}
|
||||
viewer := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences", silence, auth.RoleViewer)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, viewer)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer create status = %d", response.Code)
|
||||
}
|
||||
operator := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences", silence, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, operator)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("operator create status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
Silence alertcontrol.Silence `json:"silence"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(auditStore.Events) != 1 || auditStore.Events[0].Action != "alert_silence.create" {
|
||||
t.Fatalf("audit events = %#v", auditStore.Events)
|
||||
}
|
||||
previewBody := map[string]any{"matcher": alertcontrol.Matcher{Severities: []string{"critical"}}, "signals": []alertcontrol.Signal{{InstanceID: "i-1", Severity: "critical"}}}
|
||||
preview := requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences/preview", previewBody, auth.RoleViewer)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, preview)
|
||||
if response.Code != http.StatusOK || !contains(response.Body.String(), `"matched":true`) {
|
||||
t.Fatalf("preview response = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
window := alertcontrol.MaintenanceWindow{Name: "maintenance", Reason: "upgrade", Selector: alertcontrol.Matcher{EntityTypes: []string{"host"}}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour)}
|
||||
request := requestWithPrincipal(http.MethodPost, "/api/v1/maintenance-windows", window, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("maintenance create status = %d", response.Code)
|
||||
}
|
||||
request = requestWithPrincipal(http.MethodGet, "/api/v1/maintenance-windows", nil, auth.RoleViewer)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !contains(response.Body.String(), `"state":"active"`) {
|
||||
t.Fatalf("maintenance list = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
request = requestWithPrincipal(http.MethodPost, "/api/v1/alert-silences/"+created.Silence.ID+"/revoke?revision=999", nil, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("stale revoke status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request {
|
||||
var reader *strings.Reader
|
||||
if body == nil {
|
||||
reader = strings.NewReader("")
|
||||
} else {
|
||||
encoded, _ := json.Marshal(body)
|
||||
reader = strings.NewReader(string(encoded))
|
||||
}
|
||||
request := httptest.NewRequest(method, path, reader).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role}))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
func contains(value, part string) bool { return strings.Contains(value, part) }
|
||||
Reference in New Issue
Block a user