From b8abd8859dc0f61a57a350e7bb7768ee82f582d9 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 14:01:32 -0600 Subject: [PATCH] feat(modules): RequireModule middleware to gate route groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RequireModule middleware that returns 404 with {"error":"module '' is not enabled"} when a module is disabled. Wrap route groups: - projects → RequireModule("projects") - audit → RequireModule("audit") - integrations/odoo → RequireModule("odoo") - jobs, job-definitions, runners → RequireModule("jobs") - /api/runner (runner-facing) → RequireModule("jobs") - dag → RequireModule("dag") (extracted into sub-route) Ref #98 --- internal/api/middleware.go | 16 ++++++++++++++++ internal/api/routes.go | 27 +++++++++++++++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 74362c0..cdaaeb4 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -183,6 +183,22 @@ func (s *Server) RequireRunnerAuth(next http.Handler) http.Handler { }) } +// RequireModule returns middleware that rejects requests with 404 when +// the named module is not enabled. +func (s *Server) RequireModule(id string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.modules.IsEnabled(id) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"module '` + id + `' is not enabled"}`)) + return + } + next.ServeHTTP(w, r) + }) + } +} + func extractBearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { diff --git a/internal/api/routes.go b/internal/api/routes.go index 3aa4181..49c6fc7 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -101,6 +101,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Projects (read: viewer, write: editor) r.Route("/projects", func(r chi.Router) { + r.Use(server.RequireModule("projects")) r.Get("/", server.HandleListProjects) r.Get("/{code}", server.HandleGetProject) r.Get("/{code}/items", server.HandleGetProjectItems) @@ -150,10 +151,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.ods", server.HandleExportBOMODS) - // DAG (read: viewer, write: editor) - r.Get("/dag", server.HandleGetDAG) - r.Get("/dag/forward-cone/{nodeKey}", server.HandleGetForwardCone) - r.Get("/dag/dirty", server.HandleGetDirtySubgraph) + // DAG (gated by dag module) + r.Route("/dag", func(r chi.Router) { + r.Use(server.RequireModule("dag")) + r.Get("/", server.HandleGetDAG) + r.Get("/forward-cone/{nodeKey}", server.HandleGetForwardCone) + r.Get("/dirty", server.HandleGetDirtySubgraph) + + r.Group(func(r chi.Router) { + r.Use(server.RequireWritable) + r.Use(server.RequireRole(auth.RoleEditor)) + r.Put("/", server.HandleSyncDAG) + r.Post("/mark-dirty/{nodeKey}", server.HandleMarkDirty) + }) + }) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) @@ -174,20 +185,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Post("/bom/merge", server.HandleMergeBOM) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) - r.Put("/dag", server.HandleSyncDAG) - r.Post("/dag/mark-dirty/{nodeKey}", server.HandleMarkDirty) }) }) }) // Audit (read-only, viewer role) r.Route("/audit", func(r chi.Router) { + r.Use(server.RequireModule("audit")) r.Get("/completeness", server.HandleAuditCompleteness) r.Get("/completeness/{partNumber}", server.HandleAuditItemDetail) }) // Integrations (read: viewer, write: editor) r.Route("/integrations/odoo", func(r chi.Router) { + r.Use(server.RequireModule("odoo")) r.Get("/config", server.HandleGetOdooConfig) r.Get("/sync-log", server.HandleGetOdooSyncLog) @@ -210,6 +221,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Jobs (read: viewer, write: editor) r.Route("/jobs", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Get("/", server.HandleListJobs) r.Get("/{jobID}", server.HandleGetJob) r.Get("/{jobID}/logs", server.HandleGetJobLogs) @@ -224,6 +236,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Job definitions (read: viewer, reload: admin) r.Route("/job-definitions", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Get("/", server.HandleListJobDefinitions) r.Get("/{name}", server.HandleGetJobDefinition) @@ -235,6 +248,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Runners (admin) r.Route("/runners", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Use(server.RequireRole(auth.RoleAdmin)) r.Get("/", server.HandleListRunners) r.Post("/", server.HandleRegisterRunner) @@ -251,6 +265,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Runner-facing API (runner token auth, not user auth) r.Route("/api/runner", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Use(server.RequireRunnerAuth) r.Post("/heartbeat", server.HandleRunnerHeartbeat) r.Post("/claim", server.HandleRunnerClaim)