Files
summercms/modules/lagoon/backend_admin_migrations.go
Jakub Zych 2da8112dbb fix(09): WR-11 enforce case-insensitive unique backend user emails
Add a backend admin migration that creates a unique index on
lower(backend_users.email). Rows copied from WinterCMS may hold emails
that differ only in case, so the migration refuses to run and names the
clashing logins instead of choosing an account to drop.
2026-10-01 23:12:29 +02:00

121 lines
4.3 KiB
Go

package lagoon
import (
"fmt"
"strings"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// BackendJWTBlacklistTable is the framework-owned jti table for admin tokens.
// It is distinct from the frontend jwt_blacklist table.
const BackendJWTBlacklistTable = "backend_jwt_blacklist"
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
// the developer and publisher system roles and makes backend user emails
// unique case-insensitively. History is isolated under the summercms.cabana
// plugin id. DDL is re-runnable so the system-role seed stays idempotent if
// the history row is removed.
var BackendAdminMigrations = []*gormigrate.Migration{
{
ID: "202609240001_backend_admin_identity",
Migrate: func(tx *gorm.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS backend_user_roles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
code TEXT,
description TEXT,
permissions TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS backend_users (
id SERIAL PRIMARY KEY,
first_name TEXT,
last_name TEXT,
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
activation_code TEXT,
persist_code TEXT,
reset_password_code TEXT,
permissions TEXT,
is_activated BOOLEAN NOT NULL DEFAULT FALSE,
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
role_id INTEGER REFERENCES backend_user_roles(id),
activated_at TIMESTAMPTZ,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
tokens_valid_after TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS backend_jwt_blacklist (
jti TEXT PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS backend_users_role_id_index ON backend_users (role_id)`,
`CREATE INDEX IF NOT EXISTS backend_users_deleted_at_index ON backend_users (deleted_at)`,
`CREATE INDEX IF NOT EXISTS backend_users_activation_code_index ON backend_users (activation_code)`,
`CREATE INDEX IF NOT EXISTS backend_users_reset_password_code_index ON backend_users (reset_password_code)`,
`CREATE INDEX IF NOT EXISTS backend_user_roles_code_index ON backend_user_roles (code)`,
`INSERT INTO backend_user_roles (name, code, description, permissions, is_system)
VALUES
('Developer', 'developer', 'Site administrator with access to developer tools.', '{}', TRUE),
('Publisher', 'publisher', 'Site editor with access to publishing tools.', '{}', TRUE)
ON CONFLICT (name) DO NOTHING`,
}
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
for _, stmt := range []string{
`DROP TABLE IF EXISTS backend_jwt_blacklist`,
`DROP TABLE IF EXISTS backend_users`,
`DROP TABLE IF EXISTS backend_user_roles`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
},
{
// Emails are matched case-insensitively at login and by admin:create,
// so the table enforces the same rule. Rows copied from Winter may
// hold two emails that differ only in case; the migration refuses to
// run and names them instead of picking an account to drop.
ID: "202610010001_backend_users_email_ci_unique",
Migrate: func(tx *gorm.DB) error {
var dupes []struct {
Email string
Logins string
}
if err := tx.Raw(`SELECT lower(email) AS email, string_agg(login, ', ' ORDER BY id) AS logins
FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&dupes).Error; err != nil {
return err
}
if len(dupes) > 0 {
parts := make([]string, len(dupes))
for i, d := range dupes {
parts[i] = fmt.Sprintf("%s (logins: %s)", d.Email, d.Logins)
}
return fmt.Errorf("lagoon: backend_users has emails that differ only in case: %s; change or remove the duplicates, then run migrate again", strings.Join(parts, "; "))
}
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS backend_users_email_lower_unique ON backend_users (lower(email))`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP INDEX IF EXISTS backend_users_email_lower_unique`).Error
},
},
}