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
+88 -84
View File
@@ -13,19 +13,12 @@ import (
"github.com/go-chi/chi/v5"
)
// handleListTeams lists the caller's own teams, each with their role in it,
// or — with ?name= — looks up one team by exact name regardless of caller
// identity (TEAM-LOOKUP.md). An administrator listing every team goes
// through the admin endpoint instead: the no-name case here answers "what am
// I part of", which is what the UI's team filter and the combined queue are
// built from.
// handleListTeams lists the caller's own teams, each with their role in it.
// An administrator listing every team goes through the admin endpoint instead:
// this answers "what am I part of", which is what the UI's team filter and the
// combined queue are built from.
func handleListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
handleListTeamsByName(db, w, r, name)
return
}
caller, _ := userFromContext(r.Context())
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role, m.source
@@ -34,7 +27,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
WHERE m.user_id = $1
ORDER BY t.name`, caller.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -44,45 +37,20 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
t.CreatedAt = time.Unix(created, 0).UTC()
teams = append(teams, t)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, teams)
}
}
// handleListTeamsByName answers "is there a team named exactly this", open to
// any authenticated caller including a service account (TEAM-LOOKUP.md) —
// mirrors handleListServiceAccounts' own ?name= lookup: a one-or-zero-length
// array, never an error on no match, and no caller-identity filtering at
// all, since what it discloses (a name is taken, nothing about who's in it
// or any of its data) is the same low sensitivity that lookup already
// accepts for service-account names.
func handleListTeamsByName(db *sql.DB, w http.ResponseWriter, r *http.Request, name string) {
var t models.Team
var created int64
err := db.QueryRowContext(r.Context(),
"SELECT id, name, created_at FROM teams WHERE name = $1", name,
).Scan(&t.ID, &t.Name, &created)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusOK, []models.Team{})
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
t.CreatedAt = time.Unix(created, 0).UTC()
respond(w, http.StatusOK, []models.Team{t})
}
// handleUserTeams lists one user's teams, for the admin page's per-user view:
// "what is this person in", which /api/teams cannot answer because it is always
// about the caller.
@@ -106,7 +74,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
var exists bool
if err := db.QueryRowContext(r.Context(),
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if !exists {
@@ -121,7 +89,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
WHERE m.user_id = $1
ORDER BY t.name`, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -131,14 +99,14 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
t.CreatedAt = time.Unix(created, 0).UTC()
teams = append(teams, t)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, teams)
@@ -161,6 +129,12 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
// ExternalID makes the call idempotent for automation: a team
// already carrying it is returned as-is (200) instead of created, so
// a client that crashed between the POST and recording the id finds
// its own team again. Instance-scoped service accounts only; a name
// that belongs to a different team is still a 409.
ExternalID string `json:"external_id"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
@@ -173,6 +147,10 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
}
caller, isUser := userFromContext(r.Context())
if req.ExternalID != "" && !isInstanceServiceAccount(r.Context()) {
respond(w, http.StatusForbidden, errResp("external_id is for instance-scoped service accounts"))
return
}
if !isUser && !isInstanceServiceAccount(r.Context()) {
// A team-scoped service account authenticates as owner of exactly
// one team already (see serveAsServiceAccount); letting it create
@@ -183,37 +161,57 @@ func handleCreateTeam(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
var team models.Team
var created int64
if req.ExternalID != "" {
err := tx.QueryRowContext(r.Context(),
"SELECT id, name, created_at FROM teams WHERE external_id = $1", req.ExternalID).
Scan(&team.ID, &team.Name, &created)
if err == nil {
team.ExternalID = &req.ExternalID
team.CreatedAt = time.Unix(created, 0).UTC()
respond(w, http.StatusOK, team)
return
}
if !errors.Is(err, sql.ErrNoRows) {
serverError(w, r, err)
return
}
}
var externalID *string
if req.ExternalID != "" {
externalID = &req.ExternalID
}
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO teams (name) VALUES ($1) RETURNING id, name, created_at",
req.Name).Scan(&team.ID, &team.Name, &created); err != nil {
"INSERT INTO teams (name, external_id) VALUES ($1, $2) RETURNING id, name, created_at",
req.Name, externalID).Scan(&team.ID, &team.Name, &created); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if isUser {
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
team.ID, caller.ID, models.RoleOwner); 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
}
team.CreatedAt = time.Unix(created, 0).UTC()
team.ExternalID = externalID
if isUser {
team.Role = models.RoleOwner
}
@@ -241,7 +239,7 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
if err := db.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM incidents WHERE team_id = $1 AND resolved_at IS NULL", teamID).
Scan(&open); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if open > 0 {
@@ -251,7 +249,7 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
res, err := db.ExecContext(r.Context(), "DELETE FROM teams WHERE id = $1", teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -322,7 +320,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
WHERE m.team_id = $1
ORDER BY u.username`, teamID, todayUTC())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -334,7 +332,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
var hasTopic, disabled bool
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
m.JoinedAt = time.Unix(joined, 0).UTC()
@@ -360,7 +358,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
members = append(members, m)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, members)
@@ -396,7 +394,7 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
}
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
} else if managed {
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
@@ -408,7 +406,7 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
if req.Role == models.RoleMember {
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if last {
@@ -453,7 +451,7 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
}
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
} else if managed {
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
@@ -462,7 +460,7 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if last {
@@ -473,7 +471,7 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
res, err := db.ExecContext(r.Context(),
"DELETE FROM team_members WHERE team_id = $1 AND user_id = $2", teamID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -572,7 +570,7 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
WHERE i.team_id = $1
ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
defer rows.Close()
@@ -584,7 +582,7 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
var lastUsed, lastAlert *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed,
&lastAlert, &i.Alerts24h); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
@@ -601,7 +599,7 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
integrations = append(integrations, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, integrations)
@@ -644,7 +642,7 @@ func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
@@ -656,7 +654,11 @@ func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
RETURNING id, team_id, kind, name, created_at`,
teamID, req.Kind, req.Name, hash).
Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("an integration with that name already exists in this team"))
return
}
serverError(w, r, err)
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
@@ -703,7 +705,11 @@ func handleRenameIntegration(db *sql.DB) http.HandlerFunc {
res, err := db.ExecContext(r.Context(),
"UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, id, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("an integration with that name already exists in this team"))
return
}
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -732,7 +738,7 @@ func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
res, err := db.ExecContext(r.Context(),
"DELETE FROM integrations WHERE id = $1 AND team_id = $2", id, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -790,16 +796,6 @@ func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
return id, true
}
// defaultTeamID is the oldest team, which on an upgraded install is the
// "Default" team every pre-teams row was moved into and on a fresh one is the
// team migration 003 creates. Bootstrap puts the first user in it, so somebody
// signing in to a new server lands somewhere rather than in no team at all.
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
var id int64
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
return id, err
}
// ---------------------------------------------------------------------------
// A team's dead man's switches
// ---------------------------------------------------------------------------
@@ -833,12 +829,12 @@ func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
set, err := deadmanSetForTeam(r.Context(), db, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
respond(w, http.StatusOK, out)
@@ -901,7 +897,11 @@ func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a switch with that name already exists in this team"))
return
}
serverError(w, r, err)
return
}
@@ -975,7 +975,11 @@ func handleUpdateTeamDeadman(db *sql.DB) http.HandlerFunc {
WHERE id = $5 AND team_id = $6`,
req.Name, m.config(), req.TimeoutSeconds, req.Severity, switchID, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a switch with that name already exists in this team"))
return
}
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -990,12 +994,12 @@ func handleUpdateTeamDeadman(db *sql.DB) http.HandlerFunc {
// handleListTeamDeadman would give it.
set, err := deadmanSetForTeam(r.Context(), db, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
statuses, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
for _, s := range statuses {
@@ -1004,7 +1008,7 @@ func handleUpdateTeamDeadman(db *sql.DB) http.HandlerFunc {
return
}
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
}
}
@@ -1029,7 +1033,7 @@ func handleDeleteTeamDeadman(db *sql.DB) http.HandlerFunc {
res, err := db.ExecContext(r.Context(),
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {