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 oldest team, which on an upgraded install is the // "Default" team every pre-teams row was moved into and on a fresh one is the // team migration 003 creates. Bootstrap puts the first user in it, so somebody // signing in to a new server lands somewhere rather than in no team at all. 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 } // --------------------------------------------------------------------------- // A team's dead man's switches // --------------------------------------------------------------------------- // deadmanResponse is the wire shape of a team's switch configuration. The // timeout is seconds rather than a duration string, because that is what the // column holds and what arithmetic is done on; a client renders it. type deadmanResponse struct { TeamID int64 `json:"team_id"` Matchers string `json:"matchers"` TimeoutSeconds int64 `json:"timeout_seconds"` Severity string `json:"severity"` } func handleGetTeamDeadman(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 } out := deadmanResponse{TeamID: teamID, Severity: "critical"} err := db.QueryRowContext(r.Context(), "SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1", teamID).Scan(&out.Matchers, &out.TimeoutSeconds, &out.Severity) if err != nil && !errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusInternalServerError, errResp("internal error")) return } // A team with no row watches nothing, which is a configuration and not // an absence: answering 404 would make "off" indistinguishable from // "this server does not do this". respond(w, http.StatusOK, out) } } // handleSetTeamDeadman replaces a team's switch configuration. // // Validated by parsing: a matcher string that survives ParseDeadmanConfig with // nothing usable in it is rejected rather than stored, because a switch that // silently watches nothing is the failure this feature exists to prevent. func handleSetTeamDeadman(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 { Matchers string `json:"matchers"` TimeoutSeconds int64 `json:"timeout_seconds"` Severity string `json:"severity"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } req.Matchers = strings.TrimSpace(req.Matchers) if req.Severity == "" { req.Severity = "critical" } if req.TimeoutSeconds < 0 { respond(w, http.StatusBadRequest, errResp("timeout_seconds must not be negative")) return } if req.Matchers != "" { parsed := parseDeadmanQuietly(req.Matchers, time.Duration(req.TimeoutSeconds)*time.Second, req.Severity) if len(parsed.Matchers) == 0 { respond(w, http.StatusBadRequest, errResp( "no usable matchers: each must name an alertname, as in alertname=Watchdog,cluster=prod")) return } } if _, err := db.ExecContext(r.Context(), ` INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity, updated_at) VALUES ($1, $2, $3, $4, `+nowEpoch+`) ON CONFLICT (team_id) DO UPDATE SET matchers = excluded.matchers, timeout_seconds = excluded.timeout_seconds, severity = excluded.severity, updated_at = excluded.updated_at`, teamID, req.Matchers, req.TimeoutSeconds, req.Severity); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusOK, deadmanResponse{ TeamID: teamID, Matchers: req.Matchers, TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity, }) } }