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
+39 -17
View File
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
@@ -28,6 +29,9 @@ const (
// sessionTouchEvery bounds how often a request may slide the expiry.
sessionTouchEvery = time.Hour
// keyTouchEvery is the same bound for an API key's last_used_at.
keyTouchEvery = 5 * time.Minute
minPasswordLen = 10
// maxPasswordLen is bcrypt's limit; it rejects longer input outright.
maxPasswordLen = 72
@@ -126,14 +130,32 @@ func purgeRateLimits(ctx context.Context, db *sql.DB) {
}
}
// trustedProxies is how many X-Forwarded-For hops clientAddr trusts. Set once
// by NewRouter from config.
var trustedProxies atomic.Int32
// clientAddr is the address a login is counted against. Behind the gateway
// RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used
// when present. It can be forged, but only to dodge the address limit; the
// per-username limit does not depend on it.
// RemoteAddr is the gateway itself, so the client address is read from
// X-Forwarded-For, counting trustedProxies entries from the right: each trusted
// proxy appends the address it saw, so the entries to the left of those are
// client-supplied and could be forged to dodge the limit.
func clientAddr(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
return strings.TrimSpace(first)
if n := int(trustedProxies.Load()); n > 0 {
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, h := range strings.Split(v, ",") {
if h = strings.TrimSpace(h); h != "" {
hops = append(hops, h)
}
}
}
if len(hops) > 0 {
i := len(hops) - n
if i < 0 {
i = 0
}
return hops[i]
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
@@ -241,7 +263,7 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
"SELECT id, password_hash FROM users WHERE username = $1", username,
).Scan(&userID, &hash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -258,13 +280,13 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
limiter.clear(r.Context(), userKey)
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
user, err := fetchUser(r.Context(), db, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: true})
@@ -320,7 +342,7 @@ func handleMe(db *sql.DB) http.HandlerFunc {
}
user, err := fetchUser(r.Context(), db, caller.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
var hash sql.NullString
@@ -332,7 +354,7 @@ func handleMe(db *sql.DB) http.HandlerFunc {
// transient database problem, not a missing user — worth a 500
// rather than silently answering "no password, not dismissed",
// which a client would otherwise take at face value.
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, meResponse{
@@ -384,7 +406,7 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc {
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -397,30 +419,30 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc {
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(),
"UPDATE users SET password_hash = $1 WHERE id = $2", hash, id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM sessions WHERE user_id = $1 AND id != $2", id, keep); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)