feat: roles system backend
This commit is contained in:
1 parent
44cca46d5e
commit
8c6c6456da
50 files changed
+2578
-22
No files matched your search
@@ -118,6 +118,7 @@ type Article struct {
|
||||
Body string `json:"body"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
CreatedBy uint32 `json:"-"`
|
||||
Authors []author.Byline `json:"authors"`
|
||||
Slug string `json:"slug"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package role
|
||||
|
||||
const (
|
||||
OwnerName = "Owner"
|
||||
AdminName = "Admin"
|
||||
WriterName = "Writer"
|
||||
)
|
||||
|
||||
func allGrants(include func(Permission) bool) []Grant {
|
||||
grants := []Grant{}
|
||||
for _, permission := range catalogue {
|
||||
if include(permission) {
|
||||
grants = append(grants, Grant{Key: permission.Key, Scope: ScopeAll})
|
||||
}
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func writerGrants() []Grant {
|
||||
return []Grant{
|
||||
{Key: "content.read", Scope: ScopeAll},
|
||||
{Key: "content.create", Scope: ScopeAll},
|
||||
{Key: "content.update", Scope: ScopeAll},
|
||||
{Key: "schema.view", Scope: ScopeAll},
|
||||
{Key: "media.view", Scope: ScopeAll},
|
||||
{Key: "media.upload", Scope: ScopeAll},
|
||||
{Key: "authors.view", Scope: ScopeAll},
|
||||
{Key: "authors.edit", Scope: ScopeOwn},
|
||||
{Key: "activity.view", Scope: ScopeOwn},
|
||||
}
|
||||
}
|
||||
|
||||
func BuiltInRoles() []Role {
|
||||
return []Role{
|
||||
{
|
||||
Name: OwnerName, BuiltIn: true, Locked: true, Version: 1,
|
||||
Grants: allGrants(func(Permission) bool { return true }),
|
||||
},
|
||||
{
|
||||
Name: AdminName, BuiltIn: true, Version: 1,
|
||||
Grants: allGrants(func(p Permission) bool { return !p.OwnerOnly }),
|
||||
},
|
||||
{
|
||||
Name: WriterName, BuiltIn: true, Version: 1,
|
||||
Grants: writerGrants(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package role
|
||||
|
||||
import "strings"
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindScoped Kind = "scoped"
|
||||
KindToggle Kind = "toggle"
|
||||
)
|
||||
|
||||
const contentGroup = "content"
|
||||
|
||||
type Permission struct {
|
||||
Key string
|
||||
Group string
|
||||
Kind Kind
|
||||
OwnerOnly bool
|
||||
}
|
||||
|
||||
func (p Permission) PerType() bool {
|
||||
return strings.HasPrefix(p.Key, contentGroup+".")
|
||||
}
|
||||
|
||||
func scoped(group, key string) Permission {
|
||||
return Permission{Key: key, Group: group, Kind: KindScoped}
|
||||
}
|
||||
|
||||
func toggle(group, key string) Permission {
|
||||
return Permission{Key: key, Group: group, Kind: KindToggle}
|
||||
}
|
||||
|
||||
func ownerOnly(group, key string) Permission {
|
||||
return Permission{Key: key, Group: group, Kind: KindToggle, OwnerOnly: true}
|
||||
}
|
||||
|
||||
var catalogue = []Permission{
|
||||
scoped("content", "content.read"),
|
||||
toggle("content", "content.create"),
|
||||
scoped("content", "content.update"),
|
||||
scoped("content", "content.delete"),
|
||||
scoped("content", "content.publish"),
|
||||
toggle("schema", "schema.view"),
|
||||
toggle("schema", "schema.edit"),
|
||||
toggle("schema", "schema.delete"),
|
||||
toggle("media", "media.view"),
|
||||
toggle("media", "media.upload"),
|
||||
scoped("media", "media.delete"),
|
||||
toggle("authors", "authors.view"),
|
||||
toggle("authors", "authors.create"),
|
||||
scoped("authors", "authors.edit"),
|
||||
toggle("authors", "authors.delete"),
|
||||
scoped("activity", "activity.view"),
|
||||
toggle("activity", "activity.export"),
|
||||
toggle("users", "users.view"),
|
||||
toggle("users", "users.invite"),
|
||||
toggle("users", "users.change_role"),
|
||||
toggle("users", "users.remove"),
|
||||
toggle("settings", "settings.view"),
|
||||
toggle("settings", "settings.edit_general"),
|
||||
toggle("settings", "settings.manage_sites"),
|
||||
ownerOnly("settings", "settings.delete_site"),
|
||||
scoped("settings", "apikeys.view"),
|
||||
toggle("settings", "apikeys.create"),
|
||||
scoped("settings", "apikeys.revoke"),
|
||||
toggle("roles", "roles.view"),
|
||||
toggle("roles", "roles.manage"),
|
||||
ownerOnly("roles", "roles.edit_owner_only"),
|
||||
ownerOnly("system", "system.apply_updates"),
|
||||
ownerOnly("system", "system.edit_retention"),
|
||||
ownerOnly("system", "system.transfer_ownership"),
|
||||
}
|
||||
|
||||
func Catalogue() []Permission {
|
||||
return append([]Permission(nil), catalogue...)
|
||||
}
|
||||
|
||||
func Lookup(key string) (Permission, error) {
|
||||
for _, permission := range catalogue {
|
||||
if permission.Key == key {
|
||||
return permission, nil
|
||||
}
|
||||
}
|
||||
return Permission{}, ErrUnknownPermission
|
||||
}
|
||||
|
||||
func Groups() []string {
|
||||
groups := []string{}
|
||||
for _, permission := range catalogue {
|
||||
if len(groups) == 0 || groups[len(groups)-1] != permission.Group {
|
||||
groups = append(groups, permission.Group)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const (
|
||||
typeSeparator = ":"
|
||||
maxKeyLength = 100
|
||||
typeKeyAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-"
|
||||
)
|
||||
|
||||
func ParseTypedKey(composite string) (string, string, error) {
|
||||
base, typeKey, found := strings.Cut(composite, typeSeparator)
|
||||
if !found || len(composite) > maxKeyLength || typeKey == "" {
|
||||
return "", "", ErrUnknownPermission
|
||||
}
|
||||
permission, err := Lookup(base)
|
||||
if err != nil || !permission.PerType() {
|
||||
return "", "", ErrUnknownPermission
|
||||
}
|
||||
for _, char := range typeKey {
|
||||
if !strings.ContainsRune(typeKeyAlphabet, char) {
|
||||
return "", "", ErrUnknownPermission
|
||||
}
|
||||
}
|
||||
return base, typeKey, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRoleNotFound = errors.New("role not found")
|
||||
ErrUnknownPermission = errors.New("unknown permission")
|
||||
ErrRoleConflict = errors.New("role was modified concurrently")
|
||||
ErrInvalidScope = errors.New("invalid scope for permission")
|
||||
ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role")
|
||||
)
|
||||
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeNone Scope = "none"
|
||||
ScopeOwn Scope = "own"
|
||||
ScopeAll Scope = "all"
|
||||
)
|
||||
|
||||
type Grant struct {
|
||||
Key string `json:"key"`
|
||||
Scope Scope `json:"scope"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Locked bool `json:"locked"`
|
||||
Version uint32 `json:"version"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
|
||||
func (r Role) scopeOf(key string) Scope {
|
||||
for _, grant := range r.Grants {
|
||||
if grant.Key == key {
|
||||
return grant.Scope
|
||||
}
|
||||
}
|
||||
return ScopeNone
|
||||
}
|
||||
|
||||
func (r Role) Allows(key string, owned bool) bool {
|
||||
permission, err := Lookup(key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if permission.OwnerOnly && !r.Locked {
|
||||
return false
|
||||
}
|
||||
return scopeAllows(permission.Kind, r.scopeOf(key), owned)
|
||||
}
|
||||
|
||||
func (r Role) AllowsFor(key string, typeKey string, owned bool) bool {
|
||||
if typeKey == "" {
|
||||
return r.Allows(key, owned)
|
||||
}
|
||||
composite := key + typeSeparator + typeKey
|
||||
base, _, err := ParseTypedKey(composite)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
permission, _ := Lookup(base)
|
||||
for _, grant := range r.Grants {
|
||||
if grant.Key == composite {
|
||||
return scopeAllows(permission.Kind, grant.Scope, owned)
|
||||
}
|
||||
}
|
||||
return r.Allows(key, owned)
|
||||
}
|
||||
|
||||
func scopeAllows(kind Kind, scope Scope, owned bool) bool {
|
||||
switch scope {
|
||||
case ScopeAll:
|
||||
return true
|
||||
case ScopeOwn:
|
||||
return kind == KindScoped && owned
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveGrantKey(key string) (Permission, error) {
|
||||
if strings.Contains(key, typeSeparator) {
|
||||
base, _, err := ParseTypedKey(key)
|
||||
if err != nil {
|
||||
return Permission{}, err
|
||||
}
|
||||
return Lookup(base)
|
||||
}
|
||||
return Lookup(key)
|
||||
}
|
||||
|
||||
func ValidateGrants(locked bool, grants []Grant) error {
|
||||
for _, grant := range grants {
|
||||
permission, err := resolveGrantKey(grant.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if permission.OwnerOnly && !locked {
|
||||
return ErrOwnerOnlyGrant
|
||||
}
|
||||
if !scopeValidFor(permission.Kind, grant.Scope) {
|
||||
return ErrInvalidScope
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scopeValidFor(kind Kind, scope Scope) bool {
|
||||
if kind == KindToggle {
|
||||
return scope == ScopeAll
|
||||
}
|
||||
return scope == ScopeOwn || scope == ScopeAll
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package role
|
||||
|
||||
type Repository interface {
|
||||
List() ([]Role, error)
|
||||
Get(id uint32) (Role, error)
|
||||
GetByName(name string) (Role, error)
|
||||
ListGrants(roleID uint32) ([]Grant, error)
|
||||
Create(created Role) (Role, error)
|
||||
Save(saved Role) (Role, error)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ type User struct {
|
||||
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"`
|
||||
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user