feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.

This commit is contained in:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+12 -1
View File
@@ -34,6 +34,7 @@ const (
CategorySystem Category = "system"
CategorySites Category = "sites"
CategoryAuthors Category = "authors"
CategoryRoles Category = "roles"
)
type Outcome string
@@ -80,6 +81,10 @@ const (
ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete"
ActionRoleCreate = "role.create"
ActionRoleEdit = "role.edit"
ActionRoleDelete = "role.delete"
ActionRoleReassign = "role.reassign"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
@@ -118,6 +123,7 @@ const (
TargetRelease = "release"
TargetSite = "site"
TargetAuthor = "author"
TargetRole = "role"
)
const (
@@ -147,6 +153,10 @@ const (
ParamFormat = "format"
ParamQuality = "quality"
ParamTag = "tag"
ParamAdded = "added"
ParamChanged = "changed"
ParamMembers = "members"
ParamMoved = "moved"
FieldTitle = "title"
FieldOnline = "online"
FieldBody = "body"
@@ -158,6 +168,7 @@ const (
FieldFormat = "format"
FieldQuality = "quality"
FieldName = "name"
FieldDescription = "description"
FieldBaseURL = "base_url"
FieldDefaultRobots = "default_robots"
FieldLocale = "locale"
@@ -193,7 +204,7 @@ func ActorTypes() []ActorType {
}
func Categories() []Category {
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors}
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors, CategoryRoles}
}
func Outcomes() []Outcome {
+22 -7
View File
@@ -3,6 +3,7 @@ package role
import (
"errors"
"strings"
"time"
)
var (
@@ -14,6 +15,18 @@ var (
ErrDuplicateGrant = errors.New("permission granted more than once")
ErrMissingPermission = errors.New("missing permission")
ErrRoleHasMembers = errors.New("role still has members")
ErrRoleNameTaken = errors.New("role name already used")
ErrRoleLocked = errors.New("role is locked")
ErrBuiltInRole = errors.New("role is built in")
ErrBuiltInName = errors.New("built-in role cannot be renamed")
ErrOwnRole = errors.New("cannot change the role you hold")
ErrInvalidRoleName = errors.New("invalid role name")
ErrUnknownContentType = errors.New("unknown content type")
ErrNotBuiltIn = errors.New("role is not built in")
ErrInvalidReassign = errors.New("invalid reassign target")
ErrEscalation = errors.New("grants more than the caller holds")
)
type Scope string
@@ -30,13 +43,15 @@ type Grant struct {
}
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"`
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"`
UpdatedAt time.Time `json:"-"`
UpdatedBy *uint32 `json:"-"`
}
func resolveGrantKey(key string) (Permission, error) {
+45
View File
@@ -0,0 +1,45 @@
package role
type GrantChange struct {
Key string `json:"key"`
From Scope `json:"from"`
To Scope `json:"to"`
}
type GrantDiff struct {
Added []Grant `json:"added"`
Removed []Grant `json:"removed"`
Changed []GrantChange `json:"changed"`
}
func (d GrantDiff) Empty() bool {
return len(d.Added) == 0 && len(d.Removed) == 0 && len(d.Changed) == 0
}
func DiffGrants(before []Grant, after []Grant) GrantDiff {
diff := GrantDiff{Added: []Grant{}, Removed: []Grant{}, Changed: []GrantChange{}}
previous := scopesByKey(before)
current := scopesByKey(after)
for _, grant := range after {
held, present := previous[grant.Key]
if !present {
diff.Added = append(diff.Added, grant)
} else if held != grant.Scope {
diff.Changed = append(diff.Changed, GrantChange{Key: grant.Key, From: held, To: grant.Scope})
}
}
for _, grant := range before {
if _, present := current[grant.Key]; !present {
diff.Removed = append(diff.Removed, grant)
}
}
return diff
}
func scopesByKey(grants []Grant) map[string]Scope {
scopes := make(map[string]Scope, len(grants))
for _, grant := range grants {
scopes[grant.Key] = grant.Scope
}
return scopes
}
+73 -6
View File
@@ -1,5 +1,10 @@
package role
import (
"slices"
"strings"
)
type MissingPermissionError struct {
Key string
}
@@ -33,14 +38,13 @@ func (r Role) GrantSet() GrantSet {
}
func effectiveScope(kind Kind, scope Scope) Scope {
switch {
case scope == ScopeAll:
if scope == ScopeAll {
return ScopeAll
case scope == ScopeOwn && kind == KindScoped:
return ScopeOwn
default:
return ScopeNone
}
if scope == ScopeOwn && kind == KindScoped {
return ScopeOwn
}
return ScopeNone
}
func scopeRank(scope Scope) int {
@@ -112,3 +116,66 @@ func (g GrantSet) Holds(key string) bool {
func (g GrantSet) HoldsAll(key string) bool {
return g.ScopeFor(key, "") == ScopeAll
}
type EscalationError struct {
Key string
}
func (e EscalationError) Error() string {
return ErrEscalation.Error() + ": " + e.Key
}
func (e EscalationError) Is(target error) bool {
return target == ErrEscalation
}
func (g GrantSet) Covers(other GrantSet, typeKeys []string) error {
sortedTypeKeys := slices.Sorted(slices.Values(typeKeys))
for _, permission := range catalogue {
if scopeRank(other.ScopeFor(permission.Key, "")) > scopeRank(g.ScopeFor(permission.Key, "")) {
return EscalationError{Key: permission.Key}
}
if !permission.PerType() {
continue
}
for _, typeKey := range sortedTypeKeys {
if scopeRank(other.ScopeFor(permission.Key, typeKey)) > scopeRank(g.ScopeFor(permission.Key, typeKey)) {
return EscalationError{Key: permission.Key + typeSeparator + typeKey}
}
}
}
return nil
}
func (g GrantSet) Effective() []Grant {
effective := []Grant{}
for _, permission := range catalogue {
if scope := g.wildcard(permission.Key); scope != ScopeNone {
effective = append(effective, Grant{Key: permission.Key, Scope: scope})
}
if permission.PerType() {
effective = append(effective, g.typedGrants(permission.Key)...)
}
}
return effective
}
func (g GrantSet) typedGrants(key string) []Grant {
prefix := key + typeSeparator
composites := []string{}
for composite := range g.scopes {
if strings.HasPrefix(composite, prefix) {
composites = append(composites, composite)
}
}
slices.Sort(composites)
typed := make([]Grant, 0, len(composites))
for _, composite := range composites {
typed = append(typed, Grant{Key: composite, Scope: g.scopes[composite]})
}
return typed
}
func (r Role) EffectiveGrants() []Grant {
return r.GrantSet().Effective()
}
@@ -4,7 +4,9 @@ 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)
Delete(id uint32) error
ReassignMembers(from uint32, to uint32) (int, error)
RemoveTypedGrants(typeKey string) (int, error)
}
+44
View File
@@ -0,0 +1,44 @@
package role
import (
"slices"
"strings"
)
func NormalizeName(name string) (string, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "", ErrInvalidRoleName
}
return trimmed, nil
}
func SameName(left string, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
}
func ValidateGrantTypes(grants []Grant, allowedTypeKeys []string) error {
for _, grant := range grants {
if !isTypedKey(grant.Key) {
continue
}
_, typeKey, err := ParseTypedKey(grant.Key)
if err != nil {
return err
}
if !slices.Contains(allowedTypeKeys, typeKey) {
return ErrUnknownContentType
}
}
return nil
}
func TypedKeys(typeKey string) []string {
keys := []string{}
for _, permission := range catalogue {
if permission.PerType() {
keys = append(keys, permission.Key+typeSeparator+typeKey)
}
}
return keys
}
@@ -1,12 +1,20 @@
package mappers
import (
"time"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/models"
)
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
var editedAt time.Time
if m.EditedAt != nil {
editedAt = m.EditedAt.UTC()
}
return role.Role{
UpdatedAt: editedAt,
UpdatedBy: m.EditedBy,
ID: m.ID,
Name: m.Name,
Description: m.Description,
@@ -18,7 +26,14 @@ func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
}
func RoleToModel(r role.Role) models.Role {
var editedAt *time.Time
if !r.UpdatedAt.IsZero() {
stamp := r.UpdatedAt
editedAt = &stamp
}
return models.Role{
EditedAt: editedAt,
EditedBy: r.UpdatedBy,
ID: r.ID,
Name: r.Name,
Description: r.Description,
@@ -1,12 +1,16 @@
package models
import "time"
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"`
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"`
EditedAt *time.Time `gorm:"column:updated_at"`
EditedBy *uint32 `gorm:"column:updated_by"`
}
type RolePermission struct {
@@ -1,11 +1,14 @@
package persistence
import (
"strings"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RoleRepository struct {
@@ -37,9 +40,17 @@ func (r *RoleRepository) List() ([]role.Role, error) {
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
return nil, err
}
var rows []entity.RolePermission
if err := r.db.Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
rowsByRole := make(map[uint32][]entity.RolePermission, len(found))
for _, row := range rows {
rowsByRole[row.RoleID] = append(rowsByRole[row.RoleID], row)
}
roles := make([]role.Role, 0, len(found))
for _, row := range found {
roles = append(roles, mappers.RoleToDomain(row, nil))
roles = append(roles, mappers.RoleToDomain(row, rowsByRole[row.ID]))
}
return roles, nil
}
@@ -60,18 +71,28 @@ func (r *RoleRepository) GetByName(name string) (role.Role, error) {
return r.withGrants(found)
}
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
rows, err := r.grantRows(roleID)
if err != nil {
return nil, err
func ensureNameUnused(tx *gorm.DB, name string, excludedID uint32) error {
var taken int64
query := tx.Model(&entity.Role{}).Where("LOWER(name) = LOWER(?)", strings.TrimSpace(name))
if excludedID != 0 {
query = query.Where("id <> ?", excludedID)
}
return mappers.GrantsToDomain(rows), nil
if err := query.Count(&taken).Error; err != nil {
return err
}
if taken > 0 {
return role.ErrRoleNameTaken
}
return 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 := ensureNameUnused(tx, created.Name, 0); err != nil {
return err
}
if err := tx.Create(&model).Error; err != nil {
return err
}
@@ -92,9 +113,12 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
if err := ensureNameUnused(tx, saved.Name, saved.ID); err != nil {
return err
}
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})
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1, "updated_at": mappers.RoleToModel(saved).EditedAt, "updated_by": saved.UpdatedBy})
if update.Error != nil {
return update.Error
}
@@ -116,4 +140,66 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
return r.Get(saved.ID)
}
func (r *RoleRepository) Delete(id uint32) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
var members int64
if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&members).Error; err != nil {
return err
}
if members > 0 {
return role.ErrRoleHasMembers
}
if err := tx.Where("role_id = ?", id).Delete(&entity.RolePermission{}).Error; err != nil {
return err
}
return tx.Delete(&entity.Role{}, id).Error
})
}
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) {
moved := r.db.Model(&entity.User{}).
Where("role_id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", from, to).
UpdateColumn("role_id", to)
if moved.Error != nil {
return 0, moved.Error
}
if moved.RowsAffected > 0 {
return int(moved.RowsAffected), nil
}
var targets int64
if err := r.db.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
return 0, err
}
if targets == 0 {
return 0, role.ErrInvalidReassign
}
return 0, nil
}
func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) {
affected := 0
err := r.db.Transaction(func(tx *gorm.DB) error {
var roleIDs []uint32
doomed := role.TypedKeys(typeKey)
if err := tx.Model(&entity.RolePermission{}).Where("permission_key IN ?", doomed).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
return err
}
if len(roleIDs) == 0 {
return nil
}
if err := tx.Where("permission_key IN ?", doomed).Delete(&entity.RolePermission{}).Error; err != nil {
return err
}
affected = len(roleIDs)
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("version", gorm.Expr("version + 1")).Error
})
if err != nil {
return 0, err
}
return affected, nil
}
var _ role.Repository = &RoleRepository{}
@@ -3,12 +3,14 @@ package useCases
import (
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"errors"
"time"
)
type CreateContentTypeUseCase struct {
contentTypes contenttype.Repository
roles role.Repository
now func() time.Time
}
@@ -19,8 +21,8 @@ type CreateContentTypeCommand struct {
Fields []contenttype.Field
}
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase {
return &CreateContentTypeUseCase{contentTypes, now}
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, roles role.Repository, now func() time.Time) *CreateContentTypeUseCase {
return &CreateContentTypeUseCase{contentTypes, roles, now}
}
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
@@ -45,7 +47,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
return contenttype.ContentType{}, err
}
created := u.now()
return u.contentTypes.Create(contenttype.ContentType{
made, err := u.contentTypes.Create(contenttype.ContentType{
Key: command.Key,
Name: command.Name,
Icon: icon,
@@ -53,6 +55,13 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
return contenttype.ContentType{}, err
}
if _, err := u.roles.RemoveTypedGrants(command.Key); err != nil {
return contenttype.ContentType{}, err
}
return made, nil
}
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
@@ -0,0 +1,56 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
)
type CreateRoleUseCase struct {
roles role.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
viewer roleViewer
}
type CreateRoleCommand struct {
Caller ContentCaller
Name string
Description string
Grants []role.Grant
}
func NewCreateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *CreateRoleUseCase {
return &CreateRoleUseCase{roles: roles, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *CreateRoleUseCase) Create(ctx context.Context, command CreateRoleCommand) (RoleView, error) {
name, err := role.NormalizeName(command.Name)
if err != nil {
return RoleView{}, err
}
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
return RoleView{}, err
}
draft := role.Role{Name: name, Description: command.Description, Version: 1, Grants: normalizedGrants(command.Grants)}
if err := ensureCovered(command.Caller, u.contentTypes, draft); err != nil {
return RoleView{}, err
}
if err := ensureNameFree(u.roles, name, 0); err != nil {
return RoleView{}, err
}
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
event.Target = audit.Target{Type: audit.TargetRole, Name: name}
created, err := u.roles.Create(draft)
if err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return RoleView{}, err
}
event.Target = roleTarget(created)
event.Params[audit.ParamAdded] = created.EffectiveGrants()
u.recorder.Record(ctx, event)
return u.viewer.one(created, 0)
}
@@ -3,15 +3,17 @@ package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/role"
)
type DeleteContentTypeUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
roles role.Repository
}
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase {
return &DeleteContentTypeUseCase{contentTypes, entries}
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, roles role.Repository) *DeleteContentTypeUseCase {
return &DeleteContentTypeUseCase{contentTypes, entries, roles}
}
func (u *DeleteContentTypeUseCase) Delete(key string) error {
@@ -35,5 +37,9 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error {
if count > 0 {
return contenttype.ErrHasEntries
}
return u.contentTypes.Delete(target.ID)
if err := u.contentTypes.Delete(target.ID); err != nil {
return err
}
_, err = u.roles.RemoveTypedGrants(target.Key)
return err
}
@@ -0,0 +1,69 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
)
type DeleteRoleUseCase struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
viewer roleViewer
}
type DeleteRoleCommand struct {
Caller ContentCaller
ID uint32
}
func NewDeleteRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *DeleteRoleUseCase {
return &DeleteRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *DeleteRoleUseCase) Delete(ctx context.Context, command DeleteRoleCommand) error {
target, err := u.roles.Get(command.ID)
if err != nil {
return err
}
if err := u.check(command.Caller, target); err != nil {
return err
}
event := audit.NewEvent(audit.ActionRoleDelete, audit.CategoryRoles)
event.Target = roleTarget(target)
event.Params[audit.ParamMembers] = 0
if err := u.roles.Delete(target.ID); err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
func (u *DeleteRoleUseCase) check(caller ContentCaller, target role.Role) error {
if err := ensureEditable(target); err != nil {
return err
}
if err := ensureNotBuiltIn(target); err != nil {
return err
}
if err := ensureNotOwnRole(u.roles, u.users, caller, target); err != nil {
return err
}
if err := ensureCovered(caller, u.contentTypes, target); err != nil {
return err
}
members, err := u.viewer.members(target.ID)
if err != nil {
return err
}
if members > 0 {
return role.ErrRoleHasMembers
}
return nil
}
@@ -46,15 +46,5 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, nil
}
func effectiveGrants(grants role.GrantSet) []role.Grant {
effective := []role.Grant{}
for _, permission := range role.Catalogue() {
if scope := grants.ScopeFor(permission.Key, ""); scope != role.ScopeNone {
effective = append(effective, role.Grant{Key: permission.Key, Scope: scope})
}
}
return effective
return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil
}
@@ -0,0 +1,28 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type GetRoleUseCase struct {
roles role.Repository
viewer roleViewer
}
func NewGetRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *GetRoleUseCase {
return &GetRoleUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *GetRoleUseCase) Get(id uint32) (RoleView, error) {
found, err := u.roles.Get(id)
if err != nil {
return RoleView{}, err
}
members, err := u.viewer.members(found.ID)
if err != nil {
return RoleView{}, err
}
return u.viewer.one(found, members)
}
@@ -0,0 +1,24 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type ListRolesUseCase struct {
roles role.Repository
viewer roleViewer
}
func NewListRolesUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *ListRolesUseCase {
return &ListRolesUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *ListRolesUseCase) List() ([]RoleView, error) {
found, err := u.roles.List()
if err != nil {
return nil, err
}
return u.viewer.views(found)
}
@@ -0,0 +1,86 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type ReassignRoleMembersUseCase struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
}
type ReassignRoleMembersCommand struct {
Caller ContentCaller
SourceID uint32
TargetID uint32
}
func NewReassignRoleMembersUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ReassignRoleMembersUseCase {
return &ReassignRoleMembersUseCase{roles, users, contentTypes, recorder}
}
func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command ReassignRoleMembersCommand) (int, error) {
source, err := u.roles.Get(command.SourceID)
if err != nil {
return 0, err
}
if err := u.checkSource(command.Caller, source); err != nil {
return 0, err
}
if err := u.checkTarget(command, source); err != nil {
return 0, err
}
event := audit.NewEvent(audit.ActionRoleReassign, audit.CategoryRoles)
event.Target = roleTarget(source)
event.Params[audit.ParamFrom] = source.ID
event.Params[audit.ParamTo] = command.TargetID
event.Params[audit.ParamMoved] = 0
moved, err := u.roles.ReassignMembers(source.ID, command.TargetID)
if err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return 0, err
}
if moved > 0 {
event.Params[audit.ParamMoved] = moved
u.recorder.Record(ctx, event)
}
return moved, nil
}
func (u *ReassignRoleMembersUseCase) checkSource(caller ContentCaller, source role.Role) error {
if err := ensureEditable(source); err != nil {
return err
}
if err := ensureNotBuiltIn(source); err != nil {
return err
}
if err := ensureNotOwnRole(u.roles, u.users, caller, source); err != nil {
return err
}
return ensureCovered(caller, u.contentTypes, source)
}
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) error {
if command.TargetID == 0 || command.TargetID == source.ID {
return role.ErrInvalidReassign
}
target, err := u.roles.Get(command.TargetID)
if errors.Is(err, role.ErrRoleNotFound) {
return role.ErrInvalidReassign
}
if err != nil {
return err
}
if target.Locked {
return role.ErrInvalidReassign
}
return ensureCovered(command.Caller, u.contentTypes, target)
}
@@ -0,0 +1,170 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type UpdateRoleUseCase struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
now func() time.Time
viewer roleViewer
}
type UpdateRoleCommand struct {
Caller ContentCaller
ID uint32
Name string
Description string
Grants []role.Grant
Version uint32
}
type ResetRoleCommand struct {
Caller ContentCaller
ID uint32
Version uint32
}
func NewUpdateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder, now func() time.Time) *UpdateRoleUseCase {
return &UpdateRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, now: now, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *UpdateRoleUseCase) Update(ctx context.Context, command UpdateRoleCommand) (RoleView, error) {
target, err := u.roles.Get(command.ID)
if err != nil {
return RoleView{}, err
}
if err := u.checkTarget(command, target); err != nil {
return RoleView{}, err
}
name, err := role.NormalizeName(command.Name)
if err != nil {
return RoleView{}, err
}
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
return RoleView{}, err
}
after := target
after.Name = name
after.Description = command.Description
after.Grants = normalizedGrants(command.Grants)
if err := ensureCovered(command.Caller, u.contentTypes, after); err != nil {
return RoleView{}, err
}
if err := ensureNameFree(u.roles, name, target.ID); err != nil {
return RoleView{}, err
}
return u.write(ctx, command.Caller, target, after)
}
func (u *UpdateRoleUseCase) Reset(ctx context.Context, command ResetRoleCommand) (RoleView, error) {
target, err := u.roles.Get(command.ID)
if err != nil {
return RoleView{}, err
}
if err := ensureEditable(target); err != nil {
return RoleView{}, err
}
if !target.BuiltIn {
return RoleView{}, role.ErrNotBuiltIn
}
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
return RoleView{}, err
}
if command.Version != target.Version {
return RoleView{}, role.ErrRoleConflict
}
seeded, found := seededRole(target.Name)
if !found {
return RoleView{}, role.ErrNotBuiltIn
}
after := target
after.Description = seeded.Description
after.Grants = seeded.EffectiveGrants()
return u.write(ctx, command.Caller, target, after)
}
func (u *UpdateRoleUseCase) checkTarget(command UpdateRoleCommand, target role.Role) error {
if err := ensureEditable(target); err != nil {
return err
}
if target.BuiltIn {
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
return err
}
if renamesBuiltIn(command.Name, target) {
return role.ErrBuiltInName
}
}
if err := ensureNotOwnRole(u.roles, u.users, command.Caller, target); err != nil {
return err
}
if err := ensureCovered(command.Caller, u.contentTypes, target); err != nil {
return err
}
if command.Version != target.Version {
return role.ErrRoleConflict
}
return nil
}
func (u *UpdateRoleUseCase) write(ctx context.Context, caller ContentCaller, before role.Role, after role.Role) (RoleView, error) {
members, err := u.viewer.members(before.ID)
if err != nil {
return RoleView{}, err
}
live, err := u.viewer.liveGrants(before.EffectiveGrants())
if err != nil {
return RoleView{}, err
}
diff := role.DiffGrants(live, after.Grants)
if diff.Empty() && before.Name == after.Name && before.Description == after.Description {
return u.viewer.one(before, members)
}
event := audit.NewEvent(audit.ActionRoleEdit, audit.CategoryRoles)
event.Target = roleTarget(before)
editor := caller.UserID
after.UpdatedAt = u.now().UTC()
after.UpdatedBy = &editor
saved, err := u.roles.Save(after)
if err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return RoleView{}, err
}
event.Target = roleTarget(saved)
event.Params = grantsParams(diff, members)
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldName, before.Name, saved.Name)
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldDescription, before.Description, saved.Description)
u.recorder.Record(ctx, event)
return u.viewer.one(saved, members)
}
func requireOwnerOnlyEditing(caller ContentCaller) error {
if !caller.Grants.Holds(role.KeyRolesEditOwnerOnly) {
return role.MissingPermissionError{Key: role.KeyRolesEditOwnerOnly}
}
return nil
}
func renamesBuiltIn(requested string, target role.Role) bool {
trimmed, err := role.NormalizeName(requested)
return err != nil || trimmed != target.Name
}
func seededRole(name string) (role.Role, bool) {
for _, seeded := range role.BuiltInRoles() {
if seeded.Name == name {
return seeded, true
}
}
return role.Role{}, false
}
@@ -0,0 +1,117 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
"slices"
)
func roleTarget(found role.Role) audit.Target {
return audit.NewTarget(audit.TargetRole, found.ID, found.Name)
}
func allowedTypeKeys(contentTypes contenttype.Repository) ([]string, error) {
types, err := contentTypes.List()
if err != nil {
return nil, err
}
keys := []string{builtintype.KeyArticle}
for _, contentType := range types {
keys = append(keys, contentType.Key)
}
slices.Sort(keys)
return keys, nil
}
func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, candidate role.Role) error {
typeKeys, err := allowedTypeKeys(contentTypes)
if err != nil {
return err
}
return caller.Grants.Covers(candidate.GrantSet(), typeKeys)
}
func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error {
account, err := users.Get(caller.UserID)
if err != nil {
return err
}
own, err := resolveRole(roles, account)
if errors.Is(err, role.ErrRoleNotFound) {
return nil
}
if err != nil {
return err
}
if own.ID == target.ID {
return role.ErrOwnRole
}
return nil
}
func ensureEditable(target role.Role) error {
if target.Locked {
return role.ErrRoleLocked
}
return nil
}
func ensureNotBuiltIn(target role.Role) error {
if target.BuiltIn {
return role.ErrBuiltInRole
}
return nil
}
func ensureNameFree(roles role.Repository, name string, excludedID uint32) error {
existing, err := roles.List()
if err != nil {
return err
}
for _, other := range existing {
if other.ID != excludedID && role.SameName(other.Name, name) {
return role.ErrRoleNameTaken
}
}
return nil
}
func validateRoleGrants(contentTypes contenttype.Repository, grants []role.Grant) error {
if err := role.ValidateGrants(false, grants); err != nil {
return err
}
typeKeys, err := allowedTypeKeys(contentTypes)
if err != nil {
return err
}
return role.ValidateGrantTypes(grants, typeKeys)
}
func normalizedGrants(grants []role.Grant) []role.Grant {
return role.Role{Grants: grants}.EffectiveGrants()
}
var writeRefusals = []error{role.ErrRoleConflict, role.ErrRoleHasMembers, role.ErrRoleNotFound, role.ErrRoleNameTaken, role.ErrInvalidReassign}
func recordRoleWriteFailure(ctx context.Context, recorder audit.Recorder, event audit.Event, err error) {
for _, refusal := range writeRefusals {
if errors.Is(err, refusal) {
return
}
}
recordFailure(ctx, recorder, event, err)
}
func grantsParams(diff role.GrantDiff, members int) map[string]any {
return map[string]any{
audit.ParamAdded: diff.Added,
audit.ParamRemoved: diff.Removed,
audit.ParamChanged: diff.Changed,
audit.ParamMembers: members,
}
}
@@ -0,0 +1,143 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"errors"
"slices"
"time"
)
type RoleEditor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleView struct {
role.Role
Members int `json:"members"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedBy *RoleEditor `json:"updated_by"`
}
type roleViewer struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
}
func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) {
typeKeys, err := allowedTypeKeys(v.contentTypes)
if err != nil {
return nil, err
}
live := make([]role.Grant, 0, len(grants))
for _, grant := range grants {
if _, typeKey, parseErr := role.ParseTypedKey(grant.Key); parseErr == nil && !slices.Contains(typeKeys, typeKey) {
continue
}
live = append(live, grant)
}
return live, nil
}
func (v roleViewer) views(found []role.Role) ([]RoleView, error) {
counts, err := memberCounts(v.roles, v.users.GetAll())
if err != nil {
return nil, err
}
editors, err := v.editors(found)
if err != nil {
return nil, err
}
views := make([]RoleView, 0, len(found))
for _, item := range found {
view := toRoleView(item, counts[item.ID], editors)
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
return nil, err
}
views = append(views, view)
}
return views, nil
}
func (v roleViewer) one(found role.Role, members int) (RoleView, error) {
editors, err := v.editors([]role.Role{found})
if err != nil {
return RoleView{}, err
}
view := toRoleView(found, members, editors)
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
return RoleView{}, err
}
return view, nil
}
func (v roleViewer) members(roleID uint32) (int, error) {
counts, err := memberCounts(v.roles, v.users.GetAll())
if err != nil {
return 0, err
}
return counts[roleID], nil
}
func (v roleViewer) editors(found []role.Role) (map[uint32]string, error) {
ids := []uint32{}
for _, item := range found {
if item.UpdatedBy != nil {
ids = append(ids, *item.UpdatedBy)
}
}
names := map[uint32]string{}
if len(ids) == 0 {
return names, nil
}
editors, err := v.users.FindByIDs(ids)
if err != nil {
return nil, err
}
for _, editor := range editors {
names[editor.ID] = editor.NameOrUsername()
}
return names, nil
}
func toRoleView(found role.Role, members int, editors map[uint32]string) RoleView {
view := RoleView{Role: found, Members: members}
view.Grants = found.EffectiveGrants()
if !found.UpdatedAt.IsZero() {
stamp := found.UpdatedAt.UTC()
view.UpdatedAt = &stamp
}
if found.UpdatedBy != nil {
if name, present := editors[*found.UpdatedBy]; present {
view.UpdatedBy = &RoleEditor{ID: *found.UpdatedBy, Name: name}
}
}
return view
}
func memberCounts(roles role.Repository, accounts []user.User) (map[uint32]int, error) {
counts := map[uint32]int{}
legacyIDs := map[user.Role]uint32{}
for _, account := range accounts {
id := account.RoleID
if id == 0 {
resolved, cached := legacyIDs[account.Role]
if !cached {
found, err := resolveRole(roles, account)
if err != nil && !errors.Is(err, role.ErrRoleNotFound) {
return nil, err
}
resolved = found.ID
legacyIDs[account.Role] = resolved
}
id = resolved
}
if id != 0 {
counts[id]++
}
}
return counts, nil
}