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, makes backend user emails // unique case-insensitively, adds WinterCMS's nullable jsonable metadata // column to backend_users (the admin subtitle lives there), and creates // backend_api_tokens for personal admin API tokens. 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 }, }, { // WinterCMS 2023_02_16_000012_Db_Backend_Add_User_Metadata: a // nullable jsonable column. It is TEXT, not JSONB, so a copied value // such as [] or a blank string loads unchanged. ID: "202610090001_backend_users_metadata", Migrate: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error }, }, { // Personal admin API tokens (sha256 of g15_… only; plaintext never stored). ID: "202610110001_backend_api_tokens", Migrate: func(tx *gorm.DB) error { for _, stmt := range []string{ `CREATE TABLE IF NOT EXISTS backend_api_tokens ( id BIGSERIAL PRIMARY KEY, backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, token_hint TEXT NOT NULL, scopes TEXT NOT NULL, expires_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS backend_api_tokens_backend_user_id_index ON backend_api_tokens (backend_user_id)`, } { if err := tx.Exec(stmt).Error; err != nil { return err } } return nil }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS backend_api_tokens`).Error }, }, }