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:
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -343,12 +344,12 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
view, err := escalationStatus(r.Context(), db, teamID, policy)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, view)
|
||||
@@ -543,6 +544,10 @@ type escalationLevelJSON struct {
|
||||
type escalationTargetJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
// Username is accepted in place of user_id on a PUT, and resolved to the
|
||||
// id before anything is stored. It is never returned: the stored form is
|
||||
// the id, which survives a rename.
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
type escalationJSON struct {
|
||||
@@ -600,6 +605,27 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
for i, l := range req.Levels {
|
||||
for j, t := range l.Targets {
|
||||
if t.Username == "" {
|
||||
continue
|
||||
}
|
||||
if t.Kind != "user" || t.UserID != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("username belongs on a user target, instead of user_id"))
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
err := db.QueryRowContext(r.Context(), "SELECT id FROM users WHERE username = $1", t.Username).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusBadRequest, errResp("unknown user "+strconv.Quote(t.Username)))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
req.Levels[i].Targets[j].UserID = &id
|
||||
req.Levels[i].Targets[j].Username = ""
|
||||
}
|
||||
if l.TimeoutSeconds <= 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("every level needs a timeout"))
|
||||
return
|
||||
@@ -632,7 +658,7 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
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
|
||||
@@ -645,13 +671,13 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
fallback_topic = excluded.fallback_topic,
|
||||
updated_at = excluded.updated_at`,
|
||||
teamID, req.RepeatCount, req.FallbackTopic); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// The levels are replaced, not merged; the cascade takes the targets.
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"DELETE FROM escalation_levels WHERE team_id = $1", teamID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -661,7 +687,7 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
INSERT INTO escalation_levels (team_id, position, timeout_seconds)
|
||||
VALUES ($1, $2, $3) RETURNING id`,
|
||||
teamID, int64(i+1), l.TimeoutSeconds).Scan(&levelID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
for _, t := range l.Targets {
|
||||
@@ -676,13 +702,13 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||
|
||||
Reference in New Issue
Block a user