Files
terdut-server/internal/api/middleware.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

396 lines
14 KiB
Go

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
}