Let an operator authenticate with a seeded key, and reset the schema
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
+58
-22
@@ -5,10 +5,14 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
@@ -106,7 +110,7 @@ func securityHeaders(publicURL string) func(http.Handler) http.Handler {
|
||||
//
|
||||
// 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.
|
||||
// is in docs/api.md.
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := userFromContext(r.Context())
|
||||
@@ -141,19 +145,23 @@ func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64)
|
||||
// resolve and has to be caught afterwards.
|
||||
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||
var keyID, userID int64
|
||||
var lastUsed sql.NullInt64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, user_id FROM api_keys
|
||||
`SELECT id, user_id, last_used_at FROM api_keys
|
||||
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > $2)`,
|
||||
hashToken(token), time.Now().Unix(),
|
||||
).Scan(&keyID, &userID)
|
||||
).Scan(&keyID, &userID, &lastUsed)
|
||||
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)
|
||||
// best-effort; don't fail the request if this update fails. Throttled like
|
||||
// the session expiry, so a polling client does not write a row per request.
|
||||
if now := time.Now(); !lastUsed.Valid || now.Sub(time.Unix(lastUsed.Int64, 0)) > keyTouchEvery {
|
||||
db.ExecContext(ctx,
|
||||
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
|
||||
now.Unix(), keyID)
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
@@ -206,7 +214,7 @@ func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.
|
||||
// table with one row per membership.
|
||||
teams, err := callerMemberships(r.Context(), db, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,10 +230,8 @@ func hashToken(token string) string {
|
||||
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.
|
||||
// userFromContext returns the human behind the request, or false for a service
|
||||
// account: Caller.AsHuman() on the request's Caller.
|
||||
func userFromContext(ctx context.Context) (models.User, bool) {
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.AsHuman()
|
||||
@@ -245,13 +251,13 @@ type serviceAccountPrincipal struct {
|
||||
func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAccountPrincipal, bool) {
|
||||
var sa serviceAccountPrincipal
|
||||
var keyID int64
|
||||
var teamID sql.NullInt64
|
||||
var teamID, lastUsed sql.NullInt64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT k.id, a.id, a.name, a.scope, a.team_id
|
||||
SELECT k.id, a.id, a.name, a.scope, a.team_id, k.last_used_at
|
||||
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)
|
||||
).Scan(&keyID, &sa.id, &sa.name, &sa.scope, &teamID, &lastUsed)
|
||||
if err != nil {
|
||||
return serviceAccountPrincipal{}, false
|
||||
}
|
||||
@@ -260,9 +266,11 @@ func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAc
|
||||
}
|
||||
|
||||
// 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)
|
||||
if now := time.Now(); !lastUsed.Valid || now.Sub(time.Unix(lastUsed.Int64, 0)) > keyTouchEvery {
|
||||
db.ExecContext(ctx,
|
||||
"UPDATE service_account_keys SET last_used_at = $1 WHERE id = $2",
|
||||
now.Unix(), keyID)
|
||||
}
|
||||
return sa, true
|
||||
}
|
||||
|
||||
@@ -286,10 +294,8 @@ func serveAsServiceAccount(w http.ResponseWriter, r *http.Request, next http.Han
|
||||
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.
|
||||
// isInstanceServiceAccount is Caller.IsInstanceServiceAccount() on the
|
||||
// request's Caller.
|
||||
func isInstanceServiceAccount(ctx context.Context) bool {
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.IsInstanceServiceAccount()
|
||||
@@ -397,6 +403,13 @@ func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool
|
||||
if ok && role == models.RoleOwner {
|
||||
return true
|
||||
}
|
||||
// The operator's instance-scoped account manages every team's
|
||||
// configuration, which is what lets it use one credential instead of
|
||||
// minting one per team. This is owner reach only: it does not make the
|
||||
// account a member, so it still reads no team's incidents.
|
||||
if c, _ := callerFromContext(r.Context()); c.IsInstanceServiceAccount() {
|
||||
return true
|
||||
}
|
||||
if caller, _ := userFromContext(r.Context()); caller.IsAdmin {
|
||||
return true
|
||||
}
|
||||
@@ -414,3 +427,26 @@ func sessionFromContext(ctx context.Context) (int64, bool) {
|
||||
id, ok := ctx.Value(ctxSession).(int64)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// requestLogger logs one line per request with the matched route pattern in
|
||||
// place of the URL path. Two routes carry a credential in the path (the
|
||||
// integration key and the ack token), and chi's stock logger would write it to
|
||||
// the log verbatim.
|
||||
func requestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(ww, r)
|
||||
route := "unmatched"
|
||||
if rc := chi.RouteContext(r.Context()); rc != nil {
|
||||
if p := rc.RoutePattern(); p != "" {
|
||||
route = p
|
||||
}
|
||||
}
|
||||
status := ww.Status()
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
log.Printf("%s %s %d %dB %s", r.Method, route, status, ww.BytesWritten(), time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user