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
This commit is contained in:
Jakub Zych
2026-09-24 17:40:34 +02:00
parent 448faa465f
commit 06a7292dea
3 changed files with 76 additions and 45 deletions

View File

@@ -21,40 +21,6 @@ const (
msgServerError = "Server error" 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. // BackendUsers loads activated backend principals. It never reads frontend users.
type BackendUsers struct { type BackendUsers struct {
DB *gorm.DB DB *gorm.DB

View File

@@ -4,11 +4,55 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"time"
"git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact" "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. // Option is a dropdown choice shared with later schema plans.
type Option = pact.Option type Option = pact.Option

View File

@@ -5,25 +5,30 @@ import (
"gorm.io/gorm" "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 // BackendAdminMigrations creates Winter-shaped backend identity tables and
// seeds the developer and publisher system roles. History is isolated under // 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{ var BackendAdminMigrations = []*gormigrate.Migration{
{ {
ID: "202609240001_backend_admin_identity", ID: "202609240001_backend_admin_identity",
Migrate: func(tx *gorm.DB) error { Migrate: func(tx *gorm.DB) error {
stmts := []string{ stmts := []string{
`CREATE TABLE backend_user_roles ( `CREATE TABLE IF NOT EXISTS backend_user_roles (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
code TEXT UNIQUE, code TEXT,
description TEXT, description TEXT,
permissions TEXT, permissions TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE, is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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, id SERIAL PRIMARY KEY,
first_name TEXT, first_name TEXT,
last_name TEXT, last_name TEXT,
@@ -41,14 +46,24 @@ var BackendAdminMigrations = []*gormigrate.Migration{
last_login TIMESTAMPTZ, last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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 TABLE IF NOT EXISTS backend_jwt_blacklist (
`CREATE INDEX backend_users_deleted_at_index ON backend_users (deleted_at)`, 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) `INSERT INTO backend_user_roles (name, code, description, permissions, is_system)
VALUES VALUES
('Developer', 'developer', 'Site administrator with access to developer tools.', '{}', TRUE), ('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 { for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil { if err := tx.Exec(stmt).Error; err != nil {
@@ -58,10 +73,16 @@ VALUES
return nil return nil
}, },
Rollback: func(tx *gorm.DB) error { Rollback: func(tx *gorm.DB) error {
if err := tx.Exec(`DROP TABLE IF EXISTS backend_users`).Error; err != nil { for _, stmt := range []string{
return err `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
}, },
}, },
} }