Files
terdut-server/internal/api/users.go
T
Niklas Ye 9029d48584
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s
Let an operator authenticate with a seeded key, and reset the schema
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
  "terdut-operator" at every start, so terdut-operator needs no bootstrap
  handshake. An instance-scoped account now acts as owner of every team's
  configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
  is idempotent on it, so automation finds its own team again after a crash
  instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
  escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
  TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
  their own. Existing development databases must be recreated.

Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
  non-admins.
- The access log records the route pattern, so integration keys and ack
  tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
  right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
  concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.

Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-09 14:56:13 +02:00

521 lines
16 KiB
Go

package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// bootstrapLockKey is the transaction-scoped advisory lock handleBootstrap
// holds; distinct from the migration and notifier keys.
const bootstrapLockKey = 0x7465726475744254
func handleBootstrap(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
// Password is optional; without one the first user can only use the
// API key until somebody sets it.
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Username == "" || req.Email == "" {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
var passwordHash *string
if req.Password != "" {
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
h, err := hashPassword(req.Password)
if err != nil {
serverError(w, r, err)
return
}
passwordHash = &h
}
// Check-then-insert has to be one atomic step: two concurrent calls on
// an empty install would otherwise both see zero users and both create
// an admin. The transaction-scoped lock serialises them, and the loser
// sees the winner's row.
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
serverError(w, r, err)
return
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock($1)", bootstrapLockKey); err != nil {
serverError(w, r, err)
return
}
var count int
if err := tx.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
serverError(w, r, err)
return
}
if count > 0 {
respond(w, http.StatusForbidden, errResp("bootstrap already completed"))
return
}
var userID int64
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO users (username, email, password_hash, is_admin) VALUES ($1, $2, $3, true) RETURNING id",
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
serverError(w, r, err)
return
}
raw, hash, err := randomToken()
if err != nil {
serverError(w, r, err)
return
}
var keyID int64
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
userID, hash, "bootstrap").Scan(&keyID); err != nil {
serverError(w, r, err)
return
}
if err := tx.Commit(); err != nil {
serverError(w, r, err)
return
}
user, _ := fetchUser(r.Context(), db, userID)
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key})
}
}
// handleListUsers is readable by anyone signed in, because the assignment
// control and the schedule need to name people. What it returns about other
// people is therefore only what naming them takes: email and ntfy_topic are
// blanked unless the caller is an admin or the row is their own. The topic in
// particular is a publish secret.
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
seeAll := caller.IsAdmin
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
users := []models.User{}
for rows.Next() {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
serverError(w, r, err)
return
}
u.CreatedAt = time.Unix(ts, 0).UTC()
u.DisabledAt = unixPtr(disabled)
if !seeAll && u.ID != caller.ID {
u.Email = ""
u.NtfyTopic = nil
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, users)
}
}
func handleCreateUser(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Username == "" || req.Email == "" {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
var id int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
req.Username, req.Email).Scan(&id); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
serverError(w, r, err)
return
}
user, _ := fetchUser(r.Context(), db, id)
respond(w, http.StatusCreated, user)
}
}
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
// clears it with an empty string. The topic is a shared secret with the ntfy
// server — anyone who knows it can publish to it — so pick an unguessable one
// unless your ntfy enforces access control.
func handleSetNotifyTarget(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
}
var req struct {
NtfyTopic string `json:"ntfy_topic"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
var topic *string
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
topic = &t
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, user)
}
}
func handleDeleteUser(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
}
// Deleting yourself is how an install ends up with no administrator at
// all, and it is never what somebody meant to do.
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot delete your own account"))
return
}
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
serverError(w, r, err)
return
} else if last {
respond(w, http.StatusConflict, errResp("cannot delete the last administrator"))
return
}
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
if err != nil {
serverError(w, r, err)
return
}
n, _ := res.RowsAffected()
if n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
// rotation policy, tight enough to reject a typo (a year in hours, say) that
// would otherwise mint a key that outlives the server by decades.
const maxAPIKeyExpiryDays = 3650 // ~10 years
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, 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, userID) {
return
}
var req struct {
Name string `json:"name"`
// ExpiresInDays is optional and, left zero, means the key never
// expires — the only behavior any key had before this field
// existed, so an existing integration that does not send it is
// unaffected.
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
return
}
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
raw, hash, err := randomToken()
if err != nil {
serverError(w, r, err)
return
}
var expiresAt *int64
var expiresAtTime *time.Time
if req.ExpiresInDays > 0 {
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
u := t.Unix()
expiresAt = &u
expiresAtTime = &t
}
var keyID int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
serverError(w, r, err)
return
}
key := models.APIKey{
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
}
respond(w, http.StatusCreated, key)
}
}
// handleListAPIKeys lists a user's own API keys: never the raw key itself
// (only ever returned once, at creation), just enough to tell them apart,
// see which are stale (last_used_at) and which are about to stop working
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
// already produce, with no endpoint to read it back until now.
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, 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, userID) {
return
}
rows, err := db.QueryContext(r.Context(),
`SELECT id, name, created_at, last_used_at, expires_at
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
keys := []models.APIKey{}
for rows.Next() {
var k models.APIKey
var created int64
var lastUsed, expires *int64
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
serverError(w, r, err)
return
}
k.UserID = userID
k.CreatedAt = time.Unix(created, 0).UTC()
k.LastUsedAt = unixPtr(lastUsed)
k.ExpiresAt = unixPtr(expires)
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, keys)
}
}
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, 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, userID) {
return
}
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid key id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
if err != nil {
serverError(w, r, err)
return
}
n, _ := res.RowsAffected()
if n == 0 {
respond(w, http.StatusNotFound, errResp("api key not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// randomToken generates a random 32-byte secret encoded as hex, plus its SHA-256
// hash for storage. Used for API keys and for notification acknowledge tokens.
func randomToken() (raw, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
raw = hex.EncodeToString(b)
h := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(h[:])
return
}
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var u models.User
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
if err != nil {
return u, err
}
u.CreatedAt = time.Unix(ts, 0).UTC()
u.DisabledAt = unixPtr(disabled)
return u, nil
}
// handleSetAdmin grants or revokes the system administrator flag.
//
// Revoking is guarded twice: an install must keep at least one administrator,
// and you cannot demote yourself. The first stops the flag being lost
// altogether; the second stops the likelier accident, where the only admin
// clears their own flag while tidying up and locks the door behind them.
func handleSetAdmin(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
}
var req struct {
IsAdmin *bool `json:"is_admin"`
}
if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil {
respond(w, http.StatusBadRequest, errResp("is_admin is required"))
return
}
if !*req.IsAdmin {
var managed bool
if err := db.QueryRowContext(r.Context(),
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
id).Scan(&managed); err != nil {
serverError(w, r, err)
return
}
if managed {
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
return
}
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
return
}
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
serverError(w, r, err)
return
} else if last {
respond(w, http.StatusConflict, errResp("cannot revoke the last administrator"))
return
}
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, id)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, user)
}
}
// isLastAdmin reports whether id is an administrator and no other user is one.
// A non-admin id is never the last one, so removing them is always allowed.
func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) {
var last bool
err := db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin)
AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last)
return last, err
}