5b4683febf
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.
Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.
This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.
TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
484 lines
17 KiB
Go
484 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// The settings an administrator can change at runtime. Each is behaviour rather
|
|
// than infrastructure: what the server does, not where it is plugged in.
|
|
//
|
|
// The values are seconds, stored as text. A duration string would be friendlier
|
|
// to read in psql and worse everywhere else — it can be stored unparseable, and
|
|
// then the question is what a background loop should do at 02:00 with a
|
|
// tuning knob it cannot understand.
|
|
const (
|
|
SettingNotifyRepeat = "notify_repeat_seconds"
|
|
SettingStaleAfter = "stale_after_seconds"
|
|
SettingArchiveAfter = "archive_after_seconds"
|
|
)
|
|
|
|
// settingBounds keeps an edit from producing a server that cannot work. The
|
|
// ceilings are loose — they exist to catch a slipped decimal point, not to have
|
|
// an opinion about anybody's rota.
|
|
var settingBounds = map[string]struct {
|
|
min, max time.Duration
|
|
label string
|
|
}{
|
|
SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
|
|
SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
|
|
SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
|
|
}
|
|
|
|
// Settings reads the runtime configuration. It holds no cache: the readers are
|
|
// two background loops that tick every 30 seconds and 15 minutes, and handlers
|
|
// that run once per request, so a query each time costs nothing measurable and
|
|
// means an administrator's change takes effect on the next tick rather than at
|
|
// the next restart.
|
|
type Settings struct{ db *sql.DB }
|
|
|
|
// NewSettings returns a reader over db.
|
|
func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
|
|
|
|
// Duration reads one setting, falling back to def when the row is missing or
|
|
// unreadable. A tuning knob is never worth failing a sweep over: the fallback
|
|
// is the value the server started with.
|
|
func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
|
|
var raw string
|
|
err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
secs, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
|
|
// SeedSettings writes each key from the server's environment configuration,
|
|
// once. Never overwrites: after the first start the database owns these, and a
|
|
// redeploy must not put a chart's default back over an administrator's edit —
|
|
// the same rule as the per-team dead man's switches.
|
|
func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
|
seeds := map[string]time.Duration{
|
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
|
SettingStaleAfter: cfg.StaleAfter,
|
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
|
}
|
|
for key, d := range seeds {
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO NOTHING`,
|
|
key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// settingsResponse is what the admin page renders. The environment half is
|
|
// included and marked read-only, so somebody looking for the ntfy URL finds out
|
|
// where it lives rather than concluding the server does not have one.
|
|
type settingsResponse struct {
|
|
Editable map[string]settingValue `json:"editable"`
|
|
FromEnv map[string]string `json:"from_env"`
|
|
|
|
// Choices are settings that are a word from a fixed list rather than a
|
|
// duration. One so far: who may create an account.
|
|
Choices map[string]choiceValue `json:"choices"`
|
|
}
|
|
|
|
type choiceValue struct {
|
|
Value string `json:"value"`
|
|
Options []string `json:"options"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type settingValue struct {
|
|
Seconds int64 `json:"seconds"`
|
|
Description string `json:"description"`
|
|
MinSeconds int64 `json:"min_seconds"`
|
|
MaxSeconds int64 `json:"max_seconds"`
|
|
}
|
|
|
|
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
|
settings := NewSettings(db)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
out := settingsResponse{
|
|
Editable: map[string]settingValue{},
|
|
Choices: map[string]choiceValue{
|
|
SettingSignupMode: {
|
|
Value: signupMode(r.Context(), db),
|
|
Options: []string{SignupInviteOnly, SignupOpen},
|
|
Description: "who may create an account: invite_only means a link from a team owner, " +
|
|
"open means anybody who can reach this server",
|
|
},
|
|
},
|
|
FromEnv: map[string]string{
|
|
// Never the ntfy token or the DSN: both are credentials, and an
|
|
// admin page that renders them turns a browser tab into a place
|
|
// they leak from.
|
|
"ntfy_url": cfg.NtfyURL,
|
|
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
|
|
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
|
|
"public_url": cfg.PublicURL,
|
|
"listen_address": cfg.Addr,
|
|
},
|
|
}
|
|
for key, b := range settingBounds {
|
|
def := map[string]time.Duration{
|
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
|
SettingStaleAfter: cfg.StaleAfter,
|
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
|
}[key]
|
|
out.Editable[key] = settingValue{
|
|
Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
|
|
Description: b.label,
|
|
MinSeconds: int64(b.min.Seconds()),
|
|
MaxSeconds: int64(b.max.Seconds()),
|
|
}
|
|
}
|
|
respond(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// handleSetSettings changes one or more settings. Unknown keys are refused
|
|
// rather than stored: a typo that writes notify_repeat_second would otherwise
|
|
// sit in the table looking like configuration and doing nothing.
|
|
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req map[string]any
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if len(req) == 0 {
|
|
respond(w, http.StatusBadRequest, errResp("no settings given"))
|
|
return
|
|
}
|
|
|
|
// Validate everything before writing anything: a request that sets two
|
|
// settings and gets one wrong should change neither.
|
|
values := map[string]string{}
|
|
for key, raw := range req {
|
|
switch key {
|
|
case SettingSignupMode:
|
|
mode, _ := raw.(string)
|
|
if mode != SignupOpen && mode != SignupInviteOnly {
|
|
respond(w, http.StatusBadRequest,
|
|
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
|
|
return
|
|
}
|
|
values[key] = mode
|
|
default:
|
|
b, known := settingBounds[key]
|
|
if !known {
|
|
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
|
return
|
|
}
|
|
secs, ok := raw.(float64) // JSON numbers decode as float64
|
|
if !ok {
|
|
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
|
|
return
|
|
}
|
|
d := time.Duration(int64(secs)) * time.Second
|
|
if d < b.min || d > b.max {
|
|
respond(w, http.StatusBadRequest, errResp(
|
|
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
|
return
|
|
}
|
|
values[key] = strconv.FormatInt(int64(secs), 10)
|
|
}
|
|
}
|
|
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
for key, value := range values {
|
|
if _, err := tx.ExecContext(r.Context(), `
|
|
INSERT INTO settings (key, value, updated_at)
|
|
VALUES ($1, $2, `+nowEpoch+`)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
value = excluded.value, updated_at = excluded.updated_at`,
|
|
key, value); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// adminTeam is a team as an administrator sees it: what it is, plus how big it
|
|
// is and how much is on fire in it. One definition, so a team in the list and a
|
|
// team on its own page cannot describe themselves differently.
|
|
type adminTeam struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Members int64 `json:"members"`
|
|
OpenIncidents int64 `json:"open_incidents"`
|
|
|
|
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
|
|
// read-only here: an administrator can see why a team's OIDC-sourced
|
|
// membership looks the way it does without being able to change it out
|
|
// from under the team's owner. Setting it is PUT
|
|
// /api/teams/{teamID}/oidc-groups, owner-only.
|
|
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
|
|
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
|
|
}
|
|
|
|
// handleAdminListTeams lists every team on the server, with its size. The
|
|
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
|
// which only an administrator may ask.
|
|
func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT t.id, t.name, t.created_at,
|
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
|
(SELECT COUNT(*) FROM incidents i
|
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
|
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
|
FROM teams t
|
|
ORDER BY t.name`)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
teams := []adminTeam{}
|
|
for rows.Next() {
|
|
var t adminTeam
|
|
var created int64
|
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
|
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); 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)
|
|
}
|
|
}
|
|
|
|
// handleAdminGetTeam answers "what is this team, and who is in it" for any team
|
|
// on the server, which is the one question an administrator could not ask.
|
|
//
|
|
// GET /api/teams/{id}/members is requireTeamMember and answers 404 to somebody
|
|
// outside the team, administrator or not, and that stays exactly as it is:
|
|
// member means membership and nothing else. Reading a team's shape is a
|
|
// different thing from reading its work, so it gets an endpoint of its own
|
|
// under AdminOnly rather than an exception carved into that rule. An
|
|
// administrator still sees none of the team's incidents, alerts or rota.
|
|
func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var t adminTeam
|
|
var created int64
|
|
err := db.QueryRowContext(r.Context(), `
|
|
SELECT t.id, t.name, t.created_at,
|
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
|
(SELECT COUNT(*) FROM incidents i
|
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
|
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
|
FROM teams t
|
|
WHERE t.id = $1`, teamID).
|
|
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
|
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
|
|
|
// Same query and same ordering as handleListTeamMembers, so the two
|
|
// answers to "who is in this team" cannot disagree about the answer.
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source
|
|
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, &m.Source); 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
|
|
}
|
|
|
|
// A wrapper rather than a team with the members hung off it: "members"
|
|
// already means a count on the list endpoint, and one name must not be
|
|
// a number in one answer and an array in the next.
|
|
respond(w, http.StatusOK, map[string]any{"team": t, "members": members})
|
|
}
|
|
}
|
|
|
|
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
|
// a team has nobody left to do it.
|
|
func handleRenameTeam(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 {
|
|
Name string `json:"name"`
|
|
}
|
|
// Trimmed, as handleCreateTeam trims: without it " " is a team name
|
|
// here but not at creation, which is one rule stated twice and only
|
|
// half applied.
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
|
|
if 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 n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleSetUserDisabled takes an account out of use, or puts it back.
|
|
//
|
|
// Not a delete: the person's acknowledgements, assignments and timeline entries
|
|
// stay attached to them. Deleting a user nulls those columns, which rewrites
|
|
// what happened during an incident months after the fact.
|
|
func handleSetUserDisabled(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 {
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
|
|
respond(w, http.StatusBadRequest, errResp("disabled is required"))
|
|
return
|
|
}
|
|
|
|
if *req.Disabled {
|
|
caller, _ := userFromContext(r.Context())
|
|
if caller.ID == id {
|
|
respond(w, http.StatusConflict, errResp("cannot disable your own account"))
|
|
return
|
|
}
|
|
last, err := isLastAdmin(r.Context(), db, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if last {
|
|
respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
|
|
return
|
|
}
|
|
}
|
|
|
|
var res sql.Result
|
|
if *req.Disabled {
|
|
res, err = db.ExecContext(r.Context(),
|
|
"UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
|
|
} else {
|
|
res, err = db.ExecContext(r.Context(),
|
|
"UPDATE users SET disabled_at = NULL WHERE id = $1", id)
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
// Either no such user, or already in the state asked for. The
|
|
// second is not a failure, so check which before answering.
|
|
var exists int
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Signing back in is the only way to use a re-enabled account, and a
|
|
// disabled one must not keep a live session.
|
|
if *req.Disabled {
|
|
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
|
|
}
|
|
|
|
user, err := fetchUser(r.Context(), db, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, user)
|
|
}
|
|
}
|