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
+50 -65
View File
@@ -6,8 +6,6 @@ import (
"io"
"net/http"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
// The whole point of #4: two teams sharing one server must not see each other's
@@ -146,7 +144,6 @@ func TestTeams_IncidentsAreScopedToTheReceivingTeam(t *testing.T) {
otherID := int64(blueIncidents[0]["id"].(float64))
for _, path := range []string{
"/api/incidents/" + id64(otherID),
"/api/incidents/" + id64(otherID) + "/alerts",
"/api/incidents/" + id64(otherID) + "/timeline",
} {
resp := red.call(http.MethodGet, path, nil)
@@ -461,70 +458,58 @@ func TestTeams_OutsiderSeesNothing(t *testing.T) {
// GET /api/teams?name= (TEAM-LOOKUP.md)
// ---------------------------------------------------------------------------
func TestListTeamsByName_FindsExactMatch(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamID := createTeamAs(t, s, instanceKey, "platform")
teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=platform", nil))
if len(teams) != 1 {
t.Fatalf("expected exactly one match for ?name=platform, got %d: %v", len(teams), teams)
}
if int64(teams[0]["id"].(float64)) != teamID {
t.Errorf("id = %v, want %d", teams[0]["id"], teamID)
}
// No membership, so no role to report (models.Team's own doc comment:
// "empty when nobody in particular is asking").
if _, has := teams[0]["role"]; has {
t.Errorf("expected no role on a name-lookup match, got %v", teams[0]["role"])
}
}
func TestListTeamsByName_NoMatchIsAnEmptyArrayNotAnError(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=does-not-exist", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on no match, got %d", resp.StatusCode)
}
teams := list(t, resp)
if len(teams) != 0 {
t.Errorf("expected an empty array, got %v", teams)
}
}
// The actual motivating scenario (TEAM-LOOKUP.md): a service account that
// already created a team, interrupted before it could remember the id,
// recovers it via ?name= on the same name its own POST 409s on.
func TestListTeamsByName_RecoversAfterCreateConflict(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
original := createTeamAs(t, s, instanceKey, "recovered")
conflict := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "recovered"})
if conflict.StatusCode != http.StatusConflict {
t.Fatalf("expected 409 recreating the same name, got %d", conflict.StatusCode)
}
conflict.Body.Close()
teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=recovered", nil))
if len(teams) != 1 || int64(teams[0]["id"].(float64)) != original {
t.Fatalf("expected to recover the original team %d via ?name=, got %v", original, teams)
}
}
// Not gated by isInstanceServiceAccount or AdminOnly (TEAM-LOOKUP.md): any
// authenticated caller may ask whether a name is taken, the same low
// sensitivity GET /api/service-accounts?name= already accepts.
func TestListTeamsByName_OpenToAnyAuthenticatedCaller(t *testing.T) {
// Everybody signed in can list users to name them, but only an admin (or the
// row's owner) sees an email or an ntfy topic, which is a publish secret.
func TestListUsers_RedactsEmailAndTopicForOthers(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
_ = createTeamAs(t, s, s.key, "blue-target")
_ = newTeam(t, s, "blue")
s.exec(t, "UPDATE users SET ntfy_topic = 'secret-topic'")
// red's own member, not a member of "blue-target", still gets a match.
teams := list(t, red.call(http.MethodGet, "/api/teams?name=blue-target", nil))
if len(teams) != 1 || teams[0]["name"] != "blue-target" {
t.Errorf("expected a non-member caller to still find the team by name, got %v", teams)
var asAdmin []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &asAdmin)
for _, u := range asAdmin {
if u["email"] == "" || u["ntfy_topic"] != "secret-topic" {
t.Errorf("admin should see everything, got %v", u)
}
}
asMember := list(t, red.call(http.MethodGet, "/api/users", nil))
if len(asMember) < 3 {
t.Fatalf("expected the whole user list, got %v", asMember)
}
for _, u := range asMember {
own := u["username"] == "red-user"
if own != (u["email"] != "") || own != (u["ntfy_topic"] != nil) {
t.Errorf("only red-user's own row should keep email and topic, got %v", u)
}
}
}
// Names identify integrations and switches within a team.
func TestTeamNames_AreUniquePerTeam(t *testing.T) {
s := newTS(t) // creates one integration named "test" in the default team
dup := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/integrations", map[string]string{"name": "test"})
dup.Body.Close()
if dup.StatusCode != http.StatusConflict {
t.Errorf("duplicate integration name: expected 409, got %d", dup.StatusCode)
}
body := map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 60, "severity": "critical"}
first := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
first.Body.Close()
second := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
second.Body.Close()
if first.StatusCode != http.StatusCreated || second.StatusCode != http.StatusConflict {
t.Errorf("duplicate switch name: expected 201 then 409, got %d then %d", first.StatusCode, second.StatusCode)
}
// The same name in another team is fine.
other := newTeam(t, s, "elsewhere")
ok := s.req(t, http.MethodPost, "/api/teams/"+id64(other.id)+"/integrations", map[string]string{"name": "test"})
ok.Body.Close()
if ok.StatusCode != http.StatusCreated {
t.Errorf("same name in another team: expected 201, got %d", ok.StatusCode)
}
}