9029d48584
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
167 lines
4.8 KiB
Go
167 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
|
|
// The subquery resolves the alert's most recent incident: membership is kept in
|
|
// incident_alerts rather than as a column here, because one alert row is reused
|
|
// across occurrences and belongs to a different incident each time.
|
|
const alertSelectFrom = `
|
|
SELECT a.id, a.team_id, t.name, a.fingerprint, a.name, a.status,
|
|
a.labels, a.annotations,
|
|
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
|
(SELECT ia.incident_id
|
|
FROM incident_alerts ia
|
|
JOIN incidents i ON i.id = ia.incident_id
|
|
WHERE ia.alert_id = a.id
|
|
ORDER BY i.triggered_at DESC, i.id DESC
|
|
LIMIT 1),
|
|
a.resolution_source, a.archived_at
|
|
FROM alerts a
|
|
JOIN teams t ON t.id = a.team_id`
|
|
|
|
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
where := []string{}
|
|
args := &sqlArgs{}
|
|
|
|
where = append(where, "a.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
|
|
if team := q.Get("team_id"); team != "" {
|
|
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
|
|
where = append(where, "a.team_id = "+args.add(n))
|
|
}
|
|
}
|
|
|
|
if status := q.Get("status"); status != "" {
|
|
where = append(where, "a.status = "+args.add(status))
|
|
}
|
|
if name := q.Get("name"); name != "" {
|
|
where = append(where, "a.name = "+args.add(name))
|
|
}
|
|
if archived := q.Get("archived"); archived == "true" {
|
|
where = append(where, "a.archived_at IS NOT NULL")
|
|
} else {
|
|
where = append(where, "a.archived_at IS NULL")
|
|
}
|
|
if incidentID := q.Get("incident_id"); incidentID != "" {
|
|
if n, err := strconv.ParseInt(incidentID, 10, 64); err == nil {
|
|
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = "+args.add(n)+")")
|
|
}
|
|
}
|
|
|
|
if from := q.Get("from"); from != "" {
|
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
|
where = append(where, "a.received_at >= "+args.add(t.UTC().Unix()))
|
|
}
|
|
}
|
|
if to := q.Get("to"); to != "" {
|
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
|
where = append(where, "a.received_at < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
|
|
}
|
|
}
|
|
|
|
limit := 50
|
|
if l := q.Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 500 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
clause := "1=1"
|
|
if len(where) > 0 {
|
|
clause = strings.Join(where, " AND ")
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(),
|
|
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT %s", alertSelectFrom, clause, args.add(limit)),
|
|
args.all()...)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
alerts := []models.Alert{}
|
|
for rows.Next() {
|
|
a, err := scanAlert(rows)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
alerts = append(alerts, a)
|
|
}
|
|
respond(w, http.StatusOK, alerts)
|
|
}
|
|
}
|
|
|
|
func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
return
|
|
}
|
|
a, err := fetchAlert(r.Context(), db, id, callerTeamIDs(r.Context()))
|
|
if err == sql.ErrNoRows {
|
|
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, a)
|
|
}
|
|
}
|
|
|
|
// fetchAlert loads a single alert by ID using the shared query.
|
|
func fetchAlert(ctx context.Context, db *sql.DB, id int64, teamIDs []int64) (models.Alert, error) {
|
|
return scanAlert(db.QueryRowContext(ctx,
|
|
alertSelectFrom+" WHERE a.id = $1 AND a.team_id = ANY($2)", id, teamIDs))
|
|
}
|
|
|
|
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
|
type scanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAlert(s scanner) (models.Alert, error) {
|
|
var a models.Alert
|
|
var labelsJSON, annotationsJSON string
|
|
var startsAtUnix, receivedAtUnix int64
|
|
var endsAtUnix, archivedAtUnix *int64
|
|
|
|
if err := s.Scan(
|
|
&a.ID, &a.TeamID, &a.TeamName, &a.Fingerprint, &a.Name, &a.Status,
|
|
&labelsJSON, &annotationsJSON,
|
|
&startsAtUnix, &endsAtUnix,
|
|
&a.GeneratorURL, &receivedAtUnix,
|
|
&a.IncidentID,
|
|
&a.ResolutionSource, &archivedAtUnix,
|
|
); err != nil {
|
|
return a, err
|
|
}
|
|
|
|
json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck
|
|
json.Unmarshal([]byte(annotationsJSON), &a.Annotations) //nolint:errcheck
|
|
a.StartsAt = time.Unix(startsAtUnix, 0).UTC()
|
|
a.ReceivedAt = time.Unix(receivedAtUnix, 0).UTC()
|
|
a.EndsAt = unixPtr(endsAtUnix)
|
|
a.ArchivedAt = unixPtr(archivedAtUnix)
|
|
return a, nil
|
|
}
|