diff --git a/models/user.go b/models/user.go index 4e3055a..ad348bc 100644 --- a/models/user.go +++ b/models/user.go @@ -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{}) } diff --git a/updates/202610040001_add_users_permissions.go b/updates/202610040001_add_users_permissions.go new file mode 100644 index 0000000..1228fc3 --- /dev/null +++ b/updates/202610040001_add_users_permissions.go @@ -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...) +} diff --git a/updates/202610040002_add_users_last_seen.go b/updates/202610040002_add_users_last_seen.go new file mode 100644 index 0000000..0ab2b95 --- /dev/null +++ b/updates/202610040002_add_users_last_seen.go @@ -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...) +} diff --git a/updates/202610040003_create_frontend_permissions.go b/updates/202610040003_create_frontend_permissions.go new file mode 100644 index 0000000..51ec76d --- /dev/null +++ b/updates/202610040003_create_frontend_permissions.go @@ -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...) +} diff --git a/updates/admin_columns_test.go b/updates/admin_columns_test.go new file mode 100644 index 0000000..d075d96 --- /dev/null +++ b/updates/admin_columns_test.go @@ -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") + } +} diff --git a/updates/api_tokens_test.go b/updates/api_tokens_test.go index 29376c4..87ac8b5 100644 --- a/updates/api_tokens_test.go +++ b/updates/api_tokens_test.go @@ -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{}) { diff --git a/updates/organisations_test.go b/updates/organisations_test.go index dcd24ce..363b89c 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -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) } diff --git a/updates/user_groups_test.go b/updates/user_groups_test.go index b2c7971..18bb90e 100644 --- a/updates/user_groups_test.go +++ b/updates/user_groups_test.go @@ -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) } diff --git a/updates/user_session_test.go b/updates/user_session_test.go index d23cbbc..cc337b1 100644 --- a/updates/user_session_test.go +++ b/updates/user_session_test.go @@ -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) }