From a4fbd60441864a4acf6df805e0f5e50fdda8c472 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sun, 20 Sep 2026 13:36:24 +0200 Subject: [PATCH] Scope everything to a team, and route alerts by integration key The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 --- README.md | 78 ++++- internal/api/admin_test.go | 9 + internal/api/alertmanager.go | 108 ++++-- internal/api/alerts.go | 21 +- internal/api/api_test.go | 46 ++- internal/api/deadman.go | 16 +- internal/api/incident_store.go | 18 +- internal/api/incidents.go | 52 ++- internal/api/incidents_test.go | 2 +- internal/api/middleware.go | 97 +++++- internal/api/notify_test.go | 2 +- internal/api/router.go | 35 +- internal/api/schedule.go | 97 ++++-- internal/api/stats.go | 18 +- internal/api/teams.go | 473 +++++++++++++++++++++++++++ internal/api/teams_test.go | 350 ++++++++++++++++++++ internal/api/testdb_test.go | 4 + internal/api/users.go | 10 + internal/db/migrations/003_teams.sql | 103 ++++++ internal/models/alert.go | 8 +- internal/models/incident.go | 6 + internal/models/schedule.go | 9 +- internal/models/team.go | 54 +++ internal/web/static/js/api.js | 17 +- internal/web/static/js/app.js | 3 +- internal/web/static/js/oncall.js | 50 ++- internal/web/static/js/state.js | 13 + 27 files changed, 1549 insertions(+), 150 deletions(-) create mode 100644 internal/api/teams.go create mode 100644 internal/api/teams_test.go create mode 100644 internal/db/migrations/003_teams.sql create mode 100644 internal/models/team.go 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; }