feat: add the admin columns and the frontend permissions table

- users.permissions and users.last_seen as additive columns
- golem15_user_frontend_permissions (code, label, tab, comment)
- User gains LastSeen, read-only CreatedAt and UpdatedAt and the
  filterByGroup list scope; none of them is serialized
- migration tests roll back by position past the three new steps
This commit is contained in:
Jakub Zych
2026-10-05 12:51:41 +02:00
parent 0fe5b91b63
commit e22f7660f3
9 changed files with 238 additions and 11 deletions

View File

@@ -29,7 +29,15 @@ type User struct {
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
CreatedIPAddress *string `gorm:"column:created_ip_address"`
LastIPAddress *string `gorm:"column:last_ip_address"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
// LastSeen is stamped by login and token refresh, at most once per five
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
// never part of a user API payload.
LastSeen *time.Time `gorm:"column:last_seen" json:"-"`
// CreatedAt and UpdatedAt are read-only: the database defaults fill them
// and GORM never writes or stamps them through this model.
CreatedAt time.Time `gorm:"column:created_at;->" json:"-"`
UpdatedAt time.Time `gorm:"column:updated_at;->" json:"-"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
// Groups is the PHP belongsToMany groups relation through users_groups.
// It is loaded only on request, written only by a caller that
// associates groups, and never serialized: the user API answers
@@ -66,6 +74,25 @@ func (User) Rules() map[string]string {
}
}
// FilterByGroup is the admin list scope that keeps the members of one group,
// the PHP User::scopeFilterByGroup.
const FilterByGroup = "filterByGroup"
// FilterScopes lists the scope names a config_filter.yaml may call.
func (User) FilterScopes() []string { return []string{FilterByGroup} }
// FilterScope narrows db to the members of the group whose id is value. Any
// other scope name matches nothing.
func (User) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
if name != FilterByGroup {
return db.Where("1 = 0")
}
return db.Where("users.id IN (SELECT user_id FROM users_groups WHERE user_group_id = ?)", value)
}
func init() {
Register(User{})
}

View File

@@ -0,0 +1,25 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// The users.permissions column of PHP updates/v1.0.1/create_users_table.php:
// the user's own frontend permissions as a JSON object of code to 1 or -1,
// NULL or an empty string when the user has none.
var usersPermissionsMigrations = []*gormigrate.Migration{
{
ID: "202610040001_add_users_permissions",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE users ADD COLUMN permissions TEXT NULL`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS permissions`).Error
},
},
}
func init() {
Register(usersPermissionsMigrations...)
}

View File

@@ -0,0 +1,24 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// The users.last_seen column of PHP updates/v1.2.6/users_add_last_seen.php.
// The auth path stamps it on login and on token refresh.
var usersLastSeenMigrations = []*gormigrate.Migration{
{
ID: "202610040002_add_users_last_seen",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE users ADD COLUMN last_seen TIMESTAMPTZ NULL`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS last_seen`).Error
},
},
}
func init() {
Register(usersLastSeenMigrations...)
}

View File

