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
This commit is contained in:
133
classes/permissions.go
Normal file
133
classes/permissions.go
Normal file
@@ -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
|
||||
}
|
||||
317
classes/permissions_test.go
Normal file
317
classes/permissions_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
25
models/frontend_permission.go
Normal file
25
models/frontend_permission.go
Normal file
@@ -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{})
|
||||
}
|
||||
112
models/permission_set.go
Normal file
112
models/permission_set.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user