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

+56 -51
View File
@@ -60,57 +60,59 @@ const (
)
const (
ActionLogin = "auth.login"
ActionLoginFailed = "auth.login_failed"
ActionLogout = "auth.logout"
ActionPasswordResetRequest = "auth.password_reset_requested"
ActionPasswordResetDone = "auth.password_reset_completed"
ActionAPIKeyCreate = "api_key.create"
ActionAPIKeyRevoke = "api_key.revoke"
ActionRetentionChanged = "system.activity_retention_changed"
ActionCleanup = "system.activity_cleanup"
ActionArticleCreate = "article.create"
ActionArticleEdit = "article.edit"
ActionArticlePublish = "article.publish"
ActionArticleUnpublish = "article.unpublish"
ActionArticleDelete = "article.delete"
ActionSiteCreate = "site.create"
ActionSiteEdit = "site.edit"
ActionSiteSetDefault = "site.set_default"
ActionSiteDelete = "site.delete"
ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete"
ActionRoleCreate = "role.create"
ActionRoleEdit = "role.edit"
ActionRoleDelete = "role.delete"
ActionRoleReassign = "role.reassign"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
ActionMediaMerge = "media.merge"
ActionMediaPurge = "media.purge"
ActionVariantCreate = "media.variant_create"
ActionVariantUpdate = "media.variant_update"
ActionVariantDelete = "media.variant_delete"
ActionVariantEnable = "media.variant_enable"
ActionVariantDisable = "media.variant_disable"
ActionVariantPurge = "media.variant_purge"
ActionVariantGenerateAll = "media.variant_generate_all"
ActionVariantPurgeImage = "media.variant_purge_image"
ActionVariantPurgeAll = "media.variant_purge_all"
ActionVariantPurgeUnused = "media.variant_purge_unused"
ActionUserRegister = "user.register"
ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile"
ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew"
ActionInviteAccept = "invite.accept"
ActionMediaPurgeScheduled = "system.media_purge"
ActionUpdateChecked = "system.update_checked"
ActionUpdateDownloaded = "system.update_downloaded"
ActionRestartRequested = "system.restart_requested"
ActionLogin = "auth.login"
ActionLoginFailed = "auth.login_failed"
ActionLogout = "auth.logout"
ActionPasswordResetRequest = "auth.password_reset_requested"
ActionPasswordResetDone = "auth.password_reset_completed"
ActionAPIKeyCreate = "api_key.create"
ActionAPIKeyRevoke = "api_key.revoke"
ActionRetentionChanged = "system.activity_retention_changed"
ActionCleanup = "system.activity_cleanup"
ActionArticleCreate = "article.create"
ActionArticleEdit = "article.edit"
ActionArticlePublish = "article.publish"
ActionArticleUnpublish = "article.unpublish"
ActionArticleDelete = "article.delete"
ActionSiteCreate = "site.create"
ActionSiteEdit = "site.edit"
ActionSiteSetDefault = "site.set_default"
ActionSiteDelete = "site.delete"
ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete"
ActionRoleCreate = "role.create"
ActionRoleEdit = "role.edit"
ActionRoleDelete = "role.delete"
ActionRoleReassign = "role.reassign"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
ActionMediaMerge = "media.merge"
ActionMediaPurge = "media.purge"
ActionVariantCreate = "media.variant_create"
ActionVariantUpdate = "media.variant_update"
ActionVariantDelete = "media.variant_delete"
ActionVariantEnable = "media.variant_enable"
ActionVariantDisable = "media.variant_disable"
ActionVariantPurge = "media.variant_purge"
ActionVariantGenerateAll = "media.variant_generate_all"
ActionVariantPurgeImage = "media.variant_purge_image"
ActionVariantPurgeAll = "media.variant_purge_all"
ActionVariantPurgeUnused = "media.variant_purge_unused"
ActionUserRegister = "user.register"
ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile"
ActionUserChangeRole = "user.change_role"
ActionUserTransferOwnership = "user.transfer_ownership"
ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew"
ActionInviteAccept = "invite.accept"
ActionMediaPurgeScheduled = "system.media_purge"
ActionUpdateChecked = "system.update_checked"
ActionUpdateDownloaded = "system.update_downloaded"
ActionRestartRequested = "system.restart_requested"
)
const (
@@ -157,7 +159,10 @@ const (
ParamChanged = "changed"
ParamMembers = "members"
ParamMoved = "moved"
ParamRole = "role"
ParamRoleID = "role_id"
FieldTitle = "title"
FieldRole = "role"
FieldOnline = "online"
FieldBody = "body"
FieldFilename = "filename"
@@ -3,7 +3,6 @@ package builtintype
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/user"
"errors"
)
@@ -136,6 +135,6 @@ func userFields() []contenttype.Field {
text("username", "Username").required(),
text("display_name", "Display name"),
text("email", "Email").required(),
of("role", "Role", contenttype.TypeSelect).required().options(string(user.RoleOwner), string(user.RoleAdmin)),
text("role", "Role"),
)
}
+1
View File
@@ -31,6 +31,7 @@ type Invite struct {
HashedCode string `json:"hashed_code"`
ExpiresAt time.Time `json:"expires_at"`
InvitedBy uint32 `json:"invited_by"`
RoleID uint32 `json:"role_id"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedAt time.Time `json:"created_at"`
}
+1
View File
@@ -27,6 +27,7 @@ var (
ErrNotBuiltIn = errors.New("role is not built in")
ErrInvalidReassign = errors.New("invalid reassign target")
ErrEscalation = errors.New("grants more than the caller holds")
ErrUnknownRole = errors.New("unknown role")
)
type Scope string
@@ -9,4 +9,5 @@ type Repository interface {
Delete(id uint32) error
ReassignMembers(from uint32, to uint32) (int, error)
RemoveTypedGrants(typeKey string) (int, error)
MemberCounts() (map[uint32]int, error)
}
+3 -8
View File
@@ -19,6 +19,9 @@ var ErrUsernameTaken = errors.New("username taken")
var ErrInvalidLookup = errors.New("invalid user lookup")
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
var ErrPasswordTooLong = errors.New("password too long")
var ErrRoleChanged = errors.New("user role changed concurrently")
var ErrInvalidTransfer = errors.New("invalid ownership transfer")
var ErrConfirmationMismatch = errors.New("confirmation email does not match")
const MaxLookupIDs = 100
@@ -31,13 +34,6 @@ func ValidatePassword(password string) error {
return nil
}
type Role string
const (
RoleOwner Role = "owner"
RoleAdmin Role = "admin"
)
type User struct {
ID uint32 `json:"id"`
Username string `json:"username"`
@@ -46,7 +42,6 @@ type User struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"`
Role Role `json:"role"`
RoleID uint32 `json:"role_id"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
@@ -16,4 +16,6 @@ type Repository interface {
ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error)
UpdateRole(id uint32, from uint32, to uint32) (User, error)
TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error
}
@@ -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
}