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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user