Scope everything to a team, and route alerts by integration key
The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
@@ -459,6 +459,16 @@ nobody can delete or demote themselves.
|
|||||||
Endpoints that require the flag answer `403` with
|
Endpoints that require the flag answer `403` with
|
||||||
`{"error":"administrator access required"}`.
|
`{"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 |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
|
| `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
|
### 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 |
|
| 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
|
### Notifications
|
||||||
|
|
||||||
@@ -676,13 +709,23 @@ unknown" rather than being rejected.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/schedule` | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...], "replace"}` — all-or-nothing |
|
Each team keeps its own rota, so two teams can have two different people on call
|
||||||
| `GET` | `/api/schedule` | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` |
|
on the same day. The person taking a shift has to be in the team — paging
|
||||||
| `GET` | `/api/schedule/current` | Today's on-call user (UTC), 404 if none |
|
somebody who cannot open the incident is worse than paging nobody.
|
||||||
| `DELETE` | `/api/schedule/{id}` | Remove schedule entry |
|
|
||||||
|
| 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
|
### 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`.
|
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 |
|
| 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
|
## Upgrading to roles
|
||||||
|
|
||||||
Before this release every authenticated caller could create and delete users,
|
Before this release every authenticated caller could create and delete users,
|
||||||
|
|||||||
@@ -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")
|
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",
|
resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||||
map[string]string{"name": "test"})
|
map[string]string{"name": "test"})
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Values for alerts.resolution_source, recording why an alert left the firing
|
// Values for alerts.resolution_source, recording why an alert left the firing
|
||||||
@@ -70,8 +73,49 @@ type ingested struct {
|
|||||||
deadman bool
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
var payload amPayload
|
||||||
if err := decodeJSON(r, &payload); err != nil {
|
if err := decodeJSON(r, &payload); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||||
@@ -81,25 +125,24 @@ func handleAlertmanagerWebhook(db *sql.DB, notify NotifyConfig, deadman DeadmanC
|
|||||||
// Alertmanager retries anything that is not 2xx, and a retry of a payload
|
// 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
|
// we failed to store is more useful than an error it cannot act on — so
|
||||||
// failures are logged, not surfaced.
|
// failures are logged, not surfaced.
|
||||||
if err := ingest(r.Context(), db, notify, deadman, payload); err != nil {
|
if err := ingest(r.Context(), db, notify, deadman, teamID, payload); err != nil {
|
||||||
log.Printf("webhook ingest (group %q): %v", payload.GroupKey, err)
|
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ingest stores a payload's alerts and reconciles the incident for its group.
|
// 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
|
// 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.
|
// 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)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback() //nolint:errcheck
|
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 {
|
if err != nil {
|
||||||
return err
|
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.
|
// resolution cascade are recomputed once per incident at the end.
|
||||||
touched := map[int64]bool{}
|
touched := map[int64]bool{}
|
||||||
|
|
||||||
incidentID, err := incidentForGroup(ctx, tx, notify, payload, accepted)
|
incidentID, err := incidentForGroup(ctx, tx, notify, teamID, payload, accepted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
|
||||||
// the ordering guard rejected are left out entirely.
|
// 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()
|
now := time.Now().Unix()
|
||||||
accepted := make([]ingested, 0, len(alerts))
|
accepted := make([]ingested, 0, len(alerts))
|
||||||
|
|
||||||
@@ -171,7 +214,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
|||||||
var prevStartsAt int64
|
var prevStartsAt int64
|
||||||
existed := true
|
existed := true
|
||||||
switch err := tx.QueryRowContext(ctx,
|
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); {
|
).Scan(&prevStatus, &prevStartsAt); {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
existed = false
|
existed = false
|
||||||
@@ -210,10 +254,10 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
|||||||
// undone by a stale retry.
|
// undone by a stale retry.
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO alerts
|
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)
|
generator_url, received_at, resolution_source)
|
||||||
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11)
|
||||||
ON CONFLICT (fingerprint) DO UPDATE SET
|
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
labels = excluded.labels,
|
labels = excluded.labels,
|
||||||
annotations = excluded.annotations,
|
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
|
OR (excluded.starts_at = alerts.starts_at
|
||||||
AND (alerts.resolution_source = '`+resolutionDeadman+`'
|
AND (alerts.resolution_source = '`+resolutionDeadman+`'
|
||||||
OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`,
|
OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`,
|
||||||
a.Fingerprint, name, a.Status,
|
teamID, a.Fingerprint, name, a.Status,
|
||||||
string(labelsJSON), string(annotationsJSON),
|
string(labelsJSON), string(annotationsJSON),
|
||||||
a.StartsAt.Unix(), endsAtUnix,
|
a.StartsAt.Unix(), endsAtUnix,
|
||||||
a.GeneratorURL, now, resolutionSource,
|
a.GeneratorURL, now, resolutionSource,
|
||||||
@@ -245,7 +289,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, alerts
|
|||||||
var curStatus string
|
var curStatus string
|
||||||
var curStartsAt int64
|
var curStartsAt int64
|
||||||
if err := tx.QueryRowContext(ctx,
|
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 {
|
).Scan(&id, &curStatus, &curStartsAt); err != nil {
|
||||||
return nil, err
|
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
|
// 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
|
// switch alerts opens no incident at all, and a mixed group gets an incident for
|
||||||
// its real alerts only.
|
// 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
|
var firstName string
|
||||||
anyFiring, anyNew := false, false
|
anyFiring, anyNew := false, false
|
||||||
for _, a := range accepted {
|
for _, a := range accepted {
|
||||||
@@ -315,7 +360,8 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
|
|||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
switch err := tx.QueryRowContext(ctx,
|
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); {
|
).Scan(&id); {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
return id, nil
|
return id, nil
|
||||||
@@ -326,7 +372,7 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, payl
|
|||||||
if !anyNew {
|
if !anyNew {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
return openIncident(ctx, tx, notify, groupKey,
|
return openIncident(ctx, tx, notify, teamID, groupKey,
|
||||||
incidentTitle(payload.GroupLabels, firstName), payload.GroupLabels, nil)
|
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
|
// 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
|
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
||||||
// one because its incidents have no members to derive it from.
|
// 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) {
|
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)
|
onCall, err := currentOnCall(ctx, q, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -351,10 +397,10 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, groupKey,
|
|||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
err = q.QueryRowContext(ctx, `
|
err = q.QueryRowContext(ctx, `
|
||||||
INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
||||||
VALUES ($1, $2, $3::jsonb, 'triggered', $4, $5, $6)
|
VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
groupKey, title, string(labelsJSON), severity,
|
teamID, groupKey, title, string(labelsJSON), severity,
|
||||||
time.Now().Unix(), onCall).Scan(&id)
|
time.Now().Unix(), onCall).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
+15
-6
@@ -19,7 +19,7 @@ import (
|
|||||||
// incident_alerts rather than as a column here, because one alert row is reused
|
// incident_alerts rather than as a column here, because one alert row is reused
|
||||||
// across occurrences and belongs to a different incident each time.
|
// across occurrences and belongs to a different incident each time.
|
||||||
const alertSelectFrom = `
|
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.labels, a.annotations,
|
||||||
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
||||||
(SELECT ia.incident_id
|
(SELECT ia.incident_id
|
||||||
@@ -29,7 +29,8 @@ const alertSelectFrom = `
|
|||||||
ORDER BY i.triggered_at DESC, i.id DESC
|
ORDER BY i.triggered_at DESC, i.id DESC
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
a.resolution_source, a.archived_at
|
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 {
|
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -38,6 +39,13 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
|||||||
where := []string{}
|
where := []string{}
|
||||||
args := &sqlArgs{}
|
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 != "" {
|
if status := q.Get("status"); status != "" {
|
||||||
where = append(where, "a.status = "+args.add(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"))
|
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a, err := fetchAlert(r.Context(), db, id)
|
a, err := fetchAlert(r.Context(), db, id, callerTeamIDs(r.Context()))
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
respond(w, http.StatusNotFound, errResp("alert not found"))
|
||||||
return
|
return
|
||||||
@@ -121,8 +129,9 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchAlert loads a single alert by ID using the shared query.
|
// fetchAlert loads a single alert by ID using the shared query.
|
||||||
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
|
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", id))
|
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.
|
// 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
|
var endsAtUnix, archivedAtUnix *int64
|
||||||
|
|
||||||
if err := s.Scan(
|
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,
|
&labelsJSON, &annotationsJSON,
|
||||||
&startsAtUnix, &endsAtUnix,
|
&startsAtUnix, &endsAtUnix,
|
||||||
&a.GeneratorURL, &receivedAtUnix,
|
&a.GeneratorURL, &receivedAtUnix,
|
||||||
|
|||||||
+30
-16
@@ -276,14 +276,14 @@ func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) {
|
|||||||
func TestSchedule_ConflictOnSameDate(t *testing.T) {
|
func TestSchedule_ConflictOnSameDate(t *testing.T) {
|
||||||
s := newTS(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"}})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||||
if first.StatusCode != http.StatusCreated {
|
if first.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("first assignment returned %d", first.StatusCode)
|
t.Fatalf("first assignment returned %d", first.StatusCode)
|
||||||
}
|
}
|
||||||
first.Body.Close()
|
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"}})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||||
if second.StatusCode != http.StatusConflict {
|
if second.StatusCode != http.StatusConflict {
|
||||||
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
|
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
|
||||||
@@ -295,11 +295,11 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
|||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|
||||||
// Claim 2026-06-10 first.
|
// 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()
|
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.
|
// 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"}})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}})
|
||||||
if resp.StatusCode != http.StatusConflict {
|
if resp.StatusCode != http.StatusConflict {
|
||||||
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
||||||
@@ -307,7 +307,7 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
// 2026-06-09 must NOT have been committed (transaction rolled back).
|
// 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
|
var entries []any
|
||||||
decode(t, listResp, &entries)
|
decode(t, listResp, &entries)
|
||||||
if len(entries) != 0 {
|
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
|
// addUser creates a second person to hand a shift to. The bootstrap user is
|
||||||
// admin, id 1.
|
// 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) {
|
func addUser(t *testing.T, s *ts, username string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := s.req(t, http.MethodPost, "/api/users",
|
resp := s.req(t, http.MethodPost, "/api/users",
|
||||||
map[string]any{"username": username, "email": username + "@test.com"})
|
map[string]any{"username": username, "email": username + "@test.com"})
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
resp.Body.Close()
|
||||||
t.Fatalf("create user returned %d", resp.StatusCode)
|
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.
|
// scheduleHolder reports who is on call for one date, or "" for nobody.
|
||||||
func scheduleHolder(t *testing.T, s *ts, date string) string {
|
func scheduleHolder(t *testing.T, s *ts, date string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var entries []map[string]any
|
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 {
|
if len(entries) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -347,10 +361,10 @@ func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) {
|
|||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
addUser(t, s, "alex")
|
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()
|
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})
|
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}, "replace": true})
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("expected replace to succeed, got %d", resp.StatusCode)
|
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
|
// One row, not two: two entries for a date would mean two people believing
|
||||||
// they are on call for it.
|
// they are on call for it.
|
||||||
var entries []map[string]any
|
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 {
|
if len(entries) != 1 {
|
||||||
t.Errorf("expected exactly one entry for the date, got %d", len(entries))
|
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)
|
s := newTS(t)
|
||||||
addUser(t, s, "alex")
|
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()
|
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"}
|
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})
|
map[string]any{"user_id": 2, "dates": week, "replace": true})
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("expected the mixed week to succeed, got %d", resp.StatusCode)
|
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)
|
s := newTS(t)
|
||||||
addUser(t, s, "alex")
|
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()
|
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"}})
|
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}})
|
||||||
if resp.StatusCode != http.StatusConflict {
|
if resp.StatusCode != http.StatusConflict {
|
||||||
t.Fatalf("expected 409 without replace, got %d", resp.StatusCode)
|
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) {
|
func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) {
|
||||||
s := newTS(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})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01", "2026-06-01"}, "replace": true})
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("expected a repeated date to be accepted under replace, got %d", resp.StatusCode)
|
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()
|
resp.Body.Close()
|
||||||
|
|
||||||
var entries []map[string]any
|
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 {
|
if len(entries) != 1 {
|
||||||
t.Errorf("expected one entry for the repeated date, got %d", len(entries))
|
t.Errorf("expected one entry for the repeated date, got %d", len(entries))
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -171,6 +171,7 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
|||||||
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
|
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
|
||||||
type deadmanAlert struct {
|
type deadmanAlert struct {
|
||||||
id int64
|
id int64
|
||||||
|
teamID int64
|
||||||
fingerprint string
|
fingerprint string
|
||||||
labels map[string]string
|
labels map[string]string
|
||||||
matcher DeadmanMatcher
|
matcher DeadmanMatcher
|
||||||
@@ -236,7 +237,7 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, cfg DeadmanConfig) ([]deadma
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, fingerprint, labels, status, received_at
|
SELECT id, team_id, fingerprint, labels, status, received_at
|
||||||
FROM alerts
|
FROM alerts
|
||||||
WHERE name IN (`+args.addList(nameList)+`)
|
WHERE name IN (`+args.addList(nameList)+`)
|
||||||
AND archived_at IS NULL`, args.all()...)
|
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() {
|
for rows.Next() {
|
||||||
var a deadmanAlert
|
var a deadmanAlert
|
||||||
var labelsJSON, status string
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
|
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, `
|
if err := db.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(MAX(triggered_at), 0),
|
SELECT COALESCE(MAX(triggered_at), 0),
|
||||||
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
||||||
FROM incidents WHERE group_key = $1`,
|
FROM incidents WHERE team_id = $1 AND group_key = $2`,
|
||||||
sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if open > 0 || sw.receivedAt <= lastTriggered {
|
if open > 0 || sw.receivedAt <= lastTriggered {
|
||||||
@@ -314,7 +315,9 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
|||||||
sev = &severity
|
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)
|
"No heartbeat from "+sw.matcher.String(), sw.labels, sev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -343,7 +346,8 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
|||||||
var incidentID int64
|
var incidentID int64
|
||||||
switch err := db.QueryRowContext(ctx, `
|
switch err := db.QueryRowContext(ctx, `
|
||||||
SELECT id FROM incidents
|
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:
|
case err == sql.ErrNoRows:
|
||||||
return nil
|
return nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ type querier interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const incidentSelectFrom = `
|
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.triggered_at,
|
||||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||||
i.assigned_to, asg.username, i.snoozed_until,
|
i.assigned_to, asg.username, i.snoozed_until,
|
||||||
i.resolved_at, i.resolution_source, i.archived_at
|
i.resolved_at, i.resolution_source, i.archived_at
|
||||||
FROM incidents i
|
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 ack ON ack.id = i.acknowledged_by
|
||||||
LEFT JOIN users asg ON asg.id = i.assigned_to`
|
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
|
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
|
||||||
|
|
||||||
if err := s.Scan(
|
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,
|
&triggeredAt,
|
||||||
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||||
@@ -113,12 +114,17 @@ func todayUTC() string {
|
|||||||
return time.Now().UTC().Format("2006-01-02")
|
return time.Now().UTC().Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentOnCall returns today's on-call user, or nil when nobody is scheduled.
|
// currentOnCall returns a team's on-call user for today, or nil when nobody is
|
||||||
// A missing schedule entry is not an error — incidents just open unassigned.
|
// scheduled. A missing schedule entry is not an error — incidents just open
|
||||||
func currentOnCall(ctx context.Context, q querier) (*int64, error) {
|
// 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
|
var userID int64
|
||||||
err := q.QueryRowContext(ctx,
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-15
@@ -19,6 +19,15 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
|||||||
where := []string{}
|
where := []string{}
|
||||||
args := &sqlArgs{}
|
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
|
// Without an explicit status the queue shows open work, which is what an
|
||||||
// on-call person opens the tool to see.
|
// on-call person opens the tool to see.
|
||||||
if status := q.Get("status"); status != "" {
|
if status := q.Get("status"); status != "" {
|
||||||
@@ -96,7 +105,7 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -119,7 +128,7 @@ func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,7 +146,7 @@ func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,7 +185,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -199,7 +208,7 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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".
|
// re-send of an alert that never stopped firing. Use snooze for "not now".
|
||||||
func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,7 +253,7 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -285,7 +294,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
|||||||
// {"duration": "2h"}.
|
// {"duration": "2h"}.
|
||||||
func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -340,7 +349,7 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -359,7 +368,7 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -379,7 +388,7 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -401,7 +410,7 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
|||||||
// single query renders the whole story of an incident in order.
|
// single query renders the whole story of an incident in order.
|
||||||
func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -448,7 +457,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
|||||||
// rest of the timeline is what actually happened, and is not editable.
|
// rest of the timeline is what actually happened, and is not editable.
|
||||||
func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := incidentIDParam(w, r)
|
id, ok := incidentIDParam(w, r, db)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -479,19 +488,32 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
|||||||
// Shared handler plumbing
|
// 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)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid incident id"))
|
respond(w, http.StatusBadRequest, errResp("invalid incident id"))
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
return id, true
|
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 {
|
func incidentExists(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) bool {
|
||||||
var exists int
|
var exists int
|
||||||
if err := db.QueryRowContext(r.Context(),
|
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"))
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ func TestIncident_AutoAssignedToCurrentOnCall(t *testing.T) {
|
|||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
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}})
|
map[string]any{"user_id": 1, "dates": []string{today}})
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("schedule assignment returned %d", resp.StatusCode)
|
t.Fatalf("schedule assignment returned %d", resp.StatusCode)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type contextKey string
|
|||||||
const (
|
const (
|
||||||
ctxUser contextKey = "user"
|
ctxUser contextKey = "user"
|
||||||
ctxSession contextKey = "session"
|
ctxSession contextKey = "session"
|
||||||
|
ctxTeams contextKey = "teams"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthMiddleware accepts either of the two credentials the server issues: an
|
// 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()
|
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 {
|
if sessionID != 0 {
|
||||||
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
||||||
}
|
}
|
||||||
@@ -168,6 +179,90 @@ func userFromContext(ctx context.Context) (models.User, bool) {
|
|||||||
return u, ok
|
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
|
// sessionFromContext returns the id of the session a request was authenticated
|
||||||
// with, or false for an API-key request.
|
// with, or false for an API-key request.
|
||||||
func sessionFromContext(ctx context.Context) (int64, bool) {
|
func sessionFromContext(ctx context.Context) (int64, bool) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
|
|||||||
func putOnCall(t *testing.T, s *ts, userID int) {
|
func putOnCall(t *testing.T, s *ts, userID int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
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}})
|
map[string]any{"user_id": userID, "dates": []string{today}})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
|||||||
+30
-5
@@ -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
|
// 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.
|
// reachable from outside the cluster for the button to work.
|
||||||
r.Post("/api/bootstrap", handleBootstrap(db))
|
r.Post("/api/bootstrap", handleBootstrap(db))
|
||||||
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman))
|
|
||||||
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
|
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,
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
||||||
// which AuthMiddleware accepts in place of an API key.
|
// which AuthMiddleware accepts in place of an API key.
|
||||||
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
|
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.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
||||||
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
||||||
|
|
||||||
r.Post("/api/schedule", handleCreateSchedule(db))
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
||||||
r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id}
|
r.Get("/api/teams", handleListTeams(db))
|
||||||
r.Get("/api/schedule", handleListSchedule(db))
|
r.Post("/api/teams", handleCreateTeam(db))
|
||||||
r.Delete("/api/schedule/{id}", handleDeleteSchedule(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/incidents", handleStatsIncidents(db))
|
||||||
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
||||||
|
|||||||
+69
-26
@@ -12,8 +12,18 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"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 {
|
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var req struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Dates []string `json:"dates"`
|
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
|
var exists int
|
||||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", req.UserID).Scan(&exists); err != nil {
|
if err := db.QueryRowContext(r.Context(),
|
||||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +77,15 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
for _, d := range req.Dates {
|
for _, d := range req.Dates {
|
||||||
if req.Replace {
|
if req.Replace {
|
||||||
if _, err := tx.ExecContext(r.Context(),
|
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"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(r.Context(),
|
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) {
|
if isUniqueViolation(err) {
|
||||||
respond(w, http.StatusConflict,
|
respond(w, http.StatusConflict,
|
||||||
errResp("date already assigned: "+d+" (pass replace to take it)"))
|
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 {
|
for _, d := range req.Dates {
|
||||||
dateSet[d] = true
|
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 {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -107,6 +122,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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()
|
q := r.URL.Query()
|
||||||
from, to := q.Get("from"), q.Get("to")
|
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 {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -134,12 +156,20 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
|
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
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 {
|
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
var e models.ScheduleEntry
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
var ts int64
|
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||||
err := db.QueryRowContext(r.Context(), `
|
|
||||||
SELECT s.id, s.user_id, u.username, s.date, s.created_at
|
|
||||||
FROM schedule_entries s
|
FROM schedule_entries s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.date = $1`, today).Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts)
|
JOIN teams t ON t.id = s.team_id
|
||||||
if err == sql.ErrNoRows {
|
WHERE s.date = $1 AND s.team_id = ANY($2)
|
||||||
respond(w, http.StatusNotFound, errResp("no one is on call today"))
|
ORDER BY t.name`, today, callerTeamIDs(r.Context()))
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
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()
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
respond(w, http.StatusOK, e)
|
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.
|
// scheduleRange returns schedule entries ordered by date.
|
||||||
// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side.
|
// 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) {
|
func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to string) ([]models.ScheduleEntry, error) {
|
||||||
where := []string{}
|
|
||||||
args := &sqlArgs{}
|
args := &sqlArgs{}
|
||||||
|
where := []string{"s.team_id = " + args.add(teamID)}
|
||||||
if from != "" {
|
if from != "" {
|
||||||
where = append(where, "s.date >= "+args.add(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))
|
where = append(where, "s.date <= "+args.add(to))
|
||||||
}
|
}
|
||||||
|
|
||||||
clause := "1=1"
|
clause := strings.Join(where, " AND ")
|
||||||
if len(where) > 0 {
|
|
||||||
clause = strings.Join(where, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
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
|
FROM schedule_entries s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
|
JOIN teams t ON t.id = s.team_id
|
||||||
WHERE `+clause+`
|
WHERE `+clause+`
|
||||||
ORDER BY s.date ASC`, args.all()...)
|
ORDER BY s.date ASC`, args.all()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,7 +251,7 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e models.ScheduleEntry
|
var e models.ScheduleEntry
|
||||||
var ts int64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
|||||||
+11
-7
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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
|
// 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
|
// 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 {
|
func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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
|
limit := 10
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
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 {
|
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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(`
|
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr,
|
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 {
|
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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
|
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering
|
||||||
// SQLite's strftime('%w') returned, so the frontend needs no change.
|
// 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.
|
// mutated in place and carry no acknowledgement or closure time.
|
||||||
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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 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
|
// 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
|
// statsFilter builds a WHERE clause and args from optional ?from and ?to query
|
||||||
// params, filtering on timeCol. Archived rows are always excluded, matching the
|
// params, filtering on timeCol. Archived rows are always excluded, matching the
|
||||||
// default list views.
|
// 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{}
|
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 from := q.Get("from"); from != "" {
|
||||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||||
clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix()))
|
clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix()))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ import (
|
|||||||
// tests nothing is worse than one that does not run.
|
// tests nothing is worse than one that does not run.
|
||||||
const testDSNEnv = "TERDUT_TEST_DSN"
|
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
|
var schemaSeq int
|
||||||
|
|
||||||
// newTestDB returns a migrated database private to this test, and drops it
|
// newTestDB returns a migrated database private to this test, and drops it
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
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)
|
user, _ := fetchUser(r.Context(), db, userID)
|
||||||
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
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})
|
respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key})
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -8,6 +8,12 @@ import "time"
|
|||||||
// Incident an alert belongs to.
|
// Incident an alert belongs to.
|
||||||
type Alert struct {
|
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"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // "firing" or "resolved"
|
Status string `json:"status"` // "firing" or "resolved"
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import "time"
|
|||||||
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
||||||
// stopped firing.
|
// stopped firing.
|
||||||
type Incident struct {
|
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"`
|
ID int64 `json:"id"`
|
||||||
GroupKey string `json:"group_key"`
|
GroupKey string `json:"group_key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import "time"
|
|||||||
|
|
||||||
type ScheduleEntry struct {
|
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"`
|
UserID int64 `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Date string `json:"date"` // YYYY-MM-DD
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -87,12 +87,11 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
|
|||||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||||
|
|
||||||
// schedule
|
// schedule
|
||||||
export const schedule = (from, to) => call('GET', '/schedule', { query: { from, to } });
|
export const teams = () => call('GET', '/teams');
|
||||||
export async function onCallNow() {
|
export const schedule = (teamID, from, to) =>
|
||||||
try {
|
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||||
return await call('GET', '/schedule/current');
|
|
||||||
} catch (err) {
|
// One entry per team the viewer belongs to, for the teams that have somebody
|
||||||
if (err instanceof ApiError && err.status === 404) return null;
|
// scheduled today. An empty array means nobody anywhere, which is a real answer
|
||||||
throw err;
|
// rather than an error — unlike the pre-teams endpoint, which 404ed.
|
||||||
}
|
export const onCallNow = () => call('GET', '/schedule/current');
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import * as ui from './ui.js';
|
import * as ui from './ui.js';
|
||||||
import * as poll from './poll.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 queue from './queue.js';
|
||||||
import * as incident from './incident.js';
|
import * as incident from './incident.js';
|
||||||
import * as oncall from './oncall.js';
|
import * as oncall from './oncall.js';
|
||||||
@@ -141,6 +141,7 @@ async function boot() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
|
await loadTeams();
|
||||||
showApp();
|
showApp();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) showLogin();
|
if (err.status === 401) showLogin();
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
// 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.
|
// 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 * as api from './api.js';
|
||||||
import { h, clear, icon, spinner } from './ui.js';
|
import { h, clear, icon, spinner } from './ui.js';
|
||||||
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.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');
|
const view = () => document.getElementById('view-oncall');
|
||||||
|
|
||||||
@@ -24,10 +28,17 @@ export async function refresh() {
|
|||||||
const start = weekStart;
|
const start = weekStart;
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
try {
|
try {
|
||||||
|
const team = currentTeam();
|
||||||
|
if (!team) {
|
||||||
|
data = { now: [], week: [], upcoming: [] };
|
||||||
|
error = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [now, week, upcoming] = await Promise.all([
|
const [now, week, upcoming] = await Promise.all([
|
||||||
api.onCallNow(),
|
api.onCallNow(),
|
||||||
api.schedule(isoDate(start), isoDate(addDays(start, 6))),
|
api.schedule(team.id, isoDate(start), isoDate(addDays(start, 6))),
|
||||||
api.schedule(isoDate(today), isoDate(addDays(today, 60))),
|
api.schedule(team.id, isoDate(today), isoDate(addDays(today, 60))),
|
||||||
]);
|
]);
|
||||||
if (start !== weekStart) return;
|
if (start !== weekStart) return;
|
||||||
data = { now, week, upcoming };
|
data = { now, week, upcoming };
|
||||||
@@ -60,15 +71,32 @@ function you(userID) {
|
|||||||
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
|
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() {
|
function nowCard() {
|
||||||
const n = data.now;
|
const entries = data.now || [];
|
||||||
|
const showTeam = entries.length > 1;
|
||||||
|
if (entries.length === 0) {
|
||||||
return h('div', { class: 'card now-card' },
|
return h('div', { class: 'card now-card' },
|
||||||
h('div', { class: `avatar ${n ? '' : 'none'}`, text: n ? initial(n.username) : '–' }),
|
h('div', { class: 'avatar none', text: '–' }),
|
||||||
h('div', {},
|
h('div', {},
|
||||||
h('div', { class: 'now-label', text: 'On call now' }),
|
h('div', { class: 'now-label', text: 'On call now' }),
|
||||||
h('div', { class: 'now-name' }, n ? n.username : 'Nobody', n && you(n.user_id)),
|
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() {
|
function weekCard() {
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import * as api from './api.js';
|
|||||||
export const state = {
|
export const state = {
|
||||||
me: null, // { user, has_password }
|
me: null, // { user, has_password }
|
||||||
open: [], // the default queue: open, not snoozed
|
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() {
|
export function myID() {
|
||||||
return state.me ? state.me.user.id : null;
|
return state.me ? state.me.user.id : null;
|
||||||
}
|
}
|
||||||
@@ -24,8 +31,14 @@ export async function users() {
|
|||||||
return usersCache;
|
return usersCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadTeams() {
|
||||||
|
state.teams = await api.teams();
|
||||||
|
return state.teams;
|
||||||
|
}
|
||||||
|
|
||||||
export function reset() {
|
export function reset() {
|
||||||
state.me = null;
|
state.me = null;
|
||||||
state.open = [];
|
state.open = [];
|
||||||
|
state.teams = [];
|
||||||
usersCache = null;
|
usersCache = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user