a4fbd60441
The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
272 lines
8.7 KiB
Go
272 lines
8.7 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
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", 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
|
|
}
|