feat: roles system backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 05:02:49 +01:00
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{}
@@ -71,6 +71,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
CreatedBy: createArticle.CreatedBy,
Authors: creditedAuthors,
SEODismissed: []string{},
})
@@ -0,0 +1,69 @@
package useCases
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type Access struct {
Role role.Role
Permissions []role.Grant
}
type GetMyAccessUseCase struct {
roles role.Repository
}
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 (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) {
if account.RoleID != 0 {
return u.roles.Get(account.RoleID)
}
name, known := legacyBuiltInNames[account.Role]
if !known {
return role.Role{}, role.ErrRoleNotFound
}
found, err := u.roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
}
func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
resolved, err := u.resolveRole(account)
if err != nil {
return Access{}, err
}
granted, err := u.roles.ListGrants(resolved.ID)
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: orderedGrants(granted)}, nil
}
func orderedGrants(granted []role.Grant) []role.Grant {
scopes := make(map[string]role.Scope, len(granted))
for _, grant := range granted {
scopes[grant.Key] = grant.Scope
}
ordered := []role.Grant{}
for _, permission := range role.Catalogue() {
scope, present := scopes[permission.Key]
if present && scope != role.ScopeNone {
ordered = append(ordered, role.Grant{Key: permission.Key, Scope: scope})
}
}
return ordered
}
@@ -0,0 +1,27 @@
package useCases
import "RenewCMS/internal/domain/role"
type PermissionGroup struct {
Key string
Items []role.Permission
}
type ListPermissionsUseCase struct{}
func NewListPermissionsUseCase() *ListPermissionsUseCase {
return &ListPermissionsUseCase{}
}
func (u *ListPermissionsUseCase) List() []PermissionGroup {
groups := []PermissionGroup{}
for _, permission := range role.Catalogue() {
last := len(groups) - 1
if last < 0 || groups[last].Key != permission.Group {
groups = append(groups, PermissionGroup{Key: permission.Group})
last++
}
groups[last].Items = append(groups[last].Items, permission)
}
return groups
}