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

@@ -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)
}