774fdfcaa8
ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams
entry, service account) used to be two parallel, un-unified context
representations -- every authz predicate had to remember which one(s) it
needed, and every place that forgot either wrongly 403'd a service account
(terdut-server#23, terdut-operator#3), crashed on an unchecked zero-value
user id, or silently no-op'd. New internal/api/caller.go collapses both
into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount;
every existing predicate (userFromContext, callerTeamIDs, callerRole,
callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin,
requireTeamOwner, OperatorModeBlock) now reads through it, with identical
behavior for every untouched call site (alerts.go, incidents.go,
schedule.go, stats.go, etc.) -- confirmed by the full existing suite
passing unchanged.
Four real fixes land alongside the refactor, not just the restructuring:
1. callerMayManageServiceAccount gains the one load-bearing branch this
exists for: an instance-scoped service account may now manage (mint or
revoke a key on) any team-scoped account, not only a human admin, that
team's human owner, or the account itself. handleCreateServiceAccount
already let an instance-scoped caller *create* a team-scoped account for
any team; adopting or rotating one it didn't just create in the same
call -- terdut-operator's own documented crash-window recovery -- had no
equivalent permission and 403'd forever. Closes terdut-operator#3.
2. handleCreateInvite wrote a service-account caller's zero-value user id
straight into invites.created_by (nullable, but never passed as nil),
which foreign-key-violates against users(id) -- a 500, not success, for
any team-scoped service account minting an invite. Fixed the same way
handleCreateServiceAccount already handles the analogous case. Found
live while verifying this change, not filed separately since it's fixed
in the same place it was found.
3. handleMe and handleTestNotification 500'd for a service-account caller
(fetchUser/the ntfy_topic lookup against a zero-value user id that
matches no row); handleDismissOnboarding silently no-op'd (UPDATE ...
WHERE id = 0). All three now call Caller.AsHuman() and return an
explicit 403 ("this endpoint is for human accounts only").
4. Ratifies, rather than further narrows, two capabilities a team-scoped
service account already had by construction and this document's own
text once called "a gap acknowledged rather than closed": owner-equivalent
reach over membership/invites, and minting another service account for
its own team. terdut-operator's new TerdutTeam invite-minting feature is
about to depend on the first one, so this makes it documented, tested,
intentional behavior instead of an accident nobody was supposed to rely
on.
AdminOnly/requireSelfOrAdmin are unchanged in effect: still human-only,
forever, for every scope of service account -- confirmed by
TestAdminOnly_RefusesEveryServiceAccountScope. terdut-server#23's named
routes (POST /api/users, PUT /api/admin/settings) were never the right
thing to widen; its real fix is the terdut-operator invite feature,
recorded in SERVICE-ACCOUNTS.md's "What this unblocks" and closing that
issue once it ships.
SERVICE-ACCOUNTS.md amended in place (not a new file, its own established
convention) to describe the as-built Caller model, correct its own
aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged
as not matching shipped code, and record all of the above.
389 lines
14 KiB
Go
389 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 (
|
|
// 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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(), 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
|
|
}
|