Let an operator authenticate with a seeded key, and reset the schema
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s

- 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:
Niklas Ye
2026-10-09 14:56:13 +02:00
parent 44b2eb2cc3
commit 9029d48584
46 changed files with 1445 additions and 655 deletions
+12 -13
View File
@@ -24,7 +24,7 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
FROM alerts WHERE %s`, where), args.all()...,
).Scan(&total, &firing, &resolved)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, map[string]int64{
@@ -55,7 +55,7 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
ORDER BY cnt DESC
LIMIT %s`, where, args.add(limit)), args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -68,13 +68,13 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var e entry
if err := rows.Scan(&e.Name, &e.Count); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
result = append(result, e)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, result)
@@ -93,7 +93,7 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
GROUP BY hr
ORDER BY hr ASC`, where), args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -103,13 +103,13 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
var hr int
var cnt int64
if err := rows.Scan(&hr, &cnt); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
counts[hr] = cnt
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -129,8 +129,7 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering
// SQLite's strftime('%w') returned, so the frontend needs no change.
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday.
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT EXTRACT(DOW FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS dow,
COUNT(*) AS cnt
@@ -139,7 +138,7 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
GROUP BY dow
ORDER BY dow ASC`, where), args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -149,13 +148,13 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
var dow int
var cnt int64
if err := rows.Scan(&dow, &cnt); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
counts[dow] = cnt
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -198,7 +197,7 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
FROM incidents WHERE %s`, where), args.all()...,
).Scan(&total, &triggered, &acknowledged, &resolved, &mtta, &mttr)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}