diff --git a/README.md b/README.md index ee4d561..faf5ddf 100644 --- a/README.md +++ b/README.md @@ -459,6 +459,16 @@ nobody can delete or demote themselves. Endpoints that require the flag answer `403` with `{"error":"administrator access required"}`. +**Teams** are the unit of tenancy, and are a separate axis from the administrator +flag. A team owns its incidents, alerts, schedule and integrations, and a user +sees exactly the teams they belong to — an administrator is not implicitly in +every team, because administration is about accounts, not about reading other +people's incidents. Within a team an **owner** configures it (schedule, +integrations, membership) and a **member** works its incidents. + +Anything belonging to a team you are not in answers `404`, not `403`: whether an +incident exists is itself something only its team should learn. + | Method | Path | Description | |---|---|---| | `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures | @@ -487,9 +497,32 @@ on anybody's. ### Alert ingestion +Alerts arrive on a team's integration key. The key is both the credential and the +routing: it says that the sender may post, and which team the alerts belong to. +Create one with `POST /api/teams/{teamID}/integrations`, which returns the key +and the full URL once and stores only a SHA-256 hash. + | Method | Path | Description | |---|---|---| -| `POST` | `/api/alertmanager/webhook` | Alertmanager v4 webhook receiver (no auth) | +| `POST` | `/api/integrations/{key}/alertmanager` | Alertmanager v4 webhook receiver for the key's team. `401` for an unknown key | +| `POST` | `/api/alertmanager/webhook` | **Deprecated, unauthenticated.** The pre-teams receiver, kept for one release so an upgrade does not stop delivering while the Alertmanager config is edited. Routes everything to the oldest team | + +The deprecated path is why anything that can reach the port can still open an +incident. Move senders to a key and it goes away. + +### Teams + +| Method | Path | Who | Description | +|---|---|---|---| +| `GET` | `/api/teams` | any | The caller's own teams, each with their role | +| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner | +| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents | +| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team | +| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` | +| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner | +| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys | +| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once | +| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration | ### Notifications @@ -676,13 +709,23 @@ unknown" rather than being rejected. | Method | Path | Description | |---|---|---| -| `POST` | `/api/schedule` | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing | -| `GET` | `/api/schedule` | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` | -| `GET` | `/api/schedule/current` | Today's on-call user (UTC), 404 if none | -| `DELETE` | `/api/schedule/{id}` | Remove schedule entry | +Each team keeps its own rota, so two teams can have two different people on call +on the same day. The person taking a shift has to be in the team — paging +somebody who cannot open the incident is worse than paging nobody. + +| Method | Path | Who | Description | +|---|---|---|---| +| `POST` | `/api/teams/{teamID}/schedule` | **owner** | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing | +| `GET` | `/api/teams/{teamID}/schedule` | member | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` | +| `DELETE` | `/api/teams/{teamID}/schedule/{id}` | **owner** | Remove schedule entry | +| `GET` | `/api/schedule/current` | any | Who is on call today (UTC) in **every** team the caller is in — one entry per team, `[]` when nobody anywhere | ### Statistics +Every figure counts the caller's own teams only: a report that counted other +teams' incidents would leak their volume, and their alert names through the +top-alerts list, and would not be a number about the reader's work anyway. + All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD`, and exclude archived rows to match the default list views. Alert stats filter on `received_at`; incident stats filter on `triggered_at`. | Method | Path | Description | @@ -699,6 +742,31 @@ averages over incidents that have actually been acknowledged or resolved, and ar --- +## Upgrading to teams + +Everything that existed before teams moves into one team called **Default**, and +every existing user becomes an owner of it. The upgrade is a no-op for the +people using it: the same queue, the same schedule, the same incidents, with a +name on them. + +What changes, and will need attention: + +- **Alert ingestion moved.** `POST /api/alertmanager/webhook` still works but is + deprecated and unauthenticated, and routes everything to the oldest team. Mint + a key with `POST /api/teams/{teamID}/integrations` and point Alertmanager at + the URL it returns. The old path goes away in a later release. +- **The schedule endpoints moved** under `/api/teams/{teamID}/schedule`, and + editing the rota is now an owner's job. `GET /api/schedule/current` stayed + where it was but now returns an **array** — one entry per team with somebody + on call — instead of a single object or a 404. This is a breaking API change + for anything that reads it, terdut-tui included. +- **Uniqueness is per team now.** Two teams can legitimately see the same alert + fingerprint, the same Alertmanager groupKey, and put somebody on call on the + same date. + +Nothing else about an incident changes, and incidents never move between teams: +an alert belongs to whichever team's key it arrived on. + ## Upgrading to roles Before this release every authenticated caller could create and delete users, diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 2ac083a..97f199d 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -35,6 +35,15 @@ func member(t *testing.T, s *ts, username string) (id int64, call func(method, p t.Fatalf("a created user must not be an administrator") } + // Into the default team as a plain member: being in a team is what lets + // somebody work its incidents, and is separate from administering accounts. + resp = s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members", + map[string]any{"user_id": user.ID, "role": "member"}) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("add %s to the team: %d", username, resp.StatusCode) + } + resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys", map[string]string{"name": "test"}) if resp.StatusCode != http.StatusCreated { diff --git a/internal/api/alertmanager.go b/internal/api/alertmanager.go index 2190e60..f714970 100644 --- a/internal/api/alertmanager.go +++ b/internal/api/alertmanager.go @@ -4,9 +4,12 @@ import ( "context" "database/sql" "encoding/json" + "errors" "log" "net/http" "time" + + "github.com/go-chi/chi/v5" ) // Values for alerts.resolution_source, recording why an alert left the firing @@ -70,36 +73,76 @@ type ingested struct { deadman bool } -func handleAlertmanagerWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.HandlerFunc { +// handleIntegrationWebhook receives alerts on a team's own integration key. +// The key in the path is both the credential and the routing: it says who may +// post, and which team the alerts belong to. +func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var payload amPayload - if err := decodeJSON(r, &payload); err != nil { - respond(w, http.StatusBadRequest, errResp("invalid payload")) + teamID, err := teamIDForKey(r.Context(), db, chi.URLParam(r, "key")) + if err != nil { + if errors.Is(err, errUnknownIntegration) { + // 401 and not 404: the path is real, the key is not, and a + // sender misconfigured this way should say so in its own logs + // rather than believe it is delivering. + respond(w, http.StatusUnauthorized, errResp("unknown integration key")) + return + } + respond(w, http.StatusInternalServerError, errResp("internal error")) return } - - // Alertmanager retries anything that is not 2xx, and a retry of a payload - // we failed to store is more useful than an error it cannot act on — so - // failures are logged, not surfaced. - if err := ingest(r.Context(), db, notify, deadman, payload); err != nil { - log.Printf("webhook ingest (group %q): %v", payload.GroupKey, err) - } - - w.WriteHeader(http.StatusOK) + receiveWebhook(w, r, db, notify, deadman, teamID) } } +// handleLegacyWebhook is the pre-teams unauthenticated endpoint, kept for one +// release so an upgrade does not silently stop delivering while somebody edits +// the Alertmanager config. It routes to the oldest team, which on an upgraded +// install is the Default team everything was moved into. +// +// It is deprecated and unauthenticated — anything that can reach the port can +// open an incident. Move senders to an integration key and this goes away. +func handleLegacyWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, err := defaultTeamID(r.Context(), db) + if err != nil { + log.Printf("legacy webhook: no team to route to: %v", err) + w.WriteHeader(http.StatusOK) + return + } + log.Printf("legacy webhook: unauthenticated payload routed to team %d; "+ + "move this sender to an integration key", teamID) + receiveWebhook(w, r, db, notify, deadman, teamID) + } +} + +func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, deadman DeadmanConfig, teamID int64) { + var payload amPayload + if err := decodeJSON(r, &payload); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid payload")) + return + } + + // Alertmanager retries anything that is not 2xx, and a retry of a payload + // we failed to store is more useful than an error it cannot act on — so + // failures are logged, not surfaced. + if err := ingest(r.Context(), db, notify, deadman, teamID, payload); err != nil { + log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err) + } + + w.WriteHeader(http.StatusOK) +} + // ingest stores a payload's alerts and reconciles the incident for its group. // The whole payload is one transaction: an incident that opened but whose alerts // failed to link would be a work item nobody could act on. -func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman DeadmanConfig, payload amPayload) error { +func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman DeadmanConfig, teamID int64, payload amPayload) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() //nolint:errcheck - accepted, err := upsertAlerts(ctx, tx, deadman, payload.Alerts) + accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts) if err != nil { return err } @@ -108,7 +151,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman Deadma // resolution cascade are recomputed once per incident at the end. touched := map[int64]bool{} - incidentID, err := incidentForGroup(ctx, tx, notify, payload, accepted) + incidentID, err := incidentForGroup(ctx, tx, notify, teamID, payload, accepted) if err != nil { return err } @@ -156,7 +199,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, deadman Deadma // upsertAlerts stores each alert of a payload and reports what changed. Payloads // the ordering guard rejected are left out entirely. -func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts []amAlert) ([]ingested, error) { +func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID int64, alerts []amAlert) ([]ingested, error) { now := time.Now().Unix() accepted := make([]ingested, 0, len(alerts)) @@ -171,7 +214,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts var prevStartsAt int64 existed := true switch err := tx.QueryRowContext(ctx, - "SELECT status, starts_at FROM alerts WHERE fingerprint = $1", a.Fingerprint, + "SELECT status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2", + teamID, a.Fingerprint, ).Scan(&prevStatus, &prevStartsAt); { case err == sql.ErrNoRows: existed = false @@ -210,10 +254,10 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts // undone by a stale retry. if _, err := tx.ExecContext(ctx, ` INSERT INTO alerts - (fingerprint, name, status, labels, annotations, starts_at, ends_at, + (team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at, generator_url, received_at, resolution_source) - VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9, $10) - ON CONFLICT (fingerprint) DO UPDATE SET + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11) + ON CONFLICT (team_id, fingerprint) DO UPDATE SET status = excluded.status, labels = excluded.labels, annotations = excluded.annotations, @@ -233,7 +277,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts OR (excluded.starts_at = alerts.starts_at AND (alerts.resolution_source = '`+resolutionDeadman+`' OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`, - a.Fingerprint, name, a.Status, + teamID, a.Fingerprint, name, a.Status, string(labelsJSON), string(annotationsJSON), a.StartsAt.Unix(), endsAtUnix, a.GeneratorURL, now, resolutionSource, @@ -245,7 +289,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts var curStatus string var curStartsAt int64 if err := tx.QueryRowContext(ctx, - "SELECT id, status, starts_at FROM alerts WHERE fingerprint = $1", a.Fingerprint, + "SELECT id, status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2", + teamID, a.Fingerprint, ).Scan(&id, &curStatus, &curStartsAt); err != nil { return nil, err } @@ -284,7 +329,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts // Heartbeats do not count as anything here. A group of nothing but dead man's // switch alerts opens no incident at all, and a mixed group gets an incident for // its real alerts only. -func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payload amPayload, accepted []ingested) (int64, error) { +func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, teamID int64, payload amPayload, accepted []ingested) (int64, error) { var firstName string anyFiring, anyNew := false, false for _, a := range accepted { @@ -315,7 +360,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl var id int64 switch err := tx.QueryRowContext(ctx, - "SELECT id FROM incidents WHERE group_key = $1 AND resolved_at IS NULL", groupKey, + "SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL", + teamID, groupKey, ).Scan(&id); { case err == nil: return id, nil @@ -326,7 +372,7 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl if !anyNew { return 0, nil } - return openIncident(ctx, tx, notify, groupKey, + return openIncident(ctx, tx, notify, teamID, groupKey, incidentTitle(payload.GroupLabels, firstName), payload.GroupLabels, nil) } @@ -338,8 +384,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl // its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the // column for refreshSeverity to fill from the member alerts; the sweeper passes // one because its incidents have no members to derive it from. -func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) { - onCall, err := currentOnCall(ctx, q) +func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) { + onCall, err := currentOnCall(ctx, q, teamID) if err != nil { return 0, err } @@ -351,10 +397,10 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey, var id int64 err = q.QueryRowContext(ctx, ` - INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at, assigned_to) - VALUES ($1, $2, $3::jsonb, 'triggered', $4, $5, $6) + INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to) + VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7) RETURNING id`, - groupKey, title, string(labelsJSON), severity, + teamID, groupKey, title, string(labelsJSON), severity, time.Now().Unix(), onCall).Scan(&id) if err != nil { return 0, err diff --git a/internal/api/alerts.go b/internal/api/alerts.go index f9d666a..a2b0b2b 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -19,7 +19,7 @@ import ( // incident_alerts rather than as a column here, because one alert row is reused // across occurrences and belongs to a different incident each time. const alertSelectFrom = ` - SELECT a.id, a.fingerprint, a.name, a.status, + SELECT a.id, a.team_id, t.name, a.fingerprint, a.name, a.status, a.labels, a.annotations, a.starts_at, a.ends_at, a.generator_url, a.received_at, (SELECT ia.incident_id @@ -29,7 +29,8 @@ const alertSelectFrom = ` ORDER BY i.triggered_at DESC, i.id DESC LIMIT 1), a.resolution_source, a.archived_at - FROM alerts a` + FROM alerts a + JOIN teams t ON t.id = a.team_id` func handleListAlerts(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -38,6 +39,13 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc { where := []string{} args := &sqlArgs{} + where = append(where, "a.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")") + if team := q.Get("team_id"); team != "" { + if n, err := strconv.ParseInt(team, 10, 64); err == nil { + where = append(where, "a.team_id = "+args.add(n)) + } + } + if status := q.Get("status"); status != "" { where = append(where, "a.status = "+args.add(status)) } @@ -107,7 +115,7 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid alert id")) return } - a, err := fetchAlert(r.Context(), db, id) + a, err := fetchAlert(r.Context(), db, id, callerTeamIDs(r.Context())) if err == sql.ErrNoRows { respond(w, http.StatusNotFound, errResp("alert not found")) return @@ -121,8 +129,9 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc { } // fetchAlert loads a single alert by ID using the shared query. -func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) { - return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = $1", id)) +func fetchAlert(ctx context.Context, db *sql.DB, id int64, teamIDs []int64) (models.Alert, error) { + return scanAlert(db.QueryRowContext(ctx, + alertSelectFrom+" WHERE a.id = $1 AND a.team_id = ANY($2)", id, teamIDs)) } // scanner is satisfied by both *sql.Row and *sql.Rows. @@ -137,7 +146,7 @@ func scanAlert(s scanner) (models.Alert, error) { var endsAtUnix, archivedAtUnix *int64 if err := s.Scan( - &a.ID, &a.Fingerprint, &a.Name, &a.Status, + &a.ID, &a.TeamID, &a.TeamName, &a.Fingerprint, &a.Name, &a.Status, &labelsJSON, &annotationsJSON, &startsAtUnix, &endsAtUnix, &a.GeneratorURL, &receivedAtUnix, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 5092b69..dfd9a0c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -276,14 +276,14 @@ func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) { func TestSchedule_ConflictOnSameDate(t *testing.T) { s := newTS(t) - first := s.req(t, http.MethodPost, "/api/schedule", + first := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}) if first.StatusCode != http.StatusCreated { t.Fatalf("first assignment returned %d", first.StatusCode) } first.Body.Close() - second := s.req(t, http.MethodPost, "/api/schedule", + second := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}) if second.StatusCode != http.StatusConflict { t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode) @@ -295,11 +295,11 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) { s := newTS(t) // Claim 2026-06-10 first. - s.req(t, http.MethodPost, "/api/schedule", + s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close() // Try to assign two dates in one request where the second conflicts. - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}}) if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) @@ -307,7 +307,7 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) { resp.Body.Close() // 2026-06-09 must NOT have been committed (transaction rolled back). - listResp := s.req(t, http.MethodGet, "/api/schedule?from=2026-06-09&to=2026-06-09", nil) + listResp := s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-09&to=2026-06-09", nil) var entries []any decode(t, listResp, &entries) if len(entries) != 0 { @@ -321,21 +321,35 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) { // addUser creates a second person to hand a shift to. The bootstrap user is // admin, id 1. +// addUser creates a user and puts them in the default team, because a user who +// is in no team can be paged by nobody and take no shift — which is the rule +// these tests exercise around, not the one they are testing. func addUser(t *testing.T, s *ts, username string) { t.Helper() resp := s.req(t, http.MethodPost, "/api/users", map[string]any{"username": username, "email": username + "@test.com"}) - defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { + resp.Body.Close() t.Fatalf("create user returned %d", resp.StatusCode) } + var user struct { + ID int64 `json:"id"` + } + decode(t, resp, &user) + + member := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members", + map[string]any{"user_id": user.ID, "role": "member"}) + defer member.Body.Close() + if member.StatusCode != http.StatusNoContent { + t.Fatalf("add %s to the team returned %d", username, member.StatusCode) + } } // scheduleHolder reports who is on call for one date, or "" for nobody. func scheduleHolder(t *testing.T, s *ts, date string) string { t.Helper() var entries []map[string]any - decode(t, s.req(t, http.MethodGet, "/api/schedule?from="+date+"&to="+date, nil), &entries) + decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from="+date+"&to="+date, nil), &entries) if len(entries) == 0 { return "" } @@ -347,10 +361,10 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) { s := newTS(t) addUser(t, s, "alex") - s.req(t, http.MethodPost, "/api/schedule", + s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close() - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}, "replace": true}) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected replace to succeed, got %d", resp.StatusCode) @@ -364,7 +378,7 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) { // One row, not two: two entries for a date would mean two people believing // they are on call for it. var entries []map[string]any - decode(t, s.req(t, http.MethodGet, "/api/schedule?from=2026-06-01&to=2026-06-01", nil), &entries) + decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries) if len(entries) != 1 { t.Errorf("expected exactly one entry for the date, got %d", len(entries)) } @@ -376,11 +390,11 @@ func TestSchedule_ReplaceMixedWeek(t *testing.T) { s := newTS(t) addUser(t, s, "alex") - s.req(t, http.MethodPost, "/api/schedule", + s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-02", "2026-06-04"}}).Body.Close() week := []string{"2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"} - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 2, "dates": week, "replace": true}) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected the mixed week to succeed, got %d", resp.StatusCode) @@ -399,10 +413,10 @@ func TestSchedule_ReplaceDefaultsOff(t *testing.T) { s := newTS(t) addUser(t, s, "alex") - s.req(t, http.MethodPost, "/api/schedule", + s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close() - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}}) if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409 without replace, got %d", resp.StatusCode) @@ -420,7 +434,7 @@ func TestSchedule_ReplaceDefaultsOff(t *testing.T) { func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) { s := newTS(t) - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{"2026-06-01", "2026-06-01"}, "replace": true}) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected a repeated date to be accepted under replace, got %d", resp.StatusCode) @@ -428,7 +442,7 @@ func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) { resp.Body.Close() var entries []map[string]any - decode(t, s.req(t, http.MethodGet, "/api/schedule?from=2026-06-01&to=2026-06-01", nil), &entries) + decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries) if len(entries) != 1 { t.Errorf("expected one entry for the repeated date, got %d", len(entries)) } diff --git a/internal/api/deadman.go b/internal/api/deadman.go index e1e524d..8c1fa91 100644 --- a/internal/api/deadman.go +++ b/internal/api/deadman.go @@ -171,6 +171,7 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string) // deadmanAlert is one switch: the alert row carrying its last heartbeat. type deadmanAlert struct { id int64 + teamID int64 fingerprint string labels map[string]string matcher DeadmanMatcher @@ -236,7 +237,7 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadma } rows, err := db.QueryContext(ctx, ` - SELECT id, fingerprint, labels, status, received_at + SELECT id, team_id, fingerprint, labels, status, received_at FROM alerts WHERE name IN (`+args.addList(nameList)+`) AND archived_at IS NULL`, args.all()...) @@ -249,7 +250,7 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadma for rows.Next() { var a deadmanAlert var labelsJSON, status string - if err := rows.Scan(&a.id, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil { + if err := rows.Scan(&a.id, &a.teamID, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil { return nil, err } json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck @@ -280,8 +281,8 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti if err := db.QueryRowContext(ctx, ` SELECT COALESCE(MAX(triggered_at), 0), COUNT(*) FILTER (WHERE resolved_at IS NULL) - FROM incidents WHERE group_key = $1`, - sw.groupKey()).Scan(&lastTriggered, &open); err != nil { + FROM incidents WHERE team_id = $1 AND group_key = $2`, + sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil { return err } if open > 0 || sw.receivedAt <= lastTriggered { @@ -314,7 +315,9 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti sev = &severity } - incidentID, err := openIncident(ctx, tx, notify, sw.groupKey(), + // The incident opens in the team whose integration received the heartbeat: + // the switch belongs to whoever is watching that source, not to the install. + incidentID, err := openIncident(ctx, tx, notify, sw.teamID, sw.groupKey(), "No heartbeat from "+sw.matcher.String(), sw.labels, sev) if err != nil { return err @@ -343,7 +346,8 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error { var incidentID int64 switch err := db.QueryRowContext(ctx, ` SELECT id FROM incidents - WHERE group_key = $1 AND resolved_at IS NULL`, sw.groupKey()).Scan(&incidentID); { + WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`, + sw.teamID, sw.groupKey()).Scan(&incidentID); { case err == sql.ErrNoRows: return nil case err != nil: diff --git a/internal/api/incident_store.go b/internal/api/incident_store.go index 26def15..63d2d1f 100644 --- a/internal/api/incident_store.go +++ b/internal/api/incident_store.go @@ -51,12 +51,13 @@ type querier interface { } const incidentSelectFrom = ` - SELECT i.id, i.group_key, i.title, i.group_labels, i.status, i.severity, + SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity, i.triggered_at, i.acknowledged_by, i.acknowledged_at, ack.username, i.assigned_to, asg.username, i.snoozed_until, i.resolved_at, i.resolution_source, i.archived_at FROM incidents i + JOIN teams t ON t.id = i.team_id LEFT JOIN users ack ON ack.id = i.acknowledged_by LEFT JOIN users asg ON asg.id = i.assigned_to` @@ -67,7 +68,7 @@ func scanIncident(s scanner) (models.Incident, error) { var ackAt, snoozedUntil, resolvedAt, archivedAt *int64 if err := s.Scan( - &i.ID, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity, + &i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity, &triggeredAt, &i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser, &i.AssignedToID, &i.AssignedToUser, &snoozedUntil, @@ -113,12 +114,17 @@ func todayUTC() string { return time.Now().UTC().Format("2006-01-02") } -// currentOnCall returns today's on-call user, or nil when nobody is scheduled. -// A missing schedule entry is not an error — incidents just open unassigned. -func currentOnCall(ctx context.Context, q querier) (*int64, error) { +// currentOnCall returns a team's on-call user for today, or nil when nobody is +// scheduled. A missing schedule entry is not an error — incidents just open +// unassigned. +// +// Per team: each team keeps its own rota, so two teams can have two different +// people on call on the same day, which was the point of scoping the schedule. +func currentOnCall(ctx context.Context, q querier, teamID int64) (*int64, error) { var userID int64 err := q.QueryRowContext(ctx, - "SELECT user_id FROM schedule_entries WHERE date = $1", todayUTC()).Scan(&userID) + "SELECT user_id FROM schedule_entries WHERE team_id = $1 AND date = $2", + teamID, todayUTC()).Scan(&userID) if err == sql.ErrNoRows { return nil, nil } diff --git a/internal/api/incidents.go b/internal/api/incidents.go index 3369676..756b037 100644 --- a/internal/api/incidents.go +++ b/internal/api/incidents.go @@ -19,6 +19,15 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc { where := []string{} args := &sqlArgs{} + // The combined queue: every team the caller belongs to, in one list. A + // caller in no team sees an empty queue rather than everybody's. + where = append(where, "i.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")") + if team := q.Get("team_id"); team != "" { + if n, err := strconv.ParseInt(team, 10, 64); err == nil { + where = append(where, "i.team_id = "+args.add(n)) + } + } + // Without an explicit status the queue shows open work, which is what an // on-call person opens the tool to see. if status := q.Get("status"); status != "" { @@ -96,7 +105,7 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc { func handleGetIncident(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -119,7 +128,7 @@ func handleGetIncident(db *sql.DB) http.HandlerFunc { func handleIncidentAlerts(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -137,7 +146,7 @@ func handleIncidentAlerts(db *sql.DB) http.HandlerFunc { func handleIncidentTimeline(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -176,7 +185,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc { func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -199,7 +208,7 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc { func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -223,7 +232,7 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc { // re-send of an alert that never stopped firing. Use snooze for "not now". func handleIncidentResolve(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -244,7 +253,7 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc { func handleIncidentAssign(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -285,7 +294,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc { // {"duration": "2h"}. func handleIncidentSnooze(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -340,7 +349,7 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc { func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -359,7 +368,7 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc { func handleIncidentArchive(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -379,7 +388,7 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc { func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -401,7 +410,7 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc { // single query renders the whole story of an incident in order. func handleCreateNote(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -448,7 +457,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc { // rest of the timeline is what actually happened, and is not editable. func handleDeleteNote(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id, ok := incidentIDParam(w, r) + id, ok := incidentIDParam(w, r, db) if !ok { return } @@ -479,19 +488,32 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc { // Shared handler plumbing // --------------------------------------------------------------------------- -func incidentIDParam(w http.ResponseWriter, r *http.Request) (int64, bool) { +// incidentIDParam reads {id} from the path AND confirms the incident belongs to +// a team the caller is in. Both in one place, deliberately: every incident route +// goes through here, so scoping cannot be forgotten by writing a new handler +// that only remembers the first half. +// +// An incident in somebody else's team is reported as not found rather than +// forbidden, because "there is an incident 41 you may not see" is itself +// something only that team should know. +func incidentIDParam(w http.ResponseWriter, r *http.Request, db *sql.DB) (int64, bool) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid incident id")) return 0, false } + if !incidentExists(w, r, db, id) { + return 0, false + } return id, true } +// incidentExists reports whether the incident is one the caller may see at all. func incidentExists(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) bool { var exists int if err := db.QueryRowContext(r.Context(), - "SELECT 1 FROM incidents WHERE id = $1", id).Scan(&exists); err != nil { + "SELECT 1 FROM incidents WHERE id = $1 AND team_id = ANY($2)", + id, callerTeamIDs(r.Context())).Scan(&exists); err != nil { respond(w, http.StatusNotFound, errResp("incident not found")) return false } diff --git a/internal/api/incidents_test.go b/internal/api/incidents_test.go index 04757d1..745c0cd 100644 --- a/internal/api/incidents_test.go +++ b/internal/api/incidents_test.go @@ -425,7 +425,7 @@ func TestIncident_AutoAssignedToCurrentOnCall(t *testing.T) { s := newTS(t) today := time.Now().UTC().Format("2006-01-02") - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{today}}) if resp.StatusCode != http.StatusCreated { t.Fatalf("schedule assignment returned %d", resp.StatusCode) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 7058ca2..0e983b9 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -17,6 +17,7 @@ type contextKey string const ( ctxUser contextKey = "user" ctxSession contextKey = "session" + ctxTeams contextKey = "teams" ) // AuthMiddleware accepts either of the two credentials the server issues: an @@ -151,7 +152,17 @@ func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql. } u.CreatedAt = time.Unix(createdUnix, 0).UTC() - ctx := context.WithValue(r.Context(), ctxUser, u) + // Every scoped query needs the caller's teams, so they are loaded once here + // rather than per handler. One extra round trip per request, against a + // table with one row per membership. + teams, err := callerMemberships(r.Context(), db, userID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + ctx := context.WithValue(r.Context(), ctxTeams, teams) + ctx = context.WithValue(ctx, ctxUser, u) if sessionID != 0 { ctx = context.WithValue(ctx, ctxSession, sessionID) } @@ -168,6 +179,90 @@ func userFromContext(ctx context.Context) (models.User, bool) { return u, ok } +// membership is the caller's role in one team. +type membership struct { + teamID int64 + role string +} + +func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]membership, error) { + rows, err := db.QueryContext(ctx, + "SELECT team_id, role FROM team_members WHERE user_id = $1 ORDER BY team_id", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []membership + for rows.Next() { + var m membership + if err := rows.Scan(&m.teamID, &m.role); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// callerTeamIDs lists the teams the caller belongs to, for the `team_id = ANY` +// filter every list query carries. An admin is NOT implicitly in every team: +// administration is about accounts, not about reading other people's incidents, +// and an admin who needs to see a team's queue can add themselves to it. +func callerTeamIDs(ctx context.Context) []int64 { + ms, _ := ctx.Value(ctxTeams).([]membership) + ids := make([]int64, 0, len(ms)) + for _, m := range ms { + ids = append(ids, m.teamID) + } + return ids +} + +// callerRole reports the caller's role in one team, and whether they are in it +// at all. +func callerRole(ctx context.Context, teamID int64) (string, bool) { + ms, _ := ctx.Value(ctxTeams).([]membership) + for _, m := range ms { + if m.teamID == teamID { + return m.role, true + } + } + return "", false +} + +// requireTeamMember answers the request and reports false unless the caller +// belongs to teamID. +// +// 404, not 403: whether a team exists is itself something only its members +// should learn, and the same reasoning applies to every incident and alert +// under it. +func requireTeamMember(w http.ResponseWriter, r *http.Request, teamID int64) bool { + if _, ok := callerRole(r.Context(), teamID); !ok { + respond(w, http.StatusNotFound, errResp("not found")) + return false + } + return true +} + +// requireTeamOwner is requireTeamMember for the things only an owner may change: +// the schedule, the integrations and who is in the team. A system administrator +// passes without being a member, because somebody has to be able to repair a +// team whose owner has left. +func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool { + role, ok := callerRole(r.Context(), teamID) + if ok && role == models.RoleOwner { + return true + } + if caller, _ := userFromContext(r.Context()); caller.IsAdmin { + return true + } + if !ok { + respond(w, http.StatusNotFound, errResp("not found")) + return false + } + respond(w, http.StatusForbidden, errResp("team owner access required")) + return false +} + // sessionFromContext returns the id of the session a request was authenticated // with, or false for an API-key request. func sessionFromContext(ctx context.Context) (int64, bool) { diff --git a/internal/api/notify_test.go b/internal/api/notify_test.go index 7b29a2f..3b4dc0b 100644 --- a/internal/api/notify_test.go +++ b/internal/api/notify_test.go @@ -95,7 +95,7 @@ func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) { func putOnCall(t *testing.T, s *ts, userID int) { t.Helper() today := time.Now().UTC().Format("2006-01-02") - resp := s.req(t, http.MethodPost, "/api/schedule", + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": userID, "dates": []string{today}}) defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { diff --git a/internal/api/router.go b/internal/api/router.go index 2f0a2da..e42b79b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -27,9 +27,18 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand // the scoped token in its path rather than an API key, and has to stay // reachable from outside the cluster for the button to work. r.Post("/api/bootstrap", handleBootstrap(db)) - r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman)) r.Post("/api/notify/ack/{token}", handleNotifyAck(db)) + // Alert ingestion. The key in the path says both that the sender may post + // and which team the alerts belong to, which is why it needs no session. + r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify, deadman)) + + // DEPRECATED, and unauthenticated: anything that can reach the port can + // open an incident here. Kept for one release so an upgrade does not stop + // delivering while the Alertmanager config is edited; it routes everything + // to the oldest team. Remove it once senders carry a key. + r.Post("/api/alertmanager/webhook", handleLegacyWebhook(db, notify, deadman)) + // Signing in to the web UI. Login trades a password for a session cookie, // which AuthMiddleware accepts in place of an API key. r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL)) @@ -84,10 +93,26 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand r.Post("/api/incidents/{id}/notes", handleCreateNote(db)) r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db)) - r.Post("/api/schedule", handleCreateSchedule(db)) - r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id} - r.Get("/api/schedule", handleListSchedule(db)) - r.Delete("/api/schedule/{id}", handleDeleteSchedule(db)) + // Teams. A user sees the teams they belong to; an owner configures one. + r.Get("/api/teams", handleListTeams(db)) + r.Post("/api/teams", handleCreateTeam(db)) + r.Delete("/api/teams/{teamID}", handleDeleteTeam(db)) + r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db)) + r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db)) + r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db)) + + // Integrations: where a team's alerts come in, and the key that says so. + r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db)) + r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL)) + r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db)) + + // The rota is per team. /api/schedule/current is the exception: it + // answers across every team the caller is in, which is what somebody on + // two rotas wants to see. + r.Get("/api/schedule/current", handleCurrentSchedule(db)) + r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db)) + r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db)) + r.Delete("/api/teams/{teamID}/schedule/{id}", handleDeleteSchedule(db)) r.Get("/api/stats/incidents", handleStatsIncidents(db)) r.Get("/api/stats/alerts", handleStatsAlerts(db)) diff --git a/internal/api/schedule.go b/internal/api/schedule.go index 9e96501..6374592 100644 --- a/internal/api/schedule.go +++ b/internal/api/schedule.go @@ -12,8 +12,18 @@ import ( "github.com/go-chi/chi/v5" ) +// The schedule is per team: each team keeps its own rota, so two teams can have +// two different people on call on the same day. Editing it is an owner's job, +// like the rest of a team's configuration; reading it is any member's. func handleCreateSchedule(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } var req struct { UserID int64 `json:"user_id"` Dates []string `json:"dates"` @@ -43,10 +53,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc { } } - // Verify the user exists. + // The person taking the shift has to be in the team: paging somebody + // who cannot open the incident is worse than paging nobody. var exists int - if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", req.UserID).Scan(&exists); err != nil { - respond(w, http.StatusNotFound, errResp("user not found")) + if err := db.QueryRowContext(r.Context(), + "SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2", + teamID, req.UserID).Scan(&exists); err != nil { + respond(w, http.StatusNotFound, errResp("user is not a member of this team")) return } @@ -64,13 +77,15 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc { for _, d := range req.Dates { if req.Replace { if _, err := tx.ExecContext(r.Context(), - "DELETE FROM schedule_entries WHERE date = $1", d); err != nil { + "DELETE FROM schedule_entries WHERE team_id = $1 AND date = $2", + teamID, d); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } } if _, err := tx.ExecContext(r.Context(), - "INSERT INTO schedule_entries (user_id, date) VALUES ($1, $2)", req.UserID, d); err != nil { + "INSERT INTO schedule_entries (team_id, user_id, date) VALUES ($1, $2, $3)", + teamID, req.UserID, d); err != nil { if isUniqueViolation(err) { respond(w, http.StatusConflict, errResp("date already assigned: "+d+" (pass replace to take it)")) @@ -90,7 +105,7 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc { for _, d := range req.Dates { dateSet[d] = true } - all, err := scheduleRange(r.Context(), db, req.Dates[0], req.Dates[len(req.Dates)-1]) + all, err := scheduleRange(r.Context(), db, teamID, req.Dates[0], req.Dates[len(req.Dates)-1]) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -107,6 +122,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc { func handleListSchedule(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamMember(w, r, teamID) { + return + } q := r.URL.Query() from, to := q.Get("from"), q.Get("to") @@ -123,7 +145,7 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc { } } - entries, err := scheduleRange(r.Context(), db, from, to) + entries, err := scheduleRange(r.Context(), db, teamID, from, to) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -134,12 +156,20 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc { func handleDeleteSchedule(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid schedule id")) return } - res, err := db.ExecContext(r.Context(), "DELETE FROM schedule_entries WHERE id = $1", id) + res, err := db.ExecContext(r.Context(), + "DELETE FROM schedule_entries WHERE id = $1 AND team_id = $2", id, teamID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -152,35 +182,50 @@ func handleDeleteSchedule(db *sql.DB) http.HandlerFunc { } } +// handleCurrentSchedule answers "who is on call right now" for every team the +// caller belongs to — one entry per team, so somebody on two rotas sees both. +// A team with nobody scheduled today simply does not appear. func handleCurrentSchedule(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { today := time.Now().UTC().Format("2006-01-02") - var e models.ScheduleEntry - var ts int64 - err := db.QueryRowContext(r.Context(), ` - SELECT s.id, s.user_id, u.username, s.date, s.created_at + rows, err := db.QueryContext(r.Context(), ` + SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at FROM schedule_entries s JOIN users u ON u.id = s.user_id - WHERE s.date = $1`, today).Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts) - if err == sql.ErrNoRows { - respond(w, http.StatusNotFound, errResp("no one is on call today")) - return - } + JOIN teams t ON t.id = s.team_id + WHERE s.date = $1 AND s.team_id = ANY($2) + ORDER BY t.name`, today, callerTeamIDs(r.Context())) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } - e.CreatedAt = time.Unix(ts, 0).UTC() - respond(w, http.StatusOK, e) + defer rows.Close() + + entries := []models.ScheduleEntry{} + for rows.Next() { + var e models.ScheduleEntry + var ts int64 + if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + e.CreatedAt = time.Unix(ts, 0).UTC() + entries = append(entries, e) + } + if err := rows.Err(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, entries) } } // scheduleRange returns schedule entries ordered by date. // from and to are YYYY-MM-DD strings; an empty string means unbounded on that side. -func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.ScheduleEntry, error) { - where := []string{} +func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to string) ([]models.ScheduleEntry, error) { args := &sqlArgs{} + where := []string{"s.team_id = " + args.add(teamID)} if from != "" { where = append(where, "s.date >= "+args.add(from)) } @@ -188,15 +233,13 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S where = append(where, "s.date <= "+args.add(to)) } - clause := "1=1" - if len(where) > 0 { - clause = strings.Join(where, " AND ") - } + clause := strings.Join(where, " AND ") rows, err := db.QueryContext(ctx, ` - SELECT s.id, s.user_id, u.username, s.date, s.created_at + SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at FROM schedule_entries s JOIN users u ON u.id = s.user_id + JOIN teams t ON t.id = s.team_id WHERE `+clause+` ORDER BY s.date ASC`, args.all()...) if err != nil { @@ -208,7 +251,7 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S for rows.Next() { var e models.ScheduleEntry var ts int64 - if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts); err != nil { + if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil { return nil, err } e.CreatedAt = time.Unix(ts, 0).UTC() diff --git a/internal/api/stats.go b/internal/api/stats.go index 01f0ce6..bd55be6 100644 --- a/internal/api/stats.go +++ b/internal/api/stats.go @@ -11,7 +11,7 @@ import ( func handleStatsAlerts(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - where, args := statsFilter(r.URL.Query(), "received_at") + where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context())) // COALESCE because SUM over zero rows is NULL, not 0, and a count of // nothing is 0 — without it an empty window is a 500 rather than a @@ -37,7 +37,7 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc { func handleStatsTop(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - where, args := statsFilter(r.URL.Query(), "received_at") + where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context())) limit := 10 if l := r.URL.Query().Get("limit"); l != "" { @@ -79,7 +79,7 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc { func handleStatsByHour(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - where, args := statsFilter(r.URL.Query(), "received_at") + where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context())) rows, err := db.QueryContext(r.Context(), fmt.Sprintf(` SELECT EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr, @@ -119,7 +119,7 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc { func handleStatsByDay(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - where, args := statsFilter(r.URL.Query(), "received_at") + where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context())) // Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering // SQLite's strftime('%w') returned, so the frontend needs no change. @@ -167,7 +167,7 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc { // mutated in place and carry no acknowledgement or closure time. func handleStatsIncidents(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - where, args := statsFilter(r.URL.Query(), "triggered_at") + where, args := statsFilter(r.URL.Query(), "triggered_at", callerTeamIDs(r.Context())) // The counts are COALESCEd because SUM over zero rows is NULL, not 0. // The averages are not: mtta and mttr stay null on purpose, since zero @@ -206,9 +206,13 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc { // statsFilter builds a WHERE clause and args from optional ?from and ?to query // params, filtering on timeCol. Archived rows are always excluded, matching the // default list views. -func statsFilter(q url.Values, timeCol string) (where string, args *sqlArgs) { +// +// teamIDs scopes every figure to the caller's own teams: a report that counted +// other teams' incidents would leak their volume and their names through the +// top-alerts list, and would not be a number about the reader's work anyway. +func statsFilter(q url.Values, timeCol string, teamIDs []int64) (where string, args *sqlArgs) { args = &sqlArgs{} - clauses := []string{"archived_at IS NULL"} + clauses := []string{"archived_at IS NULL", "team_id = ANY(" + args.add(teamIDs) + ")"} if from := q.Get("from"); from != "" { if t, err := time.Parse("2006-01-02", from); err == nil { clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix())) diff --git a/internal/api/teams.go b/internal/api/teams.go new file mode 100644 index 0000000..24523e4 --- /dev/null +++ b/internal/api/teams.go @@ -0,0 +1,473 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "git.ryuvia.com/niklas/terdut-server/internal/models" + "github.com/go-chi/chi/v5" +) + +// handleListTeams lists the caller's own teams, each with their role in it. An +// administrator listing every team goes through the admin endpoint instead: +// this one answers "what am I part of", which is what the UI's team filter and +// the combined queue are built from. +func handleListTeams(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + caller, _ := userFromContext(r.Context()) + rows, err := db.QueryContext(r.Context(), ` + SELECT t.id, t.name, t.created_at, m.role + FROM teams t + JOIN team_members m ON m.team_id = t.id + WHERE m.user_id = $1 + ORDER BY t.name`, caller.ID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer rows.Close() + + teams := []models.Team{} + for rows.Next() { + var t models.Team + var created int64 + if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + t.CreatedAt = time.Unix(created, 0).UTC() + teams = append(teams, t) + } + if err := rows.Err(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, teams) + } +} + +// handleCreateTeam creates a team and makes its creator the first owner. A team +// with no owner would need an administrator to repair before anybody could use +// it, so the two happen in one transaction. +func handleCreateTeam(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + respond(w, http.StatusBadRequest, errResp("name is required")) + return + } + + caller, _ := userFromContext(r.Context()) + + tx, err := db.BeginTx(r.Context(), nil) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer tx.Rollback() //nolint:errcheck + + var team models.Team + var created int64 + if err := tx.QueryRowContext(r.Context(), + "INSERT INTO teams (name) VALUES ($1) RETURNING id, name, created_at", + req.Name).Scan(&team.ID, &team.Name, &created); err != nil { + if isUniqueViolation(err) { + respond(w, http.StatusConflict, errResp("a team with that name already exists")) + return + } + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if _, err := tx.ExecContext(r.Context(), + "INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)", + team.ID, caller.ID, models.RoleOwner); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if err := tx.Commit(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + team.CreatedAt = time.Unix(created, 0).UTC() + team.Role = models.RoleOwner + respond(w, http.StatusCreated, team) + } +} + +// handleDeleteTeam removes a team and, by cascade, its incidents, alerts, +// schedule and integrations. +// +// Refused while the team still has open incidents: deleting a team is tidying +// up, and tidying up should never be how an unacknowledged page disappears. +// Resolve or archive them first, deliberately. +func handleDeleteTeam(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } + + var open int + if err := db.QueryRowContext(r.Context(), + "SELECT COUNT(*) FROM incidents WHERE team_id = $1 AND resolved_at IS NULL", teamID). + Scan(&open); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if open > 0 { + respond(w, http.StatusConflict, errResp("team still has open incidents")) + return + } + + res, err := db.ExecContext(r.Context(), "DELETE FROM teams WHERE id = $1", teamID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if n, _ := res.RowsAffected(); n == 0 { + respond(w, http.StatusNotFound, errResp("not found")) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// handleListTeamMembers names everybody in a team. Visible to any member: you +// can see who else is on the rota you are on. +func handleListTeamMembers(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamMember(w, r, teamID) { + return + } + + rows, err := db.QueryContext(r.Context(), ` + SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at + FROM team_members m + JOIN users u ON u.id = m.user_id + WHERE m.team_id = $1 + ORDER BY u.username`, teamID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer rows.Close() + + members := []models.TeamMember{} + for rows.Next() { + var m models.TeamMember + var joined int64 + if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + m.JoinedAt = time.Unix(joined, 0).UTC() + members = append(members, m) + } + if err := rows.Err(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, members) + } +} + +// handleAddTeamMember adds a user to a team, or changes the role of somebody +// already in it. +func handleAddTeamMember(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } + + var req struct { + UserID int64 `json:"user_id"` + Role string `json:"role"` + } + if err := decodeJSON(r, &req); err != nil || req.UserID == 0 { + respond(w, http.StatusBadRequest, errResp("user_id is required")) + return + } + if req.Role == "" { + req.Role = models.RoleMember + } + if req.Role != models.RoleOwner && req.Role != models.RoleMember { + respond(w, http.StatusBadRequest, errResp("role must be owner or member")) + return + } + + _, err := db.ExecContext(r.Context(), ` + INSERT INTO team_members (team_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`, + teamID, req.UserID, req.Role) + if err != nil { + // The only foreign key that can fail here is the user: the team was + // resolved from the caller's own membership. + respond(w, http.StatusNotFound, errResp("user not found")) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// handleRemoveTeamMember takes a user out of a team. +// +// A team must keep an owner, for the same reason the install must keep an +// administrator: otherwise nobody can configure it, and repairing that needs +// somebody with more access than the team has. +func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } + userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, errResp("invalid user id")) + return + } + + last, err := isLastTeamOwner(r.Context(), db, teamID, userID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if last { + respond(w, http.StatusConflict, errResp("cannot remove the last owner of a team")) + return + } + + res, err := db.ExecContext(r.Context(), + "DELETE FROM team_members WHERE team_id = $1 AND user_id = $2", teamID, userID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if n, _ := res.RowsAffected(); n == 0 { + respond(w, http.StatusNotFound, errResp("not a member of this team")) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) { + var last bool + err := db.QueryRowContext(ctx, ` + SELECT EXISTS (SELECT 1 FROM team_members + WHERE team_id = $1 AND user_id = $2 AND role = 'owner') + AND NOT EXISTS (SELECT 1 FROM team_members + WHERE team_id = $1 AND user_id <> $2 AND role = 'owner')`, + teamID, userID).Scan(&last) + return last, err +} + +// --------------------------------------------------------------------------- +// Integrations +// --------------------------------------------------------------------------- + +// handleListIntegrations lists a team's integrations. Never the keys: those +// exist in plaintext only in the response that created them. +func handleListIntegrations(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamMember(w, r, teamID) { + return + } + + rows, err := db.QueryContext(r.Context(), ` + SELECT id, team_id, kind, name, created_at, last_used_at + FROM integrations + WHERE team_id = $1 + ORDER BY id`, teamID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer rows.Close() + + integrations := []models.Integration{} + for rows.Next() { + var i models.Integration + var created int64 + var lastUsed *int64 + if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + i.CreatedAt = time.Unix(created, 0).UTC() + i.LastUsedAt = unixPtr(lastUsed) + integrations = append(integrations, i) + } + if err := rows.Err(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, integrations) + } +} + +// handleCreateIntegration mints an integration key. The key is returned once, +// in this response, and only its hash is kept — the same handling as an API key +// or an acknowledgement token. +func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } + + var req struct { + Name string `json:"name"` + Kind string `json:"kind"` + } + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + respond(w, http.StatusBadRequest, errResp("name is required")) + return + } + if req.Kind == "" { + req.Kind = models.IntegrationAlertmanager + } + if req.Kind != models.IntegrationAlertmanager { + respond(w, http.StatusBadRequest, errResp("unsupported integration kind")) + return + } + + raw, hash, err := randomToken() + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + var i models.Integration + var created int64 + if err := db.QueryRowContext(r.Context(), ` + INSERT INTO integrations (team_id, kind, name, key_hash) + VALUES ($1, $2, $3, $4) + RETURNING id, team_id, kind, name, created_at`, + teamID, req.Kind, req.Name, hash). + Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + i.CreatedAt = time.Unix(created, 0).UTC() + i.Key = raw + i.URL = strings.TrimSuffix(publicURL, "/") + integrationPath(raw, i.Kind) + respond(w, http.StatusCreated, i) + } +} + +func handleDeleteIntegration(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + if !requireTeamOwner(w, r, teamID) { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, errResp("invalid integration id")) + return + } + + res, err := db.ExecContext(r.Context(), + "DELETE FROM integrations WHERE id = $1 AND team_id = $2", id, teamID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if n, _ := res.RowsAffected(); n == 0 { + respond(w, http.StatusNotFound, errResp("not found")) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// integrationPath is where a sender of this kind posts. Built in one place so +// the URL handed out at creation and the route the router registers cannot +// drift apart. +func integrationPath(key, kind string) string { + return "/api/integrations/" + key + "/" + kind +} + +// teamIDForKey resolves an integration key to its team, and stamps the key's +// last use. An unknown key is not an error worth distinguishing: the caller is +// told nothing beyond "no". +func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) { + var teamID int64 + err := db.QueryRowContext(ctx, + "SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID) + if errors.Is(err, sql.ErrNoRows) { + return 0, errUnknownIntegration + } + if err != nil { + return 0, err + } + // Best effort, like an API key's: a failed stamp must not reject an alert. + db.ExecContext(ctx, //nolint:errcheck + "UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2", + time.Now().Unix(), hashToken(key)) + return teamID, nil +} + +var errUnknownIntegration = errors.New("unknown integration key") + +// teamParam reads {teamID} from the path. +func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(chi.URLParam(r, "teamID"), 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, errResp("invalid team id")) + return 0, false + } + return id, true +} + +// defaultTeamID is the team the deprecated unauthenticated webhook routes to: +// the oldest one, which on an upgraded install is the "Default" team every +// pre-teams row was moved into. +func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) { + var id int64 + err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id) + return id, err +} diff --git a/internal/api/teams_test.go b/internal/api/teams_test.go new file mode 100644 index 0000000..1a49d37 --- /dev/null +++ b/internal/api/teams_test.go @@ -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) + } +} diff --git a/internal/api/testdb_test.go b/internal/api/testdb_test.go index d8f4c9a..647add3 100644 --- a/internal/api/testdb_test.go +++ b/internal/api/testdb_test.go @@ -30,6 +30,10 @@ import ( // tests nothing is worse than one that does not run. const testDSNEnv = "TERDUT_TEST_DSN" +// defaultTeam is the team migration 003 creates and the bootstrap user owns, as +// a path segment. Every test that does not say otherwise works inside it. +const defaultTeam = "1" + var schemaSeq int // newTestDB returns a migrated database private to this test, and drops it diff --git a/internal/api/users.go b/internal/api/users.go index f1955f9..ab28337 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -77,6 +77,16 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { return } + // The default team exists from migration 003, on a fresh install too. + // Without a membership the first user signs in to a working server with + // no queue, no schedule and nowhere for an integration to hang off. + if teamID, err := defaultTeamID(r.Context(), db); err == nil { + db.ExecContext(r.Context(), //nolint:errcheck + "INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) "+ + "ON CONFLICT (team_id, user_id) DO NOTHING", + teamID, userID, models.RoleOwner) + } + user, _ := fetchUser(r.Context(), db, userID) key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt} respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key}) diff --git a/internal/db/migrations/003_teams.sql b/internal/db/migrations/003_teams.sql new file mode 100644 index 0000000..ea1358d --- /dev/null +++ b/internal/db/migrations/003_teams.sql @@ -0,0 +1,103 @@ +-- Teams: the unit of tenancy. Everything a person works on now belongs to one. +-- +-- Until this migration the install was one shared space — every user saw every +-- alert and every incident, and the Alertmanager webhook was unauthenticated, so +-- anything that could reach the port could open an incident for everybody. +-- +-- The shape, in one paragraph: a team owns its incidents, alerts, schedule and +-- integrations. A user belongs to as many teams as they like, with a role in +-- each: an `owner` configures the team, a `member` works its incidents. An +-- integration key is what an alert arrives on, and the key is what says which +-- team the alert belongs to. +-- +-- EVERYTHING EXISTING MOVES INTO ONE DEFAULT TEAM, and every existing user +-- becomes an owner of it. That keeps an upgrade a no-op for the people using it: +-- the same queue, the same schedule, the same incidents, with a name on them. + +CREATE TABLE teams ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint +); + +-- role is free text with a CHECK rather than an enum, so adding a third role +-- later is a migration and not a type rewrite. +CREATE TABLE team_members ( + team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + joined_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint, + PRIMARY KEY (team_id, user_id) +); + +CREATE INDEX team_members_user_idx ON team_members(user_id); + +-- How alerts get in, and the only thing that says which team they belong to. +-- The key is stored as a SHA-256 hash, like api_keys and the ack tokens: a +-- leaked database gives nobody the ability to post alerts. +CREATE TABLE integrations ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('alertmanager')), + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint, + last_used_at BIGINT +); + +CREATE INDEX integrations_team_idx ON integrations(team_id); + +-- --------------------------------------------------------------------------- +-- The default team, and everything that already exists moving into it. +-- +-- Created unconditionally, even on an empty install, so there is always a team +-- for the bootstrap user to land in and for the first integration to hang off. +-- --------------------------------------------------------------------------- + +INSERT INTO teams (name) VALUES ('Default'); + +INSERT INTO team_members (team_id, user_id, role) +SELECT (SELECT id FROM teams WHERE name = 'Default'), id, 'owner' FROM users; + +-- --------------------------------------------------------------------------- +-- team_id on everything a team owns. +-- +-- Added nullable, backfilled, then made NOT NULL: adding a NOT NULL column with +-- no default to a table with rows is rejected, and a DEFAULT pointing at the +-- default team would quietly keep working after the default team is gone. +-- --------------------------------------------------------------------------- + +ALTER TABLE alerts ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE; +ALTER TABLE incidents ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE; +ALTER TABLE schedule_entries ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE; + +UPDATE alerts SET team_id = (SELECT id FROM teams WHERE name = 'Default'); +UPDATE incidents SET team_id = (SELECT id FROM teams WHERE name = 'Default'); +UPDATE schedule_entries SET team_id = (SELECT id FROM teams WHERE name = 'Default'); + +ALTER TABLE alerts ALTER COLUMN team_id SET NOT NULL; +ALTER TABLE incidents ALTER COLUMN team_id SET NOT NULL; +ALTER TABLE schedule_entries ALTER COLUMN team_id SET NOT NULL; + +-- --------------------------------------------------------------------------- +-- The uniqueness rules were all written for one tenant, and every one of them +-- is wrong now: two teams monitoring two clusters legitimately see the same +-- fingerprint, the same groupKey, and want somebody on call on the same day. +-- --------------------------------------------------------------------------- + +ALTER TABLE alerts DROP CONSTRAINT alerts_fingerprint_key; +CREATE UNIQUE INDEX alerts_team_fingerprint_idx ON alerts(team_id, fingerprint); + +DROP INDEX incidents_open_group_key_idx; +-- Still load-bearing, now per team: at most one OPEN incident per group_key +-- within a team. This is what makes "resolved incident + a new alert occurrence +-- = a new incident" work, and what the webhook's find-or-open lookup relies on. +CREATE UNIQUE INDEX incidents_open_group_key_idx + ON incidents(team_id, group_key) WHERE resolved_at IS NULL; + +ALTER TABLE schedule_entries DROP CONSTRAINT schedule_entries_date_key; +CREATE UNIQUE INDEX schedule_entries_team_date_idx ON schedule_entries(team_id, date); + +-- The list views all filter by team first. +CREATE INDEX alerts_team_received_idx ON alerts(team_id, received_at DESC); +CREATE INDEX incidents_team_triggered_idx ON incidents(team_id, triggered_at DESC); diff --git a/internal/models/alert.go b/internal/models/alert.go index 13bf2a2..01047e8 100644 --- a/internal/models/alert.go +++ b/internal/models/alert.go @@ -7,7 +7,13 @@ import "time" // to it — acknowledgement, assignment, notes and closure all live on the // Incident an alert belongs to. type Alert struct { - ID int64 `json:"id"` + ID int64 `json:"id"` + + // TeamID is the team whose integration received this alert, and TeamName + // rides along so a combined list can label a row without a second request. + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name,omitempty"` + Fingerprint string `json:"fingerprint"` Name string `json:"name"` Status string `json:"status"` // "firing" or "resolved" diff --git a/internal/models/incident.go b/internal/models/incident.go index d771600..91dc89e 100644 --- a/internal/models/incident.go +++ b/internal/models/incident.go @@ -11,6 +11,12 @@ import "time" // the webhook and the sweeper may flip to "resolved" once every member alert has // stopped firing. type Incident struct { + // TeamID is the team that owns this incident, fixed when it opens: an + // incident never moves between teams. TeamName rides along so the combined + // queue can badge each row without a second request. + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name,omitempty"` + ID int64 `json:"id"` GroupKey string `json:"group_key"` Title string `json:"title"` diff --git a/internal/models/schedule.go b/internal/models/schedule.go index e85d9bd..aad427c 100644 --- a/internal/models/schedule.go +++ b/internal/models/schedule.go @@ -3,7 +3,14 @@ package models import "time" type ScheduleEntry struct { - ID int64 `json:"id"` + ID int64 `json:"id"` + + // TeamID is whose rota this shift belongs to; TeamName rides along so the + // combined "who is on call" view can label each entry without a second + // request. + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name,omitempty"` + UserID int64 `json:"user_id"` Username string `json:"username"` Date string `json:"date"` // YYYY-MM-DD diff --git a/internal/models/team.go b/internal/models/team.go new file mode 100644 index 0000000..8695b8d --- /dev/null +++ b/internal/models/team.go @@ -0,0 +1,54 @@ +package models + +import "time" + +// Team is the unit of tenancy: it owns its incidents, alerts, schedule and +// integrations, and a user sees exactly the teams they belong to. +type Team struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + + // Role is the caller's own role in this team, populated when a team is + // listed for a particular person. Empty when nobody in particular is + // asking, as in the admin listing. + Role string `json:"role,omitempty"` +} + +// Team roles. An owner configures the team — its schedule, its integrations and +// who is in it. A member works its incidents. +const ( + RoleOwner = "owner" + RoleMember = "member" +) + +// TeamMember is one person's membership of one team. +type TeamMember struct { + TeamID int64 `json:"team_id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` +} + +// Integration is how alerts get in, and the only thing that says which team an +// arriving alert belongs to. +type Integration struct { + ID int64 `json:"id"` + TeamID int64 `json:"team_id"` + Kind string `json:"kind"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + + // Key is the raw integration key, shown once when the integration is + // created and never stored. URL is the address to point the sender at, + // likewise only complete at creation time. + Key string `json:"key,omitempty"` + URL string `json:"url,omitempty"` +} + +// Integration kinds. +const ( + IntegrationAlertmanager = "alertmanager" +) diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 33e6096..150e3f1 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -87,12 +87,11 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }); // schedule -export const schedule = (from, to) => call('GET', '/schedule', { query: { from, to } }); -export async function onCallNow() { - try { - return await call('GET', '/schedule/current'); - } catch (err) { - if (err instanceof ApiError && err.status === 404) return null; - throw err; - } -} +export const teams = () => call('GET', '/teams'); +export const schedule = (teamID, from, to) => + call('GET', `/teams/${teamID}/schedule`, { query: { from, to } }); + +// One entry per team the viewer belongs to, for the teams that have somebody +// scheduled today. An empty array means nobody anywhere, which is a real answer +// rather than an error — unlike the pre-teams endpoint, which 404ed. +export const onCallNow = () => call('GET', '/schedule/current'); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 9aa28e2..5415278 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -3,7 +3,7 @@ import * as api from './api.js'; import * as ui from './ui.js'; import * as poll from './poll.js'; -import { state, reset } from './state.js'; +import { state, reset, loadTeams } from './state.js'; import * as queue from './queue.js'; import * as incident from './incident.js'; import * as oncall from './oncall.js'; @@ -141,6 +141,7 @@ async function boot() { try { state.me = await api.me(); + await loadTeams(); showApp(); } catch (err) { if (err.status === 401) showLogin(); diff --git a/internal/web/static/js/oncall.js b/internal/web/static/js/oncall.js index dfd22a0..26394d1 100644 --- a/internal/web/static/js/oncall.js +++ b/internal/web/static/js/oncall.js @@ -1,10 +1,14 @@ // On-call: who is on duty now, the week around it, and your own next shifts. // Read-only for now; the TUI edits the schedule. +// +// One team's rota at a time — the viewer's first team, since a viewer in one +// team has nothing to choose between. "On call now" is the exception and shows +// every team the viewer is in, because somebody on two rotas wants both. import * as api from './api.js'; import { h, clear, icon, spinner } from './ui.js'; import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js'; -import { myID } from './state.js'; +import { myID, currentTeam } from './state.js'; const view = () => document.getElementById('view-oncall'); @@ -24,10 +28,17 @@ export async function refresh() { const start = weekStart; const today = new Date(); try { + const team = currentTeam(); + if (!team) { + data = { now: [], week: [], upcoming: [] }; + error = null; + render(); + return; + } const [now, week, upcoming] = await Promise.all([ api.onCallNow(), - api.schedule(isoDate(start), isoDate(addDays(start, 6))), - api.schedule(isoDate(today), isoDate(addDays(today, 60))), + api.schedule(team.id, isoDate(start), isoDate(addDays(start, 6))), + api.schedule(team.id, isoDate(today), isoDate(addDays(today, 60))), ]); if (start !== weekStart) return; data = { now, week, upcoming }; @@ -60,15 +71,32 @@ function you(userID) { return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null; } +// One card per team with somebody on call, and a single empty card when there +// is nobody anywhere. The team's name is shown only when the viewer is in more +// than one, so the common case reads exactly as it did before teams existed. function nowCard() { - const n = data.now; - return h('div', { class: 'card now-card' }, - h('div', { class: `avatar ${n ? '' : 'none'}`, text: n ? initial(n.username) : '–' }), - h('div', {}, - h('div', { class: 'now-label', text: 'On call now' }), - h('div', { class: 'now-name' }, n ? n.username : 'Nobody', n && you(n.user_id)), - ), - ); + const entries = data.now || []; + const showTeam = entries.length > 1; + if (entries.length === 0) { + return h('div', { class: 'card now-card' }, + h('div', { class: 'avatar none', text: '–' }), + h('div', {}, + h('div', { class: 'now-label', text: 'On call now' }), + h('div', { class: 'now-name', text: 'Nobody' }), + ), + ); + } + return h('div', {}, ...entries.map((n) => + h('div', { class: 'card now-card' }, + h('div', { class: 'avatar', text: initial(n.username) }), + h('div', {}, + h('div', { + class: 'now-label', + text: showTeam ? `On call now · ${n.team_name}` : 'On call now', + }), + h('div', { class: 'now-name' }, n.username, you(n.user_id)), + ), + ))); } function weekCard() { diff --git a/internal/web/static/js/state.js b/internal/web/static/js/state.js index 9a05cc1..f14addc 100644 --- a/internal/web/static/js/state.js +++ b/internal/web/static/js/state.js @@ -6,8 +6,15 @@ import * as api from './api.js'; export const state = { me: null, // { user, has_password } open: [], // the default queue: open, not snoozed + teams: [], // the teams the viewer belongs to, each with their role }; +// The team whose schedule and settings the views act on. A viewer in one team — +// which is everybody until somebody makes a second — never has to choose. +export function currentTeam() { + return state.teams[0] || null; +} + export function myID() { return state.me ? state.me.user.id : null; } @@ -24,8 +31,14 @@ export async function users() { return usersCache; } +export async function loadTeams() { + state.teams = await api.teams(); + return state.teams; +} + export function reset() { state.me = null; state.open = []; + state.teams = []; usersCache = null; }