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:
@@ -78,6 +78,10 @@ func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, co
|
||||
|
||||
s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
|
||||
|
||||
// A fresh install has no team, so the tests that want "the" team make it
|
||||
// here: it is id 1, owned by the admin, which is what defaultTeam names.
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "Default"}), &struct{}{})
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
@@ -768,7 +772,7 @@ func TestWebhook_IgnoresOutOfOrderRetry(t *testing.T) {
|
||||
// An expiry resolve writes ends_at as an upper bound, not an observed end: an
|
||||
// Alertmanager watermark already on the row is preserved, and a row that never
|
||||
// carried one is stamped at sweep time. Clients are told to read it that way —
|
||||
// see "resolution_source says how much to trust ends_at" in the README.
|
||||
// see "resolution_source says how much to trust ends_at" in docs/api.md.
|
||||
func TestExpiry_EndsAtIsUpperBound(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
@@ -813,7 +817,7 @@ func TestExpiry_EndsAtIsUpperBound(t *testing.T) {
|
||||
//
|
||||
// received_at is documented as a public liveness signal, so these lock the
|
||||
// behaviour clients are told they may rely on. See "received_at is a liveness
|
||||
// heartbeat" in the README and the comment on models.Alert.ReceivedAt.
|
||||
// heartbeat" in docs/api.md and the comment on models.Alert.ReceivedAt.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The heartbeat itself: an unchanged firing notification — what Alertmanager
|
||||
@@ -875,3 +879,38 @@ func TestStats_ByDayReturnsSevenSlots(t *testing.T) {
|
||||
t.Errorf("expected 7 day slots, got %d", len(slots))
|
||||
}
|
||||
}
|
||||
|
||||
// Two simultaneous bootstraps on an empty install must not both win.
|
||||
func TestBootstrap_ConcurrentCallsCreateOneAdmin(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig(), "test"))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
const n = 8
|
||||
codes := make(chan int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
body, _ := json.Marshal(map[string]string{"username": fmt.Sprintf("u%d", i), "email": fmt.Sprintf("u%d@x.com", i)})
|
||||
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
codes <- 0
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
codes <- resp.StatusCode
|
||||
}(i)
|
||||
}
|
||||
created := 0
|
||||
for i := 0; i < n; i++ {
|
||||
if <-codes == http.StatusCreated {
|
||||
created++
|
||||
}
|
||||
}
|
||||
var users int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM users").Scan(&users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created != 1 || users != 1 {
|
||||
t.Errorf("expected exactly one bootstrap to win, got %d created and %d users", created, users)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user