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)