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:
Jakub Zych
2026-10-05 13:21:51 +02:00
parent 970eba7734
commit cdea47a178
5 changed files with 604 additions and 0 deletions

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

View File

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