Files
Niklas Ye 5b4683febf Let each team name its own OIDC group, not a global mapping
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.

Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.

This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
2026-09-27 11:43:57 +02:00

457 lines
15 KiB
Go

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's own OIDC group binding follows the same rule as its schedule and
// its integrations: an owner sets it, a member may only read it, an outsider
// learns nothing, and an administrator can still reach it to repair a team
// whose owner has left.
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "sre") // team.call authenticates as its owner
// A plain member of the same team.
var plain struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": plain.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
memberCall := 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
}
// A member of a different team altogether.
_, outsiderCall := member(t, s, "outsider")
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
}
var got struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &got)
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
t.Errorf("owner GET after PUT: %+v", got)
}
resp = memberCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("member GET: %d, want 200", resp.StatusCode)
}
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
}
// 404, not 403: whether the team exists is itself something only its
// members should learn.
resp = outsiderCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
}
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
}
// An administrator who is not a member may still set it, the same bypass
// that lets one repair a team whose owner has left.
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
}
// An empty string clears a binding, stored as NULL rather than the literal
// empty string, so an empty group claim can never accidentally match it.
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
resp.Body.Close()
var cleared struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &cleared)
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
t.Errorf("cleared: %+v", cleared)
}
}
// 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)
}
}