From cdea47a1782899fbfd5aa970b96b1bc1c2ad99c9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:21:51 +0200 Subject: [PATCH] feat: frontend permissions and the merged-permission resolver - PermissionSet reads the stored JSON tolerantly and writes an object of integers; FrontendPermission is the catalogue row - MergedPermissions, HasPermission and UserHasPermission port PHP getMergedPermissions and Winter's hasPermission: groups merge with most positive wins, the user's own values override, held means exactly 1, wildcards as in Winter - User gains Permissions (never serialized) and declares the avatar attachment relation the user API already writes --- classes/permissions.go | 133 ++++++++++++++ classes/permissions_test.go | 317 ++++++++++++++++++++++++++++++++++ models/frontend_permission.go | 25 +++ models/permission_set.go | 112 ++++++++++++ models/user.go | 17 ++ 5 files changed, 604 insertions(+) create mode 100644 classes/permissions.go create mode 100644 classes/permissions_test.go create mode 100644 models/frontend_permission.go create mode 100644 models/permission_set.go diff --git a/classes/permissions.go b/classes/permissions.go new file mode 100644 index 0000000..afea290 --- /dev/null +++ b/classes/permissions.go @@ -0,0 +1,133 @@ +package classes + +import ( + "context" + "fmt" + "strings" + + "git.golem15.com/golem15/sm-user-plugin/models" + "gorm.io/gorm" +) + +// MergedPermissions returns the frontend permissions of a user as PHP +// User::getMergedPermissions builds them: +// +// 1. the permissions of every group the user belongs to are merged, and +// where two groups name the same code the higher value wins ("most +// positive wins": 1 over 0 over -1); +// 2. the user's own permissions are laid over the result, and a user value +// replaces the groups' value whatever it is (a user-level -1 overrides a +// group's 1, and a user-level 1 overrides a group's -1). +// +// The result maps a code to 1 (allowed), -1 (denied) or 0. Use HasPermission +// to ask it a question. A deactivated user's permissions are read as well. +func MergedPermissions(ctx context.Context, db *gorm.DB, userID uint) (map[string]int, error) { + if db == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + var groups []struct{ Permissions models.PermissionSet } + err := fresh(ctx, db). + Table("user_groups"). + Select("user_groups.permissions"). + Joins("JOIN users_groups ON users_groups.user_group_id = user_groups.id"). + Where("users_groups.user_id = ?", userID). + Order("user_groups.id"). + Scan(&groups).Error + if err != nil { + return nil, err + } + merged := map[string]int{} + for _, group := range groups { + for code, value := range group.Permissions { + if held, ok := merged[code]; !ok || value > held { + merged[code] = value + } + } + } + var own []struct{ Permissions models.PermissionSet } + err = fresh(ctx, db). + Table("users"). + Select("users.permissions"). + Where("users.id = ?", userID). + Scan(&own).Error + if err != nil { + return nil, err + } + for _, row := range own { + for code, value := range row.Permissions { + merged[code] = value + } + } + return merged, nil +} + +// HasPermission reports whether merged holds code, the single-permission +// case of Winter's User::hasPermission. A permission is held only when the +// matching value is exactly 1. Wildcards work as in Winter: +// +// - code ending in "*" is held when a different stored code starts with the +// part before the asterisk and has the value 1; +// - code starting with "*" is held when a different stored code ends with +// the part after the asterisk and has the value 1; +// - otherwise code is held when it is stored with the value 1, or when a +// stored code ending in "*" with the value 1 is a proper prefix of it. +// +// A stored code that starts with "*" is not treated as a wildcard; Winter +// does not treat it as one either. +func HasPermission(merged map[string]int, code string) bool { + switch { + case len(code) > 1 && strings.HasSuffix(code, "*"): + prefix := strings.TrimSuffix(code, "*") + for stored, value := range merged { + if prefix != stored && strings.HasPrefix(stored, prefix) && value == 1 { + return true + } + } + return false + case len(code) > 1 && strings.HasPrefix(code, "*"): + suffix := strings.TrimPrefix(code, "*") + for stored, value := range merged { + if suffix != stored && strings.HasSuffix(stored, suffix) && value == 1 { + return true + } + } + return false + } + for stored, value := range merged { + if len(stored) > 1 && strings.HasSuffix(stored, "*") { + prefix := strings.TrimSuffix(stored, "*") + if prefix != code && strings.HasPrefix(code, prefix) && value == 1 { + return true + } + continue + } + if stored == code && value == 1 { + return true + } + } + return false +} + +// UserHasPermission reports whether the user holds the frontend permission +// code: MergedPermissions followed by HasPermission. +func UserHasPermission(ctx context.Context, db *gorm.DB, userID uint, code string) (bool, error) { + merged, err := MergedPermissions(ctx, db, userID) + if err != nil { + return false, err + } + return HasPermission(merged, code), nil +} + +// FrontendPermissionOptions returns the rows of +// golem15_user_frontend_permissions ordered by tab, then code, as PHP +// FrontendPermission::listTabbedPermissions reads them. +func FrontendPermissionOptions(ctx context.Context, db *gorm.DB) ([]models.FrontendPermission, error) { + if db == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + rows := []models.FrontendPermission{} + if err := fresh(ctx, db).Order("tab ASC, code ASC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} diff --git a/classes/permissions_test.go b/classes/permissions_test.go new file mode 100644 index 0000000..ce3d9e4 --- /dev/null +++ b/classes/permissions_test.go @@ -0,0 +1,317 @@ +package classes + +import ( + "fmt" + "reflect" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" +) + +// TestMergedPermissions pins the port of PHP User::getMergedPermissions and +// Winter's hasPermission: groups merge with "most positive wins", the user's +// own values override the groups' whatever they are, a permission is held +// only at exactly 1, and wildcards work as in Winter. +func TestMergedPermissions(t *testing.T) { + ctx := t.Context() + db := throttleDB(t) + + type ask struct { + code string + want bool + } + cases := []struct { + name string + groups []string // permissions column of each group, as stored + own string // users.permissions, as stored; "NULL" stores NULL + merged map[string]int + asks []ask + }{ + { + name: "two groups, the higher value wins", + groups: []string{`{"blog.post":-1,"blog.edit":0}`, `{"blog.post":1,"blog.edit":-1}`}, + own: "NULL", + merged: map[string]int{"blog.post": 1, "blog.edit": 0}, + asks: []ask{{"blog.post", true}, {"blog.edit", false}, {"blog.other", false}}, + }, + { + name: "the order of the groups does not matter", + groups: []string{`{"blog.post":1}`, `{"blog.post":-1}`}, + own: "NULL", + merged: map[string]int{"blog.post": 1}, + asks: []ask{{"blog.post", true}}, + }, + { + name: "a user-level deny overrides a group's allow", + groups: []string{`{"blog.post":1,"blog.edit":1}`}, + own: `{"blog.post":-1}`, + merged: map[string]int{"blog.post": -1, "blog.edit": 1}, + asks: []ask{{"blog.post", false}, {"blog.edit", true}}, + }, + { + // The port is not "deny wins". + name: "a user-level allow overrides a group's deny", + groups: []string{`{"blog.post":-1}`, `{"blog.post":-1}`}, + own: `{"blog.post":1}`, + merged: map[string]int{"blog.post": 1}, + asks: []ask{{"blog.post", true}}, + }, + { + name: "a code held only at 0 or -1 is not held", + groups: []string{`{"blog.zero":0,"blog.denied":-1}`}, + own: `{"blog.own_denied":-1}`, + merged: map[string]int{"blog.zero": 0, "blog.denied": -1, "blog.own_denied": -1}, + asks: []ask{{"blog.zero", false}, {"blog.denied", false}, {"blog.own_denied", false}}, + }, + { + name: "a user with no groups has only their own permissions", + groups: nil, + own: `{"blog.post":1,"shop.buy":-1}`, + merged: map[string]int{"blog.post": 1, "shop.buy": -1}, + asks: []ask{{"blog.post", true}, {"shop.buy", false}}, + }, + { + name: "a user with nothing stored holds nothing", + groups: nil, + own: "NULL", + merged: map[string]int{}, + asks: []ask{{"blog.post", false}, {"*", false}, {"blog.*", false}}, + }, + { + name: "an asked code ending in an asterisk matches a stored code by prefix", + groups: []string{`{"blog.post":1,"shop.buy":-1}`}, + own: "NULL", + merged: map[string]int{"blog.post": 1, "shop.buy": -1}, + asks: []ask{ + {"blog.*", true}, + {"shop.*", false}, // the only match is denied + {"blog.post*", false}, // the prefix must not equal the stored code + {"news.*", false}, + }, + }, + { + name: "an asked code starting with an asterisk matches a stored code by suffix", + groups: []string{`{"blog.post":1,"shop.post":-1,"shop.buy":-1}`}, + own: "NULL", + merged: map[string]int{"blog.post": 1, "shop.post": -1, "shop.buy": -1}, + asks: []ask{{"*.post", true}, {"*.buy", false}, {"*blog.post", false}}, + }, + { + name: "a stored code ending in an asterisk grants the codes under it", + groups: []string{`{"blog.*":1,"shop.*":-1}`}, + own: `{"news.*":0}`, + merged: map[string]int{"blog.*": 1, "shop.*": -1, "news.*": 0}, + asks: []ask{ + {"blog.post", true}, + {"blog.", false}, // the stored prefix itself is not under it + {"shop.buy", false}, + {"news.read", false}, + }, + }, + { + // Winter treats a leading asterisk as a wildcard only on the asked + // code, never on a stored one. Asking "*.post" still matches the + // stored "*.post" as a longer code with that suffix. + name: "a stored code starting with an asterisk is not a wildcard", + groups: []string{`{"*.post":1}`}, + own: "NULL", + merged: map[string]int{"*.post": 1}, + asks: []ask{{"blog.post", false}, {"*.post", true}}, + }, + { + name: "stored shapes are read tolerantly", + groups: []string{``, `[]`, `{"blog.post":"1","blog.bad":"yes","blog.edit":"-1"}`}, + own: ``, + merged: map[string]int{"blog.post": 1, "blog.edit": -1}, + asks: []ask{{"blog.post", true}, {"blog.bad", false}, {"blog.edit", false}}, + }, + } + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + user := models.User{Email: fmt.Sprintf("merged-%d@example.test", i)} + if err := db.Omit("Groups").Create(&user).Error; err != nil { + t.Fatal(err) + } + if c.own != "NULL" { + if err := db.Exec(`UPDATE users SET permissions = ? WHERE id = ?`, c.own, user.ID).Error; err != nil { + t.Fatal(err) + } + } + for j, stored := range c.groups { + var groupID uint + if err := db.Raw(`INSERT INTO user_groups (name, permissions, created_at, updated_at) VALUES (?, ?, NOW(), NOW()) RETURNING id`, + fmt.Sprintf("Merged %d.%d", i, j), stored).Scan(&groupID).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, user.ID, groupID).Error; err != nil { + t.Fatal(err) + } + } + merged, err := MergedPermissions(ctx, db, user.ID) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(merged, c.merged) { + t.Fatalf("merged = %v, want %v", merged, c.merged) + } + for _, a := range c.asks { + if got := HasPermission(merged, a.code); got != a.want { + t.Errorf("HasPermission(%q) = %v, want %v", a.code, got, a.want) + } + got, err := UserHasPermission(ctx, db, user.ID, a.code) + if err != nil || got != a.want { + t.Errorf("UserHasPermission(%q) = %v %v, want %v", a.code, got, err, a.want) + } + } + }) + } + + // A deactivated user's permissions are still read. + gone := models.User{Email: "merged-gone@example.test"} + if err := db.Omit("Groups").Create(&gone).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`UPDATE users SET permissions = '{"blog.post":1}', deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil { + t.Fatal(err) + } + if held, err := UserHasPermission(ctx, db, gone.ID, "blog.post"); err != nil || !held { + t.Fatalf("deactivated user: %v %v", held, err) + } + // A user who does not exist holds nothing. + if merged, err := MergedPermissions(ctx, db, 987654321); err != nil || len(merged) != 0 { + t.Fatalf("unknown user: %v %v", merged, err) + } + // A damaged row is an error, never "no permissions". + damaged := models.User{Email: "merged-damaged@example.test"} + if err := db.Omit("Groups").Create(&damaged).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`UPDATE users SET permissions = '{"blog.post":' WHERE id = ?`, damaged.ID).Error; err != nil { + t.Fatal(err) + } + if _, err := MergedPermissions(ctx, db, damaged.ID); err == nil { + t.Fatal("damaged permissions were read without an error") + } + if _, err := UserHasPermission(ctx, db, damaged.ID, "blog.post"); err == nil { + t.Fatal("UserHasPermission swallowed the error") + } + if err := db.Exec(`DELETE FROM users WHERE id = ?`, damaged.ID).Error; err != nil { + t.Fatal(err) + } + if _, err := MergedPermissions(ctx, nil, gone.ID); err == nil { + t.Fatal("MergedPermissions without a handle") + } + if _, err := FrontendPermissionOptions(ctx, nil); err == nil { + t.Fatal("FrontendPermissionOptions without a handle") + } + + // The option catalogue is read by tab, then code. + if err := db.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab, comment) VALUES + ('merged.b', 'B', 'Zeta', NULL), ('merged.c', 'C', 'Alpha', 'A comment'), ('merged.a', 'A', 'Zeta', NULL)`).Error; err != nil { + t.Fatal(err) + } + options, err := FrontendPermissionOptions(ctx, db) + if err != nil { + t.Fatal(err) + } + var codes []string + for _, option := range options { + if len(option.Code) > 7 && option.Code[:7] == "merged." { + codes = append(codes, option.Code) + if option.Code == "merged.c" && (option.Comment == nil || *option.Comment != "A comment" || option.Tab != "Alpha" || option.Label != "C") { + t.Fatalf("option = %+v", option) + } + } + } + if want := []string{"merged.c", "merged.a", "merged.b"}; !reflect.DeepEqual(codes, want) { + t.Fatalf("option order = %v, want %v", codes, want) + } +} + +// TestPermissionSetScan pins the tolerant read and the strict write of a +// stored permission column. +func TestPermissionSetScan(t *testing.T) { + scans := []struct { + name string + src any + want models.PermissionSet + wantErr bool + }{ + {"NULL", nil, models.PermissionSet{}, false}, + {"empty string", "", models.PermissionSet{}, false}, + {"blank string", " \n", models.PermissionSet{}, false}, + {"the text []", "[]", models.PermissionSet{}, false}, + {"the text null", "null", models.PermissionSet{}, false}, + {"empty object", "{}", models.PermissionSet{}, false}, + {"bytes", []byte(`{"a.b":1}`), models.PermissionSet{"a.b": 1}, false}, + {"integers", `{"a.b":1,"c.d":-1,"e.f":0}`, models.PermissionSet{"a.b": 1, "c.d": -1, "e.f": 0}, false}, + {"numeric strings", `{"a.b":"1","c.d":"-1"," e.f":" 1 "}`, models.PermissionSet{"a.b": 1, "c.d": -1, " e.f": 1}, false}, + {"a non-numeric entry is skipped", `{"a.b":1,"c.d":"yes","e.f":true,"g.h":null,"i.j":1.5,"k.l":[1],"m.n":{"x":1}}`, models.PermissionSet{"a.b": 1}, false}, + {"malformed JSON", `{"a.b":1`, nil, true}, + {"a non-empty list", `["a.b"]`, nil, true}, + {"a scalar", `1`, nil, true}, + {"a string scalar", `"a.b"`, nil, true}, + {"trailing data", `{"a.b":1} {"c.d":1}`, nil, true}, + {"an unsupported driver type", 42, nil, true}, + } + for _, c := range scans { + t.Run(c.name, func(t *testing.T) { + set := models.PermissionSet{"stale": 1} + err := set.Scan(c.src) + if c.wantErr { + if err == nil { + t.Fatalf("Scan(%v) = %v, want an error", c.src, set) + } + return + } + if err != nil || set == nil || !reflect.DeepEqual(set, c.want) { + t.Fatalf("Scan(%v) = %v %v, want %v", c.src, set, err, c.want) + } + }) + } + + values := []struct { + name string + set models.PermissionSet + want any + }{ + {"nil set", nil, nil}, + {"empty set", models.PermissionSet{}, nil}, + {"only zeros", models.PermissionSet{"a.b": 0}, nil}, + {"a zero is left out", models.PermissionSet{"b.c": -1, "a.b": 1, "z.z": 0}, `{"a.b":1,"b.c":-1}`}, + } + for _, c := range values { + t.Run("value of "+c.name, func(t *testing.T) { + got, err := c.set.Value() + if err != nil || !reflect.DeepEqual(got, c.want) { + t.Fatalf("Value() = %#v %v, want %#v", got, err, c.want) + } + }) + } + + // Through GORM: a stored set comes back as written, an empty one as NULL. + db := throttleDB(t) + user := models.User{Email: "permission-set@example.test", Permissions: models.PermissionSet{"a.b": 1, "c.d": -1, "e.f": 0}} + if err := db.Omit("Groups").Create(&user).Error; err != nil { + t.Fatal(err) + } + var stored *string + if err := db.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil || stored == nil || *stored != `{"a.b":1,"c.d":-1}` { + t.Fatalf("stored permissions = %v err = %v", stored, err) + } + var loaded models.User + if err := db.Take(&loaded, user.ID).Error; err != nil || !reflect.DeepEqual(loaded.Permissions, models.PermissionSet{"a.b": 1, "c.d": -1}) { + t.Fatalf("loaded permissions = %v err = %v", loaded.Permissions, err) + } + loaded.Permissions = models.PermissionSet{} + if err := db.Omit("Groups").Save(&loaded).Error; err != nil { + t.Fatal(err) + } + if err := db.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil { + t.Fatal(err) + } + var isNull bool + if err := db.Raw(`SELECT permissions IS NULL FROM users WHERE id = ?`, user.ID).Scan(&isNull).Error; err != nil || !isNull { + t.Fatalf("an empty set was not stored as NULL: %v", err) + } +} diff --git a/models/frontend_permission.go b/models/frontend_permission.go new file mode 100644 index 0000000..c051f0f --- /dev/null +++ b/models/frontend_permission.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// FrontendPermission is one row of golem15_user_frontend_permissions, the +// PHP FrontendPermission model: a permission the user and group permission +// editors offer. Rows come from migrations or seeds of the host application; +// there is no registry that plugins declare permissions in. Label, Tab and +// Comment are shown as stored; a value that is a translation key is +// translated. +type FrontendPermission struct { + ID uint `gorm:"column:id;primaryKey"` + Code string `gorm:"column:code"` + Label string `gorm:"column:label"` + Tab string `gorm:"column:tab"` + Comment *string `gorm:"column:comment"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (FrontendPermission) TableName() string { return "golem15_user_frontend_permissions" } + +func init() { + Register(FrontendPermission{}) +} diff --git a/models/permission_set.go b/models/permission_set.go new file mode 100644 index 0000000..bc32e0d --- /dev/null +++ b/models/permission_set.go @@ -0,0 +1,112 @@ +package models + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// PermissionSet is a frontend permission map as Winter stores it in +// users.permissions and user_groups.permissions: a JSON object of permission +// code to integer, where 1 allows, -1 denies and 0 (or no entry) inherits. +// +// Reading is tolerant, because the rows a cutover imports were written by +// several PHP code paths: NULL, an empty string, the texts [] and null all +// mean "no permissions", and a value may be an integer or a numeric string. +// Writing is strict: an object of integers without zero values, or NULL for +// an empty set. +type PermissionSet map[string]int + +// GormDataType tells GORM the column type: a map is not a type it knows. +func (PermissionSet) GormDataType() string { return "text" } + +// Scan implements sql.Scanner. An entry whose value is not numeric is +// skipped; content that is not a JSON object (and none of the empty forms +// above) is an error, so a damaged row is never read as "no permissions", +// which would silently drop its denials. +func (p *PermissionSet) Scan(src any) error { + var raw []byte + switch v := src.(type) { + case nil: + *p = PermissionSet{} + return nil + case string: + raw = []byte(v) + case []byte: + raw = v + default: + return fmt.Errorf("golem15.user: cannot scan %T into a permission set", src) + } + set, err := ParsePermissionSet(raw) + if err != nil { + return err + } + *p = set + return nil +} + +// ParsePermissionSet decodes the stored text of a permission column with the +// tolerance Scan documents. +func ParsePermissionSet(raw []byte) (PermissionSet, error) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "[]" || text == "null" { + return PermissionSet{}, nil + } + dec := json.NewDecoder(bytes.NewReader([]byte(text))) + dec.UseNumber() + var entries map[string]any + if err := dec.Decode(&entries); err != nil { + return nil, fmt.Errorf("golem15.user: stored permissions are not a JSON object: %w", err) + } + if dec.More() { + return nil, fmt.Errorf("golem15.user: stored permissions carry trailing data") + } + set := make(PermissionSet, len(entries)) + for code, value := range entries { + if n, ok := permissionInt(value); ok { + set[code] = n + } + } + return set, nil +} + +// permissionInt reads one stored permission value: a JSON integer or a +// string holding one. Anything else is not numeric. +func permissionInt(value any) (int, bool) { + var text string + switch v := value.(type) { + case json.Number: + text = v.String() + case string: + text = strings.TrimSpace(v) + default: + return 0, false + } + n, err := strconv.Atoi(text) + if err != nil { + return 0, false + } + return n, true +} + +// Value implements driver.Valuer: NULL for a set without a non-zero value, +// else a JSON object of integers with the zero values left out. +func (p PermissionSet) Value() (driver.Value, error) { + kept := make(map[string]int, len(p)) + for code, value := range p { + if value != 0 { + kept[code] = value + } + } + if len(kept) == 0 { + return nil, nil + } + raw, err := json.Marshal(kept) + if err != nil { + return nil, err + } + return string(raw), nil +} diff --git a/models/user.go b/models/user.go index bde751d..579dab0 100644 --- a/models/user.go +++ b/models/user.go @@ -3,6 +3,7 @@ package models import ( "time" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gorm.io/gorm" ) @@ -33,6 +34,11 @@ type User struct { // 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:"-"` + // Permissions are the user's own frontend permissions (1 allows, -1 + // denies). classes.MergedPermissions combines them with the groups'. They + // are written by the admin permission editor only and are never part of + // a user API payload. + Permissions PermissionSet `gorm:"column:permissions" 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:"-"` @@ -50,6 +56,17 @@ func (User) TableName() string { return "users" } // MorphName is the PHP class string stored in system_files.attachment_type. func (User) MorphName() string { return `Golem15\User\Models\User` } +// AvatarField is the attachOne relation name of the user's display picture, +// the value of system_files.field the user API writes. +const AvatarField = "avatar" + +// AttachRelations declares the public attachOne avatar (PHP $attachOne), so +// the admin form's file upload edits the same attachment the user API +// serves. +func (User) AttachRelations() []attach.Relation { + return []attach.Relation{{Name: AvatarField, Public: true}} +} + // Fillable is the register/update field list. Username, PIN, and GDPR columns // stay out of Phase 7 because no ported handler reads them. func (User) Fillable() []string {