Files
sm-user-plugin/classes/permissions.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

134 lines
4.3 KiB
Go

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
}