Let an operator authenticate with a seeded key, and reset the schema
- 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
This commit is contained in:
+28
-46
@@ -37,7 +37,7 @@ func handleListClusters(db *sql.DB) http.HandlerFunc {
|
||||
label, strings.Join(where, " AND ")),
|
||||
args.all()...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -46,13 +46,13 @@ func handleListClusters(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
clusters = append(clusters, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, clusters)
|
||||
@@ -138,7 +138,7 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
incidentSelectFrom, strings.Join(where, " AND "), order, args.add(limit)),
|
||||
args.all()...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -147,13 +147,13 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
i, err := scanIncident(rows)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
incidents = append(incidents, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, incidents)
|
||||
@@ -172,35 +172,17 @@ func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if inc.Alerts, err = incidentAlerts(r, db, id); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, inc)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !incidentExists(w, r, db, id) {
|
||||
return
|
||||
}
|
||||
alerts, err := incidentAlerts(r, db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
@@ -225,7 +207,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
WHERE e.incident_id = $1
|
||||
ORDER BY e.created_at ASC, e.id ASC`, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -239,14 +221,14 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
&e.ActorUserID, &e.ActorUsername,
|
||||
&e.ActorServiceAccountID, &e.ActorServiceAccountName,
|
||||
&e.AlertID, &e.Detail, &ts); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
events = append(events, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, events)
|
||||
@@ -262,7 +244,7 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
acked, err := acknowledgeIncidentAs(r.Context(), db, id, userID, saID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !acked {
|
||||
@@ -272,7 +254,7 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
// (acknowledged) already holds.
|
||||
inc, err := fetchIncident(r.Context(), db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if inc.Status == "resolved" {
|
||||
@@ -300,7 +282,7 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnacknowledged, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -335,16 +317,16 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
// A person closing an incident is the clearest possible "I have this".
|
||||
if err := stopEscalation(r.Context(), db, id); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evResolved, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if req.Resolution != "" {
|
||||
if err := logEvent(r.Context(), db, id, evResolutionNote, userID, saID, nil, &req.Resolution); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -385,7 +367,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||
// the actor_* columns.
|
||||
actorUserID, actorSAID := callerActorIDs(r.Context())
|
||||
if err := logAssignedEvent(r.Context(), db, id, req.UserID, actorUserID, actorSAID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
@@ -443,7 +425,7 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
detail := until.UTC().Format(time.RFC3339)
|
||||
if err := logEvent(r.Context(), db, id, evSnoozed, userID, saID, nil, &detail); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
@@ -462,7 +444,7 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnsnoozed, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -478,7 +460,7 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE incidents SET archived_at = "+nowEpoch+" WHERE id = $1", id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -487,7 +469,7 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if err := logEvent(r.Context(), db, id, evArchived, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
@@ -503,7 +485,7 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE incidents SET archived_at = NULL WHERE id = $1", id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -512,7 +494,7 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if err := logEvent(r.Context(), db, id, evUnarchived, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -557,7 +539,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`, id, noteType, userID, saID, req.Content, now.Unix()).Scan(&eventID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -598,7 +580,7 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||
AND (user_id = $5 OR service_account_id = $6)`,
|
||||
eventID, id, evNote, evResolutionNote, userID, saID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -652,7 +634,7 @@ func incidentExists(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64
|
||||
func updateOpenIncident(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64, query string, args ...any) bool {
|
||||
res, err := db.ExecContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return false
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
@@ -668,7 +650,7 @@ func updateOpenIncident(w http.ResponseWriter, r *http.Request, db *sql.DB, id i
|
||||
func respondIncident(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) {
|
||||
inc, err := fetchIncident(r.Context(), db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, inc)
|
||||
|
||||
Reference in New Issue
Block a user