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:
+16
-16
@@ -170,13 +170,13 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
|
||||
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() //nolint:errcheck
|
||||
@@ -194,7 +194,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
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
|
||||
}
|
||||
role = models.RoleOwner
|
||||
@@ -216,7 +216,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||
teamID, userID, role); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
res, err := tx.ExecContext(r.Context(),
|
||||
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -236,14 +236,14 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Signed in immediately: the alternative is a form that says "now go
|
||||
// and log in", which is the same credential typed twice.
|
||||
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
user, _ := fetchUser(r.Context(), db, userID)
|
||||
@@ -291,7 +291,7 @@ func handleListInvites(db *sql.DB) http.HandlerFunc {
|
||||
WHERE team_id = $1
|
||||
ORDER BY id DESC`, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -303,7 +303,7 @@ func handleListInvites(db *sql.DB) http.HandlerFunc {
|
||||
var revoked *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
|
||||
&i.MaxUses, &i.Uses, &revoked); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
@@ -312,7 +312,7 @@ func handleListInvites(db *sql.DB) http.HandlerFunc {
|
||||
out = append(out, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, out)
|
||||
@@ -356,7 +356,7 @@ func handleCreateInvite(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
|
||||
}
|
||||
// created_by is nullable (ON DELETE SET NULL) for exactly this
|
||||
@@ -382,7 +382,7 @@ func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
||||
hash, teamID, req.Role, createdBy, expires.Unix(), req.MaxUses).
|
||||
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
out.CreatedAt = time.Unix(created, 0).UTC()
|
||||
@@ -412,7 +412,7 @@ func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
|
||||
"UPDATE invites SET revoked_at = "+nowEpoch+
|
||||
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -450,7 +450,7 @@ func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
||||
var topic *string
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if topic == nil || *topic == "" {
|
||||
@@ -501,7 +501,7 @@ func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
||||
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
Reference in New Issue
Block a user