feat: roles system backend
This commit is contained in:
1 parent
44cca46d5e
commit
8c6c6456da
50 files changed
+2578
-22
No files matched your search
@@ -0,0 +1,25 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func BackfillCreatedBy(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var owner entity.User
|
||||
result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Unscoped().Model(&entity.Article{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&entity.Entry{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error
|
||||
})
|
||||
}
|
||||
@@ -7,16 +7,25 @@ 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{})
|
||||
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{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureImagePathIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := SeedBuiltInRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillUserRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillUserRoleIDs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillCreatedBy(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := SeedDefaultSite(db, publicURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SeedBuiltInRoles(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, builtIn := range role.BuiltInRoles() {
|
||||
if err := seedBuiltInRole(tx, builtIn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func seedBuiltInRole(tx *gorm.DB, builtIn role.Role) error {
|
||||
id, found, err := builtInRoleID(tx, builtIn.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
return reconcileBuiltInRole(tx, id, builtIn)
|
||||
}
|
||||
model := mappers.RoleToModel(builtIn)
|
||||
if err := tx.Create(&model).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(mappers.GrantsToModel(model.ID, builtIn.Grants)).Error
|
||||
}
|
||||
|
||||
func builtInRoleID(tx *gorm.DB, name string) (uint32, bool, error) {
|
||||
var found entity.Role
|
||||
result := tx.Where("name = ? AND built_in = ?", name, true).Order("id asc").Limit(1).Find(&found)
|
||||
if result.Error != nil {
|
||||
return 0, false, result.Error
|
||||
}
|
||||
return found.ID, result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func reconcileBuiltInRole(tx *gorm.DB, id uint32, builtIn role.Role) error {
|
||||
var stored entity.Role
|
||||
if err := tx.First(&stored, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !builtIn.Locked && stored.Version != 1 {
|
||||
return nil
|
||||
}
|
||||
var present []string
|
||||
if err := tx.Model(&entity.RolePermission{}).Where("role_id = ?", id).Pluck("permission_key", &present).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
missing := []role.Grant{}
|
||||
for _, grant := range builtIn.Grants {
|
||||
if !slices.Contains(present, grant.Key) {
|
||||
missing = append(missing, grant)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(mappers.GrantsToModel(id, missing)).Error
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ func ArticleToDomain(m models.Article) article.Article {
|
||||
Body: m.Body,
|
||||
IsOnline: m.IsOnline,
|
||||
SiteID: m.SiteID,
|
||||
CreatedBy: m.CreatedBy,
|
||||
Slug: m.Slug,
|
||||
Excerpt: m.Excerpt,
|
||||
SEOTitle: m.SEOTitle,
|
||||
@@ -60,6 +61,7 @@ func ArticleToModel(a article.Article) models.Article {
|
||||
Body: a.Body,
|
||||
IsOnline: a.IsOnline,
|
||||
SiteID: a.SiteID,
|
||||
CreatedBy: a.CreatedBy,
|
||||
Slug: a.Slug,
|
||||
Excerpt: a.Excerpt,
|
||||
SEOTitle: a.SEOTitle,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
|
||||
return role.Role{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
BuiltIn: m.BuiltIn,
|
||||
Locked: m.Locked,
|
||||
Version: m.Version,
|
||||
Grants: GrantsToDomain(grants),
|
||||
}
|
||||
}
|
||||
|
||||
func RoleToModel(r role.Role) models.Role {
|
||||
return models.Role{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
BuiltIn: r.BuiltIn,
|
||||
Locked: r.Locked,
|
||||
Version: r.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func GrantsToDomain(rows []models.RolePermission) []role.Grant {
|
||||
grants := make([]role.Grant, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
grants = append(grants, role.Grant{Key: row.Key, Scope: role.Scope(row.Scope)})
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func GrantsToModel(roleID uint32, grants []role.Grant) []models.RolePermission {
|
||||
rows := make([]models.RolePermission, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
rows = append(rows, models.RolePermission{RoleID: roleID, Key: grant.Key, Scope: string(grant.Scope)})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -19,6 +19,20 @@ func DomainToNullableString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func nullableIDToDomain(value *uint32) uint32 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func domainToNullableID(value uint32) *uint32 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func UserToDomain(m models.User) user.User {
|
||||
return user.User{
|
||||
ID: m.ID,
|
||||
@@ -29,6 +43,7 @@ func UserToDomain(m models.User) user.User {
|
||||
DisplayName: m.DisplayName,
|
||||
IsVerified: m.IsVerified,
|
||||
Role: user.Role(m.Role),
|
||||
RoleID: nullableIDToDomain(m.RoleID),
|
||||
VerificationCode: nullableStringToDomain(m.VerificationCode),
|
||||
VerificationExpiration: m.VerificationExpiration,
|
||||
PasswordResetExpiration: m.PasswordResetExpiration,
|
||||
@@ -47,6 +62,7 @@ func UserToModel(a user.User) models.User {
|
||||
DisplayName: a.DisplayName,
|
||||
IsVerified: a.IsVerified,
|
||||
Role: string(a.Role),
|
||||
RoleID: domainToNullableID(a.RoleID),
|
||||
VerificationCode: DomainToNullableString(a.VerificationCode),
|
||||
VerificationExpiration: a.VerificationExpiration,
|
||||
PasswordResetExpiration: a.PasswordResetExpiration,
|
||||
|
||||
@@ -13,6 +13,7 @@ type Article struct {
|
||||
Body string
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
SiteID uint32 `gorm:"index"`
|
||||
CreatedBy uint32 `gorm:"not null;default:0"`
|
||||
Slug string `gorm:"size:100;not null;default:''"`
|
||||
Excerpt string `gorm:"size:300;not null;default:''"`
|
||||
SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"`
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
type Role struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"not null;size:100;index"`
|
||||
Description string `gorm:"not null;default:'';size:255"`
|
||||
BuiltIn bool `gorm:"not null;default:false"`
|
||||
Locked bool `gorm:"not null;default:false"`
|
||||
Version uint32 `gorm:"not null;default:1"`
|
||||
}
|
||||
|
||||
type RolePermission struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
RoleID uint32 `gorm:"not null;uniqueIndex:idx_role_permission_key"`
|
||||
Key string `gorm:"column:permission_key;not null;size:100;uniqueIndex:idx_role_permission_key"`
|
||||
Scope string `gorm:"not null;size:8"`
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type User struct {
|
||||
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
|
||||
PasswordResetExpiration time.Time
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RoleRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRoleRepository(db *gorm.DB) *RoleRepository {
|
||||
return &RoleRepository{db}
|
||||
}
|
||||
|
||||
func (r *RoleRepository) grantRows(roleID uint32) ([]entity.RolePermission, error) {
|
||||
var rows []entity.RolePermission
|
||||
if err := r.db.Where("role_id = ?", roleID).Order("id asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) withGrants(found entity.Role) (role.Role, error) {
|
||||
rows, err := r.grantRows(found.ID)
|
||||
if err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
return mappers.RoleToDomain(found, rows), nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) List() ([]role.Role, error) {
|
||||
var found []entity.Role
|
||||
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles := make([]role.Role, 0, len(found))
|
||||
for _, row := range found {
|
||||
roles = append(roles, mappers.RoleToDomain(row, nil))
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Get(id uint32) (role.Role, error) {
|
||||
var found entity.Role
|
||||
if err := r.db.First(&found, id).Error; err != nil {
|
||||
return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound)
|
||||
}
|
||||
return r.withGrants(found)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) GetByName(name string) (role.Role, error) {
|
||||
var found entity.Role
|
||||
if err := r.db.Where("name = ?", name).Order("built_in DESC, id ASC").First(&found).Error; err != nil {
|
||||
return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound)
|
||||
}
|
||||
return r.withGrants(found)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
|
||||
rows, err := r.grantRows(roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mappers.GrantsToDomain(rows), nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Create(created role.Role) (role.Role, error) {
|
||||
model := mappers.RoleToModel(created)
|
||||
model.ID = 0
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&model).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := mappers.GrantsToModel(model.ID, created.Grants)
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
return r.withGrants(model)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
|
||||
return mapNotFoundErr(err, role.ErrRoleNotFound)
|
||||
}
|
||||
update := tx.Model(&entity.Role{}).
|
||||
Where("id = ? AND version = ?", saved.ID, saved.Version).
|
||||
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1})
|
||||
if update.Error != nil {
|
||||
return update.Error
|
||||
}
|
||||
if update.RowsAffected == 0 {
|
||||
return role.ErrRoleConflict
|
||||
}
|
||||
if err := tx.Where("role_id = ?", saved.ID).Delete(&entity.RolePermission{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := mappers.GrantsToModel(saved.ID, saved.Grants)
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
return r.Get(saved.ID)
|
||||
}
|
||||
|
||||
var _ role.Repository = &RoleRepository{}
|
||||
Reference in new issue
Block a user