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

@@ -1,7 +1,7 @@
package database
import (
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/domain/role"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
@@ -9,8 +9,15 @@ import (
func BackfillCreatedBy(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
ownerRoleID, seeded, err := builtInRoleID(tx, role.OwnerName)
if err != nil {
return err
}
if !seeded {
return nil
}
var owner entity.User
result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner)
result := tx.Where("role_id = ?", ownerRoleID).Order("id asc").Limit(1).Find(&owner)
if result.Error != nil {
return result.Error
}
@@ -0,0 +1,28 @@
package database
import (
"RenewCMS/internal/domain/role"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func BackfillInviteRoles(db *gorm.DB) error {
if !legacyRoleColumnExists(db) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
done, err := markerApplied(tx, inviteRolesMarker)
if err != nil || done {
return err
}
adminID, err := requireBuiltInRoleID(tx, role.AdminName)
if err != nil {
return err
}
if err := tx.Model(&entity.Invite{}).Where("role_id IS NULL OR role_id = 0").UpdateColumn("role_id", adminID).Error; err != nil {
return err
}
return applyMarker(tx, inviteRolesMarker)
})
}
@@ -0,0 +1,94 @@
package database
import (
"RenewCMS/internal/domain/role"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
usersTable = "users"
legacyRoleColumn = "role"
legacyOwnerValue = "owner"
legacyAdminValue = "admin"
legacyUnsetValue = ""
unassignedRoleCond = "(role_id IS NULL OR role_id = 0)"
)
func legacyRoleColumnExists(db *gorm.DB) bool {
return db.Migrator().HasColumn(usersTable, legacyRoleColumn)
}
func requireBuiltInRoleID(tx *gorm.DB, name string) (uint32, error) {
id, found, err := builtInRoleID(tx, name)
if err != nil {
return 0, err
}
if !found {
return 0, role.ErrRoleNotFound
}
return id, nil
}
func BackfillLegacyUserRoles(db *gorm.DB) error {
if !legacyRoleColumnExists(db) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
done, err := markerApplied(tx, legacyUserRolesMarker)
if err != nil || done {
return err
}
ownerID, err := requireBuiltInRoleID(tx, role.OwnerName)
if err != nil {
return err
}
adminID, err := requireBuiltInRoleID(tx, role.AdminName)
if err != nil {
return err
}
writerID, err := requireBuiltInRoleID(tx, role.WriterName)
if err != nil {
return err
}
legacy := clause.Column{Name: legacyRoleColumn}
assign := func(roleID uint32, condition string, args ...any) error {
return tx.Table(usersTable).Where(unassignedRoleCond).Where(condition, args...).UpdateColumn("role_id", roleID).Error
}
var owners []uint32
if err := tx.Table(usersTable).Where(unassignedRoleCond).Where("? = ?", legacy, legacyOwnerValue).Order("id asc").Pluck("id", &owners).Error; err != nil {
return err
}
if len(owners) > 0 {
if err := assign(ownerID, "id = ?", owners[0]); err != nil {
return err
}
}
if err := assign(adminID, "? = ?", legacy, legacyOwnerValue); err != nil {
return err
}
if err := assign(adminID, "? = ?", legacy, legacyAdminValue); err != nil {
return err
}
if len(owners) == 0 {
var lowest []uint32
if err := tx.Model(&entity.User{}).Order("id asc").Limit(1).Pluck("id", &lowest).Error; err != nil {
return err
}
if len(lowest) > 0 {
if err := assign(ownerID, "? = ? AND id = ?", legacy, legacyUnsetValue, lowest[0]); err != nil {
return err
}
}
}
if err := assign(adminID, "? = ?", legacy, legacyUnsetValue); err != nil {
return err
}
if err := assign(writerID, "1 = 1"); err != nil {
return err
}
return applyMarker(tx, legacyUserRolesMarker)
})
}
@@ -7,7 +7,7 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{})
if err != nil {
return err
}
@@ -17,10 +17,10 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := SeedBuiltInRoles(db); err != nil {
return err
}
if err := BackfillUserRoles(db); err != nil {
if err := BackfillLegacyUserRoles(db); err != nil {
return err
}
if err := BackfillUserRoleIDs(db); err != nil {
if err := BackfillInviteRoles(db); err != nil {
return err
}
if err := BackfillCreatedBy(db); err != nil {
@@ -0,0 +1,25 @@
package database
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
)
const (
legacyUserRolesMarker = "legacy_user_roles"
inviteRolesMarker = "invite_roles"
)
func markerApplied(tx *gorm.DB, name string) (bool, error) {
var count int64
if err := tx.Model(&entity.MigrationMarker{}).Where("name = ?", name).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func applyMarker(tx *gorm.DB, name string) error {
return tx.Create(&entity.MigrationMarker{Name: name, AppliedAt: time.Now()}).Error
}
@@ -1,33 +0,0 @@
package database
import (
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func BackfillUserRoles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var firstUser entity.User
result := tx.Where("role = ''").Order("id asc").Limit(1).Find(&firstUser)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
var lowest entity.User
if err := tx.Order("id asc").First(&lowest).Error; err != nil {
return err
}
if lowest.Role == "" {
if err := tx.Model(&entity.User{}).Where("id = ?", lowest.ID).Update("role", string(domainUser.RoleOwner)).Error; err != nil {
return err
}
}
return tx.Model(&entity.User{}).Where("role = ''").Update("role", string(domainUser.RoleAdmin)).Error
})
}
@@ -1,35 +0,0 @@
package database
import (
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
var legacyRoleNames = map[domainUser.Role]string{
domainUser.RoleOwner: role.OwnerName,
domainUser.RoleAdmin: role.AdminName,
}
func BackfillUserRoleIDs(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for legacy, name := range legacyRoleNames {
roleID, found, err := builtInRoleID(tx, name)
if err != nil {
return err
}
if !found {
continue
}
err = tx.Model(&entity.User{}).
Where("role = ? AND (role_id IS NULL OR role_id = 0)", string(legacy)).
UpdateColumn("role_id", roleID).Error
if err != nil {
return err
}
}
return nil
})
}
@@ -12,6 +12,7 @@ func InviteToDomain(m models.Invite) invite.Invite {
HashedCode: m.HashedCode,
ExpiresAt: m.ExpiresAt,
InvitedBy: m.InvitedBy,
RoleID: nullableIDToDomain(m.RoleID),
AcceptedAt: m.AcceptedAt,
CreatedAt: m.CreatedAt,
}
@@ -24,6 +25,7 @@ func InviteToModel(i invite.Invite) models.Invite {
HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy,
RoleID: DomainToNullableID(i.RoleID),
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
@@ -26,7 +26,7 @@ func nullableIDToDomain(value *uint32) uint32 {
return *value
}
func domainToNullableID(value uint32) *uint32 {
func DomainToNullableID(value uint32) *uint32 {
if value == 0 {
return nil
}
@@ -42,7 +42,6 @@ func UserToDomain(m models.User) user.User {
Email: m.Email,
DisplayName: m.DisplayName,
IsVerified: m.IsVerified,
Role: user.Role(m.Role),
RoleID: nullableIDToDomain(m.RoleID),
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
@@ -61,8 +60,7 @@ func UserToModel(a user.User) models.User {
Email: a.Email,
DisplayName: a.DisplayName,
IsVerified: a.IsVerified,
Role: string(a.Role),
RoleID: domainToNullableID(a.RoleID),
RoleID: DomainToNullableID(a.RoleID),
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
@@ -7,7 +7,8 @@ type Invite struct {
Email string `gorm:"index;not null;size:255"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
InvitedBy uint32 `gorm:"not null"`
RoleID *uint32 `gorm:"index"`
AcceptedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -0,0 +1,8 @@
package models
import "time"
type MigrationMarker struct {
Name string `gorm:"primaryKey;size:64"`
AppliedAt time.Time `gorm:"not null"`
}
@@ -12,7 +12,6 @@ type User struct {
Email string `gorm:"unique;not null;size:255"`
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:'';size:32"`
RoleID *uint32 `gorm:"index"`
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
@@ -145,8 +145,8 @@ func (r *RoleRepository) Delete(id uint32) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
var members int64
if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&members).Error; err != nil {
members, err := countMembers(tx, id)
if err != nil {
return err
}
if members > 0 {
@@ -159,24 +159,68 @@ func (r *RoleRepository) Delete(id uint32) error {
})
}
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) {
moved := r.db.Model(&entity.User{}).
Where("role_id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", from, to).
UpdateColumn("role_id", to)
if moved.Error != nil {
return 0, moved.Error
}
if moved.RowsAffected > 0 {
return int(moved.RowsAffected), nil
}
var targets int64
if err := r.db.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
func countMembers(tx *gorm.DB, id uint32) (int64, error) {
var users, invites int64
if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&users).Error; err != nil {
return 0, err
}
if targets == 0 {
return 0, role.ErrInvalidReassign
if err := tx.Model(&entity.Invite{}).Where("role_id = ? AND accepted_at IS NULL", id).Count(&invites).Error; err != nil {
return 0, err
}
return 0, nil
return users + invites, nil
}
type memberRow struct {
RoleID uint32
Members int
}
func (r *RoleRepository) MemberCounts() (map[uint32]int, error) {
var users, invites []memberRow
if err := r.db.Model(&entity.User{}).Select("role_id, COUNT(*) AS members").Where("role_id IS NOT NULL").Group("role_id").Scan(&users).Error; err != nil {
return nil, err
}
if err := r.db.Model(&entity.Invite{}).Select("role_id, COUNT(*) AS members").Where("role_id IS NOT NULL AND accepted_at IS NULL").Group("role_id").Scan(&invites).Error; err != nil {
return nil, err
}
counts := map[uint32]int{}
for _, row := range append(users, invites...) {
if row.RoleID != 0 {
counts[row.RoleID] += row.Members
}
}
return counts, nil
}
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) {
moved := 0
err := r.db.Transaction(func(tx *gorm.DB) error {
target := "EXISTS (SELECT 1 FROM roles WHERE id = ?)"
users := tx.Model(&entity.User{}).Where("role_id = ? AND "+target, from, to).UpdateColumn("role_id", to)
if users.Error != nil {
return users.Error
}
invites := tx.Model(&entity.Invite{}).Where("role_id = ? AND accepted_at IS NULL AND "+target, from, to).UpdateColumn("role_id", to)
if invites.Error != nil {
return invites.Error
}
moved = int(users.RowsAffected + invites.RowsAffected)
if moved > 0 {
return nil
}
var targets int64
if err := tx.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
return err
}
if targets == 0 {
return role.ErrInvalidReassign
}
return nil
})
if err != nil {
return 0, err
}
return moved, nil
}
func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) {
@@ -1,12 +1,14 @@
package persistence
import (
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserRepository struct {
@@ -34,7 +36,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email,
IsVerified: user.IsVerified,
Role: string(user.Role),
RoleID: mappers.DomainToNullableID(user.RoleID),
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration,
}
@@ -145,14 +147,17 @@ func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string, exp
func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
localUser.IsVerified = true
localUser.VerificationCode = mappers.DomainToNullableString("")
err = u.db.Save(&localUser).Error
err := u.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.User{}).First(&localUser, userId).Error; err != nil {
return mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
if err := refuseSecondVerifiedOwner(tx, localUser); err != nil {
return err
}
localUser.IsVerified = true
localUser.VerificationCode = mappers.DomainToNullableString("")
return tx.Save(&localUser).Error
})
if err != nil {
return domainUser.User{}, err
}
@@ -160,6 +165,26 @@ func (u *UserRepository) ClearVerificationCode(userId uint32) (domainUser.User,
return mappers.UserToDomain(localUser), nil
}
func refuseSecondVerifiedOwner(tx *gorm.DB, candidate entity.User) error {
if candidate.RoleID == nil {
return nil
}
var roles []entity.Role
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND built_in = ? AND name = ?", *candidate.RoleID, true, role.OwnerName).Find(&roles).Error
if err != nil || len(roles) == 0 {
return err
}
var others int64
err = tx.Model(&entity.User{}).Where("role_id = ? AND is_verified = ? AND id <> ?", *candidate.RoleID, true, candidate.ID).Count(&others).Error
if err != nil {
return err
}
if others > 0 {
return domainUser.ErrInvalidVerificationCode
}
return nil
}
func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (domainUser.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
@@ -195,4 +220,68 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil
}
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)
if from == 0 {
query = query.Where("(role_id IS NULL OR role_id = 0)")
} else {
query = query.Where("role_id = ?", from)
}
result := query.Update("role_id", to)
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
return nil
}
return u.explainRoleRefusal(tx, id, to)
})
if err != nil {
return domainUser.User{}, err
}
return u.Get(id)
}
func (u *UserRepository) explainRoleRefusal(tx *gorm.DB, id uint32, to uint32) error {
var users int64
if err := tx.Model(&entity.User{}).Where("id = ?", id).Count(&users).Error; err != nil {
return err
}
if users == 0 {
return domainUser.ErrUserNotFound
}
var roles int64
if err := tx.Model(&entity.Role{}).Where("id = ?", to).Count(&roles).Error; err != nil {
return err
}
if roles == 0 {
return role.ErrRoleNotFound
}
return domainUser.ErrRoleChanged
}
func (u *UserRepository) TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error {
if from == to {
return domainUser.ErrInvalidTransfer
}
return u.db.Transaction(func(tx *gorm.DB) error {
demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Update("role_id", formerRoleID)
if demoted.Error != nil {
return demoted.Error
}
if demoted.RowsAffected == 0 {
return domainUser.ErrRoleChanged
}
promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Update("role_id", ownerRoleID)
if promoted.Error != nil {
return promoted.Error
}
if promoted.RowsAffected == 0 {
return domainUser.ErrInvalidTransfer
}
return nil
})
}
var _ domainUser.Repository = &UserRepository{}
@@ -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
}