b0a02c010b
Closes #5. Three of the server's tunables were environment variables, which meant changing how long an incident waits before being paged again required editing a chart, merging it and waiting for a reconcile. They are behaviour rather than infrastructure, and the difference is who needs to change them and how often. The split is by who owns the value. What stays in the environment is where the server is plugged in: the listen address, the DSN, the ntfy URL and token, the public URL. Those are needed before the database is open and two of them are credentials -- the settings endpoint reports that ntfy is configured and that a token is set, and never what either is. What moves is how it behaves: the notify repeat interval, the stale window and the archive window. The environment variable becomes the seed rather than the setting, written once on first start and never overwritten, so a redeploy cannot put a chart's default back over an administrator's edit -- the rule the per-team dead man's switches already follow. The loops read the current value per tick, so a change at 02:00 is obeyed at 02:00. Key/value rather than a column per knob: #6 and #7 will both add settings, and a table shaped one-column-per-setting needs a migration for each. The cost is that values are text and the accessor has to say what type it wanted, which settings.go does in one place. Unknown keys are refused rather than stored -- a typo that wrote notify_repeat_second would otherwise sit in the table looking like configuration and doing nothing -- and each value has bounds loose enough to catch a slipped decimal point without having an opinion about anybody's rota. Disabling an account is new, and is not deleting one. Deleting a user nulls acknowledged_by and assigned_to, which quietly rewrites who did what during an incident months after the fact. A disabled user cannot authenticate by either credential, loses their sessions immediately, and stays the name on every acknowledgement they made. The check is part of the lookup in serveAs rather than a test afterwards, so there is no path where the row is loaded and the flag is then forgotten. The page itself is a fourth tab, shown only to an administrator and only as a courtesy: every endpoint under it is refused with 403 regardless, so somebody who types /admin gets an explanation rather than a blank screen. It lists teams with their size and open-incident count, users with their flags, and the settings with their bounds -- plus the environment half, read-only, so somebody hunting for the ntfy URL learns where it lives instead of concluding the server has none. Delete is disabled rather than offered-and-refused for a team with open incidents, and neither admin action is offered on your own account, since the server refuses both. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
276 lines
9.0 KiB
Go
276 lines
9.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
ctxUser contextKey = "user"
|
|
ctxSession contextKey = "session"
|
|
ctxTeams contextKey = "teams"
|
|
)
|
|
|
|
// AuthMiddleware accepts either of the two credentials the server issues: an
|
|
// API key in an Authorization header (the TUI, scripts) or a session cookie
|
|
// (the web UI). A request carrying a Bearer header is judged on that alone and
|
|
// never falls back to the cookie.
|
|
//
|
|
// Only the cookie needs a CSRF guard. A browser attaches it to requests other
|
|
// sites make, whereas an Authorization header is only ever set by the client
|
|
// that holds the key.
|
|
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
|
crossOrigin := http.NewCrossOriginProtection()
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if header := r.Header.Get("Authorization"); header != "" {
|
|
token, ok := strings.CutPrefix(header, "Bearer ")
|
|
if !ok || token == "" {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
userID, ok := apiKeyUser(r.Context(), db, token)
|
|
if !ok {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
serveAs(w, r, next, db, userID, 0)
|
|
return
|
|
}
|
|
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
sessionID, userID, ok := sessionUser(r.Context(), db, c.Value)
|
|
if !ok {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
if err := crossOrigin.Check(r); err != nil {
|
|
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
|
|
return
|
|
}
|
|
serveAs(w, r, next, db, userID, sessionID)
|
|
})
|
|
}
|
|
}
|
|
|
|
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
|
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
|
//
|
|
// 403 and not 404: the route exists and the caller is authenticated, they are
|
|
// simply not allowed. Hiding the endpoint would buy nothing — every one of them
|
|
// is in the README.
|
|
func AdminOnly(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
caller, ok := userFromContext(r.Context())
|
|
if !ok || !caller.IsAdmin {
|
|
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requireSelfOrAdmin guards the endpoints that are self-service for your own
|
|
// account and administration for anybody else's: your password, your ntfy
|
|
// topic, your API keys. Reports whether the request may proceed, and answers it
|
|
// if not.
|
|
//
|
|
// An API key is not an escalation: it carries exactly the rights of the user it
|
|
// belongs to, so minting your own is no more than signing in again.
|
|
func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool {
|
|
caller, ok := userFromContext(r.Context())
|
|
if !ok || (caller.ID != targetID && !caller.IsAdmin) {
|
|
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// apiKeyUser resolves an API key to its user and stamps its last use.
|
|
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
|
var keyID, userID int64
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
|
|
).Scan(&keyID, &userID)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
// best-effort; don't fail the request if this update fails
|
|
db.ExecContext(ctx,
|
|
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
|
|
time.Now().Unix(), keyID)
|
|
return userID, true
|
|
}
|
|
|
|
// sessionUser resolves a session token to its session and user. The expiry
|
|
// slides forward with use, but at most once per sessionTouchEvery, so a page
|
|
// that polls does not write to the database on every request.
|
|
func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, userID int64, ok bool) {
|
|
now := time.Now()
|
|
var lastSeen int64
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT id, user_id, last_seen_at FROM sessions
|
|
WHERE token_hash = $1 AND expires_at > $2`,
|
|
hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
|
|
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
|
|
db.ExecContext(ctx,
|
|
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
|
|
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
|
|
}
|
|
return sessionID, userID, true
|
|
}
|
|
|
|
// serveAs loads the user and hands the request on with it in the context.
|
|
// sessionID is zero for API-key requests.
|
|
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
|
|
var u models.User
|
|
var createdUnix int64
|
|
// disabled_at IS NULL is part of the lookup rather than a check afterwards:
|
|
// a disabled account is one that cannot authenticate, by either credential,
|
|
// and the way to be sure of that is for there to be no path where the row
|
|
// is loaded and the flag is then forgotten.
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
|
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
|
return
|
|
}
|
|
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
|
|
|
|
// Every scoped query needs the caller's teams, so they are loaded once here
|
|
// rather than per handler. One extra round trip per request, against a
|
|
// table with one row per membership.
|
|
teams, err := callerMemberships(r.Context(), db, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), ctxTeams, teams)
|
|
ctx = context.WithValue(ctx, ctxUser, u)
|
|
if sessionID != 0 {
|
|
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func userFromContext(ctx context.Context) (models.User, bool) {
|
|
u, ok := ctx.Value(ctxUser).(models.User)
|
|
return u, ok
|
|
}
|
|
|
|
// membership is the caller's role in one team.
|
|
type membership struct {
|
|
teamID int64
|
|
role string
|
|
}
|
|
|
|
func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]membership, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
"SELECT team_id, role FROM team_members WHERE user_id = $1 ORDER BY team_id", userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []membership
|
|
for rows.Next() {
|
|
var m membership
|
|
if err := rows.Scan(&m.teamID, &m.role); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// callerTeamIDs lists the teams the caller belongs to, for the `team_id = ANY`
|
|
// filter every list query carries. An admin is NOT implicitly in every team:
|
|
// administration is about accounts, not about reading other people's incidents,
|
|
// and an admin who needs to see a team's queue can add themselves to it.
|
|
func callerTeamIDs(ctx context.Context) []int64 {
|
|
ms, _ := ctx.Value(ctxTeams).([]membership)
|
|
ids := make([]int64, 0, len(ms))
|
|
for _, m := range ms {
|
|
ids = append(ids, m.teamID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// callerRole reports the caller's role in one team, and whether they are in it
|
|
// at all.
|
|
func callerRole(ctx context.Context, teamID int64) (string, bool) {
|
|
ms, _ := ctx.Value(ctxTeams).([]membership)
|
|
for _, m := range ms {
|
|
if m.teamID == teamID {
|
|
return m.role, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// requireTeamMember answers the request and reports false unless the caller
|
|
// belongs to teamID.
|
|
//
|
|
// 404, not 403: whether a team exists is itself something only its members
|
|
// should learn, and the same reasoning applies to every incident and alert
|
|
// under it.
|
|
func requireTeamMember(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
|
if _, ok := callerRole(r.Context(), teamID); !ok {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// requireTeamOwner is requireTeamMember for the things only an owner may change:
|
|
// the schedule, the integrations and who is in the team. A system administrator
|
|
// passes without being a member, because somebody has to be able to repair a
|
|
// team whose owner has left.
|
|
func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
|
role, ok := callerRole(r.Context(), teamID)
|
|
if ok && role == models.RoleOwner {
|
|
return true
|
|
}
|
|
if caller, _ := userFromContext(r.Context()); caller.IsAdmin {
|
|
return true
|
|
}
|
|
if !ok {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return false
|
|
}
|
|
respond(w, http.StatusForbidden, errResp("team owner access required"))
|
|
return false
|
|
}
|
|
|
|
// sessionFromContext returns the id of the session a request was authenticated
|
|
// with, or false for an API-key request.
|
|
func sessionFromContext(ctx context.Context) (int64, bool) {
|
|
id, ok := ctx.Value(ctxSession).(int64)
|
|
return id, ok
|
|
}
|