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:
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