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"
)
// 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

View File

@@ -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

View File

@@ -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 {
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
},
},
}