From 06a7292dea867ce42962775eff2b87bf781b276c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 17:40:34 +0200 Subject: [PATCH] feat(09-02): implement exact backend identity migrations - Add the admin jti table, reset cutoff, and Winter indexes without AutoMigrate - Reapply the developer and publisher seed idempotently and allow repeated role codes --- cabana/auth.go | 34 ----------------------- cabana/contracts.go | 44 ++++++++++++++++++++++++++++++ lagoon/backend_admin_migrations.go | 43 +++++++++++++++++++++-------- 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/cabana/auth.go b/cabana/auth.go index d1e444a..0bf3ecb 100644 --- a/cabana/auth.go +++ b/cabana/auth.go @@ -21,40 +21,6 @@ const ( msgServerError = "Server error" ) -// BackendUserRole is the Winter backend_user_roles row. -type BackendUserRole struct { - ID uint `gorm:"column:id;primaryKey"` - Name string `gorm:"column:name"` - Code string `gorm:"column:code"` - Description string `gorm:"column:description"` - Permissions string `gorm:"column:permissions"` - IsSystem bool `gorm:"column:is_system"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` -} - -func (BackendUserRole) TableName() string { return "backend_user_roles" } - -// BackendUser is the Winter backend_users row. It is not a frontend user. -type BackendUser struct { - ID uint `gorm:"column:id;primaryKey"` - FirstName string `gorm:"column:first_name"` - LastName string `gorm:"column:last_name"` - Login string `gorm:"column:login"` - Email string `gorm:"column:email"` - Password string `gorm:"column:password"` - IsActivated bool `gorm:"column:is_activated"` - IsSuperuser bool `gorm:"column:is_superuser"` - RoleID *uint `gorm:"column:role_id"` - LastLogin *time.Time `gorm:"column:last_login"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` - Role BackendUserRole -} - -func (BackendUser) TableName() string { return "backend_users" } - // BackendUsers loads activated backend principals. It never reads frontend users. type BackendUsers struct { DB *gorm.DB diff --git a/cabana/contracts.go b/cabana/contracts.go index 4ff93ab..9c040fc 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -4,11 +4,55 @@ import ( "encoding/json" "net/http" "strings" + "time" "git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/pact" + "gorm.io/gorm" ) +// BackendUserRole is the Winter backend_user_roles row. +type BackendUserRole struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Code string `gorm:"column:code"` + Description string `gorm:"column:description"` + Permissions string `gorm:"column:permissions"` + IsSystem bool `gorm:"column:is_system"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (BackendUserRole) TableName() string { return "backend_user_roles" } + +// BackendUser is the Winter backend_users row. It is not a frontend user. +// Nullable Winter columns are mapped so a copied row round-trips without a +// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column. +type BackendUser struct { + ID uint `gorm:"column:id;primaryKey"` + FirstName string `gorm:"column:first_name"` + LastName string `gorm:"column:last_name"` + Login string `gorm:"column:login"` + Email string `gorm:"column:email"` + Password string `gorm:"column:password"` + ActivationCode string `gorm:"column:activation_code"` + PersistCode string `gorm:"column:persist_code"` + ResetPasswordCode string `gorm:"column:reset_password_code"` + Permissions string `gorm:"column:permissions"` + IsActivated bool `gorm:"column:is_activated"` + IsSuperuser bool `gorm:"column:is_superuser"` + RoleID *uint `gorm:"column:role_id"` + ActivatedAt *time.Time `gorm:"column:activated_at"` + LastLogin *time.Time `gorm:"column:last_login"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` + TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"` + Role BackendUserRole +} + +func (BackendUser) TableName() string { return "backend_users" } + // Option is a dropdown choice shared with later schema plans. type Option = pact.Option diff --git a/lagoon/backend_admin_migrations.go b/lagoon/backend_admin_migrations.go index 60fd400..75ba5cc 100644 --- a/lagoon/backend_admin_migrations.go +++ b/lagoon/backend_admin_migrations.go @@ -5,25 +5,30 @@ import ( "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 and // seeds the developer and publisher system roles. History is isolated under -// the summercms.cabana plugin id. +// 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 backend_user_roles ( + `CREATE TABLE IF NOT EXISTS backend_user_roles ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, - code TEXT 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 backend_users ( + `CREATE TABLE IF NOT EXISTS backend_users ( id SERIAL PRIMARY KEY, first_name TEXT, last_name TEXT, @@ -41,14 +46,24 @@ var BackendAdminMigrations = []*gormigrate.Migration{ last_login TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ + deleted_at TIMESTAMPTZ, + tokens_valid_after TIMESTAMPTZ )`, - `CREATE INDEX backend_users_role_id_index ON backend_users (role_id)`, - `CREATE INDEX backend_users_deleted_at_index ON backend_users (deleted_at)`, + `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)`, + ('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 { @@ -58,10 +73,16 @@ VALUES return nil }, Rollback: func(tx *gorm.DB) error { - if err := tx.Exec(`DROP TABLE IF EXISTS backend_users`).Error; err != nil { - return err + 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 tx.Exec(`DROP TABLE IF EXISTS backend_user_roles`).Error + return nil }, }, }