Files
sm-user-plugin/models/permission_set.go
Jakub Zych cdea47a178 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
2026-10-05 13:21:51 +02:00

113 lines
3.1 KiB
Go

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
}