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:
@@ -502,3 +502,45 @@ func TestEscalation_StatusWithoutALadder(t *testing.T) {
|
||||
t.Errorf("a team with no ladder should read as empty, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A user target may name the person instead of carrying an id; the server
|
||||
// resolves it and stores the id.
|
||||
func TestEscalation_UserTargetByUsername(t *testing.T) {
|
||||
s := newTS(t)
|
||||
id := teamUser(t, s, "alice", "")
|
||||
|
||||
put := func(username string) *http.Response {
|
||||
return s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||
"repeat_count": 0,
|
||||
"levels": []map[string]any{{
|
||||
"timeout_seconds": 300,
|
||||
"targets": []map[string]any{{"kind": "user", "username": username}},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
resp := put("alice")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
t.Fatalf("PUT by username: %d", resp.StatusCode)
|
||||
}
|
||||
var got struct {
|
||||
Levels []struct {
|
||||
Targets []struct {
|
||||
UserID *int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"targets"`
|
||||
} `json:"levels"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil), &got)
|
||||
if len(got.Levels) != 1 || len(got.Levels[0].Targets) != 1 ||
|
||||
got.Levels[0].Targets[0].UserID == nil || *got.Levels[0].Targets[0].UserID != id {
|
||||
t.Errorf("expected the target stored as user %d, got %+v", id, got)
|
||||
}
|
||||
|
||||
bad := put("nobody")
|
||||
bad.Body.Close()
|
||||
if bad.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("unknown username should be a 400, got %d", bad.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user