feat: Users API, ownership transfer and removal of the legacy role string

This commit is contained in:
Florian Sylvain committed 2026-10-27 03:08:15 +01:00
1 parent 094df63a7f
commit 80f7037d3a
124 files changed
+4644 -824

No files matched your search

@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
@@ -14,6 +15,7 @@ import (
type AcceptInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
roles role.Repository
hasher hash.Hasher
recorder audit.Recorder
}
@@ -24,8 +26,16 @@ type AcceptInviteCommand struct {
Password string
}
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase {
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher, recorder}
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase {
return &AcceptInviteUseCase{userRepository, inviteRepository, roles, hasher, recorder}
}
func (a *AcceptInviteUseCase) invitedRole(pending invite.Invite) (role.Role, error) {
assigned, err := assignableRole(a.roles, pending.RoleID)
if errors.Is(err, role.ErrUnknownRole) || errors.Is(err, role.ErrRoleLocked) {
return role.Role{}, invite.ErrInviteInvalid
}
return assigned, err
}
func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptInviteCommand) (user.User, error) {
@@ -39,11 +49,18 @@ func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptIn
}
event.Target = inviteTarget(pending)
created, err := a.accept(command, pending, now)
assigned, err := a.invitedRole(pending)
if err != nil {
a.recordRefusal(ctx, event, command, err)
return user.User{}, err
}
created, err := a.accept(command, pending, assigned, now)
if err != nil {
a.recordRefusal(ctx, event, command, err)
return user.User{}, err
}
event = inviteRoleParams(event, assigned)
event.Actor = audit.UserActor(created)
a.recorder.Record(ctx, event)
return created, nil
@@ -54,7 +71,7 @@ func (a *AcceptInviteUseCase) recordRefusal(ctx context.Context, event audit.Eve
recordFailure(ctx, a.recorder, event, cause)
}
func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, now time.Time) (user.User, error) {
func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, assigned role.Role, now time.Time) (user.User, error) {
if a.emailHasAccount(pending.Email) {
if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil {
return user.User{}, err
@@ -80,7 +97,7 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
Role: user.RoleAdmin,
RoleID: assigned.ID,
})
if errors.Is(err, invite.ErrInviteNotFound) {
return user.User{}, invite.ErrInviteInvalid
@@ -0,0 +1,110 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type ChangeUserRoleUseCase struct {
users user.Repository
roles role.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
}
type ChangeUserRoleCommand struct {
Caller ContentCaller
TargetID uint32
RoleID uint32
}
func NewChangeUserRoleUseCase(users user.Repository, roles role.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ChangeUserRoleUseCase {
return &ChangeUserRoleUseCase{users: users, roles: roles, contentTypes: contentTypes, recorder: recorder}
}
var roleChangeRefusals = []error{user.ErrRoleChanged, user.ErrUserNotFound, role.ErrRoleNotFound, role.ErrUnknownRole}
func (u *ChangeUserRoleUseCase) ChangeRole(ctx context.Context, command ChangeUserRoleCommand) (DirectoryEntry, error) {
target, err := u.users.Get(command.TargetID)
if err != nil {
return DirectoryEntry{}, err
}
current, held, err := u.heldRole(target)
if err != nil {
return DirectoryEntry{}, err
}
if held && current.Locked {
return DirectoryEntry{}, role.ErrRoleLocked
}
next, err := assignableRole(u.roles, command.RoleID)
if err != nil {
return DirectoryEntry{}, err
}
if err := u.checkCoverage(command, target, current, held, next); err != nil {
return DirectoryEntry{}, err
}
if held && current.ID == next.ID {
ref := NewRoleRef(next)
return userEntry(target, &ref), nil
}
return u.write(ctx, target, current, next)
}
func (u *ChangeUserRoleUseCase) heldRole(target user.User) (role.Role, bool, error) {
current, err := resolveRole(u.roles, target)
if errors.Is(err, role.ErrRoleNotFound) {
return role.Role{}, false, nil
}
if err != nil {
return role.Role{}, false, err
}
return current, true, nil
}
func (u *ChangeUserRoleUseCase) checkCoverage(command ChangeUserRoleCommand, target user.User, current role.Role, held bool, next role.Role) error {
if target.ID == command.Caller.UserID {
return role.ErrOwnRole
}
typeKeys, err := allowedTypeKeys(u.contentTypes)
if err != nil {
return err
}
if held {
if err := ensureCoveredBy(command.Caller, typeKeys, current); err != nil {
return err
}
}
return ensureCoveredBy(command.Caller, typeKeys, next)
}
func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, current role.Role, next role.Role) (DirectoryEntry, error) {
event := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username)
updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID)
if errors.Is(err, role.ErrRoleNotFound) {
return DirectoryEntry{}, role.ErrUnknownRole
}
if err != nil {
u.recordWriteFailure(ctx, event, err)
return DirectoryEntry{}, err
}
event.Params[audit.ParamFrom] = current.ID
event.Params[audit.ParamTo] = next.ID
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name)
u.recorder.Record(ctx, event)
ref := NewRoleRef(next)
return userEntry(updated, &ref), nil
}
func (u *ChangeUserRoleUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
for _, refusal := range roleChangeRefusals {
if errors.Is(err, refusal) {
return
}
}
recordFailure(ctx, u.recorder, event, err)
}
@@ -2,9 +2,11 @@ package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
@@ -15,52 +17,98 @@ import (
type CreateInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
roles role.Repository
contentTypes contenttype.Repository
hasher hash.Hasher
mailer inviteMailer
recorder audit.Recorder
}
type CreateInviteCommand struct {
Caller ContentCaller
Email string
InvitedBy uint32
RoleID uint32
}
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder}
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, contentTypes contenttype.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, roles, contentTypes, hasher, inviteMailer{mailRepository, publicURL}, recorder}
}
func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (invite.Invite, error) {
var inviteRoleRefusals = []error{role.ErrUnknownRole, role.ErrRoleLocked, role.ErrEscalation}
func isInviteRoleRefusal(err error) bool {
for _, refusal := range inviteRoleRefusals {
if errors.Is(err, refusal) {
return true
}
}
return false
}
func inviteRoleParams(event audit.Event, assigned role.Role) audit.Event {
event.Params[audit.ParamRoleID] = assigned.ID
event.Params[audit.ParamRole] = assigned.Name
return event
}
func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (InviteView, error) {
event := audit.NewEvent(audit.ActionInviteCreate, audit.CategoryUsers)
created, err := g.create(command)
created, assigned, err := g.create(command)
if err != nil {
recordFailure(ctx, g.recorder, event, err)
return invite.Invite{}, err
if !isInviteRoleRefusal(err) {
recordFailure(ctx, g.recorder, event, err)
}
return InviteView{}, err
}
event.Target = inviteTarget(created)
g.recorder.Record(ctx, event)
return created, nil
g.recorder.Record(ctx, inviteRoleParams(event, assigned))
return InviteView{Invite: created, Role: NewRoleRef(assigned)}, nil
}
func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, error) {
func (g *CreateInviteUseCase) assignedRole(command CreateInviteCommand) (role.Role, error) {
if command.RoleID == 0 {
return builtInRole(g.roles, role.WriterName)
}
return assignableRole(g.roles, command.RoleID)
}
func (g *CreateInviteUseCase) grantableRole(command CreateInviteCommand) (role.Role, error) {
assigned, err := g.assignedRole(command)
if err != nil {
return role.Role{}, err
}
if err := ensureCovered(command.Caller, g.contentTypes, assigned); err != nil {
return role.Role{}, err
}
return assigned, nil
}
func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, role.Role, error) {
email := strings.ToLower(command.Email)
now := time.Now()
inviter, err := g.userRepository.Get(command.InvitedBy)
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
assigned, err := g.grantableRole(command)
if err != nil {
return invite.Invite{}, role.Role{}, err
}
if g.emailHasAccount(email) {
return invite.Invite{}, invite.ErrEmailHasAccount
return invite.Invite{}, role.Role{}, invite.ErrEmailHasAccount
}
if err := g.discardOpenInvite(email, now); err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
rawCode, hashedCode, err := newInviteCode(g.hasher)
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
createdInvite, err := g.inviteRepository.Create(invite.Invite{
@@ -68,20 +116,21 @@ func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite
HashedCode: hashedCode,
ExpiresAt: now.Add(invite.Lifetime),
InvitedBy: command.InvitedBy,
RoleID: assigned.ID,
CreatedAt: now,
})
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil {
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
return invite.Invite{}, errors.Join(sendErr, deleteErr)
return invite.Invite{}, role.Role{}, errors.Join(sendErr, deleteErr)
}
return invite.Invite{}, sendErr
return invite.Invite{}, role.Role{}, sendErr
}
return createdInvite, nil
return createdInvite, assigned, nil
}
func (g *CreateInviteUseCase) emailHasAccount(email string) bool {
+10 -3
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
@@ -15,6 +16,7 @@ import (
type CreateUserUseCase struct {
userRepository user.Repository
roles role.Repository
hasher hash.Hasher
mailRepository mail.Repository
publicURL string
@@ -32,8 +34,8 @@ type MailValidationData struct {
VerificationCode string
}
func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase {
return &CreateUserUseCase{userRepository, hasher, mailRepository, publicURL, recorder}
func NewCreateUserUseCase(userRepository user.Repository, roles role.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase {
return &CreateUserUseCase{userRepository, roles, hasher, mailRepository, publicURL, recorder}
}
func (g *CreateUserUseCase) CreateUser(ctx context.Context, createUser CreateUserCommand) (user.User, error) {
@@ -59,6 +61,11 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
return user.User{}, err
}
ownerRole, err := builtInRole(g.roles, role.OwnerName)
if err != nil {
return user.User{}, err
}
rawUuid := uuid.NewString()
hashedPassword, err := g.hasher.Hash(createUser.Password)
@@ -77,7 +84,7 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
PasswordResetCode: "",
Email: createUser.Email,
IsVerified: false,
Role: user.RoleOwner,
RoleID: ownerRole.ID,
VerificationCode: hashedVerificationCode,
VerificationExpiration: time.Now().Add(2 * time.Hour),
})
@@ -18,27 +18,11 @@ func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles}
}
var legacyBuiltInNames = map[user.Role]string{
user.RoleOwner: role.OwnerName,
user.RoleAdmin: role.AdminName,
}
func resolveRole(roles role.Repository, account user.User) (role.Role, error) {
if account.RoleID != 0 {
return roles.Get(account.RoleID)
}
name, known := legacyBuiltInNames[account.Role]
if !known {
if account.RoleID == 0 {
return role.Role{}, role.ErrRoleNotFound
}
found, err := roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
return roles.Get(account.RoleID)
}
func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"sort"
"time"
@@ -15,6 +16,7 @@ const (
type ListUsersAndInvitesUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
roles role.Repository
}
type DirectoryEntry struct {
@@ -23,30 +25,38 @@ type DirectoryEntry struct {
Username string
DisplayName string
Email string
Role string
Role *RoleRef
Status string
CreatedAt time.Time
}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository, roles}
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
func userEntry(account user.User, found *RoleRef) DirectoryEntry {
return DirectoryEntry{
ID: account.ID,
Kind: EntryKindUser,
Username: account.Username,
DisplayName: account.DisplayName,
Email: account.Email,
Role: found,
Status: string(invite.StatusAccepted),
CreatedAt: account.CreatedAt,
}
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, error) {
refs, err := roleRefsByID(g.roles)
if err != nil {
return nil, err
}
now := time.Now()
entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, DirectoryEntry{
ID: existingUser.ID,
Kind: EntryKindUser,
Username: existingUser.Username,
DisplayName: existingUser.DisplayName,
Email: existingUser.Email,
Role: string(existingUser.Role),
Status: string(invite.StatusAccepted),
CreatedAt: existingUser.CreatedAt,
})
entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID)))
}
for _, existingInvite := range g.inviteRepository.GetAll() {
@@ -58,6 +68,7 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
ID: existingInvite.ID,
Kind: EntryKindInvite,
Email: existingInvite.Email,
Role: lookupRoleRef(refs, existingInvite.RoleID),
Status: string(status),
CreatedAt: existingInvite.CreatedAt,
})
@@ -67,5 +78,5 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
return entries[i].CreatedAt.After(entries[j].CreatedAt)
})
return entries
return entries, nil
}
+31 -15
View File
@@ -2,9 +2,11 @@ package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
@@ -14,61 +16,75 @@ import (
type RenewInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
roles role.Repository
contentTypes contenttype.Repository
hasher hash.Hasher
mailer inviteMailer
recorder audit.Recorder
}
type RenewInviteCommand struct {
Caller ContentCaller
ID uint32
RenewedBy uint32
}
func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase {
return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder}
func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository, contentTypes contenttype.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase {
return &RenewInviteUseCase{userRepository, inviteRepository, roles, contentTypes, hasher, inviteMailer{mailRepository, publicURL}, recorder}
}
func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (invite.Invite, error) {
func (u *RenewInviteUseCase) coveredRole(caller ContentCaller, previous invite.Invite) (role.Role, error) {
return coveredAssignableRole(caller, u.roles, u.contentTypes, previous.RoleID)
}
func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (InviteView, error) {
event := audit.NewEvent(audit.ActionInviteRenew, audit.CategoryUsers)
renewed, err := u.renew(command)
renewed, held, err := u.renew(command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return invite.Invite{}, err
if !isInviteRoleRefusal(err) {
recordFailure(ctx, u.recorder, event, err)
}
return InviteView{}, err
}
event.Target = inviteTarget(renewed)
u.recorder.Record(ctx, event)
return renewed, nil
return InviteView{Invite: renewed, Role: NewRoleRef(held)}, nil
}
func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, error) {
func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, role.Role, error) {
now := time.Now()
renewer, err := u.userRepository.Get(command.RenewedBy)
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
previous, err := findManageableInvite(u.inviteRepository, command.ID)
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
held, err := u.coveredRole(command.Caller, previous)
if err != nil {
return invite.Invite{}, role.Role{}, err
}
rawCode, hashedCode, err := newInviteCode(u.hasher)
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
renewed, err := u.inviteRepository.Renew(previous.ID, hashedCode, now.Add(invite.Lifetime), now)
if err != nil {
return invite.Invite{}, err
return invite.Invite{}, role.Role{}, err
}
if sendErr := u.mailer.send(renewed.Email, rawCode, renewer); sendErr != nil {
if _, restoreErr := u.inviteRepository.Renew(previous.ID, previous.HashedCode, previous.ExpiresAt, previous.CreatedAt); restoreErr != nil {
return invite.Invite{}, errors.Join(sendErr, restoreErr)
return invite.Invite{}, role.Role{}, errors.Join(sendErr, restoreErr)
}
return invite.Invite{}, sendErr
return invite.Invite{}, role.Role{}, sendErr
}
return renewed, nil
return renewed, held, nil
}
@@ -0,0 +1,103 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type TransferOwnershipUseCase struct {
users user.Repository
roles role.Repository
recorder audit.Recorder
}
type TransferOwnershipCommand struct {
Caller ContentCaller
UserID uint32
ConfirmEmail string
}
type OwnershipSuccessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type OwnershipPredecessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Role RoleRef `json:"role"`
}
type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"`
FormerOwner OwnershipPredecessor `json:"former_owner"`
}
func NewTransferOwnershipUseCase(users user.Repository, roles role.Repository, recorder audit.Recorder) *TransferOwnershipUseCase {
return &TransferOwnershipUseCase{users: users, roles: roles, recorder: recorder}
}
var transferRefusals = []error{user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrUserNotFound}
func (u *TransferOwnershipUseCase) Transfer(ctx context.Context, command TransferOwnershipCommand) (OwnershipTransfer, error) {
successor, err := u.successor(command)
if err != nil {
return OwnershipTransfer{}, err
}
former, err := u.users.Get(command.Caller.UserID)
if err != nil {
return OwnershipTransfer{}, err
}
ownerRole, err := builtInRole(u.roles, role.OwnerName)
if err != nil {
return OwnershipTransfer{}, err
}
adminRole, err := builtInRole(u.roles, role.AdminName)
if err != nil {
return OwnershipTransfer{}, err
}
event := audit.NewEvent(audit.ActionUserTransferOwnership, audit.CategoryUsers)
event.Security = true
event.Target = audit.NewTarget(audit.TargetUser, successor.ID, successor.Username)
if err := u.users.TransferOwnership(former.ID, successor.ID, ownerRole.ID, adminRole.ID); err != nil {
u.recordWriteFailure(ctx, event, err)
return OwnershipTransfer{}, err
}
event.Params[audit.ParamFrom] = former.ID
event.Params[audit.ParamTo] = successor.ID
u.recorder.Record(ctx, event)
return OwnershipTransfer{
Owner: OwnershipSuccessor{ID: successor.ID, Name: successor.NameOrUsername()},
FormerOwner: OwnershipPredecessor{ID: former.ID, Name: former.NameOrUsername(), Role: NewRoleRef(adminRole)},
}, nil
}
func (u *TransferOwnershipUseCase) successor(command TransferOwnershipCommand) (user.User, error) {
successor, err := u.users.Get(command.UserID)
if errors.Is(err, user.ErrUserNotFound) {
return user.User{}, user.ErrInvalidTransfer
}
if err != nil {
return user.User{}, err
}
if successor.ID == command.Caller.UserID || !successor.IsVerified {
return user.User{}, user.ErrInvalidTransfer
}
if successor.Email != command.ConfirmEmail {
return user.User{}, user.ErrConfirmationMismatch
}
return successor, nil
}
func (u *TransferOwnershipUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
for _, refusal := range transferRefusals {
if errors.Is(err, refusal) {
return
}
}
recordFailure(ctx, u.recorder, event, err)
}
@@ -0,0 +1,42 @@
package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
)
type InviteView struct {
invite.Invite
Role RoleRef
}
type RoleRef struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
func NewRoleRef(found role.Role) RoleRef {
return RoleRef{ID: found.ID, Name: found.Name, BuiltIn: found.BuiltIn, Locked: found.Locked}
}
func roleRefsByID(roles role.Repository) (map[uint32]RoleRef, error) {
listed, err := roles.List()
if err != nil {
return nil, err
}
refs := make(map[uint32]RoleRef, len(listed))
for _, found := range listed {
refs[found.ID] = NewRoleRef(found)
}
return refs, nil
}
func lookupRoleRef(refs map[uint32]RoleRef, id uint32) *RoleRef {
ref, known := refs[id]
if !known {
return nil
}
return &ref
}
@@ -11,6 +11,17 @@ import (
"slices"
)
func builtInRole(roles role.Repository, name string) (role.Role, error) {
found, err := roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
}
func roleTarget(found role.Role) audit.Target {
return audit.NewTarget(audit.TargetRole, found.ID, found.Name)
}
@@ -33,9 +44,41 @@ func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, ca
if err != nil {
return err
}
return ensureCoveredBy(caller, typeKeys, candidate)
}
func ensureCoveredBy(caller ContentCaller, typeKeys []string, candidate role.Role) error {
return caller.Grants.Covers(candidate.GrantSet(), typeKeys)
}
func assignableRole(roles role.Repository, id uint32) (role.Role, error) {
if id == 0 {
return role.Role{}, role.ErrUnknownRole
}
found, err := roles.Get(id)
if errors.Is(err, role.ErrRoleNotFound) {
return role.Role{}, role.ErrUnknownRole
}
if err != nil {
return role.Role{}, err
}
if found.Locked {
return role.Role{}, role.ErrRoleLocked
}
return found, nil
}
func coveredAssignableRole(caller ContentCaller, roles role.Repository, contentTypes contenttype.Repository, id uint32) (role.Role, error) {
found, err := assignableRole(roles, id)
if err != nil {
return role.Role{}, err
}
if err := ensureCovered(caller, contentTypes, found); err != nil {
return role.Role{}, err
}
return found, nil
}
func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error {
account, err := users.Get(caller.UserID)
if err != nil {
+2 -27
View File
@@ -4,7 +4,6 @@ import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"errors"
"slices"
"time"
)
@@ -43,7 +42,7 @@ func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) {
}
func (v roleViewer) views(found []role.Role) ([]RoleView, error) {
counts, err := memberCounts(v.roles, v.users.GetAll())
counts, err := v.roles.MemberCounts()
if err != nil {
return nil, err
}
@@ -75,7 +74,7 @@ func (v roleViewer) one(found role.Role, members int) (RoleView, error) {
}
func (v roleViewer) members(roleID uint32) (int, error) {
counts, err := memberCounts(v.roles, v.users.GetAll())
counts, err := v.roles.MemberCounts()
if err != nil {
return 0, err
}
@@ -117,27 +116,3 @@ func toRoleView(found role.Role, members int, editors map[uint32]string) RoleVie
}
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
}