package api import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "net/http" "strings" "time" "git.ryuvia.com/niklas/terdut-server/internal/config" "git.ryuvia.com/niklas/terdut-server/internal/models" ) type contextKey string const ( ctxUser contextKey = "user" ctxSession contextKey = "session" ctxTeams contextKey = "teams" ctxServiceAccount contextKey = "service_account" ) // 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 } if userID, ok := apiKeyUser(r.Context(), db, token); ok { serveAs(w, r, next, db, userID, 0) return } // Tried second, not first: a user API key is the common case, // and a service-account key is visibly prefixed (tdsa_) so this // second lookup is rarely reached on a request that was going // to fail anyway. if sa, ok := serviceAccountFor(r.Context(), db, token); ok { serveAsServiceAccount(w, r, next, sa) return } respond(w, http.StatusUnauthorized, errResp("unauthorized")) 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 { // LEAST keeps a capped session (a single sign-on login) from sliding // past its ceiling; with no ceiling COALESCE makes it the plain slide. db.ExecContext(ctx, ` UPDATE sessions SET last_seen_at = $1, expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint)) 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 } // serviceAccountPrincipal is a service account as resolved from its key: // enough to authorize requests, never the key itself. type serviceAccountPrincipal struct { id int64 name string scope string teamID int64 // meaningless (zero) for instance scope } // serviceAccountFor resolves a service-account key to its account and stamps // its last use, the same shape apiKeyUser has for a user's own key. func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAccountPrincipal, bool) { var sa serviceAccountPrincipal var keyID int64 var teamID sql.NullInt64 err := db.QueryRowContext(ctx, ` SELECT k.id, a.id, a.name, a.scope, a.team_id FROM service_account_keys k JOIN service_accounts a ON a.id = k.service_account_id WHERE k.key_hash = $1`, hashToken(token), ).Scan(&keyID, &sa.id, &sa.name, &sa.scope, &teamID) if err != nil { return serviceAccountPrincipal{}, false } if teamID.Valid { sa.teamID = teamID.Int64 } // best-effort; don't fail the request if this update fails db.ExecContext(ctx, "UPDATE service_account_keys SET last_used_at = $1 WHERE id = $2", time.Now().Unix(), keyID) return sa, true } // serveAsServiceAccount hands the request on with a service account's // identity in context. A team-scoped account gets a single synthetic // membership — owner of its own team, nothing else — which is what makes it // satisfy requireTeamMember/requireTeamOwner exactly as a real owner would, // without teaching either function about a second kind of caller. An // instance-scoped account gets no memberships at all: it acts on teams by id, // not by belonging to one. // // No CSRF check, for the same reason an API key needs none: a service-account // key is only ever set by the client that holds it, never attached by a // browser to a request another site makes. func serveAsServiceAccount(w http.ResponseWriter, r *http.Request, next http.Handler, sa serviceAccountPrincipal) { ctx := r.Context() if sa.scope == models.ServiceAccountScopeTeam { ctx = context.WithValue(ctx, ctxTeams, []membership{{teamID: sa.teamID, role: models.RoleOwner}}) } ctx = context.WithValue(ctx, ctxServiceAccount, sa) next.ServeHTTP(w, r.WithContext(ctx)) } func serviceAccountFromContext(ctx context.Context) (serviceAccountPrincipal, bool) { sa, ok := ctx.Value(ctxServiceAccount).(serviceAccountPrincipal) return sa, ok } // isInstanceServiceAccount reports whether the caller is an instance-scoped // service account — the one identity allowed to create a team and mint a // team-scoped account against any of them, the two things system // administration can already do that this extends to automation. func isInstanceServiceAccount(ctx context.Context) bool { sa, ok := serviceAccountFromContext(ctx) return ok && sa.scope == models.ServiceAccountScopeInstance } // operatorReason marks a write that operator mode refused as such, distinct // from every other 403 this server returns, so a client — the web UI or // terdut-tui — can tell "you may not" from "this is managed elsewhere" and // show the right message instead of a bare "forbidden". const operatorReason = "operator_managed" // OperatorModeBlock refuses a human write (session or a user's own API key) // on a route it wraps, while letting a service account through. That is the // whole point of operator mode: automation holding a service-account key // (terdut-operator, most likely) keeps reconciling these resources, and a // person in the web UI or terdut-tui gets a clear "edit this through your // GitOps source instead" rather than a write that the next resync would only // undo. // // Checked after AuthMiddleware, the same way AdminOnly is: by the time a // request reaches here the caller is already known to be a service account // or not. A router that never enables operator mode pays nothing for this — // it hands back next unchanged rather than wrapping it in a check that would // always pass. func OperatorModeBlock(cfg config.Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { if !cfg.OperatorMode { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, ok := serviceAccountFromContext(r.Context()); ok { next.ServeHTTP(w, r) return } respond(w, http.StatusForbidden, map[string]string{ "error": "this server is in operator mode; edit this through your GitOps source instead of the web UI or API", "reason": operatorReason, }) }) } } // 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 }