d827ceedff
First half of #7. Until now the only way to get an account was for somebody who already had one to create it, and the login page told people to "ask an admin" -- workable for one operator, impossible for a team. Two modes, chosen by an administrator in the settings table: invite_only, which is the default, and open. A third domain-restricted mode was considered and dropped, because with no email in this server there is nothing to verify an address against and it would only check the domain of a string somebody typed. The default is the closed door. An install that gets a public hostname before anybody has thought about sign-up should not be collecting accounts from the internet, and the failure mode of a typo in the setting is invite_only rather than open. An invite is a link, not an email. Adding SMTP to send one message would be a subsystem to run, secure and monitor; the person inviting sends the link however they already talk to the person they are inviting. A link carries the team and the role, because an account in no team sees an empty queue and can be paged by nobody -- that is not a state to invite somebody into. Links are single-use by default, expire after seven days, and can be revoked before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log. The uses counter is incremented inside the sign-up transaction and guarded by `uses < max_uses`, so two people redeeming the last use at once cannot both get in. GET /api/signup reports the mode and whether a link is usable, so the form can say "this link has expired" before somebody picks a password rather than after. It gives one answer for expired, revoked, used up and never existed: telling a stranger which it was tells them something about links they do not hold. Sign-up signs you in. The alternative is a form that says "now go and log in", which is the same credential typed twice. login and signup now share startSession rather than each minting a cookie. Rate-limited per address on its own limiter, not login's: a burst of sign-ups must not lock somebody out of logging in. The settings table grew a second shape for this. It held only durations; signup_mode is a word from a fixed list, so the admin endpoint now validates everything before writing anything -- a request that sets two settings and gets one wrong changes neither. Still to come in #7: the sign-up and invite-redemption pages, the first-run checklist, and the in-app integration instructions. The schema carries onboarding_dismissed_at for the checklist already. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
174 lines
7.9 KiB
Go
174 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"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 is passed through to the webhook,
|
|
// the only handler that has to decide where a new incident's page goes; a zero
|
|
// notify disables notifications. Dead man's switches are per team and read from
|
|
// the database, so nothing about them is wired in here.
|
|
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
|
// One limiter each, both process-wide for the life of the router: login
|
|
// counts failed passwords, sign-up counts account creation, and mixing the
|
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
|
loginLimit := newLoginLimiter()
|
|
signupLimiter := newLoginLimiter()
|
|
|
|
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/notify/ack/{token}", handleNotifyAck(db))
|
|
|
|
// Alert ingestion. The key in the path says both that the sender may post
|
|
// and which team the alerts belong to, which is why it needs no session.
|
|
//
|
|
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
|
|
// took no credential at all, was removed in v0.13.0 once the cluster's
|
|
// Alertmanager had moved onto a key; a sender still posting there gets the
|
|
// JSON 404 every unknown /api path gets.
|
|
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
|
|
|
// Signing up. Both are unauthenticated by necessity: the caller has no
|
|
// account yet. The info endpoint says whether the door is open and whether
|
|
// an invite link is good, so the form can say so before somebody picks a
|
|
// password.
|
|
r.Get("/api/signup", handleSignupInfo(db))
|
|
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
|
|
|
// 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, loginLimit, 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))
|
|
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
|
|
|
|
// What exists on this server, and how it behaves. /api/teams
|
|
// answers "what am I in"; this one answers "what is there".
|
|
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
|
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
|
r.Put("/api/admin/settings", handleSetSettings(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))
|
|
|
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
|
r.Get("/api/teams", handleListTeams(db))
|
|
r.Post("/api/teams", handleCreateTeam(db))
|
|
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
|
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
|
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
|
|
|
// Invite links into this team.
|
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
|
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
|
|
|
// A team's escalation ladder: who is paged when nobody answers.
|
|
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
|
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
|
|
|
// A team's own dead man's switches: which of its alerts are heartbeats,
|
|
// and how long a silence has to last before somebody is paged.
|
|
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db))
|
|
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(db))
|
|
|
|
// Integrations: where a team's alerts come in, and the key that says so.
|
|
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
|
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
|
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
|
|
|
// The rota is per team. /api/schedule/current is the exception: it
|
|
// answers across every team the caller is in, which is what somebody on
|
|
// two rotas wants to see.
|
|
r.Get("/api/schedule/current", handleCurrentSchedule(db))
|
|
r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db))
|
|
r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db))
|
|
r.Delete("/api/teams/{teamID}/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
|
|
}
|