a4dd60f6b8
Service accounts (SERVICE-ACCOUNTS.md) are a scoped, non-human credential: not a users row, so they never touch OIDC sync, login or the is_admin flag. Instance scope can create a team and mint a team-scoped account for it; team scope is owner-equivalent for that one team and nothing else. This is what unblocks terdut-operator's DESIGN.md §6 — no more impersonating a human admin, and a real rotation story instead of the unworkable delete-and-re-bootstrap /api/bootstrap can't actually do. - migration 014: service_accounts + service_account_keys - POST /api/service-accounts, POST/DELETE .../keys, GET ?name= self-lookup - AuthMiddleware resolves a tdsa_-prefixed key to a distinct principal; a team-scoped account gets a synthetic single membership so requireTeamMember/requireTeamOwner work on it unmodified - handleCreateTeam accepts an instance-scoped caller; the team it creates has no human owner, which is the expected shape for one an operator is about to hand a team-scoped credential to Operator mode (TERDUT_OPERATOR_MODE / values.operatorMode) declares an install gitops-managed: session and user-API-key writes to teams, escalation policies, dead man's switches and integrations get 403 reason=operator_managed, while a service account's writes still go through. Team membership/invites and the schedule are deliberately left out — never gitops-managed by design, and still human day-to-day work. /api/auth/config reports operator_mode so the web UI can grey these sections out from the start rather than only after a write fails. Also: GET /api/version (both terdut-tui and terdut-operator currently detect server capability by route-probing; this gives them a real answer), and a PUT for dead man's switches so a reconciler can update one in place instead of deleting and recreating it.
1010 lines
33 KiB
Go
1010 lines
33 KiB
Go
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, m.source
|
|
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, &t.Source); 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, m.source
|
|
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, &t.Source); 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.
|
|
//
|
|
// An instance-scoped service account may also create a team (SERVICE-ACCOUNTS.md:
|
|
// it acts with the same reach system administration has over teams), but it
|
|
// is not a users row and cannot become an owner the way a person does. The
|
|
// team it creates starts with no human owner at all — not a bug, the expected
|
|
// shape for one terdut-operator is about to provision: a system administrator
|
|
// can always act as owner to repair or hand it off (requireTeamOwner), and
|
|
// the account that created it mints itself a team-scoped credential for it
|
|
// next, via POST /api/service-accounts.
|
|
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, isUser := userFromContext(r.Context())
|
|
if !isUser && !isInstanceServiceAccount(r.Context()) {
|
|
// A team-scoped service account authenticates as owner of exactly
|
|
// one team already (see serveAsServiceAccount); letting it create
|
|
// another would reach outside that boundary.
|
|
respond(w, http.StatusForbidden, errResp("instance-scoped service account or user access required"))
|
|
return
|
|
}
|
|
|
|
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 isUser {
|
|
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()
|
|
if isUser {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Member statuses, as the Members page colours them.
|
|
const (
|
|
memberOnCall = "oncall"
|
|
memberReachable = "reachable"
|
|
memberUnpageable = "unpageable"
|
|
)
|
|
|
|
// memberStatus is a team member with what matters about them at 03:00: whether
|
|
// they are on call, whether a page to them would go anywhere, and whether they
|
|
// have been around. The extra fields are output only.
|
|
type memberStatus struct {
|
|
models.TeamMember
|
|
|
|
// Status is unpageable when a page to them would go nowhere — even when
|
|
// they are on call, since that is the case that matters most — on_call when
|
|
// the rota has them today, reachable otherwise.
|
|
Status string `json:"status"`
|
|
|
|
OnCall bool `json:"on_call"`
|
|
|
|
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
|
|
NextShift *string `json:"next_shift,omitempty"`
|
|
|
|
// Pageable is whether they have an ntfy topic and an enabled account — the
|
|
// conditions pageLevel and the notifier skip on. Never the topic itself.
|
|
Pageable bool `json:"pageable"`
|
|
Problem string `json:"problem,omitempty"`
|
|
|
|
// LastActiveAt is the last time they used a session or an API key.
|
|
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
|
}
|
|
|
|
// handleListTeamMembers names everybody in a team, with their status. 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, m.source,
|
|
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
|
|
u.disabled_at IS NOT NULL,
|
|
GREATEST(
|
|
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
|
|
COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)),
|
|
EXISTS (SELECT 1 FROM schedule_entries s
|
|
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2),
|
|
(SELECT MIN(date) FROM schedule_entries s
|
|
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2)
|
|
FROM team_members m
|
|
JOIN users u ON u.id = m.user_id
|
|
WHERE m.team_id = $1
|
|
ORDER BY u.username`, teamID, todayUTC())
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
members := []memberStatus{}
|
|
for rows.Next() {
|
|
var m memberStatus
|
|
var joined, lastActive int64
|
|
var hasTopic, disabled bool
|
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
|
|
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
m.JoinedAt = time.Unix(joined, 0).UTC()
|
|
if lastActive > 0 {
|
|
t := time.Unix(lastActive, 0).UTC()
|
|
m.LastActiveAt = &t
|
|
}
|
|
switch {
|
|
case disabled:
|
|
m.Problem = "account is disabled"
|
|
case !hasTopic:
|
|
m.Problem = "has no ntfy topic"
|
|
}
|
|
m.Pageable = m.Problem == ""
|
|
switch {
|
|
case !m.Pageable:
|
|
m.Status = memberUnpageable
|
|
case m.OnCall:
|
|
m.Status = memberOnCall
|
|
default:
|
|
m.Status = memberReachable
|
|
}
|
|
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
|
|
}
|
|
|
|
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
} else if managed {
|
|
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
|
return
|
|
}
|
|
|
|
// Demoting the last owner is removing them by another route: the team
|
|
// would have nobody who can edit it.
|
|
if req.Role == models.RoleMember {
|
|
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if last {
|
|
respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team"))
|
|
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
|
|
}
|
|
|
|
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
} else if managed {
|
|
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
|
|
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
|
|
|
|
// isSSOManagedMember reports whether the membership comes from the group sync.
|
|
// Editing it here would be undone at the person's next sign-in, so it is refused
|
|
// instead of appearing to work.
|
|
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
|
var managed bool
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
|
|
teamID, userID).Scan(&managed)
|
|
return managed, err
|
|
}
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// sourceQuietAfter is how long a source may go without posting before the
|
|
// Sources page calls it quiet rather than active. A day is longer than any
|
|
// repeat_interval worth having, so an Alertmanager that is up and has anything
|
|
// firing never crosses it; a source with nothing firing may, and that is a
|
|
// reason to look, not proof of a fault — which is why this is a colour and not
|
|
// an alarm. Dead man's switches are where silence pages.
|
|
const sourceQuietAfter = 24 * time.Hour
|
|
|
|
const (
|
|
sourceActive = "active"
|
|
sourceQuiet = "quiet"
|
|
sourceNever = "never"
|
|
)
|
|
|
|
// integrationStatus is an integration as the Sources page shows it.
|
|
type integrationStatus struct {
|
|
models.Integration
|
|
|
|
// Status is active when the key posted within sourceQuietAfter, quiet when
|
|
// it has posted but not lately, never when it has not posted at all.
|
|
Status string `json:"status"`
|
|
|
|
// LastAlertAt is when an alert last arrived on this source, which is not the
|
|
// same as when it last posted: a payload with nothing usable in it stamps
|
|
// last_used_at and not this. Absent until an alert has arrived since
|
|
// migration 010 started recording it.
|
|
LastAlertAt *time.Time `json:"last_alert_at,omitempty"`
|
|
|
|
// Alerts24h counts the distinct alerts this source refreshed in the last
|
|
// day. An alert re-sent every few hours counts once, not once per re-send.
|
|
Alerts24h int64 `json:"alerts_24h"`
|
|
}
|
|
|
|
// handleListIntegrations lists a team's integrations with what each has been
|
|
// delivering. 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
|
|
}
|
|
|
|
now := time.Now()
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at,
|
|
-- Scalar subqueries, not a join and GROUP BY: each is a
|
|
-- single range over alerts_integration_idx, where the join
|
|
-- would read every alert a source ever delivered.
|
|
(SELECT MAX(received_at) FROM alerts WHERE integration_id = i.id),
|
|
(SELECT COUNT(*) FROM alerts
|
|
WHERE integration_id = i.id AND received_at >= $2)
|
|
FROM integrations i
|
|
WHERE i.team_id = $1
|
|
ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix())
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
integrations := []integrationStatus{}
|
|
for rows.Next() {
|
|
var i integrationStatus
|
|
var created int64
|
|
var lastUsed, lastAlert *int64
|
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed,
|
|
&lastAlert, &i.Alerts24h); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
|
i.LastUsedAt = unixPtr(lastUsed)
|
|
i.LastAlertAt = unixPtr(lastAlert)
|
|
switch {
|
|
case i.LastUsedAt == nil:
|
|
i.Status = sourceNever
|
|
case now.Sub(*i.LastUsedAt) > sourceQuietAfter:
|
|
i.Status = sourceQuiet
|
|
default:
|
|
i.Status = sourceActive
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// handleRenameIntegration renames a source. The key is untouched, so nothing
|
|
// posting with it notices.
|
|
func handleRenameIntegration(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
|
|
}
|
|
|
|
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
|
|
}
|
|
if len(req.Name) > 100 {
|
|
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// alertSource is who an arriving webhook is from: the integration whose key it
|
|
// used, and the team that integration puts its alerts in.
|
|
type alertSource struct {
|
|
integrationID int64
|
|
teamID int64
|
|
}
|
|
|
|
// sourceForKey resolves an integration key to its source, and stamps the key's
|
|
// last use. An unknown key is not an error worth distinguishing: the caller is
|
|
// told nothing beyond "no".
|
|
func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) {
|
|
var src alertSource
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT id, team_id FROM integrations WHERE key_hash = $1", hashToken(key)).
|
|
Scan(&src.integrationID, &src.teamID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return alertSource{}, errUnknownIntegration
|
|
}
|
|
if err != nil {
|
|
return alertSource{}, 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 id = $2",
|
|
time.Now().Unix(), src.integrationID)
|
|
return src, 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{},
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleUpdateTeamDeadman replaces one switch's configuration in place.
|
|
// Added alongside create/delete so an automated caller (terdut-operator) can
|
|
// reconcile a spec change without deleting and recreating the switch, which
|
|
// would otherwise be the only option and would needlessly rotate its id for
|
|
// no reason a reconciler's diff should ever manufacture.
|
|
func handleUpdateTeamDeadman(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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(), `
|
|
UPDATE deadman_switches
|
|
SET name = $1, matcher = $2, timeout_seconds = $3, severity = $4
|
|
WHERE id = $5 AND team_id = $6`,
|
|
req.Name, m.config(), req.TimeoutSeconds, req.Severity, 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
|
|
}
|
|
|
|
// The full status, not the bare request echoed back: an update can
|
|
// change whether the switch is dormant, alive or dead (a longer
|
|
// timeout can revive one that just went dead), and a caller
|
|
// reconciling against status deserves the same view
|
|
// handleListTeamDeadman would give it.
|
|
set, err := deadmanSetForTeam(r.Context(), db, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
statuses, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
for _, s := range statuses {
|
|
if s.ID == switchID {
|
|
respond(w, http.StatusOK, s)
|
|
return
|
|
}
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|