diff --git a/gen/db/raw_exec.go b/gen/db/raw_exec.go new file mode 100644 index 0000000..ce78d58 --- /dev/null +++ b/gen/db/raw_exec.go @@ -0,0 +1,13 @@ +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgconn" +) + +// ExecRaw executes raw SQL against the underlying sqlc DB connection. +// Use this sparingly for operational tasks that are not modelled by sqlc queries. +func (q *Queries) ExecRaw(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, sql, args...) +} diff --git a/internal/config/config.go b/internal/config/config.go index f843f50..eaf41f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -140,6 +140,9 @@ type Config struct { AccountDeletionPurgeEnabled bool AccountDeletionPurgeInterval time.Duration AccountDeletionPurgeBatchSize int32 + DBResetReseedEnabled bool + DBResetReseedToken string + DBSeedDir string } func NewConfig() (*Config, error) { @@ -562,6 +565,15 @@ func (c *Config) loadEnv() error { } } + // Dangerous DB reset+reseed endpoint configuration (disabled by default) + dbResetReseedEnabled := strings.TrimSpace(os.Getenv("DB_RESET_RESEED_ENABLED")) + c.DBResetReseedEnabled = dbResetReseedEnabled == "true" || dbResetReseedEnabled == "1" + c.DBResetReseedToken = strings.TrimSpace(os.Getenv("DB_RESET_RESEED_TOKEN")) + c.DBSeedDir = strings.TrimSpace(os.Getenv("DB_SEED_DIR")) + if c.DBSeedDir == "" { + c.DBSeedDir = "db/data" + } + return nil } diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index d0d8f37..337d2f4 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -223,6 +223,9 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "rbac.permissions.list", Name: "List Permissions", Description: "List all permissions", GroupName: "RBAC"}, {Key: "rbac.permissions.groups", Name: "List Permission Groups", Description: "List permission groups", GroupName: "RBAC"}, {Key: "rbac.permissions.sync", Name: "Sync Permissions", Description: "Sync permissions from code", GroupName: "RBAC"}, + + // Internal operations + {Key: "internal.db.reset_reseed", Name: "Reset And Reseed Database", Description: "Dangerous operation: clears all data and re-seeds from SQL files", GroupName: "Internal Operations"}, } // DefaultRolePermissions maps each system role to the permission keys it should @@ -306,6 +309,9 @@ var DefaultRolePermissions = map[string][]string{ "rbac.roles.list", "rbac.roles.get", "rbac.roles.create", "rbac.roles.update", "rbac.roles.delete", "rbac.roles.set_permissions", "rbac.roles.get_permissions", "rbac.permissions.list", "rbac.permissions.groups", "rbac.permissions.sync", + + // Internal operations + "internal.db.reset_reseed", }, "STUDENT": { diff --git a/internal/web_server/handlers/maintenance_handler.go b/internal/web_server/handlers/maintenance_handler.go new file mode 100644 index 0000000..30c52fa --- /dev/null +++ b/internal/web_server/handlers/maintenance_handler.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "crypto/subtle" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type resetAndReseedReq struct { + Confirm string `json:"confirm"` +} + +// ResetAndReseedDatabase godoc +// @Summary Reset and reseed database +// @Description Dangerous operation: truncates all public tables (except schema_migrations) and reseeds from db/data SQL files. +// @Tags internal +// @Accept json +// @Produce json +// @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN" +// @Param body body resetAndReseedReq true "Confirmation payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/internal/db/reset-reseed [post] +func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error { + if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Operation is disabled", + Error: "DB_RESET_RESEED_ENABLED must be set to true", + }) + } + + var req resetAndReseedReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if strings.TrimSpace(req.Confirm) != "RESET_AND_RESEED" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Confirmation required", + Error: `set confirm to "RESET_AND_RESEED"`, + }) + } + + expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken) + providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token")) + if expectedToken == "" || subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Invalid reset token", + Error: "missing or invalid X-Seed-Reset-Token", + }) + } + + seedDir := strings.TrimSpace(h.Cfg.DBSeedDir) + if seedDir == "" { + seedDir = "db/data" + } + seedFiles, err := filepath.Glob(filepath.Join(seedDir, "*.sql")) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to resolve seed files", + Error: err.Error(), + }) + } + if len(seedFiles) == 0 { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "No seed files found", + Error: fmt.Sprintf("no .sql files found in %s", seedDir), + }) + } + sort.Strings(seedFiles) + + var sqlBuilder strings.Builder + sqlBuilder.WriteString("BEGIN;\n") + sqlBuilder.WriteString(` +DO $$ +DECLARE + truncate_stmt text; +BEGIN + SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ') + || ' RESTART IDENTITY CASCADE' + INTO truncate_stmt + FROM pg_tables + WHERE schemaname = 'public' + AND tablename <> 'schema_migrations'; + + IF truncate_stmt IS NOT NULL THEN + EXECUTE truncate_stmt; + END IF; +END $$; +`) + + for _, file := range seedFiles { + content, readErr := os.ReadFile(file) + if readErr != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to read seed file", + Error: fmt.Sprintf("%s: %v", file, readErr), + }) + } + sqlBuilder.WriteString("\n-- seed file: ") + sqlBuilder.WriteString(file) + sqlBuilder.WriteString("\n") + sqlBuilder.Write(content) + sqlBuilder.WriteString("\n") + } + sqlBuilder.WriteString("COMMIT;") + + if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reset and reseed database", + Error: err.Error(), + }) + } + + // Ensure permission definitions and default assignments are in sync after reseed. + if err := h.rbacSvc.SeedPermissions(c.Context()); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Database reseeded but RBAC permission sync failed", + Error: err.Error(), + }) + } + if err := h.rbacSvc.SeedDefaultRolePermissions(c.Context()); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Database reseeded but RBAC role default sync failed", + Error: err.Error(), + }) + } + if err := h.rbacSvc.Reload(c.Context()); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Database reseeded but RBAC cache reload failed", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Database reset and reseed completed successfully", + Data: map[string]interface{}{ + "seed_dir": seedDir, + "seed_files": seedFiles, + "seeded_count": len(seedFiles), + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 905025d..5fc84f4 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -244,6 +244,7 @@ func (a *App) initAppRoutes() { groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount) groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion) groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers) + groupV1.Post("/internal/db/reset-reseed", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ResetAndReseedDatabase) groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID) groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser) groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)