Scope everything to a team, and route alerts by integration key
The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The whole point of #4: two teams sharing one server must not see each other's
|
||||
// work. These tests build two of them and check the boundary from both sides.
|
||||
|
||||
type teamFixture struct {
|
||||
id int64
|
||||
key string // integration key: how alerts get in
|
||||
call func(method, path string, body any) *http.Response
|
||||
}
|
||||
|
||||
// newTeam creates a team with its own member, integration key and API key. The
|
||||
// admin does the creating, as an install's first user would.
|
||||
func newTeam(t *testing.T, s *ts, name string) teamFixture {
|
||||
t.Helper()
|
||||
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": name}), &team)
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/integrations",
|
||||
map[string]string{"name": name + " alertmanager"}), &integration)
|
||||
if integration.Key == "" {
|
||||
t.Fatalf("%s: integration key was not returned", name)
|
||||
}
|
||||
|
||||
// A member of this team and no other.
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": name + "-user", "email": name + "@test.com"}), &user)
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "owner"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("%s: add member: %d", name, resp.StatusCode)
|
||||
}
|
||||
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
|
||||
return teamFixture{
|
||||
id: team.ID,
|
||||
key: integration.Key,
|
||||
call: func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postToIntegration sends one firing alert on a team's integration key, the way
|
||||
// a real Alertmanager receiver would.
|
||||
func postToIntegration(t *testing.T, s *ts, key, fingerprint, name string) {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"version": "4",
|
||||
"status": "firing",
|
||||
"groupKey": "{}:{alertname=\"" + name + "\"}",
|
||||
"groupLabels": map[string]string{"alertname": name},
|
||||
"alerts": []map[string]any{
|
||||
amAlert(fingerprint, name, "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+key+"/alertmanager",
|
||||
"application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post alert: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("post alert: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func list(t *testing.T, resp *http.Response) []map[string]any {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
decode(t, resp, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// An alert posted on one team's key opens an incident in that team and nowhere
|
||||
// else, and neither team can read the other's queue.
|
||||
func TestTeams_IncidentsAreScopedToTheReceivingTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-blue", "BlueDiskFull")
|
||||
|
||||
redIncidents := list(t, red.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(redIncidents) != 1 {
|
||||
t.Fatalf("red should see exactly its own incident, saw %d", len(redIncidents))
|
||||
}
|
||||
if title := redIncidents[0]["title"]; title != "RedDiskFull" {
|
||||
t.Errorf("red saw %v", title)
|
||||
}
|
||||
if teamID := int64(redIncidents[0]["team_id"].(float64)); teamID != red.id {
|
||||
t.Errorf("red's incident belongs to team %d, want %d", teamID, red.id)
|
||||
}
|
||||
|
||||
blueIncidents := list(t, blue.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(blueIncidents) != 1 || blueIncidents[0]["title"] != "BlueDiskFull" {
|
||||
t.Fatalf("blue should see exactly its own incident, saw %v", blueIncidents)
|
||||
}
|
||||
|
||||
// Reading the other team's incident by id is not found rather than
|
||||
// forbidden: its existence is the other team's business.
|
||||
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)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("red reading %s: expected 404, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// And cannot act on it either.
|
||||
for _, path := range []string{"/acknowledge", "/resolve", "/archive"} {
|
||||
resp := red.call(http.MethodPost, "/api/incidents/"+id64(otherID)+path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("red posting %s: expected 404, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts, the raw signal record, are scoped the same way.
|
||||
func TestTeams_AlertsAndStatsAreScoped(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-blue-1", "BlueDiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-blue-2", "BlueMemory")
|
||||
|
||||
if alerts := list(t, red.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 1 {
|
||||
t.Errorf("red should see 1 alert, saw %d", len(alerts))
|
||||
}
|
||||
if alerts := list(t, blue.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 2 {
|
||||
t.Errorf("blue should see 2 alerts, saw %d", len(alerts))
|
||||
}
|
||||
|
||||
// Statistics count your own work only — otherwise a team's volume, and the
|
||||
// names of its alerts, leak through the totals.
|
||||
var stats map[string]any
|
||||
decode(t, red.call(http.MethodGet, "/api/stats/alerts", nil), &stats)
|
||||
if total := stats["total"].(float64); total != 1 {
|
||||
t.Errorf("red's alert stats counted %v alerts, want 1", total)
|
||||
}
|
||||
|
||||
top := list(t, red.call(http.MethodGet, "/api/stats/alerts/top", nil))
|
||||
for _, row := range top {
|
||||
if name := row["name"].(string); name != "RedDiskFull" {
|
||||
t.Errorf("red's top alerts named %q, which is not theirs", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same fingerprint, the same groupKey and the same date are all legitimate
|
||||
// in two teams at once: two clusters running the same rules, two rotas.
|
||||
func TestTeams_SameFingerprintInTwoTeams(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
postToIntegration(t, s, red.key, "fp-shared", "DiskFull")
|
||||
postToIntegration(t, s, blue.key, "fp-shared", "DiskFull")
|
||||
|
||||
for _, team := range []struct {
|
||||
name string
|
||||
f teamFixture
|
||||
}{{"red", red}, {"blue", blue}} {
|
||||
incidents := list(t, team.f.call(http.MethodGet, "/api/incidents", nil))
|
||||
if len(incidents) != 1 {
|
||||
t.Errorf("%s: expected its own incident for the shared fingerprint, saw %d",
|
||||
team.name, len(incidents))
|
||||
}
|
||||
}
|
||||
|
||||
// And both rotas can name somebody for the same day.
|
||||
for _, team := range []struct {
|
||||
name string
|
||||
f teamFixture
|
||||
}{{"red", red}, {"blue", blue}} {
|
||||
var members []map[string]any
|
||||
decode(t, team.f.call(http.MethodGet, "/api/teams/"+id64(team.f.id)+"/members", nil), &members)
|
||||
userID := int64(members[0]["user_id"].(float64))
|
||||
|
||||
resp := team.f.call(http.MethodPost, "/api/teams/"+id64(team.f.id)+"/schedule",
|
||||
map[string]any{"user_id": userID, "dates": []string{"2026-10-01"}})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("%s: taking 2026-10-01 returned %d", team.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown key delivers nothing, and says so rather than accepting silently.
|
||||
func TestTeams_UnknownIntegrationKeyIsRejected(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "red")
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/not-a-real-key/alertmanager",
|
||||
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for an unknown key, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if incidents := list(t, team.call(http.MethodGet, "/api/incidents", nil)); len(incidents) != 0 {
|
||||
t.Errorf("a rejected payload opened %d incident(s)", len(incidents))
|
||||
}
|
||||
}
|
||||
|
||||
// Team configuration is an owner's job; working incidents is a member's.
|
||||
func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "red")
|
||||
|
||||
// A plain member of the same team.
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||
map[string]any{"user_id": user.ID, "role": "member"})
|
||||
resp.Body.Close()
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
|
||||
call := func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
base := "/api/teams/" + id64(team.id)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{"mint an integration key", http.MethodPost, base + "/integrations",
|
||||
map[string]string{"name": "mine"}},
|
||||
{"take a shift", http.MethodPost, base + "/schedule",
|
||||
map[string]any{"user_id": user.ID, "dates": []string{"2026-11-01"}}},
|
||||
{"add a member", http.MethodPost, base + "/members",
|
||||
map[string]any{"user_id": 1}},
|
||||
{"delete the team", http.MethodDelete, base, nil},
|
||||
} {
|
||||
resp := call(c.method, c.path, c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// But they can read what the team is doing.
|
||||
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
|
||||
resp := call(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("reading %s: expected 200, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
blue := newTeam(t, s, "blue")
|
||||
|
||||
base := "/api/teams/" + id64(red.id)
|
||||
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
|
||||
resp := blue.call(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("blue reading %s: expected 404, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// /api/teams lists your own, never the install's.
|
||||
teams := list(t, blue.call(http.MethodGet, "/api/teams", nil))
|
||||
if len(teams) != 1 || teams[0]["name"] != "blue" {
|
||||
t.Errorf("blue's team list: %v", teams)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user