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
+11 -11
View File
@@ -81,7 +81,7 @@ func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http
deviceCode, deviceHash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -105,7 +105,7 @@ func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http
}
if err != nil {
log.Printf("device login: start: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -160,7 +160,7 @@ func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
status, caller.ID, code, time.Now().Unix())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -190,7 +190,7 @@ func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) ht
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() //nolint:errcheck
@@ -206,7 +206,7 @@ func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) ht
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -226,11 +226,11 @@ func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) ht
}
if _, err := tx.ExecContext(r.Context(),
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); 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
}
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
@@ -240,7 +240,7 @@ func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) ht
// Approved. Single use: the row goes before the session is made, so two
// racing polls cannot both be given one.
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
var disabled, sso bool
@@ -252,7 +252,7 @@ func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) ht
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if disabled {
@@ -269,12 +269,12 @@ func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) ht
}
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
log.Printf("device login: start session: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
user, err := fetchUser(r.Context(), db, userID.Int64)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})