Files
terdut-server/internal/api/router.go
T
Niklas Ye d827ceedff
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 14s
CI / test (pull_request) Successful in 2m30s
Add self-service sign-up and invite links
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
2026-09-21 09:06:48 +02:00

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
}