Files
terdut-server/internal/api/teams.go
T
Niklas Ye a4dd60f6b8 Add service accounts and operator mode
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.
2026-09-29 21:25:17 +02:00

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)
}
}