@@ -0,0 +1,36 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// PHP updates/v2.8.0/create_frontend_permissions_table.php: the catalogue of
// frontend permissions the user and group permission editors offer. Rows
// come from migrations or seeds of the host application.
var frontendPermissionsMigrations = []*gormigrate.Migration{
{
ID: "202610040003_create_frontend_permissions",
Migrate: func(tx *gorm.DB) error {
return execStmts(tx, []string{
`CREATE TABLE golem15_user_frontend_permissions (
id SERIAL PRIMARY KEY,
code VARCHAR(255) NOT NULL,
label VARCHAR(255) NOT NULL,
tab VARCHAR(255) NOT NULL,
comment VARCHAR(255) NULL,
created_at TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NULL
)`,
`CREATE UNIQUE INDEX golem15_user_frontend_permissions_code_unique ON golem15_user_frontend_permissions (code)`,
})
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_user_frontend_permissions`).Error
},
},
}
func init() {
Register(frontendPermissionsMigrations...)
}

View File

@@ -0,0 +1,109 @@
package updates
import (
"testing"
"git.golem15.com/golem15/summercms/modules/lagoon"
"github.com/go-gormigrate/gormigrate/v2"
)
// TestAdminColumnsMigrations applies the three admin migrations on a real
// Postgres, checks what each one adds, rolls each one back on its own and
// applies them again.
func TestAdminColumnsMigrations(t *testing.T) {
sqlDB := dedicatedDB(t, "user_admin_columns")
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
m := gormigrate.New(gdb, &gormigrate.Options{
TableName: "summer_migrations_golem15_user",
UseTransaction: true,
}, All())
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
columnType := func(table, column string) (string, string) {
t.Helper()
var row struct {
DataType string
IsNullable string
}
if err := gdb.Raw(`SELECT data_type, is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, table, column).Scan(&row).Error; err != nil {
t.Fatal(err)
}
return row.DataType, row.IsNullable
}
if typ, null := columnType("users", "permissions"); typ != "text" || null != "YES" {
t.Fatalf("users.permissions = %q nullable %q", typ, null)
}
if typ, null := columnType("users", "last_seen"); typ != "timestamp with time zone" || null != "YES" {
t.Fatalf("users.last_seen = %q nullable %q", typ, null)
}
const table = "golem15_user_frontend_permissions"
want := map[string][2]string{
"id": {"integer", "NO"},
"code": {"character varying", "NO"},
"label": {"character varying", "NO"},
"tab": {"character varying", "NO"},
"comment": {"character varying", "YES"},
"created_at": {"timestamp with time zone", "YES"},
"updated_at": {"timestamp with time zone", "YES"},
}
for column, shape := range want {
if typ, null := columnType(table, column); typ != shape[0] || null != shape[1] {
t.Fatalf("%s.%s = %q nullable %q", table, column, typ, null)
}
}
insert := `INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, ?, ?)`
if err := gdb.Exec(insert, "blog.post", "Post", "Blog").Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(insert, "blog.post", "Post again", "Blog").Error; err == nil {
t.Fatal("a duplicate permission code was accepted")
}
// An existing user keeps working with both columns empty.
if err := gdb.Exec(`INSERT INTO users (email) VALUES ('columns@example.test')`).Error; err != nil {
t.Fatal(err)
}
var empty int64
if err := gdb.Raw(`SELECT COUNT(*) FROM users WHERE permissions IS NULL AND last_seen IS NULL`).Scan(&empty).Error; err != nil || empty != 1 {
t.Fatalf("users with empty admin columns = %d err = %v", empty, err)
}
if err := m.RollbackMigration(frontendPermissionsMigrations[0]); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasTable(table) {
t.Fatalf("%s survived rollback", table)
}
if err := m.RollbackMigration(usersLastSeenMigrations[0]); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasColumn("users", "last_seen") || !gdb.Migrator().HasColumn("users", "permissions") {
t.Fatal("last_seen rollback touched the wrong column")
}
if err := m.RollbackMigration(usersPermissionsMigrations[0]); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasColumn("users", "permissions") {
t.Fatal("users.permissions survived rollback")
}
for _, kept := range []string{"users", "user_groups", "user_throttle", "user_api_tokens"} {
if !gdb.Migrator().HasTable(kept) {
t.Fatalf("rollback dropped %s", kept)
}
}
var users int64
if err := gdb.Raw(`SELECT COUNT(*) FROM users`).Scan(&users).Error; err != nil || users != 1 {
t.Fatalf("users after rollback = %d err = %v", users, err)
}
if err := m.Migrate(); err != nil {
t.Fatalf("re-up: %v", err)
}
if !gdb.Migrator().HasTable(table) || !gdb.Migrator().HasColumn("users", "permissions") || !gdb.Migrator().HasColumn("users", "last_seen") {
t.Fatal("re-up did not restore the admin columns")
}
}

View File

@@ -33,7 +33,7 @@ func TestAPITokenMigration(t *testing.T) {
if oauthClientType != "text" {
t.Fatalf("oauth_client_id type = %q, want text for legacy OAuth client IDs", oauthClientType)
}
if err := m.RollbackLast(); err != nil {
if err := m.RollbackMigration(apiTokenMigrations[0]); err != nil {
t.Fatal(err)
}
if db.Migrator().HasTable(&models.APIToken{}) {

View File

@@ -45,7 +45,10 @@ func TestCreateOrganisationsMigration(t *testing.T) {
"202609220006_create_user_throttle",
"202609220007_create_jwt_blacklist",
"202610020001_create_user_groups",
"202610040001_add_users_permissions",
"202610040001_create_user_api_tokens",
"202610040002_add_users_last_seen",
"202610040003_create_frontend_permissions",
}
if len(ids) != len(want) {
t.Fatalf("history = %v", ids)
@@ -56,8 +59,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
}
}
// API tokens, user groups, jwt blacklist, throttle, extend-users, then organisations.
for range 6 {
// The three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, extend-users, then organisations.
for range 9 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -54,7 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
t.Fatalf("users_groups primary key = %q", pk)
}
for range 2 { // API tokens, then user groups.
// Frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 5 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -47,8 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
t.Fatalf("defaults = %+v", row)
}
// API tokens, user groups, jwt blacklist, throttle, then extend-users.
for range 5 {
// The three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, then extend-users.
for range 8 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -87,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// API tokens, user groups, jwt blacklist, then throttle.
for range 4 {
// The three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 7 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -119,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// API tokens, user groups, then jwt blacklist.
for range 3 {
// The three admin migrations, API tokens, user groups, then jwt blacklist.
for range 6 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}