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:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user