Phase 4 — P4-03/04: RBAC + user management #14

Merged
steve merged 30 commits from p4-03-04-rbac-user-mgmt into main 2026-05-05 11:01:44 +01:00
Showing only changes of commit c1e974aad9 - Show all commits
+133 -216
View File
@@ -85,11 +85,6 @@ func New(deps Deps) *Server {
r.Use(middleware.Recoverer)
r.Use(requestLogger)
// Health endpoint — unauthenticated, no audit, deliberately cheap.
r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusNoContent)
})
s := &Server{
deps: deps,
drainLocks: make(map[string]*sync.Mutex),
@@ -113,132 +108,17 @@ func New(deps Deps) *Server {
// routes wires the API tree. Subtrees live in this file by area so a
// reader can scan one place and see the surface.
func (s *Server) routes(r chi.Router) {
r.Route("/api", func(r chi.Router) {
r.Post("/auth/login", s.handleLogin)
r.Post("/auth/logout", s.handleLogout)
r.Post("/bootstrap", s.handleBootstrap)
// Agent enrollment (open endpoint — token is the credential).
r.Post("/agents/enroll", s.handleAgentEnroll)
// Announce-and-approve enrolment (open endpoint — fingerprint
// comparison in the UI is the gate). Per-IP rate-limited and
// globally capped (P2-18).
r.Post("/agents/announce", s.handleAnnounce)
// Pending host management — admin-only (gated inside the handler).
r.Post("/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
r.Post("/pending-hosts/{id}/reject", s.handleRejectPendingHost)
// Operator → server (authenticated). Spec.md §6.1's
// /hosts/{id}/enrollment-token (regenerate) lands when the
// host page can call it; for now just the create endpoint.
r.Post("/enrollment-tokens", s.handleCreateEnrollmentToken)
// Fleet read endpoints — back the dashboard.
r.Get("/hosts", s.handleListHosts)
r.Get("/fleet/summary", s.handleFleetSummary)
// Run-now: dispatch a job to a host's agent.
r.Post("/hosts/{id}/jobs", s.handleRunNow)
// Snapshot projection (refreshed by the agent after each backup).
r.Get("/hosts/{id}/snapshots", s.handleListHostSnapshots)
// Repo credentials — operator can edit after enrollment. The
// initial set is supplied at token-mint time (see enrollment.go).
// GET returns a redacted view (URL, username, has_password).
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
// Admin credentials — the prune-capable slot (separate from the
// everyday repo creds). Optional: hosts that don't prune against
// a rest-server repo with a separate admin user never need this.
r.Get("/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
r.Put("/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
r.Delete("/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
// Per-host schedule CRUD. Mutations bump host_schedule_version
// and async-push to a connected agent (see schedule_push.go).
r.Get("/hosts/{id}/schedules", s.handleListSchedules)
r.Post("/hosts/{id}/schedules", s.handleCreateSchedule)
r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
// Source-group CRUD. A group is "what gets backed up" — paths,
// excludes, retention, retry. Group name doubles as the
// snapshot tag (restic --tag <name>).
r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups)
r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup)
r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
// Repo maintenance cadences (forget / prune / check). Driven
// by the server-side ticker (P2R-06), not the agent's cron.
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
// Host-wide bandwidth caps (host.bandwidth_up_kbps /
// bandwidth_down_kbps). Apply to every restic invocation.
r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
// Per-source-group Run-now (JSON variant). HTMX action is
// mounted at the equivalent path outside /api below — both
// resolve to the same handler, which sniffs HX-Request.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
// Repo-level run-now: prune (needs admin creds), check, unlock.
// HTMX forms are also mounted outside /api below.
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
// Cancel a running job. Operator-driven, sends command.cancel
// to the agent which kills the restic subprocess; the agent's
// resulting job.finished (status=canceled) is what flips the
// job row.
r.Post("/jobs/{id}/cancel", s.handleCancelJob)
// Snapshot diff (P3-09). Dispatches a JobDiff against two
// snapshots; output streams to the standard live job page.
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// Alert list (JSON variant). Same filter shape as the UI page.
r.Get("/alerts", s.handleAPIAlerts)
// Audit log (JSON variant).
r.Get("/audit", s.handleAPIAudit)
// Notification channel test-fire. Dispatches a synthetic payload
// through a single named channel; returns JSON result.
r.Post("/notifications/{id}/test", s.handleAPINotificationTest)
// Public, unauthenticated.
r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusNoContent)
})
// HTMX form variant of diff (mounted outside /api so HTMX forms
// can post against it without the api/ prefix).
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// Per-source-group Run-now (HTMX form action). Available even
// when the server is started without UI templates so REST callers
// against the non-/api path also work.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
// Repo-level run-now (HTMX form actions). Same handlers as the /api
// variants — wantsHTML sniff distinguishes JSON vs HTMX response.
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
// Retired routes — see ui_handlers.go for the messages. Mounted
// outside the UI gate so cached browser tabs get a clear 410
// even if the server runs without templates.
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
// Pending-host WebSocket (announce-and-approve, P2-18b). Mounted
// before /ws/agent so the more-specific route matches first.
r.Get("/ws/agent/pending", s.handlePendingWS)
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
r.Post("/api/auth/login", s.handleLogin)
r.Post("/api/auth/logout", s.handleLogout)
r.Post("/api/bootstrap", s.handleBootstrap)
r.Post("/api/agents/enroll", s.handleAgentEnroll)
r.Post("/api/agents/announce", s.handleAnnounce)
r.Get("/agent/binary", s.handleAgentBinary)
r.Get("/install/*", s.handleInstallAsset)
if s.deps.Hub != nil {
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
Hub: s.deps.Hub,
@@ -250,101 +130,138 @@ func (s *Server) routes(r chi.Router) {
OnScheduleFire: s.dispatchScheduledJob,
}))
}
// Agent binaries + install scripts. Open endpoints — content is
// unprivileged on its own, gating happens via the enrollment
// token. See agent_assets.go.
r.Get("/agent/binary", s.handleAgentBinary)
r.Get("/install/*", s.handleInstallAsset)
// Static assets (Tailwind CSS bundle, future favicon).
r.Get("/ws/agent/pending", s.handlePendingWS)
r.Mount("/static/", staticHandler())
// HTML UI. The renderer is required — fail loud if the binary
// was built without templates (impossible in practice given
// embed, but guards bad test wiring).
if s.deps.UI != nil {
r.Get("/", s.handleUIDashboard)
r.Get("/login", s.handleUILoginGet)
r.Post("/login", s.handleUILoginPost)
r.Post("/logout", s.handleUILogoutPost)
// Per-host Run-now and manual Init-repo are mounted at the
// outer router (so they reply 410 even without UI). Per-
// source-group Run-now lives there too — same reason.
// Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
// Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once
// the agent enrols.
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail)
// Sources tab + source-group CRUD forms.
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
// Repo tab — connection / bandwidth / maintenance. Three
// independent forms so saving one doesn't touch the others.
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
// Admin credentials form (separate slot for prune-capable user).
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
// Schedules tab + create/edit/delete forms.
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
// Live job log.
r.Get("/jobs/{id}", s.handleUIJobDetail)
// Restore wizard (P3-01/P3-02). Two GET variants land on the
// same handler; the second deep-links a chosen snapshot.
r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
// Alerts list + operator actions.
r.Get("/alerts", s.handleUIAlerts)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
// Audit log (read-only).
r.Get("/audit", s.handleUIAudit)
r.Get("/audit.csv", s.handleUIAuditCSV)
// Settings shell + Notifications sub-tab CRUD.
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
}
// Browser job-log stream (separate from /ws/agent so the auth
// layer is session-cookie not bearer). Mounted regardless of
// whether the UI is up — JSON callers may also subscribe.
if s.deps.JobHub != nil {
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
}
// Viewer band — anyone authenticated can read.
r.Group(func(r chi.Router) {
r.Use(s.requireRole(store.RoleViewer))
// Job log download (txt + ndjson). Source of truth is the
// persisted job_logs table; safe to call any time, no pause
// needed against the live stream.
r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload)
// Read APIs.
r.Get("/api/hosts", s.handleListHosts)
r.Get("/api/fleet/summary", s.handleFleetSummary)
r.Get("/api/hosts/{id}/snapshots", s.handleListHostSnapshots)
r.Get("/api/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
r.Get("/api/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
r.Get("/api/hosts/{id}/schedules", s.handleListSchedules)
r.Get("/api/hosts/{id}/source-groups", s.handleListSourceGroups)
r.Get("/api/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Get("/api/alerts", s.handleAPIAlerts)
r.Get("/api/audit", s.handleAPIAudit)
// Job log stream + download (read-only; any authenticated user).
if s.deps.JobHub != nil {
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
}
r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload)
if s.deps.UI != nil {
r.Get("/", s.handleUIDashboard)
r.Get("/hosts/{id}", s.handleUIHostDetail)
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
r.Get("/jobs/{id}", s.handleUIJobDetail)
r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
r.Get("/alerts", s.handleUIAlerts)
r.Get("/audit", s.handleUIAudit)
r.Get("/audit.csv", s.handleUIAuditCSV)
}
})
// Operator band — mutating endpoints up to backup ops.
r.Group(func(r chi.Router) {
r.Use(s.requireRole(store.RoleOperator))
// Pending hosts approval.
r.Post("/api/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
r.Post("/api/pending-hosts/{id}/reject", s.handleRejectPendingHost)
r.Post("/api/enrollment-tokens", s.handleCreateEnrollmentToken)
// Run-now, restore, repo ops (JSON).
r.Post("/api/hosts/{id}/jobs", s.handleRunNow)
r.Put("/api/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
r.Put("/api/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
r.Delete("/api/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
r.Post("/api/hosts/{id}/schedules", s.handleCreateSchedule)
r.Put("/api/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
r.Delete("/api/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
r.Post("/api/hosts/{id}/source-groups", s.handleCreateSourceGroup)
r.Put("/api/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
r.Delete("/api/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
r.Put("/api/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
r.Put("/api/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
r.Post("/api/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
r.Post("/api/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// HTMX form variants outside /api.
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
if s.deps.UI != nil {
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
}
})
// Admin band — channels, server-shape config.
r.Group(func(r chi.Router) {
r.Use(s.requireRole(store.RoleAdmin))
r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest)
if s.deps.UI != nil {
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
}
})
}
// Start begins listening. Blocks until ListenAndServe returns