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:
@@ -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
47
classes/user_groups.go
Normal 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
|
||||
}
|
||||
@@ -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
29
models/user_group.go
Normal 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{})
|
||||
}
|
||||
54
updates/202610020001_create_user_groups.go
Normal file
54
updates/202610020001_create_user_groups.go
Normal 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...)
|
||||
}
|
||||
@@ -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
147
updates/user_groups_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user