new reseed endpoint
This commit is contained in:
parent
8f719c2a32
commit
0cb58b35f8
13
gen/db/raw_exec.go
Normal file
13
gen/db/raw_exec.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
155
internal/web_server/handlers/maintenance_handler.go
Normal file
155
internal/web_server/handlers/maintenance_handler.go
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user