926aa2d3ec
Part of the same security-hardening pass as the last five commits, and
the last item in its backlog. User API keys had no expiry at all --
unlike service-account keys, visibly distinct only by their "tdsa_"
prefix -- and, it turns out while implementing this, no way to list
them either: only create (returns the raw key once) and delete-by-id
existed, so a key's owner had no way to even discover what keys they
had short of remembering IDs from creation time.
handleCreateAPIKey takes an optional expires_in_days (0, the default,
keeps today's behavior: never expires, so no existing integration is
affected). apiKeyUser's lookup now carries `expires_at IS NULL OR
expires_at > now` as part of the query itself, the same way serveAs's
disabled_at check already works -- an expired key simply fails to
resolve, like a wrong one, rather than resolving and being caught
after the fact. New GET /api/users/{id}/api-keys (requireSelfOrAdmin,
same as create/delete) lists id/name/created_at/last_used_at/expires_at,
never the raw key.
Scoped down from the original plan on request: no web UI change, since
there turned out to be no existing API-keys UI at all to extend --
building one from scratch would have been a real feature addition, not
a hardening tweak.
Mirrored the additive expires_at field in terdut-tui's APIKey struct
(separate commit, separate repo) per this workspace's version-coupling
rule; the TUI does not create or list expiring keys itself yet.
New tests (api_keys_test.go): default never-expires, expires_in_days
sets expires_at, out-of-range values rejected, an expired key fails
auth after a fresh one worked, the listing never includes the raw key.
Also added the new GET route to authz_scope_test.go's self-or-admin
table from the previous commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
417 lines
16 KiB
Go
417 lines
16 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 (
|
|
// ctxCaller holds the one Caller (see caller.go) every authorization
|
|
// predicate in this package reads from — a human and a service account
|
|
// used to be two parallel, un-unified context keys (ctxUser/ctxTeams vs.
|
|
// ctxServiceAccount); this is why that was a mistake, not a smaller
|
|
// version of the same idea.
|
|
ctxCaller contextKey = "caller"
|
|
ctxSession contextKey = "session"
|
|
)
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// securityHeaders sets headers that cost nothing to send on every response,
|
|
// API or static site alike. nosniff is unconditional; HSTS only fires once
|
|
// cookieSecure's signal says the browser is actually looking at this server
|
|
// over HTTPS — TLS terminates at the gateway, which (as of this writing) sets
|
|
// neither header itself.
|
|
//
|
|
// max-age is 180 days rather than the usual year-plus: short enough that if
|
|
// HTTPS here ever broke for real, the header would age out of a browser's
|
|
// cache well within a release cycle instead of locking anyone out of a
|
|
// working server. Raise it once this has run clean for a while.
|
|
func securityHeaders(publicURL string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if cookieSecure(publicURL, r) {
|
|
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// expires_at IS NULL OR > now is part of the lookup itself, the same way
|
|
// serveAs's disabled_at check is: an expired key is one that cannot
|
|
// authenticate, by construction, rather than one that happens to still
|
|
// resolve and has to be caught afterwards.
|
|
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 AND (expires_at IS NULL OR expires_at > $2)`,
|
|
hashToken(token), time.Now().Unix(),
|
|
).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(), ctxCaller, Caller{user: &u, memberships: teams})
|
|
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[:])
|
|
}
|
|
|
|
// userFromContext is a thin compatibility wrapper over Caller.AsHuman(), so
|
|
// every call site written before the Caller abstraction (alerts.go,
|
|
// incidents.go, schedule.go, stats.go, and more) needs no change and keeps
|
|
// its exact existing behavior.
|
|
func userFromContext(ctx context.Context) (models.User, bool) {
|
|
c, _ := callerFromContext(ctx)
|
|
return c.AsHuman()
|
|
}
|
|
|
|
// 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) {
|
|
var memberships []membership
|
|
if sa.scope == models.ServiceAccountScopeTeam {
|
|
memberships = []membership{{teamID: sa.teamID, role: models.RoleOwner}}
|
|
}
|
|
ctx := context.WithValue(r.Context(), ctxCaller, Caller{sa: &sa, memberships: memberships})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
}
|
|
|
|
// isInstanceServiceAccount is a thin compatibility wrapper over
|
|
// Caller.IsInstanceServiceAccount(), for call sites outside this package's
|
|
// core predicates (handleCreateTeam, handleCreateServiceAccount) that
|
|
// needed this exact, narrow check before the Caller abstraction existed.
|
|
func isInstanceServiceAccount(ctx context.Context) bool {
|
|
c, _ := callerFromContext(ctx)
|
|
return c.IsInstanceServiceAccount()
|
|
}
|
|
|
|
// 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) {
|
|
caller, _ := callerFromContext(r.Context())
|
|
if _, ok := caller.ServiceAccountID(); 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 {
|
|
c, _ := callerFromContext(ctx)
|
|
return c.TeamIDs()
|
|
}
|
|
|
|
// 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) {
|
|
c, _ := callerFromContext(ctx)
|
|
return c.Role(teamID)
|
|
}
|
|
|
|
// 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
|
|
}
|