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

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