feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.
This commit is contained in:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -34,6 +34,7 @@ const (
|
||||
CategorySystem Category = "system"
|
||||
CategorySites Category = "sites"
|
||||
CategoryAuthors Category = "authors"
|
||||
CategoryRoles Category = "roles"
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
@@ -80,6 +81,10 @@ const (
|
||||
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"
|
||||
@@ -118,6 +123,7 @@ const (
|
||||
TargetRelease = "release"
|
||||
TargetSite = "site"
|
||||
TargetAuthor = "author"
|
||||
TargetRole = "role"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -147,6 +153,10 @@ const (
|
||||
ParamFormat = "format"
|
||||
ParamQuality = "quality"
|
||||
ParamTag = "tag"
|
||||
ParamAdded = "added"
|
||||
ParamChanged = "changed"
|
||||
ParamMembers = "members"
|
||||
ParamMoved = "moved"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
@@ -158,6 +168,7 @@ const (
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
FieldName = "name"
|
||||
FieldDescription = "description"
|
||||
FieldBaseURL = "base_url"
|
||||
FieldDefaultRobots = "default_robots"
|
||||
FieldLocale = "locale"
|
||||
@@ -193,7 +204,7 @@ func ActorTypes() []ActorType {
|
||||
}
|
||||
|
||||
func Categories() []Category {
|
||||
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors}
|
||||
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors, CategoryRoles}
|
||||
}
|
||||
|
||||
func Outcomes() []Outcome {
|
||||
|
||||
@@ -3,6 +3,7 @@ package role
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,6 +15,18 @@ var (
|
||||
ErrDuplicateGrant = errors.New("permission granted more than once")
|
||||
|
||||
ErrMissingPermission = errors.New("missing permission")
|
||||
|
||||
ErrRoleHasMembers = errors.New("role still has members")
|
||||
ErrRoleNameTaken = errors.New("role name already used")
|
||||
ErrRoleLocked = errors.New("role is locked")
|
||||
ErrBuiltInRole = errors.New("role is built in")
|
||||
ErrBuiltInName = errors.New("built-in role cannot be renamed")
|
||||
ErrOwnRole = errors.New("cannot change the role you hold")
|
||||
ErrInvalidRoleName = errors.New("invalid role name")
|
||||
ErrUnknownContentType = errors.New("unknown content type")
|
||||
ErrNotBuiltIn = errors.New("role is not built in")
|
||||
ErrInvalidReassign = errors.New("invalid reassign target")
|
||||
ErrEscalation = errors.New("grants more than the caller holds")
|
||||
)
|
||||
|
||||
type Scope string
|
||||
@@ -30,13 +43,15 @@ type Grant struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
UpdatedBy *uint32 `json:"-"`
|
||||
}
|
||||
|
||||
func resolveGrantKey(key string) (Permission, error) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package role
|
||||
|
||||
type GrantChange struct {
|
||||
Key string `json:"key"`
|
||||
From Scope `json:"from"`
|
||||
To Scope `json:"to"`
|
||||
}
|
||||
|
||||
type GrantDiff struct {
|
||||
Added []Grant `json:"added"`
|
||||
Removed []Grant `json:"removed"`
|
||||
Changed []GrantChange `json:"changed"`
|
||||
}
|
||||
|
||||
func (d GrantDiff) Empty() bool {
|
||||
return len(d.Added) == 0 && len(d.Removed) == 0 && len(d.Changed) == 0
|
||||
}
|
||||
|
||||
func DiffGrants(before []Grant, after []Grant) GrantDiff {
|
||||
diff := GrantDiff{Added: []Grant{}, Removed: []Grant{}, Changed: []GrantChange{}}
|
||||
previous := scopesByKey(before)
|
||||
current := scopesByKey(after)
|
||||
for _, grant := range after {
|
||||
held, present := previous[grant.Key]
|
||||
if !present {
|
||||
diff.Added = append(diff.Added, grant)
|
||||
} else if held != grant.Scope {
|
||||
diff.Changed = append(diff.Changed, GrantChange{Key: grant.Key, From: held, To: grant.Scope})
|
||||
}
|
||||
}
|
||||
for _, grant := range before {
|
||||
if _, present := current[grant.Key]; !present {
|
||||
diff.Removed = append(diff.Removed, grant)
|
||||
}
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
func scopesByKey(grants []Grant) map[string]Scope {
|
||||
scopes := make(map[string]Scope, len(grants))
|
||||
for _, grant := range grants {
|
||||
scopes[grant.Key] = grant.Scope
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MissingPermissionError struct {
|
||||
Key string
|
||||
}
|
||||
@@ -33,14 +38,13 @@ func (r Role) GrantSet() GrantSet {
|
||||
}
|
||||
|
||||
func effectiveScope(kind Kind, scope Scope) Scope {
|
||||
switch {
|
||||
case scope == ScopeAll:
|
||||
if scope == ScopeAll {
|
||||
return ScopeAll
|
||||
case scope == ScopeOwn && kind == KindScoped:
|
||||
return ScopeOwn
|
||||
default:
|
||||
return ScopeNone
|
||||
}
|
||||
if scope == ScopeOwn && kind == KindScoped {
|
||||
return ScopeOwn
|
||||
}
|
||||
return ScopeNone
|
||||
}
|
||||
|
||||
func scopeRank(scope Scope) int {
|
||||
@@ -112,3 +116,66 @@ func (g GrantSet) Holds(key string) bool {
|
||||
func (g GrantSet) HoldsAll(key string) bool {
|
||||
return g.ScopeFor(key, "") == ScopeAll
|
||||
}
|
||||
|
||||
type EscalationError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e EscalationError) Error() string {
|
||||
return ErrEscalation.Error() + ": " + e.Key
|
||||
}
|
||||
|
||||
func (e EscalationError) Is(target error) bool {
|
||||
return target == ErrEscalation
|
||||
}
|
||||
|
||||
func (g GrantSet) Covers(other GrantSet, typeKeys []string) error {
|
||||
sortedTypeKeys := slices.Sorted(slices.Values(typeKeys))
|
||||
for _, permission := range catalogue {
|
||||
if scopeRank(other.ScopeFor(permission.Key, "")) > scopeRank(g.ScopeFor(permission.Key, "")) {
|
||||
return EscalationError{Key: permission.Key}
|
||||
}
|
||||
if !permission.PerType() {
|
||||
continue
|
||||
}
|
||||
for _, typeKey := range sortedTypeKeys {
|
||||
if scopeRank(other.ScopeFor(permission.Key, typeKey)) > scopeRank(g.ScopeFor(permission.Key, typeKey)) {
|
||||
return EscalationError{Key: permission.Key + typeSeparator + typeKey}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GrantSet) Effective() []Grant {
|
||||
effective := []Grant{}
|
||||
for _, permission := range catalogue {
|
||||
if scope := g.wildcard(permission.Key); scope != ScopeNone {
|
||||
effective = append(effective, Grant{Key: permission.Key, Scope: scope})
|
||||
}
|
||||
if permission.PerType() {
|
||||
effective = append(effective, g.typedGrants(permission.Key)...)
|
||||
}
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
func (g GrantSet) typedGrants(key string) []Grant {
|
||||
prefix := key + typeSeparator
|
||||
composites := []string{}
|
||||
for composite := range g.scopes {
|
||||
if strings.HasPrefix(composite, prefix) {
|
||||
composites = append(composites, composite)
|
||||
}
|
||||
}
|
||||
slices.Sort(composites)
|
||||
typed := make([]Grant, 0, len(composites))
|
||||
for _, composite := range composites {
|
||||
typed = append(typed, Grant{Key: composite, Scope: g.scopes[composite]})
|
||||
}
|
||||
return typed
|
||||
}
|
||||
|
||||
func (r Role) EffectiveGrants() []Grant {
|
||||
return r.GrantSet().Effective()
|
||||
}
|
||||
@@ -4,7 +4,9 @@ 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)
|
||||
Delete(id uint32) error
|
||||
ReassignMembers(from uint32, to uint32) (int, error)
|
||||
RemoveTypedGrants(typeKey string) (int, error)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeName(name string) (string, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "", ErrInvalidRoleName
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func SameName(left string, right string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
|
||||
}
|
||||
|
||||
func ValidateGrantTypes(grants []Grant, allowedTypeKeys []string) error {
|
||||
for _, grant := range grants {
|
||||
if !isTypedKey(grant.Key) {
|
||||
continue
|
||||
}
|
||||
_, typeKey, err := ParseTypedKey(grant.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedTypeKeys, typeKey) {
|
||||
return ErrUnknownContentType
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TypedKeys(typeKey string) []string {
|
||||
keys := []string{}
|
||||
for _, permission := range catalogue {
|
||||
if permission.PerType() {
|
||||
keys = append(keys, permission.Key+typeSeparator+typeKey)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
|
||||
var editedAt time.Time
|
||||
if m.EditedAt != nil {
|
||||
editedAt = m.EditedAt.UTC()
|
||||
}
|
||||
return role.Role{
|
||||
UpdatedAt: editedAt,
|
||||
UpdatedBy: m.EditedBy,
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
@@ -18,7 +26,14 @@ func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
|
||||
}
|
||||
|
||||
func RoleToModel(r role.Role) models.Role {
|
||||
var editedAt *time.Time
|
||||
if !r.UpdatedAt.IsZero() {
|
||||
stamp := r.UpdatedAt
|
||||
editedAt = &stamp
|
||||
}
|
||||
return models.Role{
|
||||
EditedAt: editedAt,
|
||||
EditedBy: r.UpdatedBy,
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
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"`
|
||||
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"`
|
||||
EditedAt *time.Time `gorm:"column:updated_at"`
|
||||
EditedBy *uint32 `gorm:"column:updated_by"`
|
||||
}
|
||||
|
||||
type RolePermission struct {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RoleRepository struct {
|
||||
@@ -37,9 +40,17 @@ func (r *RoleRepository) List() ([]role.Role, error) {
|
||||
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []entity.RolePermission
|
||||
if err := r.db.Order("id asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rowsByRole := make(map[uint32][]entity.RolePermission, len(found))
|
||||
for _, row := range rows {
|
||||
rowsByRole[row.RoleID] = append(rowsByRole[row.RoleID], row)
|
||||
}
|
||||
roles := make([]role.Role, 0, len(found))
|
||||
for _, row := range found {
|
||||
roles = append(roles, mappers.RoleToDomain(row, nil))
|
||||
roles = append(roles, mappers.RoleToDomain(row, rowsByRole[row.ID]))
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
@@ -60,18 +71,28 @@ func (r *RoleRepository) GetByName(name string) (role.Role, error) {
|
||||
return r.withGrants(found)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
|
||||
rows, err := r.grantRows(roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func ensureNameUnused(tx *gorm.DB, name string, excludedID uint32) error {
|
||||
var taken int64
|
||||
query := tx.Model(&entity.Role{}).Where("LOWER(name) = LOWER(?)", strings.TrimSpace(name))
|
||||
if excludedID != 0 {
|
||||
query = query.Where("id <> ?", excludedID)
|
||||
}
|
||||
return mappers.GrantsToDomain(rows), nil
|
||||
if err := query.Count(&taken).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if taken > 0 {
|
||||
return role.ErrRoleNameTaken
|
||||
}
|
||||
return 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 := ensureNameUnused(tx, created.Name, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,9 +113,12 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
|
||||
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
|
||||
return mapNotFoundErr(err, role.ErrRoleNotFound)
|
||||
}
|
||||
if err := ensureNameUnused(tx, saved.Name, saved.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
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})
|
||||
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1, "updated_at": mappers.RoleToModel(saved).EditedAt, "updated_by": saved.UpdatedBy})
|
||||
if update.Error != nil {
|
||||
return update.Error
|
||||
}
|
||||
@@ -116,4 +140,66 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
|
||||
return r.Get(saved.ID)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Delete(id uint32) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) 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 {
|
||||
return err
|
||||
}
|
||||
if members > 0 {
|
||||
return role.ErrRoleHasMembers
|
||||
}
|
||||
if err := tx.Where("role_id = ?", id).Delete(&entity.RolePermission{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&entity.Role{}, id).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 {
|
||||
return 0, err
|
||||
}
|
||||
if targets == 0 {
|
||||
return 0, role.ErrInvalidReassign
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) {
|
||||
affected := 0
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var roleIDs []uint32
|
||||
doomed := role.TypedKeys(typeKey)
|
||||
if err := tx.Model(&entity.RolePermission{}).Where("permission_key IN ?", doomed).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Where("permission_key IN ?", doomed).Delete(&entity.RolePermission{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
affected = len(roleIDs)
|
||||
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("version", gorm.Expr("version + 1")).Error
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
var _ role.Repository = &RoleRepository{}
|
||||
@@ -3,12 +3,14 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateContentTypeUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
roles role.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -19,8 +21,8 @@ type CreateContentTypeCommand struct {
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase {
|
||||
return &CreateContentTypeUseCase{contentTypes, now}
|
||||
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, roles role.Repository, now func() time.Time) *CreateContentTypeUseCase {
|
||||
return &CreateContentTypeUseCase{contentTypes, roles, now}
|
||||
}
|
||||
|
||||
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
|
||||
@@ -45,7 +47,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
created := u.now()
|
||||
return u.contentTypes.Create(contenttype.ContentType{
|
||||
made, err := u.contentTypes.Create(contenttype.ContentType{
|
||||
Key: command.Key,
|
||||
Name: command.Name,
|
||||
Icon: icon,
|
||||
@@ -53,6 +55,13 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
if _, err := u.roles.RemoveTypedGrants(command.Key); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
return made, nil
|
||||
}
|
||||
|
||||
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
)
|
||||
|
||||
type CreateRoleUseCase struct {
|
||||
roles role.Repository
|
||||
contentTypes contenttype.Repository
|
||||
recorder audit.Recorder
|
||||
viewer roleViewer
|
||||
}
|
||||
|
||||
type CreateRoleCommand struct {
|
||||
Caller ContentCaller
|
||||
Name string
|
||||
Description string
|
||||
Grants []role.Grant
|
||||
}
|
||||
|
||||
func NewCreateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *CreateRoleUseCase {
|
||||
return &CreateRoleUseCase{roles: roles, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
|
||||
}
|
||||
|
||||
func (u *CreateRoleUseCase) Create(ctx context.Context, command CreateRoleCommand) (RoleView, error) {
|
||||
name, err := role.NormalizeName(command.Name)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
draft := role.Role{Name: name, Description: command.Description, Version: 1, Grants: normalizedGrants(command.Grants)}
|
||||
if err := ensureCovered(command.Caller, u.contentTypes, draft); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if err := ensureNameFree(u.roles, name, 0); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
|
||||
event.Target = audit.Target{Type: audit.TargetRole, Name: name}
|
||||
created, err := u.roles.Create(draft)
|
||||
if err != nil {
|
||||
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||
return RoleView{}, err
|
||||
}
|
||||
event.Target = roleTarget(created)
|
||||
event.Params[audit.ParamAdded] = created.EffectiveGrants()
|
||||
u.recorder.Record(ctx, event)
|
||||
return u.viewer.one(created, 0)
|
||||
}
|
||||
@@ -3,15 +3,17 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/role"
|
||||
)
|
||||
|
||||
type DeleteContentTypeUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
roles role.Repository
|
||||
}
|
||||
|
||||
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase {
|
||||
return &DeleteContentTypeUseCase{contentTypes, entries}
|
||||
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, roles role.Repository) *DeleteContentTypeUseCase {
|
||||
return &DeleteContentTypeUseCase{contentTypes, entries, roles}
|
||||
}
|
||||
|
||||
func (u *DeleteContentTypeUseCase) Delete(key string) error {
|
||||
@@ -35,5 +37,9 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error {
|
||||
if count > 0 {
|
||||
return contenttype.ErrHasEntries
|
||||
}
|
||||
return u.contentTypes.Delete(target.ID)
|
||||
if err := u.contentTypes.Delete(target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = u.roles.RemoveTypedGrants(target.Key)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
)
|
||||
|
||||
type DeleteRoleUseCase struct {
|
||||
roles role.Repository
|
||||
users user.Repository
|
||||
contentTypes contenttype.Repository
|
||||
recorder audit.Recorder
|
||||
viewer roleViewer
|
||||
}
|
||||
|
||||
type DeleteRoleCommand struct {
|
||||
Caller ContentCaller
|
||||
ID uint32
|
||||
}
|
||||
|
||||
func NewDeleteRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *DeleteRoleUseCase {
|
||||
return &DeleteRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
|
||||
}
|
||||
|
||||
func (u *DeleteRoleUseCase) Delete(ctx context.Context, command DeleteRoleCommand) error {
|
||||
target, err := u.roles.Get(command.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.check(command.Caller, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionRoleDelete, audit.CategoryRoles)
|
||||
event.Target = roleTarget(target)
|
||||
event.Params[audit.ParamMembers] = 0
|
||||
if err := u.roles.Delete(target.ID); err != nil {
|
||||
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *DeleteRoleUseCase) check(caller ContentCaller, target role.Role) error {
|
||||
if err := ensureEditable(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNotBuiltIn(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNotOwnRole(u.roles, u.users, caller, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureCovered(caller, u.contentTypes, target); err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := u.viewer.members(target.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if members > 0 {
|
||||
return role.ErrRoleHasMembers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -46,15 +46,5 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, nil
|
||||
}
|
||||
|
||||
func effectiveGrants(grants role.GrantSet) []role.Grant {
|
||||
effective := []role.Grant{}
|
||||
for _, permission := range role.Catalogue() {
|
||||
if scope := grants.ScopeFor(permission.Key, ""); scope != role.ScopeNone {
|
||||
effective = append(effective, role.Grant{Key: permission.Key, Scope: scope})
|
||||
}
|
||||
}
|
||||
return effective
|
||||
return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
)
|
||||
|
||||
type GetRoleUseCase struct {
|
||||
roles role.Repository
|
||||
viewer roleViewer
|
||||
}
|
||||
|
||||
func NewGetRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *GetRoleUseCase {
|
||||
return &GetRoleUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
|
||||
}
|
||||
|
||||
func (u *GetRoleUseCase) Get(id uint32) (RoleView, error) {
|
||||
found, err := u.roles.Get(id)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
members, err := u.viewer.members(found.ID)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
return u.viewer.one(found, members)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
)
|
||||
|
||||
type ListRolesUseCase struct {
|
||||
roles role.Repository
|
||||
viewer roleViewer
|
||||
}
|
||||
|
||||
func NewListRolesUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *ListRolesUseCase {
|
||||
return &ListRolesUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
|
||||
}
|
||||
|
||||
func (u *ListRolesUseCase) List() ([]RoleView, error) {
|
||||
found, err := u.roles.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.viewer.views(found)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ReassignRoleMembersUseCase struct {
|
||||
roles role.Repository
|
||||
users user.Repository
|
||||
contentTypes contenttype.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type ReassignRoleMembersCommand struct {
|
||||
Caller ContentCaller
|
||||
SourceID uint32
|
||||
TargetID uint32
|
||||
}
|
||||
|
||||
func NewReassignRoleMembersUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ReassignRoleMembersUseCase {
|
||||
return &ReassignRoleMembersUseCase{roles, users, contentTypes, recorder}
|
||||
}
|
||||
|
||||
func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command ReassignRoleMembersCommand) (int, error) {
|
||||
source, err := u.roles.Get(command.SourceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := u.checkSource(command.Caller, source); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := u.checkTarget(command, source); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionRoleReassign, audit.CategoryRoles)
|
||||
event.Target = roleTarget(source)
|
||||
event.Params[audit.ParamFrom] = source.ID
|
||||
event.Params[audit.ParamTo] = command.TargetID
|
||||
event.Params[audit.ParamMoved] = 0
|
||||
moved, err := u.roles.ReassignMembers(source.ID, command.TargetID)
|
||||
if err != nil {
|
||||
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||
return 0, err
|
||||
}
|
||||
if moved > 0 {
|
||||
event.Params[audit.ParamMoved] = moved
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
return moved, nil
|
||||
}
|
||||
|
||||
func (u *ReassignRoleMembersUseCase) checkSource(caller ContentCaller, source role.Role) error {
|
||||
if err := ensureEditable(source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNotBuiltIn(source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNotOwnRole(u.roles, u.users, caller, source); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureCovered(caller, u.contentTypes, source)
|
||||
}
|
||||
|
||||
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) error {
|
||||
if command.TargetID == 0 || command.TargetID == source.ID {
|
||||
return role.ErrInvalidReassign
|
||||
}
|
||||
target, err := u.roles.Get(command.TargetID)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return role.ErrInvalidReassign
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Locked {
|
||||
return role.ErrInvalidReassign
|
||||
}
|
||||
return ensureCovered(command.Caller, u.contentTypes, target)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateRoleUseCase struct {
|
||||
roles role.Repository
|
||||
users user.Repository
|
||||
contentTypes contenttype.Repository
|
||||
recorder audit.Recorder
|
||||
now func() time.Time
|
||||
viewer roleViewer
|
||||
}
|
||||
|
||||
type UpdateRoleCommand struct {
|
||||
Caller ContentCaller
|
||||
ID uint32
|
||||
Name string
|
||||
Description string
|
||||
Grants []role.Grant
|
||||
Version uint32
|
||||
}
|
||||
|
||||
type ResetRoleCommand struct {
|
||||
Caller ContentCaller
|
||||
ID uint32
|
||||
Version uint32
|
||||
}
|
||||
|
||||
func NewUpdateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder, now func() time.Time) *UpdateRoleUseCase {
|
||||
return &UpdateRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, now: now, viewer: roleViewer{roles, users, contentTypes}}
|
||||
}
|
||||
|
||||
func (u *UpdateRoleUseCase) Update(ctx context.Context, command UpdateRoleCommand) (RoleView, error) {
|
||||
target, err := u.roles.Get(command.ID)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if err := u.checkTarget(command, target); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
name, err := role.NormalizeName(command.Name)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
after := target
|
||||
after.Name = name
|
||||
after.Description = command.Description
|
||||
after.Grants = normalizedGrants(command.Grants)
|
||||
if err := ensureCovered(command.Caller, u.contentTypes, after); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if err := ensureNameFree(u.roles, name, target.ID); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
return u.write(ctx, command.Caller, target, after)
|
||||
}
|
||||
|
||||
func (u *UpdateRoleUseCase) Reset(ctx context.Context, command ResetRoleCommand) (RoleView, error) {
|
||||
target, err := u.roles.Get(command.ID)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if err := ensureEditable(target); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if !target.BuiltIn {
|
||||
return RoleView{}, role.ErrNotBuiltIn
|
||||
}
|
||||
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
if command.Version != target.Version {
|
||||
return RoleView{}, role.ErrRoleConflict
|
||||
}
|
||||
seeded, found := seededRole(target.Name)
|
||||
if !found {
|
||||
return RoleView{}, role.ErrNotBuiltIn
|
||||
}
|
||||
after := target
|
||||
after.Description = seeded.Description
|
||||
after.Grants = seeded.EffectiveGrants()
|
||||
return u.write(ctx, command.Caller, target, after)
|
||||
}
|
||||
|
||||
func (u *UpdateRoleUseCase) checkTarget(command UpdateRoleCommand, target role.Role) error {
|
||||
if err := ensureEditable(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if target.BuiltIn {
|
||||
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
|
||||
return err
|
||||
}
|
||||
if renamesBuiltIn(command.Name, target) {
|
||||
return role.ErrBuiltInName
|
||||
}
|
||||
}
|
||||
if err := ensureNotOwnRole(u.roles, u.users, command.Caller, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureCovered(command.Caller, u.contentTypes, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.Version != target.Version {
|
||||
return role.ErrRoleConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UpdateRoleUseCase) write(ctx context.Context, caller ContentCaller, before role.Role, after role.Role) (RoleView, error) {
|
||||
members, err := u.viewer.members(before.ID)
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
live, err := u.viewer.liveGrants(before.EffectiveGrants())
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
diff := role.DiffGrants(live, after.Grants)
|
||||
if diff.Empty() && before.Name == after.Name && before.Description == after.Description {
|
||||
return u.viewer.one(before, members)
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionRoleEdit, audit.CategoryRoles)
|
||||
event.Target = roleTarget(before)
|
||||
editor := caller.UserID
|
||||
after.UpdatedAt = u.now().UTC()
|
||||
after.UpdatedBy = &editor
|
||||
saved, err := u.roles.Save(after)
|
||||
if err != nil {
|
||||
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||
return RoleView{}, err
|
||||
}
|
||||
event.Target = roleTarget(saved)
|
||||
event.Params = grantsParams(diff, members)
|
||||
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldName, before.Name, saved.Name)
|
||||
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldDescription, before.Description, saved.Description)
|
||||
u.recorder.Record(ctx, event)
|
||||
return u.viewer.one(saved, members)
|
||||
}
|
||||
|
||||
func requireOwnerOnlyEditing(caller ContentCaller) error {
|
||||
if !caller.Grants.Holds(role.KeyRolesEditOwnerOnly) {
|
||||
return role.MissingPermissionError{Key: role.KeyRolesEditOwnerOnly}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renamesBuiltIn(requested string, target role.Role) bool {
|
||||
trimmed, err := role.NormalizeName(requested)
|
||||
return err != nil || trimmed != target.Name
|
||||
}
|
||||
|
||||
func seededRole(name string) (role.Role, bool) {
|
||||
for _, seeded := range role.BuiltInRoles() {
|
||||
if seeded.Name == name {
|
||||
return seeded, true
|
||||
}
|
||||
}
|
||||
return role.Role{}, false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func roleTarget(found role.Role) audit.Target {
|
||||
return audit.NewTarget(audit.TargetRole, found.ID, found.Name)
|
||||
}
|
||||
|
||||
func allowedTypeKeys(contentTypes contenttype.Repository) ([]string, error) {
|
||||
types, err := contentTypes.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := []string{builtintype.KeyArticle}
|
||||
for _, contentType := range types {
|
||||
keys = append(keys, contentType.Key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, candidate role.Role) error {
|
||||
typeKeys, err := allowedTypeKeys(contentTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return caller.Grants.Covers(candidate.GrantSet(), typeKeys)
|
||||
}
|
||||
|
||||
func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error {
|
||||
account, err := users.Get(caller.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
own, err := resolveRole(roles, account)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if own.ID == target.ID {
|
||||
return role.ErrOwnRole
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureEditable(target role.Role) error {
|
||||
if target.Locked {
|
||||
return role.ErrRoleLocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureNotBuiltIn(target role.Role) error {
|
||||
if target.BuiltIn {
|
||||
return role.ErrBuiltInRole
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureNameFree(roles role.Repository, name string, excludedID uint32) error {
|
||||
existing, err := roles.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, other := range existing {
|
||||
if other.ID != excludedID && role.SameName(other.Name, name) {
|
||||
return role.ErrRoleNameTaken
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoleGrants(contentTypes contenttype.Repository, grants []role.Grant) error {
|
||||
if err := role.ValidateGrants(false, grants); err != nil {
|
||||
return err
|
||||
}
|
||||
typeKeys, err := allowedTypeKeys(contentTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return role.ValidateGrantTypes(grants, typeKeys)
|
||||
}
|
||||
|
||||
func normalizedGrants(grants []role.Grant) []role.Grant {
|
||||
return role.Role{Grants: grants}.EffectiveGrants()
|
||||
}
|
||||
|
||||
var writeRefusals = []error{role.ErrRoleConflict, role.ErrRoleHasMembers, role.ErrRoleNotFound, role.ErrRoleNameTaken, role.ErrInvalidReassign}
|
||||
|
||||
func recordRoleWriteFailure(ctx context.Context, recorder audit.Recorder, event audit.Event, err error) {
|
||||
for _, refusal := range writeRefusals {
|
||||
if errors.Is(err, refusal) {
|
||||
return
|
||||
}
|
||||
}
|
||||
recordFailure(ctx, recorder, event, err)
|
||||
}
|
||||
|
||||
func grantsParams(diff role.GrantDiff, members int) map[string]any {
|
||||
return map[string]any{
|
||||
audit.ParamAdded: diff.Added,
|
||||
audit.ParamRemoved: diff.Removed,
|
||||
audit.ParamChanged: diff.Changed,
|
||||
audit.ParamMembers: members,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RoleEditor struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RoleView struct {
|
||||
role.Role
|
||||
Members int `json:"members"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
UpdatedBy *RoleEditor `json:"updated_by"`
|
||||
}
|
||||
|
||||
type roleViewer struct {
|
||||
roles role.Repository
|
||||
users user.Repository
|
||||
contentTypes contenttype.Repository
|
||||
}
|
||||
|
||||
func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) {
|
||||
typeKeys, err := allowedTypeKeys(v.contentTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
live := make([]role.Grant, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
if _, typeKey, parseErr := role.ParseTypedKey(grant.Key); parseErr == nil && !slices.Contains(typeKeys, typeKey) {
|
||||
continue
|
||||
}
|
||||
live = append(live, grant)
|
||||
}
|
||||
return live, nil
|
||||
}
|
||||
|
||||
func (v roleViewer) views(found []role.Role) ([]RoleView, error) {
|
||||
counts, err := memberCounts(v.roles, v.users.GetAll())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
editors, err := v.editors(found)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]RoleView, 0, len(found))
|
||||
for _, item := range found {
|
||||
view := toRoleView(item, counts[item.ID], editors)
|
||||
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (v roleViewer) one(found role.Role, members int) (RoleView, error) {
|
||||
editors, err := v.editors([]role.Role{found})
|
||||
if err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
view := toRoleView(found, members, editors)
|
||||
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
|
||||
return RoleView{}, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (v roleViewer) members(roleID uint32) (int, error) {
|
||||
counts, err := memberCounts(v.roles, v.users.GetAll())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return counts[roleID], nil
|
||||
}
|
||||
|
||||
func (v roleViewer) editors(found []role.Role) (map[uint32]string, error) {
|
||||
ids := []uint32{}
|
||||
for _, item := range found {
|
||||
if item.UpdatedBy != nil {
|
||||
ids = append(ids, *item.UpdatedBy)
|
||||
}
|
||||
}
|
||||
names := map[uint32]string{}
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
editors, err := v.users.FindByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, editor := range editors {
|
||||
names[editor.ID] = editor.NameOrUsername()
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func toRoleView(found role.Role, members int, editors map[uint32]string) RoleView {
|
||||
view := RoleView{Role: found, Members: members}
|
||||
view.Grants = found.EffectiveGrants()
|
||||
if !found.UpdatedAt.IsZero() {
|
||||
stamp := found.UpdatedAt.UTC()
|
||||
view.UpdatedAt = &stamp
|
||||
}
|
||||
if found.UpdatedBy != nil {
|
||||
if name, present := editors[*found.UpdatedBy]; present {
|
||||
view.UpdatedBy = &RoleEditor{ID: *found.UpdatedBy, Name: name}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func memberCounts(roles role.Repository, accounts []user.User) (map[uint32]int, error) {
|
||||
counts := map[uint32]int{}
|
||||
legacyIDs := map[user.Role]uint32{}
|
||||
for _, account := range accounts {
|
||||
id := account.RoleID
|
||||
if id == 0 {
|
||||
resolved, cached := legacyIDs[account.Role]
|
||||
if !cached {
|
||||
found, err := resolveRole(roles, account)
|
||||
if err != nil && !errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
resolved = found.ID
|
||||
legacyIDs[account.Role] = resolved
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
if id != 0 {
|
||||
counts[id]++
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
Reference in new issue
Block a user