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