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":[]
This commit is contained in:
Jakub Zych
2026-10-02 11:44:01 +02:00
parent c65ab3cdac
commit 86970074b7
8 changed files with 301 additions and 13 deletions

View File

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

47
classes/user_groups.go Normal file
View File

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

View File

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

29
models/user_group.go Normal file
View File

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

View File

@@ -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...)
}

View File

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

147
updates/user_groups_test.go Normal file
View File

@@ -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")
}
}

View File

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