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:
+19
-10
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -17,8 +18,7 @@ import (
|
||||
// sqlArgs accumulates query arguments and hands back the placeholder for each.
|
||||
//
|
||||
// Postgres numbers its placeholders, so a dynamically assembled WHERE clause has
|
||||
// to keep its $1, $2, … in step with the order of the values — which SQLite's
|
||||
// positional `?` did for free. Handing out the placeholder and storing the value
|
||||
// to keep its $1, $2, … in step with the order of the values. Handing out the placeholder and storing the value
|
||||
// in one call is what keeps them in step: a filter can be added, removed or
|
||||
// reordered without renumbering anything by hand.
|
||||
type sqlArgs struct{ vals []any }
|
||||
@@ -31,8 +31,7 @@ func (a *sqlArgs) add(v any) string {
|
||||
|
||||
// addList stores every value and returns their placeholders as "$1, $2, …",
|
||||
// ready to drop into an IN (…) clause. Returns an empty string for no values,
|
||||
// which no caller should reach: `IN ()` is a syntax error in Postgres as it was
|
||||
// in SQLite, so callers check for an empty set before building the query.
|
||||
// which no caller should reach: `IN ()` is a syntax error in Postgres, so callers check for an empty set before building the query.
|
||||
func (a *sqlArgs) addList(vs []any) string {
|
||||
parts := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
@@ -45,7 +44,7 @@ func (a *sqlArgs) addList(vs []any) string {
|
||||
func (a *sqlArgs) all() []any { return a.vals }
|
||||
|
||||
// nowEpoch is the SQL expression for "now, as unix seconds", matching how every
|
||||
// timestamp in this schema is stored. SQLite spelled it unixepoch().
|
||||
// timestamp in this schema is stored.
|
||||
//
|
||||
// FLOOR, not a bare cast: EXTRACT returns fractional seconds and casting to
|
||||
// bigint rounds half up, so a row written at .6 of a second would claim a
|
||||
@@ -56,11 +55,9 @@ const nowEpoch = "FLOOR(EXTRACT(EPOCH FROM now()))::bigint"
|
||||
// isUniqueViolation reports whether err is a broken unique constraint, which
|
||||
// callers turn into 409 Conflict rather than 500.
|
||||
//
|
||||
// Postgres reports it as SQLSTATE 23505 on a typed error; the SQLite driver this
|
||||
// replaced only put "UNIQUE constraint failed" in the message, which is why the
|
||||
// check used to be a substring match. Matching the code means a renamed
|
||||
// constraint or a translated message cannot quietly turn a conflict back into a
|
||||
// 500.
|
||||
// Postgres reports it as SQLSTATE 23505 on a typed error. Matching the code
|
||||
// means a renamed constraint or a translated message cannot quietly turn a
|
||||
// conflict back into a 500.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
|
||||
@@ -72,6 +69,18 @@ func respond(w http.ResponseWriter, status int, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// serverError answers 500 and logs why. The response stays opaque, so the log
|
||||
// line is the only record of what failed.
|
||||
func serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
// The route pattern, not the path: two routes carry a credential in it.
|
||||
route := r.URL.Path
|
||||
if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
|
||||
route = rc.RoutePattern()
|
||||
}
|
||||
log.Printf("%s %s: %v", r.Method, route, err)
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
}
|
||||
|
||||
// maxBodyBytes caps an ordinary JSON request body. 1 MiB is far more than any
|
||||
// endpoint below needs — it exists so an unauthenticated caller (signup,
|
||||
// login, bootstrap) can't make the server buffer an arbitrarily large body
|
||||
|
||||
Reference in New Issue
Block a user