1377d9005b
Until now every authenticated caller could create and delete users, set anybody's password and mint anybody's API keys -- auth.go said so in a comment. Defensible with one operator and a hand-made account; not once people sign themselves up (#7), and not in a multi-tenant install (#4), where the user list is no longer everybody who works here. users.is_admin is the flag. AdminOnly gates creating and deleting users and granting the flag itself. The endpoints that are self-service for your own account and administration for somebody else's -- password, ntfy topic, API keys -- go through requireSelfOrAdmin instead, because which rule applies depends on the {id} in the path rather than on the route. Minting your own API key stays self-service. A key carries exactly the rights of the user it belongs to, so issuing one is no more than signing in again; requiring an admin for it would mean a responder cannot set up the TUI without somebody else in the room. /api/users stays readable by everybody. The queue's assignment control and the on-call schedule both have to name people, and hiding the roster from the people on it buys nothing. THE MIGRATION MAKES EVERY EXISTING USER AN ADMINISTRATOR. They already hold these powers, so nobody's access changes on upgrade: it names what is already true and leaves demotion as a deliberate act. Promoting only user 1 would silently strip the others, and could leave an install whose only administrator is an account nobody has a password for. Two guards keep an install administrable: the last administrator can be neither deleted nor demoted, and nobody can delete or demote themselves -- the likelier accident, where the only admin clears their own flag while tidying up and locks the door behind them. No UI changes: there are no account-management screens yet. models.User carries is_admin (not omitempty, so a client can tell false from an old server), which is what #5's admin page will render from.
114 lines
4.7 KiB
Go
114 lines
4.7 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// NewRouter builds the HTTP surface. notify and deadman are passed through to
|
|
// the webhook, the only handler that has to decide where a new incident's page
|
|
// goes and which arriving alerts are heartbeats rather than problems. A zero
|
|
// notify disables notifications; a zero deadman disables dead man's switches.
|
|
func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
|
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
|
|
// Acknowledge button in a push notification. The last one is authorised by
|
|
// the scoped token in its path rather than an API key, and has to stay
|
|
// reachable from outside the cluster for the button to work.
|
|
r.Post("/api/bootstrap", handleBootstrap(db))
|
|
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db, notify, deadman))
|
|
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
|
|
|
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
|
// which AuthMiddleware accepts in place of an API key.
|
|
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
|
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
|
|
|
// All other /api routes require a valid API key.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(AuthMiddleware(db))
|
|
|
|
r.Get("/api/me", handleMe(db))
|
|
|
|
// Readable by anyone signed in: the queue's assignment control and the
|
|
// on-call schedule both need to name people.
|
|
r.Get("/api/users", handleListUsers(db))
|
|
|
|
// Your own account, or anybody's if you are an admin. The handlers call
|
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
|
// which rule applies depends on the {id} in the path.
|
|
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
|
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
|
|
|
// Administration: who exists, and who is an administrator. Until #3
|
|
// these were open to any authenticated caller, which meant every user
|
|
// could delete every other one.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(AdminOnly)
|
|
|
|
r.Post("/api/users", handleCreateUser(db))
|
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
|
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
|
})
|
|
|
|
// Alerts are read-only: they are Alertmanager's record, not a work
|
|
// queue. Everything a person does happens on the incident instead.
|
|
r.Get("/api/alerts", handleListAlerts(db))
|
|
r.Get("/api/alerts/{id}", handleGetAlert(db))
|
|
|
|
r.Get("/api/incidents", handleListIncidents(db))
|
|
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
|
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
|
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
|
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
|
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
|
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
|
r.Post("/api/incidents/{id}/assign", handleIncidentAssign(db))
|
|
r.Post("/api/incidents/{id}/snooze", handleIncidentSnooze(db))
|
|
r.Delete("/api/incidents/{id}/snooze", handleIncidentUnsnooze(db))
|
|
r.Post("/api/incidents/{id}/archive", handleIncidentArchive(db))
|
|
r.Delete("/api/incidents/{id}/archive", handleIncidentUnarchive(db))
|
|
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
|
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
|
|
|
r.Post("/api/schedule", handleCreateSchedule(db))
|
|
r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id}
|
|
r.Get("/api/schedule", handleListSchedule(db))
|
|
r.Delete("/api/schedule/{id}", handleDeleteSchedule(db))
|
|
|
|
r.Get("/api/stats/incidents", handleStatsIncidents(db))
|
|
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
|
r.Get("/api/stats/alerts/top", handleStatsTop(db))
|
|
r.Get("/api/stats/alerts/by-hour", handleStatsByHour(db))
|
|
r.Get("/api/stats/alerts/by-day", handleStatsByDay(db))
|
|
})
|
|
|
|
// Anything else under /api is a mistake in a client, and should say so in
|
|
// JSON rather than get the web UI's HTML.
|
|
r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
}))
|
|
|
|
// Everything outside /api is the web UI.
|
|
site, err := web.Handler()
|
|
if err != nil {
|
|
panic(err) // the site is embedded at build time; this cannot fail at runtime
|
|
}
|
|
r.Handle("/*", site)
|
|
|
|
return r
|
|
}
|