From 8167d9c21648cb8426a894ca23cd116e5d74f9c7 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 15:15:48 -0600 Subject: [PATCH] feat(api): admin settings API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four admin-only endpoints under /api/admin/settings: - GET / — full config (secrets redacted) - GET /{module} — single module config - PUT /{module} — toggle modules + persist config overrides - POST /{module}/test — test external connectivity (database, storage) PUT publishes a settings.changed SSE event. Config overrides are persisted for future hot-reload support; changes to database/storage/ server/schemas namespaces return restart_required: true. Wires SettingsRepository into Server struct. Closes #99 --- internal/api/handlers.go | 3 + internal/api/routes.go | 9 + internal/api/settings_handlers.go | 316 ++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 internal/api/settings_handlers.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b5afdb1..8302d76 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -51,6 +51,7 @@ type Server struct { jobDefsDir string modules *modules.Registry cfg *config.Config + settings *db.SettingsRepository } // NewServer creates a new API server. @@ -77,6 +78,7 @@ func NewServer( itemFiles := db.NewItemFileRepository(database) dag := db.NewDAGRepository(database) jobs := db.NewJobRepository(database) + settings := db.NewSettingsRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -103,6 +105,7 @@ func NewServer( jobDefsDir: jobDefsDir, modules: registry, cfg: cfg, + settings: settings, } } diff --git a/internal/api/routes.go b/internal/api/routes.go index 9829c6c..d099b0b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -262,6 +262,15 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/generate-part-number", server.HandleGeneratePartNumber) }) + + // Admin settings (admin only) + r.Route("/admin/settings", func(r chi.Router) { + r.Use(server.RequireRole(auth.RoleAdmin)) + r.Get("/", server.HandleGetAllSettings) + r.Get("/{module}", server.HandleGetModuleSettings) + r.Put("/{module}", server.HandleUpdateModuleSettings) + r.Post("/{module}/test", server.HandleTestModuleConnectivity) + }) }) // Runner-facing API (runner token auth, not user auth) diff --git a/internal/api/settings_handlers.go b/internal/api/settings_handlers.go new file mode 100644 index 0000000..f695f41 --- /dev/null +++ b/internal/api/settings_handlers.go @@ -0,0 +1,316 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" +) + +// HandleGetAllSettings returns the full config grouped by module with secrets redacted. +func (s *Server) HandleGetAllSettings(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "core": s.buildCoreSettings(), + "schemas": s.buildSchemasSettings(), + "storage": s.buildStorageSettings(r.Context()), + "database": s.buildDatabaseSettings(r.Context()), + "auth": s.buildAuthSettings(), + "projects": map[string]any{"enabled": s.modules.IsEnabled("projects")}, + "audit": map[string]any{"enabled": s.modules.IsEnabled("audit")}, + "odoo": s.buildOdooSettings(), + "freecad": s.buildFreecadSettings(), + "jobs": s.buildJobsSettings(), + "dag": map[string]any{"enabled": s.modules.IsEnabled("dag")}, + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetModuleSettings returns settings for a single module. +func (s *Server) HandleGetModuleSettings(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + var settings any + switch module { + case "core": + settings = s.buildCoreSettings() + case "schemas": + settings = s.buildSchemasSettings() + case "storage": + settings = s.buildStorageSettings(r.Context()) + case "database": + settings = s.buildDatabaseSettings(r.Context()) + case "auth": + settings = s.buildAuthSettings() + case "projects": + settings = map[string]any{"enabled": s.modules.IsEnabled("projects")} + case "audit": + settings = map[string]any{"enabled": s.modules.IsEnabled("audit")} + case "odoo": + settings = s.buildOdooSettings() + case "freecad": + settings = s.buildFreecadSettings() + case "jobs": + settings = s.buildJobsSettings() + case "dag": + settings = map[string]any{"enabled": s.modules.IsEnabled("dag")} + default: + writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module) + return + } + + writeJSON(w, http.StatusOK, settings) +} + +// HandleUpdateModuleSettings handles module toggle and config overrides. +func (s *Server) HandleUpdateModuleSettings(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + // Validate module exists + if s.modules.Get(module) == nil { + writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module) + return + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + user := auth.UserFromContext(r.Context()) + username := "system" + if user != nil { + username = user.Username + } + + var updated []string + restartRequired := false + + // Handle module toggle + if enabledVal, ok := body["enabled"]; ok { + enabled, ok := enabledVal.(bool) + if !ok { + writeError(w, http.StatusBadRequest, "invalid_value", "'enabled' must be a boolean") + return + } + + if err := s.modules.SetEnabled(module, enabled); err != nil { + writeError(w, http.StatusBadRequest, "toggle_failed", err.Error()) + return + } + + if err := s.settings.SetModuleState(r.Context(), module, enabled, username); err != nil { + s.logger.Error().Err(err).Str("module", module).Msg("failed to persist module state") + writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save module state") + return + } + + updated = append(updated, module+".enabled") + } + + // Handle config overrides (future use — persisted but not hot-reloaded) + for key, value := range body { + if key == "enabled" { + continue + } + + fullKey := module + "." + key + if err := s.settings.SetOverride(r.Context(), fullKey, value, username); err != nil { + s.logger.Error().Err(err).Str("key", fullKey).Msg("failed to persist setting override") + writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save setting: "+key) + return + } + updated = append(updated, fullKey) + + // These namespaces require a restart to take effect + if strings.HasPrefix(fullKey, "database.") || + strings.HasPrefix(fullKey, "storage.") || + strings.HasPrefix(fullKey, "server.") || + strings.HasPrefix(fullKey, "schemas.") { + restartRequired = true + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "updated": updated, + "restart_required": restartRequired, + }) + + // Publish SSE event + s.broker.Publish("settings.changed", mustMarshal(map[string]any{ + "module": module, + "changed_keys": updated, + "updated_by": username, + })) +} + +// HandleTestModuleConnectivity tests external connectivity for a module. +func (s *Server) HandleTestModuleConnectivity(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + start := time.Now() + var success bool + var message string + + switch module { + case "database": + if err := s.db.Pool().Ping(r.Context()); err != nil { + success = false + message = "Database ping failed: " + err.Error() + } else { + success = true + message = "Database connection OK" + } + case "storage": + if s.storage == nil { + success = false + message = "Storage not configured" + } else if err := s.storage.Ping(r.Context()); err != nil { + success = false + message = "Storage ping failed: " + err.Error() + } else { + success = true + message = "Storage connection OK" + } + case "auth", "odoo": + success = false + message = "Connectivity test not implemented for " + module + default: + writeError(w, http.StatusBadRequest, "not_testable", "No connectivity test available for module: "+module) + return + } + + latency := time.Since(start).Milliseconds() + + writeJSON(w, http.StatusOK, map[string]any{ + "success": success, + "message": message, + "latency_ms": latency, + }) +} + +// --- build helpers (read config, redact secrets) --- + +func redact(s string) string { + if s == "" { + return "" + } + return "****" +} + +func (s *Server) buildCoreSettings() map[string]any { + return map[string]any{ + "enabled": true, + "host": s.cfg.Server.Host, + "port": s.cfg.Server.Port, + "base_url": s.cfg.Server.BaseURL, + "readonly": s.cfg.Server.ReadOnly, + } +} + +func (s *Server) buildSchemasSettings() map[string]any { + return map[string]any{ + "enabled": true, + "directory": s.cfg.Schemas.Directory, + "default": s.cfg.Schemas.Default, + "count": len(s.schemas), + } +} + +func (s *Server) buildStorageSettings(ctx context.Context) map[string]any { + result := map[string]any{ + "enabled": true, + "endpoint": s.cfg.Storage.Endpoint, + "bucket": s.cfg.Storage.Bucket, + "use_ssl": s.cfg.Storage.UseSSL, + "region": s.cfg.Storage.Region, + } + if s.storage != nil { + if err := s.storage.Ping(ctx); err != nil { + result["status"] = "unavailable" + } else { + result["status"] = "ok" + } + } else { + result["status"] = "not_configured" + } + return result +} + +func (s *Server) buildDatabaseSettings(ctx context.Context) map[string]any { + result := map[string]any{ + "enabled": true, + "host": s.cfg.Database.Host, + "port": s.cfg.Database.Port, + "name": s.cfg.Database.Name, + "user": s.cfg.Database.User, + "password": redact(s.cfg.Database.Password), + "sslmode": s.cfg.Database.SSLMode, + "max_connections": s.cfg.Database.MaxConnections, + } + if err := s.db.Pool().Ping(ctx); err != nil { + result["status"] = "unavailable" + } else { + result["status"] = "ok" + } + return result +} + +func (s *Server) buildAuthSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("auth"), + "session_secret": redact(s.cfg.Auth.SessionSecret), + "local": map[string]any{ + "enabled": s.cfg.Auth.Local.Enabled, + "default_admin_username": s.cfg.Auth.Local.DefaultAdminUsername, + "default_admin_password": redact(s.cfg.Auth.Local.DefaultAdminPassword), + }, + "ldap": map[string]any{ + "enabled": s.cfg.Auth.LDAP.Enabled, + "url": s.cfg.Auth.LDAP.URL, + "base_dn": s.cfg.Auth.LDAP.BaseDN, + "bind_dn": s.cfg.Auth.LDAP.BindDN, + "bind_password": redact(s.cfg.Auth.LDAP.BindPassword), + }, + "oidc": map[string]any{ + "enabled": s.cfg.Auth.OIDC.Enabled, + "issuer_url": s.cfg.Auth.OIDC.IssuerURL, + "client_id": s.cfg.Auth.OIDC.ClientID, + "client_secret": redact(s.cfg.Auth.OIDC.ClientSecret), + "redirect_url": s.cfg.Auth.OIDC.RedirectURL, + }, + } +} + +func (s *Server) buildOdooSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("odoo"), + "url": s.cfg.Odoo.URL, + "database": s.cfg.Odoo.Database, + "username": s.cfg.Odoo.Username, + "api_key": redact(s.cfg.Odoo.APIKey), + } +} + +func (s *Server) buildFreecadSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("freecad"), + "uri_scheme": s.cfg.FreeCAD.URIScheme, + "executable": s.cfg.FreeCAD.Executable, + } +} + +func (s *Server) buildJobsSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("jobs"), + "directory": s.cfg.Jobs.Directory, + "runner_timeout": s.cfg.Jobs.RunnerTimeout, + "job_timeout_check": s.cfg.Jobs.JobTimeoutCheck, + "default_priority": s.cfg.Jobs.DefaultPriority, + "definitions_count": len(s.jobDefs), + } +}