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.
This commit is contained in:
Jakub Zych
2026-10-01 23:12:29 +02:00
parent 5d79f7d0bb
commit 2da8112dbb
4 changed files with 108 additions and 12 deletions

View File

@@ -121,7 +121,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. |
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. |
| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. |
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables and seeds the system roles. |
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, seeds the system roles and adds a case-insensitive unique index on `backend_users.email`. The index migration refuses to run while emails differ only in case and names the clashing logins. |
| `lagoon.QueueMigrations` | Migrates River's schema to `lagoon.RiverSchemaVersion` and creates the `summer_jobs` job record table. |
| `lagoon.QueueHistoryID` | History id of the job-queue set, `summercms.conga`. |
| `lagoon.JobsTable` | Name of the job record table, `summer_jobs`. |

View File

@@ -1,6 +1,9 @@
package lagoon
import (
"fmt"
"strings"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
@@ -9,10 +12,11 @@ import (
// It is distinct from the frontend jwt_blacklist table.
const BackendJWTBlacklistTable = "backend_jwt_blacklist"
// BackendAdminMigrations creates Winter-shaped backend identity tables and
// seeds the developer and publisher system roles. 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.
// 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",
@@ -85,4 +89,32 @@ ON CONFLICT (name) DO NOTHING`,
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
},
},
}

View File

@@ -51,9 +51,7 @@ func TestPhase09MigrationsFreshRollback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := admin.RollbackLast(); err != nil {
t.Fatal(err)
}
rollbackBackendAdmin(t, admin)
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} {
if gdb.Migrator().HasTable(name) {
t.Fatalf("%s survived admin rollback", name)
@@ -205,9 +203,7 @@ func TestBackendAdminRollback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
rollbackBackendAdmin(t, m)
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} {
if gdb.Migrator().HasTable(name) {
t.Fatalf("%s survived admin rollback", name)
@@ -443,3 +439,71 @@ func assertNoAutoMigrate(t *testing.T) {
}
}
}
// rollbackBackendAdmin rolls back every BackendAdminMigrations entry, newest
// first, so the identity tables are dropped as a set.
func rollbackBackendAdmin(t *testing.T, m *gormigrate.Gormigrate) {
t.Helper()
for range BackendAdminMigrations {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
}
}
func TestBackendAdminEmailCaseInsensitiveUnique(t *testing.T) {
db, _ := dedicatedDB(t, "lagoon_admin_email_ci")
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password) VALUES ('ada', 'Ada@Example.Test', 'x')`).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password) VALUES ('ada-2', 'ada@example.test', 'x')`).Error; err == nil {
t.Fatal("an email differing only in case was accepted")
}
}
func TestBackendAdminEmailIndexRefusesCaseDuplicates(t *testing.T) {
db, _ := dedicatedDB(t, "lagoon_admin_email_dupes")
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
// A Winter copy can hold emails that differ only in case.
for _, row := range [][2]string{{"ada", "Ada@Example.Test"}, {"ada-old", "ada@example.test"}} {
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password) VALUES (?, ?, 'x')`, row[0], row[1]).Error; err != nil {
t.Fatal(err)
}
}
err = Migrate(gdb, nil)
if err == nil {
t.Fatal("migrate created the unique email index over case duplicates")
}
if !strings.Contains(err.Error(), "ada@example.test (logins: ada, ada-old)") {
t.Fatalf("error does not name the duplicates: %v", err)
}
if err := gdb.Exec(`UPDATE backend_users SET email = 'ada.old@example.test' WHERE login = 'ada-old'`).Error; err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatalf("migrate after resolving duplicates: %v", err)
}
if !indexOn(indexDefs(t, gdb, "backend_users"), "lower(email)") {
t.Fatal("unique lower(email) index missing")
}
}