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) } } // handleUserTeams lists one user's teams, for the admin page's per-user view: // "what is this person in", which /api/teams cannot answer because it is always // about the caller. // // Self or admin, matching the other per-user endpoints. It says which teams // somebody belongs to and in what role — not anything those teams own, so it // stays on the accounts side of the line the administrator flag draws. func handleUserTeams(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } if !requireSelfOrAdmin(w, r, id) { return } // A user with no teams and a user who does not exist both list nothing, // so the existence check is what tells them apart. var exists bool if err := db.QueryRowContext(r.Context(), "SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if !exists { respond(w, http.StatusNotFound, errResp("user not found")) return } 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`, 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 // --------------------------------------------------------------------------- // deadmanSwitchRequest is what creating a switch takes. The timeout is seconds, // because that is what the column holds and what arithmetic is done on; a client // renders it. type deadmanSwitchRequest struct { Name string `json:"name"` Matcher string `json:"matcher"` TimeoutSeconds int64 `json:"timeout_seconds"` Severity string `json:"severity"` } // deadmanSeverities are the severities an incident can open at. var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true} // handleListTeamDeadman lists a team's switches with what each one's heartbeats // are doing. A team with none gets an empty list, which is a configuration and // not an absence: answering 404 would make "off" indistinguishable from "this // server does not do this". func handleListTeamDeadman(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 } set, err := deadmanSetForTeam(r.Context(), db, teamID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now()) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusOK, out) } } // handleCreateTeamDeadman adds one switch. // // Validated by parsing: a matcher with no alertname is rejected rather than // stored, because a switch that silently watches nothing is the failure this // feature exists to prevent. func handleCreateTeamDeadman(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 deadmanSwitchRequest if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } req.Matcher = strings.TrimSpace(req.Matcher) req.Name = strings.TrimSpace(req.Name) if req.Severity == "" { req.Severity = "critical" } if !deadmanSeverities[req.Severity] { respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info")) return } if req.TimeoutSeconds <= 0 { respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive")) return } if strings.Contains(req.Matcher, ";") { respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;")) return } m, err := parseDeadmanMatcher(req.Matcher) if err != nil { respond(w, http.StatusBadRequest, errResp( "unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod")) return } if req.Name == "" { req.Name = m.config() } if len(req.Name) > 100 { respond(w, http.StatusBadRequest, errResp("name is too long")) return } var id int64 if err := db.QueryRowContext(r.Context(), ` INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity) VALUES ($1, $2, $3, $4, $5) RETURNING id`, teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusCreated, deadmanSwitchStatus{ ID: id, Name: req.Name, Matcher: m.config(), TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity, Status: switchDormant, Sources: []deadmanSource{}, }) } } // handleDeleteTeamDeadman removes a switch. An incident it already opened stays // open until somebody resolves it: deleting the switch says "stop watching", not // "the problem is gone". func handleDeleteTeamDeadman(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 } switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid switch id")) return } res, err := db.ExecContext(r.Context(), "DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if n, _ := res.RowsAffected(); n == 0 { respond(w, http.StatusNotFound, errResp("switch not found")) return } w.WriteHeader(http.StatusNoContent) } }