feat: roles system backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 05:02:49 +01:00
1 parent 44cca46d5e
commit 8c6c6456da
50 files changed
+2578 -22

No files matched your search

+1
View File
@@ -118,6 +118,7 @@ type Article struct {
Body string `json:"body"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedBy uint32 `json:"-"`
Authors []author.Byline `json:"authors"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
+48
View File
@@ -0,0 +1,48 @@
package role
const (
OwnerName = "Owner"
AdminName = "Admin"
WriterName = "Writer"
)
func allGrants(include func(Permission) bool) []Grant {
grants := []Grant{}
for _, permission := range catalogue {
if include(permission) {
grants = append(grants, Grant{Key: permission.Key, Scope: ScopeAll})
}
}
return grants
}
func writerGrants() []Grant {
return []Grant{
{Key: "content.read", Scope: ScopeAll},
{Key: "content.create", Scope: ScopeAll},
{Key: "content.update", Scope: ScopeAll},
{Key: "schema.view", Scope: ScopeAll},
{Key: "media.view", Scope: ScopeAll},
{Key: "media.upload", Scope: ScopeAll},
{Key: "authors.view", Scope: ScopeAll},
{Key: "authors.edit", Scope: ScopeOwn},
{Key: "activity.view", Scope: ScopeOwn},
}
}
func BuiltInRoles() []Role {
return []Role{
{
Name: OwnerName, BuiltIn: true, Locked: true, Version: 1,
Grants: allGrants(func(Permission) bool { return true }),
},
{
Name: AdminName, BuiltIn: true, Version: 1,
Grants: allGrants(func(p Permission) bool { return !p.OwnerOnly }),
},
{
Name: WriterName, BuiltIn: true, Version: 1,
Grants: writerGrants(),
},
}
}
+118
View File
@@ -0,0 +1,118 @@
package role
import "strings"
type Kind string
const (
KindScoped Kind = "scoped"
KindToggle Kind = "toggle"
)
const contentGroup = "content"
type Permission struct {
Key string
Group string
Kind Kind
OwnerOnly bool
}
func (p Permission) PerType() bool {
return strings.HasPrefix(p.Key, contentGroup+".")
}
func scoped(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindScoped}
}
func toggle(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindToggle}
}
func ownerOnly(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindToggle, OwnerOnly: true}
}
var catalogue = []Permission{
scoped("content", "content.read"),
toggle("content", "content.create"),
scoped("content", "content.update"),
scoped("content", "content.delete"),
scoped("content", "content.publish"),
toggle("schema", "schema.view"),
toggle("schema", "schema.edit"),
toggle("schema", "schema.delete"),
toggle("media", "media.view"),
toggle("media", "media.upload"),
scoped("media", "media.delete"),
toggle("authors", "authors.view"),
toggle("authors", "authors.create"),
scoped("authors", "authors.edit"),
toggle("authors", "authors.delete"),
scoped("activity", "activity.view"),
toggle("activity", "activity.export"),
toggle("users", "users.view"),
toggle("users", "users.invite"),
toggle("users", "users.change_role"),
toggle("users", "users.remove"),
toggle("settings", "settings.view"),
toggle("settings", "settings.edit_general"),
toggle("settings", "settings.manage_sites"),
ownerOnly("settings", "settings.delete_site"),
scoped("settings", "apikeys.view"),
toggle("settings", "apikeys.create"),
scoped("settings", "apikeys.revoke"),
toggle("roles", "roles.view"),
toggle("roles", "roles.manage"),
ownerOnly("roles", "roles.edit_owner_only"),
ownerOnly("system", "system.apply_updates"),
ownerOnly("system", "system.edit_retention"),
ownerOnly("system", "system.transfer_ownership"),
}
func Catalogue() []Permission {
return append([]Permission(nil), catalogue...)
}
func Lookup(key string) (Permission, error) {
for _, permission := range catalogue {
if permission.Key == key {
return permission, nil
}
}
return Permission{}, ErrUnknownPermission
}
func Groups() []string {
groups := []string{}
for _, permission := range catalogue {
if len(groups) == 0 || groups[len(groups)-1] != permission.Group {
groups = append(groups, permission.Group)
}
}
return groups
}
const (
typeSeparator = ":"
maxKeyLength = 100
typeKeyAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-"
)
func ParseTypedKey(composite string) (string, string, error) {
base, typeKey, found := strings.Cut(composite, typeSeparator)
if !found || len(composite) > maxKeyLength || typeKey == "" {
return "", "", ErrUnknownPermission
}
permission, err := Lookup(base)
if err != nil || !permission.PerType() {
return "", "", ErrUnknownPermission
}
for _, char := range typeKey {
if !strings.ContainsRune(typeKeyAlphabet, char) {
return "", "", ErrUnknownPermission
}
}
return base, typeKey, nil
}
+120
View File
@@ -0,0 +1,120 @@
package role
import (
"errors"
"strings"
)
var (
ErrRoleNotFound = errors.New("role not found")
ErrUnknownPermission = errors.New("unknown permission")
ErrRoleConflict = errors.New("role was modified concurrently")
ErrInvalidScope = errors.New("invalid scope for permission")
ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role")
)
type Scope string
const (
ScopeNone Scope = "none"
ScopeOwn Scope = "own"
ScopeAll Scope = "all"
)
type Grant struct {
Key string `json:"key"`
Scope Scope `json:"scope"`
}
type Role struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []Grant `json:"grants"`
}
func (r Role) scopeOf(key string) Scope {
for _, grant := range r.Grants {
if grant.Key == key {
return grant.Scope
}
}
return ScopeNone
}
func (r Role) Allows(key string, owned bool) bool {
permission, err := Lookup(key)
if err != nil {
return false
}
if permission.OwnerOnly && !r.Locked {
return false
}
return scopeAllows(permission.Kind, r.scopeOf(key), owned)
}
func (r Role) AllowsFor(key string, typeKey string, owned bool) bool {
if typeKey == "" {
return r.Allows(key, owned)
}
composite := key + typeSeparator + typeKey
base, _, err := ParseTypedKey(composite)
if err != nil {
return false
}
permission, _ := Lookup(base)
for _, grant := range r.Grants {
if grant.Key == composite {
return scopeAllows(permission.Kind, grant.Scope, owned)
}
}
return r.Allows(key, owned)
}
func scopeAllows(kind Kind, scope Scope, owned bool) bool {
switch scope {
case ScopeAll:
return true
case ScopeOwn:
return kind == KindScoped && owned
default:
return false
}
}
func resolveGrantKey(key string) (Permission, error) {
if strings.Contains(key, typeSeparator) {
base, _, err := ParseTypedKey(key)
if err != nil {
return Permission{}, err
}
return Lookup(base)
}
return Lookup(key)
}
func ValidateGrants(locked bool, grants []Grant) error {
for _, grant := range grants {
permission, err := resolveGrantKey(grant.Key)
if err != nil {
return err
}
if permission.OwnerOnly && !locked {
return ErrOwnerOnlyGrant
}
if !scopeValidFor(permission.Kind, grant.Scope) {
return ErrInvalidScope
}
}
return nil
}
func scopeValidFor(kind Kind, scope Scope) bool {
if kind == KindToggle {
return scope == ScopeAll
}
return scope == ScopeOwn || scope == ScopeAll
}
@@ -0,0 +1,10 @@
package role
type Repository interface {
List() ([]Role, error)
Get(id uint32) (Role, error)
GetByName(name string) (Role, error)
ListGrants(roleID uint32) ([]Grant, error)
Create(created Role) (Role, error)
Save(saved Role) (Role, error)
}
+1
View File
@@ -47,6 +47,7 @@ type User struct {
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"`
Role Role `json:"role"`
RoleID uint32 `json:"role_id"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
@@ -0,0 +1,25 @@
package database
import (
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func BackfillCreatedBy(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var owner entity.User
result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
if err := tx.Unscoped().Model(&entity.Article{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error; err != nil {
return err
}
return tx.Model(&entity.Entry{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error
})
}
@@ -7,16 +7,25 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{})
if err != nil {
return err
}
if err := ensureImagePathIndex(db); err != nil {
return err
}
if err := SeedBuiltInRoles(db); err != nil {
return err
}
if err := BackfillUserRoles(db); err != nil {
return err
}
if err := BackfillUserRoleIDs(db); err != nil {
return err
}
if err := BackfillCreatedBy(db); err != nil {
return err
}
if err := SeedDefaultSite(db, publicURL); err != nil {
return err
}
@@ -0,0 +1,70 @@
package database
import (
"slices"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func SeedBuiltInRoles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for _, builtIn := range role.BuiltInRoles() {
if err := seedBuiltInRole(tx, builtIn); err != nil {
return err
}
}
return nil
})
}
func seedBuiltInRole(tx *gorm.DB, builtIn role.Role) error {
id, found, err := builtInRoleID(tx, builtIn.Name)
if err != nil {
return err
}
if found {
return reconcileBuiltInRole(tx, id, builtIn)
}
model := mappers.RoleToModel(builtIn)
if err := tx.Create(&model).Error; err != nil {
return err
}
return tx.Create(mappers.GrantsToModel(model.ID, builtIn.Grants)).Error
}
func builtInRoleID(tx *gorm.DB, name string) (uint32, bool, error) {
var found entity.Role
result := tx.Where("name = ? AND built_in = ?", name, true).Order("id asc").Limit(1).Find(&found)
if result.Error != nil {
return 0, false, result.Error
}
return found.ID, result.RowsAffected > 0, nil
}
func reconcileBuiltInRole(tx *gorm.DB, id uint32, builtIn role.Role) error {
var stored entity.Role
if err := tx.First(&stored, id).Error; err != nil {
return err
}
if !builtIn.Locked && stored.Version != 1 {
return nil
}
var present []string
if err := tx.Model(&entity.RolePermission{}).Where("role_id = ?", id).Pluck("permission_key", &present).Error; err != nil {
return err
}
missing := []role.Grant{}
for _, grant := range builtIn.Grants {
if !slices.Contains(present, grant.Key) {
missing = append(missing, grant)
}
}
if len(missing) == 0 {
return nil
}
return tx.Create(mappers.GrantsToModel(id, missing)).Error
}
@@ -0,0 +1,35 @@
package database
import (
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
var legacyRoleNames = map[domainUser.Role]string{
domainUser.RoleOwner: role.OwnerName,
domainUser.RoleAdmin: role.AdminName,
}
func BackfillUserRoleIDs(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for legacy, name := range legacyRoleNames {
roleID, found, err := builtInRoleID(tx, name)
if err != nil {
return err
}
if !found {
continue
}
err = tx.Model(&entity.User{}).
Where("role = ? AND (role_id IS NULL OR role_id = 0)", string(legacy)).
UpdateColumn("role_id", roleID).Error
if err != nil {
return err
}
}
return nil
})
}
@@ -37,6 +37,7 @@ func ArticleToDomain(m models.Article) article.Article {
Body: m.Body,
IsOnline: m.IsOnline,
SiteID: m.SiteID,
CreatedBy: m.CreatedBy,
Slug: m.Slug,
Excerpt: m.Excerpt,
SEOTitle: m.SEOTitle,
@@ -60,6 +61,7 @@ func ArticleToModel(a article.Article) models.Article {
Body: a.Body,
IsOnline: a.IsOnline,
SiteID: a.SiteID,
CreatedBy: a.CreatedBy,
Slug: a.Slug,
Excerpt: a.Excerpt,
SEOTitle: a.SEOTitle,
@@ -0,0 +1,45 @@
package mappers
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/models"
)
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
return role.Role{
ID: m.ID,
Name: m.Name,
Description: m.Description,
BuiltIn: m.BuiltIn,
Locked: m.Locked,
Version: m.Version,
Grants: GrantsToDomain(grants),
}
}
func RoleToModel(r role.Role) models.Role {
return models.Role{
ID: r.ID,
Name: r.Name,
Description: r.Description,
BuiltIn: r.BuiltIn,
Locked: r.Locked,
Version: r.Version,
}
}
func GrantsToDomain(rows []models.RolePermission) []role.Grant {
grants := make([]role.Grant, 0, len(rows))
for _, row := range rows {
grants = append(grants, role.Grant{Key: row.Key, Scope: role.Scope(row.Scope)})
}
return grants
}
func GrantsToModel(roleID uint32, grants []role.Grant) []models.RolePermission {
rows := make([]models.RolePermission, 0, len(grants))
for _, grant := range grants {
rows = append(rows, models.RolePermission{RoleID: roleID, Key: grant.Key, Scope: string(grant.Scope)})
}
return rows
}
@@ -19,6 +19,20 @@ func DomainToNullableString(value string) *string {
return &value
}
func nullableIDToDomain(value *uint32) uint32 {
if value == nil {
return 0
}
return *value
}
func domainToNullableID(value uint32) *uint32 {
if value == 0 {
return nil
}
return &value
}
func UserToDomain(m models.User) user.User {
return user.User{
ID: m.ID,
@@ -29,6 +43,7 @@ func UserToDomain(m models.User) user.User {
DisplayName: m.DisplayName,
IsVerified: m.IsVerified,
Role: user.Role(m.Role),
RoleID: nullableIDToDomain(m.RoleID),
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration,
@@ -47,6 +62,7 @@ func UserToModel(a user.User) models.User {
DisplayName: a.DisplayName,
IsVerified: a.IsVerified,
Role: string(a.Role),
RoleID: domainToNullableID(a.RoleID),
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
@@ -13,6 +13,7 @@ type Article struct {
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32 `gorm:"index"`
CreatedBy uint32 `gorm:"not null;default:0"`
Slug string `gorm:"size:100;not null;default:''"`
Excerpt string `gorm:"size:300;not null;default:''"`
SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"`
@@ -0,0 +1,17 @@
package models
type Role struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"not null;size:100;index"`
Description string `gorm:"not null;default:'';size:255"`
BuiltIn bool `gorm:"not null;default:false"`
Locked bool `gorm:"not null;default:false"`
Version uint32 `gorm:"not null;default:1"`
}
type RolePermission struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
RoleID uint32 `gorm:"not null;uniqueIndex:idx_role_permission_key"`
Key string `gorm:"column:permission_key;not null;size:100;uniqueIndex:idx_role_permission_key"`
Scope string `gorm:"not null;size:8"`
}
@@ -13,6 +13,7 @@ type User struct {
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:'';size:32"`
RoleID *uint32 `gorm:"index"`
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
@@ -0,0 +1,119 @@
package persistence
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
type RoleRepository struct {
db *gorm.DB
}
func NewRoleRepository(db *gorm.DB) *RoleRepository {
return &RoleRepository{db}
}
func (r *RoleRepository) grantRows(roleID uint32) ([]entity.RolePermission, error) {
var rows []entity.RolePermission
if err := r.db.Where("role_id = ?", roleID).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *RoleRepository) withGrants(found entity.Role) (role.Role, error) {
rows, err := r.grantRows(found.ID)
if err != nil {
return role.Role{}, err
}
return mappers.RoleToDomain(found, rows), nil
}
func (r *RoleRepository) List() ([]role.Role, error) {
var found []entity.Role
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
return nil, err
}
roles := make([]role.Role, 0, len(found))
for _, row := range found {
roles = append(roles, mappers.RoleToDomain(row, nil))
}
return roles, nil
}
func (r *RoleRepository) Get(id uint32) (role.Role, error) {
var found entity.Role
if err := r.db.First(&found, id).Error; err != nil {
return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound)
}
return r.withGrants(found)
}
func (r *RoleRepository) GetByName(name string) (role.Role, error) {
var found entity.Role
if err := r.db.Where("name = ?", name).Order("built_in DESC, id ASC").First(&found).Error; err != nil {
return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound)
}
return r.withGrants(found)
}
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
rows, err := r.grantRows(roleID)
if err != nil {
return nil, err
}
return mappers.GrantsToDomain(rows), nil
}
func (r *RoleRepository) Create(created role.Role) (role.Role, error) {
model := mappers.RoleToModel(created)
model.ID = 0
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&model).Error; err != nil {
return err
}
rows := mappers.GrantsToModel(model.ID, created.Grants)
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
if err != nil {
return role.Role{}, err
}
return r.withGrants(model)
}
func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
update := tx.Model(&entity.Role{}).
Where("id = ? AND version = ?", saved.ID, saved.Version).
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1})
if update.Error != nil {
return update.Error
}
if update.RowsAffected == 0 {
return role.ErrRoleConflict
}
if err := tx.Where("role_id = ?", saved.ID).Delete(&entity.RolePermission{}).Error; err != nil {
return err
}
rows := mappers.GrantsToModel(saved.ID, saved.Grants)
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
if err != nil {
return role.Role{}, err
}
return r.Get(saved.ID)
}
var _ role.Repository = &RoleRepository{}
@@ -71,6 +71,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
CreatedBy: createArticle.CreatedBy,
Authors: creditedAuthors,
SEODismissed: []string{},
})
@@ -0,0 +1,69 @@
package useCases
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type Access struct {
Role role.Role
Permissions []role.Grant
}
type GetMyAccessUseCase struct {
roles role.Repository
}
func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles}
}
var legacyBuiltInNames = map[user.Role]string{
user.RoleOwner: role.OwnerName,
user.RoleAdmin: role.AdminName,
}
func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) {
if account.RoleID != 0 {
return u.roles.Get(account.RoleID)
}
name, known := legacyBuiltInNames[account.Role]
if !known {
return role.Role{}, role.ErrRoleNotFound
}
found, err := u.roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
}
func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
resolved, err := u.resolveRole(account)
if err != nil {
return Access{}, err
}
granted, err := u.roles.ListGrants(resolved.ID)
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: orderedGrants(granted)}, nil
}
func orderedGrants(granted []role.Grant) []role.Grant {
scopes := make(map[string]role.Scope, len(granted))
for _, grant := range granted {
scopes[grant.Key] = grant.Scope
}
ordered := []role.Grant{}
for _, permission := range role.Catalogue() {
scope, present := scopes[permission.Key]
if present && scope != role.ScopeNone {
ordered = append(ordered, role.Grant{Key: permission.Key, Scope: scope})
}
}
return ordered
}
@@ -0,0 +1,27 @@
package useCases
import "RenewCMS/internal/domain/role"
type PermissionGroup struct {
Key string
Items []role.Permission
}
type ListPermissionsUseCase struct{}
func NewListPermissionsUseCase() *ListPermissionsUseCase {
return &ListPermissionsUseCase{}
}
func (u *ListPermissionsUseCase) List() []PermissionGroup {
groups := []PermissionGroup{}
for _, permission := range role.Catalogue() {
last := len(groups) - 1
if last < 0 || groups[last].Key != permission.Group {
groups = append(groups, PermissionGroup{Key: permission.Group})
last++
}
groups[last].Items = append(groups[last].Items, permission)
}
return groups
}