From c0beed021c0ca5defd953b47e4c236626c04108d Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 5 Feb 2026 15:44:54 +0300 Subject: [PATCH] Enforce pricelist write checks and auto-restart on DB settings change --- README.md | 25 ++++++++++++++++--------- cmd/qfs/main.go | 24 ++++++++++++++++++------ cmd/qfs/versioning_api_test.go | 2 +- internal/handlers/pricelist.go | 18 ++++++++++++++++++ internal/handlers/setup.go | 22 +++++++++++++++++----- 5 files changed, 70 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9219d20..02fe12d 100644 --- a/README.md +++ b/README.md @@ -87,27 +87,34 @@ go run ./cmd/qfs -migrate ### Минимальные права БД для пользователя квотаций -Если нужен пользователь, который может создавать/редактировать квотации, но не может управлять ценами: +Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты: ```sql -DROP USER IF EXISTS 'quote_user'@'%'; -CREATE USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER'; +-- 1) Создать (или оставить существующего) пользователя +CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER'; --- чтение данных для расчета/просмотра +-- 2) Сбросить лишние права (без пересоздания пользователя) +REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%'; + +-- 3) Чтение данных для конфигуратора и синка GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%'; +GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%'; -GRANT SELECT ON RFQ_LOG.qt_users TO 'quote_user'@'%'; --- работа с квотациями -GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; +-- 4) Работа с конфигурациями +GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; FLUSH PRIVILEGES; + +SHOW GRANTS FOR 'quote_user'@'%'; ``` -Важно: этот вариант не ограничивает редактирование только своими записями в `qt_configurations`. -Если пересоздавать пользователя нельзя, используйте `SHOW GRANTS FOR 'quote_user'@'%';` и сделайте точечные `REVOKE`. +Важно: +- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами; +- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него; +- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя. ### 4. Импорт метаданных компонентов diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 2a5412b..f3c957f 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -165,7 +165,9 @@ func main() { } gin.SetMode(cfg.Server.Mode) - router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser) + restartSig := make(chan struct{}, 1) + + router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) @@ -207,9 +209,15 @@ func main() { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - slog.Info("shutting down server...") + shouldRestart := false + select { + case <-quit: + slog.Info("shutting down server...") + case <-restartSig: + shouldRestart = true + slog.Info("restarting application after connection settings update...") + } // Stop background sync worker first syncWorker.Stop() @@ -224,6 +232,10 @@ func main() { } slog.Info("server stopped") + + if shouldRestart { + restartProcess() + } } func setConfigDefaults(cfg *config.Config) { @@ -394,7 +406,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) { return db, nil } -func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string) (*gin.Engine, *sync.Service, error) { +func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) { // mariaDB may be nil if we're in offline mode // Repositories @@ -469,8 +481,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect return nil, nil, fmt.Errorf("creating sync handler: %w", err) } - // Setup handler (for reconfiguration) - no restart signal in normal mode - setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil) + // Setup handler (for reconfiguration) + setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig) if err != nil { return nil, nil, fmt.Errorf("creating setup handler: %w", err) } diff --git a/cmd/qfs/versioning_api_test.go b/cmd/qfs/versioning_api_test.go index 98edf17..e86335b 100644 --- a/cmd/qfs/versioning_api_test.go +++ b/cmd/qfs/versioning_api_test.go @@ -37,7 +37,7 @@ func TestConfigurationVersioningAPI(t *testing.T) { cfg := &config.Config{} setConfigDefaults(cfg) - router, _, err := setupRouter(cfg, local, connMgr, nil, "tester") + router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) if err != nil { t.Fatalf("setup router: %v", err) } diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index f6dc2f5..e20f4a1 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -87,6 +87,15 @@ func (h *PricelistHandler) Get(c *gin.Context) { // Create creates a new pricelist from current prices func (h *PricelistHandler) Create(c *gin.Context) { + canWrite, debugInfo := h.service.CanWriteDebug() + if !canWrite { + c.JSON(http.StatusForbidden, gin.H{ + "error": "pricelist write is not allowed", + "debug": debugInfo, + }) + return + } + // Get the database username as the creator createdBy := h.localDB.GetDBUser() if createdBy == "" { @@ -104,6 +113,15 @@ func (h *PricelistHandler) Create(c *gin.Context) { // Delete deletes a pricelist by ID func (h *PricelistHandler) Delete(c *gin.Context) { + canWrite, debugInfo := h.service.CanWriteDebug() + if !canWrite { + c.JSON(http.StatusForbidden, gin.H{ + "error": "pricelist write is not allowed", + "debug": debugInfo, + }) + return + } + idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 5f9f7b3..5598376 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -148,6 +148,8 @@ func (h *SetupHandler) TestConnection(c *gin.Context) { // SaveConnection saves the connection settings and signals restart func (h *SetupHandler) SaveConnection(c *gin.Context) { + existingSettings, _ := h.localDB.GetSettings() + host := c.PostForm("host") portStr := c.PostForm("port") database := c.PostForm("database") @@ -202,19 +204,29 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) { } } - // Always restart to properly initialize all services with the new connection - restartRequired := h.restartSig == nil + settingsChanged := existingSettings == nil || + existingSettings.Host != host || + existingSettings.Port != port || + existingSettings.Database != database || + existingSettings.User != user || + existingSettings.PasswordEncrypted != password + + restartQueued := settingsChanged && h.restartSig != nil c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Settings saved.", - "restart_required": restartRequired, + "restart_required": settingsChanged, + "restart_queued": restartQueued, }) // Signal restart after response is sent (if restart signal is configured) - if h.restartSig != nil { + if restartQueued { go func() { time.Sleep(500 * time.Millisecond) // Give time for response to be sent - h.restartSig <- struct{}{} + select { + case h.restartSig <- struct{}{}: + default: + } }() } }