refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types

This commit is contained in:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
1 parent 5d6f378fce
commit b39a41be88
124 files changed
+8816 -523

No files matched your search

+2
View File
@@ -105,6 +105,7 @@ const (
ActionUserUpdateProfile = "user.update_profile"
ActionUserChangeRole = "user.change_role"
ActionUserTransferOwnership = "user.transfer_ownership"
ActionUserRemove = "user.remove"
ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew"
@@ -135,6 +136,7 @@ const (
ParamRemoved = "removed"
ParamFrom = "from"
ParamTo = "to"
ParamToName = "to_name"
ParamMaxGBFrom = "max_gb_from"
ParamMaxGBTo = "max_gb_to"
ParamMerged = "merged"
+13 -12
View File
@@ -16,18 +16,19 @@ var (
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")
ErrUnknownRole = errors.New("unknown role")
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")
ErrReassignTargetNotFound = errors.New("reassign target not found")
ErrEscalation = errors.New("grants more than the caller holds")
ErrUnknownRole = errors.New("unknown role")
)
type Scope string
@@ -10,6 +10,7 @@ type Repository interface {
FindByIDs(ids []uint32) ([]User, error)
Create(user User) (User, error)
Delete(id uint32) error
DeleteIfRole(id uint32, roleID uint32) error
UpdateVerificationStatus(userId uint32, isVerified bool) (User, error)
UpdatePassword(userId uint32, password string) (User, error)
UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (User, error)
@@ -52,6 +52,26 @@ func (u *UserRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domainUser.ErrUserNotFound)
}
func (u *UserRepository) DeleteIfRole(id uint32, roleID uint32) error {
return u.db.Transaction(func(tx *gorm.DB) error {
result := tx.Where("id = ? AND role_id = ?", id, roleID).Delete(&entity.User{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
return nil
}
var users int64
if err := tx.Model(&entity.User{}).Where("id = ?", id).Count(&users).Error; err != nil {
return err
}
if users == 0 {
return domainUser.ErrUserNotFound
}
return domainUser.ErrRoleChanged
})
}
func (u *UserRepository) GetAll() ([]domainUser.User, error) {
var users []entity.User
if err := u.db.Model(&entity.User{}).Find(&users).Error; err != nil {
@@ -34,7 +34,8 @@ func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command Reass
if err := u.checkSource(command.Caller, source); err != nil {
return 0, err
}
if err := u.checkTarget(command, source); err != nil {
target, err := u.checkTarget(command, source)
if err != nil {
return 0, err
}
@@ -42,6 +43,7 @@ func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command Reass
event.Target = roleTarget(source)
event.Params[audit.ParamFrom] = source.ID
event.Params[audit.ParamTo] = command.TargetID
event.Params[audit.ParamToName] = target.Name
event.Params[audit.ParamMoved] = 0
moved, err := u.roles.ReassignMembers(source.ID, command.TargetID)
if err != nil {
@@ -68,19 +70,22 @@ func (u *ReassignRoleMembersUseCase) checkSource(caller ContentCaller, source ro
return ensureCovered(caller, u.contentTypes, source)
}
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) error {
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) (role.Role, error) {
if command.TargetID == 0 || command.TargetID == source.ID {
return role.ErrInvalidReassign
return role.Role{}, role.ErrInvalidReassign
}
target, err := u.roles.Get(command.TargetID)
if errors.Is(err, role.ErrRoleNotFound) {
return role.ErrInvalidReassign
return role.Role{}, role.ErrReassignTargetNotFound
}
if err != nil {
return err
return role.Role{}, err
}
if target.Locked {
return role.ErrInvalidReassign
return role.Role{}, role.ErrInvalidReassign
}
return ensureCovered(command.Caller, u.contentTypes, target)
if err := ensureCovered(command.Caller, u.contentTypes, target); err != nil {
return role.Role{}, err
}
return target, nil
}
@@ -0,0 +1,79 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type RemoveUserUseCase struct {
users user.Repository
roles role.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
}
type RemoveUserCommand struct {
Caller ContentCaller
TargetID uint32
ExpectedRoleID uint32
}
func NewRemoveUserUseCase(users user.Repository, roles role.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *RemoveUserUseCase {
return &RemoveUserUseCase{users: users, roles: roles, contentTypes: contentTypes, recorder: recorder}
}
func (u *RemoveUserUseCase) Remove(ctx context.Context, command RemoveUserCommand) error {
if command.ExpectedRoleID == 0 {
return role.ErrUnknownRole
}
target, err := u.users.Get(command.TargetID)
if err != nil {
return err
}
if err := u.check(command, target); err != nil {
return err
}
event := audit.NewEvent(audit.ActionUserRemove, audit.CategoryUsers)
event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username)
if err := u.users.DeleteIfRole(target.ID, command.ExpectedRoleID); err != nil {
u.recordWriteFailure(ctx, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
func (u *RemoveUserUseCase) check(command RemoveUserCommand, target user.User) error {
if target.ID == command.Caller.UserID {
return role.ErrOwnRole
}
if target.RoleID != command.ExpectedRoleID {
return user.ErrRoleChanged
}
held, err := resolveRole(u.roles, target)
if errors.Is(err, role.ErrRoleNotFound) {
return nil
}
if err != nil {
return err
}
if held.Locked {
return role.ErrRoleLocked
}
typeKeys, err := allowedTypeKeys(u.contentTypes)
if err != nil {
return err
}
return ensureCoveredBy(command.Caller, typeKeys, held)
}
func (u *RemoveUserUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
if errors.Is(err, user.ErrRoleChanged) || errors.Is(err, user.ErrUserNotFound) {
return
}
recordFailure(ctx, u.recorder, event, err)
}