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
+4 -10
View File
@@ -23,12 +23,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in.
trustedProxies.Store(int32(cfg.TrustedProxies))
loginLimit := newLoginLimiter(db)
signupLimiter := newLoginLimiter(db)
oidcLimit := newLoginLimiter(db)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(requestLogger)
r.Use(middleware.Recoverer)
r.Use(securityHeaders(notify.PublicURL))
@@ -48,11 +49,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// Alert ingestion. The key in the path says both that the sender may post
// and which team the alerts belong to, which is why it needs no session.
//
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
// took no credential at all, was removed in v0.13.0 once the cluster's
// Alertmanager had moved onto a key; a sender still posting there gets the
// JSON 404 every unknown /api path gets.
// This is the only way in.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
// Signing up. Both are unauthenticated by necessity: the caller has no
@@ -116,9 +113,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
// Administration: who exists, and who is an administrator. Until #3
// these were open to any authenticated caller, which meant every user
// could delete every other one.
// Administration: who exists, and who is an administrator.
r.Group(func(r chi.Router) {
r.Use(AdminOnly)
@@ -147,7 +142,6 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Get("/api/incidents", handleListIncidents(db))
r.Get("/api/incidents/clusters", handleListClusters(db))
r.Get("/api/incidents/{id}", handleGetIncident(db))
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))