From 86970074b77fb4c0705a085238dd61e363751918 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 11:44:01 +0200 Subject: [PATCH] feat: add Winter user groups - user_groups (seeded with the guest and registered groups) and the users_groups pivot, folded from the PHP v1.1.1 and v2.8.0 migrations into migration 202610020001_create_user_groups - models.UserGroup and a User.Groups relation that is never serialized - classes.UserGroupCodes and classes.HasGroupCode for server-side checks such as the site-admin group code admin - additive only: no route, payload or existing column changes; the user payload still answers "groups":[] --- README.md | 7 +- classes/user_groups.go | 47 +++++++ models/user.go | 5 + models/user_group.go | 29 ++++ updates/202610020001_create_user_groups.go | 54 ++++++++ updates/organisations_test.go | 5 +- updates/user_groups_test.go | 147 +++++++++++++++++++++ updates/user_session_test.go | 20 +-- 8 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 classes/user_groups.go create mode 100644 models/user_group.go create mode 100644 updates/202610020001_create_user_groups.go create mode 100644 updates/user_groups_test.go diff --git a/README.md b/README.md index c4df630..340fe5c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Module `git.golem15.com/golem15/sm-user-plugin`, package `user`, plugin ID `gole The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. -It owns the `users` table extensions, `user_throttle`, `jwt_blacklist` and `golem15_user_organisations`, and the JSON API under `/_user/api/v1`. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs and config keys are a contract: change them only in a backwards-compatible way. +It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `golem15_user_organisations`, `user_groups` and `users_groups`, and the JSON API under `/_user/api/v1`. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs and config keys are a contract: change them only in a backwards-compatible way. ## Features @@ -24,6 +24,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist` and `gole - **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls. - **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage). - **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. +- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No route of this plugin reads or writes groups, and the user payload still answers `"groups":[]`. - **Mail templates.** Activation, reset and reactivation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). - **Extension events.** `RegisterEvent` fires after a user row is created; `GetApiArrayEvent` lets other plugins merge keys into the user payload. - **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list). @@ -91,8 +92,8 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle Packages: -- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, the activation and reset code helpers, the throttle helpers, mail locale helpers and the two events. -- `models`: `User`, `Throttle`, `Organisation`. +- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserGroupCodes` and `HasGroupCode`, the activation and reset code helpers, the throttle helpers, mail locale helpers and the two events. +- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`. - `updates`: the gormigrate migrations, returned by `updates.All()`. - `console`: `RequirePasswordChange`, the `user:require-password-change` command. diff --git a/classes/user_groups.go b/classes/user_groups.go new file mode 100644 index 0000000..9ae9be5 --- /dev/null +++ b/classes/user_groups.go @@ -0,0 +1,47 @@ +package classes + +import ( + "context" + "fmt" + + "gorm.io/gorm" +) + +// UserGroupCodes returns the codes of the user's groups ordered by +// user_groups.id; groups without a code are skipped. A user in no group +// gets an empty slice. +func UserGroupCodes(ctx context.Context, db *gorm.DB, userID uint) ([]string, error) { + if db == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + codes := []string{} + err := db.WithContext(ctx). + Table("user_groups"). + Joins("JOIN users_groups ON users_groups.user_group_id = user_groups.id"). + Where("users_groups.user_id = ? AND user_groups.code IS NOT NULL", userID). + Order("user_groups.id"). + Pluck("user_groups.code", &codes).Error + if err != nil { + return nil, err + } + return codes, nil +} + +// HasGroupCode reports whether the user belongs to a group with code, the +// PHP `$user->groups->pluck('code')->contains($code)` check (for example +// the site-admin group code admin). +func HasGroupCode(ctx context.Context, db *gorm.DB, userID uint, code string) (bool, error) { + if db == nil { + return false, fmt.Errorf("golem15.user: gorm handle is missing") + } + var n int64 + err := db.WithContext(ctx). + Table("user_groups"). + Joins("JOIN users_groups ON users_groups.user_group_id = user_groups.id"). + Where("users_groups.user_id = ? AND user_groups.code = ?", userID, code). + Count(&n).Error + if err != nil { + return false, err + } + return n > 0, nil +} diff --git a/models/user.go b/models/user.go index 562e600..4e3055a 100644 --- a/models/user.go +++ b/models/user.go @@ -30,6 +30,11 @@ type User struct { CreatedIPAddress *string `gorm:"column:created_ip_address"` LastIPAddress *string `gorm:"column:last_ip_address"` 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 + // "groups":[] from its own payload builder. + Groups []UserGroup `gorm:"many2many:users_groups;joinForeignKey:user_id;joinReferences:user_group_id" json:"-"` } func (User) TableName() string { return "users" } diff --git a/models/user_group.go b/models/user_group.go new file mode 100644 index 0000000..5a837ff --- /dev/null +++ b/models/user_group.go @@ -0,0 +1,29 @@ +package models + +import "time" + +// UserGroup is a row of user_groups, the PHP UserGroup model. Membership +// lives in the users_groups pivot (User.Groups). Permissions is the raw +// jsonable text column. +type UserGroup 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"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (UserGroup) TableName() string { return "user_groups" } + +// The PHP UserGroup::GROUP_GUEST and GROUP_REGISTERED codes, seeded by the +// create_user_groups migration. +const ( + GroupGuest = "guest" + GroupRegistered = "registered" +) + +func init() { + Register(UserGroup{}) +} diff --git a/updates/202610020001_create_user_groups.go b/updates/202610020001_create_user_groups.go new file mode 100644 index 0000000..5eb12ed --- /dev/null +++ b/updates/202610020001_create_user_groups.go @@ -0,0 +1,54 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// Folds PHP updates/v1.1.1/create_user_groups_table.php, +// updates/v1.1.1/seed_user_groups_table.php and +// updates/v2.8.0/add_permissions_to_user_groups.php: the user_groups table +// with its Guest and Registered rows, and the users_groups pivot keyed by +// the user_group primary key. +var userGroupsMigrations = []*gormigrate.Migration{ + { + ID: "202610020001_create_user_groups", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE user_groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + code VARCHAR(255) NULL, + description TEXT NULL, + permissions TEXT NULL, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL +)`, + `CREATE INDEX user_groups_code_index ON user_groups (code)`, + `CREATE TABLE users_groups ( + user_id INTEGER NOT NULL, + user_group_id INTEGER NOT NULL, + CONSTRAINT user_group PRIMARY KEY (user_id, user_group_id) +)`, + `INSERT INTO user_groups (name, code, description, created_at, updated_at) VALUES + ('Guest', 'guest', 'Default group for guest users.', NOW(), NOW()), + ('Registered', 'registered', 'Default group for registered users.', NOW(), NOW())`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Exec(`DROP TABLE IF EXISTS users_groups`).Error; err != nil { + return err + } + return tx.Exec(`DROP TABLE IF EXISTS user_groups`).Error + }, + }, +} + +func init() { + Register(userGroupsMigrations...) +} diff --git a/updates/organisations_test.go b/updates/organisations_test.go index 703ace9..6c855fd 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -44,6 +44,7 @@ func TestCreateOrganisationsMigration(t *testing.T) { "202609220005_extend_users", "202609220006_create_user_throttle", "202609220007_create_jwt_blacklist", + "202610020001_create_user_groups", } if len(ids) != len(want) { t.Fatalf("history = %v", ids) @@ -54,8 +55,8 @@ func TestCreateOrganisationsMigration(t *testing.T) { } } - // jwt blacklist, throttle, extend-users, then organisations. - for range 4 { + // user groups, jwt blacklist, throttle, extend-users, then organisations. + for range 5 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } diff --git a/updates/user_groups_test.go b/updates/user_groups_test.go new file mode 100644 index 0000000..0de3db3 --- /dev/null +++ b/updates/user_groups_test.go @@ -0,0 +1,147 @@ +package updates + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" +) + +func TestUserGroupsMigration(t *testing.T) { + sqlDB := dedicatedDB(t, "user_groups") + 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) + } + for _, table := range []string{"user_groups", "users_groups"} { + if !gdb.Migrator().HasTable(table) { + t.Fatalf("%s missing after migrate", table) + } + } + for _, col := range []string{"id", "name", "code", "description", "permissions", "created_at", "updated_at"} { + if !gdb.Migrator().HasColumn("user_groups", col) { + t.Fatalf("user_groups.%s missing", col) + } + } + var seeded []models.UserGroup + if err := gdb.Order("id").Find(&seeded).Error; err != nil { + t.Fatal(err) + } + if len(seeded) != 2 || seeded[0].Name != "Guest" || *seeded[0].Code != models.GroupGuest || + *seeded[0].Description != "Default group for guest users." || + seeded[1].Name != "Registered" || *seeded[1].Code != models.GroupRegistered || + *seeded[1].Description != "Default group for registered users." || + seeded[0].CreatedAt == nil || seeded[0].Permissions != nil { + t.Fatalf("seed rows = %+v", seeded) + } + var pk string + if err := gdb.Raw(`SELECT conname FROM pg_constraint WHERE conrelid = 'users_groups'::regclass AND contype = 'p'`).Scan(&pk).Error; err != nil { + t.Fatal(err) + } + if pk != "user_group" { + t.Fatalf("users_groups primary key = %q", pk) + } + + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } + if gdb.Migrator().HasTable("user_groups") || gdb.Migrator().HasTable("users_groups") { + t.Fatal("user groups tables survived rollback") + } + if !gdb.Migrator().HasTable("jwt_blacklist") || !gdb.Migrator().HasTable("users") { + t.Fatal("rollback dropped an earlier table") + } + if err := m.Migrate(); err != nil { + t.Fatalf("re-up: %v", err) + } + var n int64 + if err := gdb.Table("user_groups").Count(&n).Error; err != nil || n != 2 { + t.Fatalf("re-up rows = %d err = %v", n, err) + } +} + +func TestUserGroupsCodesAndRelation(t *testing.T) { + sqlDB := dedicatedDB(t, "user_groups_codes") + 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) + } + admin := "admin" + adminGroup := models.UserGroup{Name: "Admins", Code: &admin} + if err := gdb.Create(&adminGroup).Error; err != nil { + t.Fatal(err) + } + noCode := models.UserGroup{Name: "Uncoded"} + if err := gdb.Create(&noCode).Error; err != nil { + t.Fatal(err) + } + site := models.User{Email: "site-admin@example.test"} + plain := models.User{Email: "plain@example.test"} + for _, u := range []*models.User{&site, &plain} { + if err := gdb.Omit("Groups").Create(u).Error; err != nil { + t.Fatal(err) + } + } + for _, gid := range []uint{noCode.ID, adminGroup.ID, 2} { + if err := gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, site.ID, gid).Error; err != nil { + t.Fatal(err) + } + } + + codes, err := classes.UserGroupCodes(t.Context(), gdb, site.ID) + if err != nil { + t.Fatal(err) + } + if want := []string{"registered", "admin"}; !reflect.DeepEqual(codes, want) { + t.Fatalf("codes = %v, want %v", codes, want) + } + codes, err = classes.UserGroupCodes(t.Context(), gdb, plain.ID) + if err != nil || codes == nil || len(codes) != 0 { + t.Fatalf("plain codes = %#v err = %v", codes, err) + } + for _, c := range []struct { + user uint + code string + want bool + }{{site.ID, "admin", true}, {plain.ID, "admin", false}, {site.ID, "guest", false}} { + got, err := classes.HasGroupCode(t.Context(), gdb, c.user, c.code) + if err != nil || got != c.want { + t.Fatalf("HasGroupCode(%d, %s) = %v, %v", c.user, c.code, got, err) + } + } + + // The relation loads through the pivot and is never serialized. + var loaded models.User + if err := gdb.Preload("Groups").Take(&loaded, site.ID).Error; err != nil { + t.Fatal(err) + } + if len(loaded.Groups) != 3 { + t.Fatalf("groups = %+v", loaded.Groups) + } + raw, err := json.Marshal(loaded) + if err != nil || strings.Contains(string(raw), "Groups") || strings.Contains(string(raw), "Admins") { + t.Fatalf("user JSON = %s err = %v", raw, err) + } + if _, err := classes.UserGroupCodes(t.Context(), nil, site.ID); err == nil { + t.Fatal("nil handle must be an error") + } +} diff --git a/updates/user_session_test.go b/updates/user_session_test.go index 475beff..68ba0a8 100644 --- a/updates/user_session_test.go +++ b/updates/user_session_test.go @@ -47,7 +47,8 @@ func TestExtendUsersMigration(t *testing.T) { if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded { t.Fatalf("defaults = %+v", row) } - for range 3 { + // user groups, jwt blacklist, throttle, then extend-users. + for range 4 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } @@ -86,11 +87,11 @@ func TestCreateUserThrottleMigration(t *testing.T) { t.Fatalf("missing column %s", col) } } - if err := m.RollbackLast(); err != nil { - t.Fatal(err) - } - if err := m.RollbackLast(); err != nil { - t.Fatal(err) + // user groups, jwt blacklist, then throttle. + for range 3 { + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } } if gdb.Migrator().HasTable("user_throttle") { t.Fatal("user_throttle survived rollback") @@ -118,8 +119,11 @@ func TestCreateJwtBlacklistMigration(t *testing.T) { t.Fatalf("missing column %s", col) } } - if err := m.RollbackLast(); err != nil { - t.Fatal(err) + // user groups, then jwt blacklist. + for range 2 { + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } } if gdb.Migrator().HasTable("jwt_blacklist") { t.Fatal("jwt_blacklist survived rollback")