feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user