refac: debt on role backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 23:34:35 +01:00
1 parent de142f6c5f
commit a043f7f41a
130 files changed
+3591 -512

No files matched your search

+9 -9
View File
@@ -18,15 +18,15 @@ func allGrants(include func(Permission) bool) []Grant {
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},
{Key: KeyContentRead, Scope: ScopeAll},
{Key: KeyContentCreate, Scope: ScopeAll},
{Key: KeyContentUpdate, Scope: ScopeAll},
{Key: KeySchemaView, Scope: ScopeAll},
{Key: KeyMediaView, Scope: ScopeAll},
{Key: KeyMediaUpload, Scope: ScopeAll},
{Key: KeyAuthorsView, Scope: ScopeAll},
{Key: KeyAuthorsEdit, Scope: ScopeOwn},
{Key: KeyActivityView, Scope: ScopeOwn},
}
}
+15 -51
View File
@@ -11,6 +11,7 @@ var (
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")
ErrDuplicateGrant = errors.New("permission granted more than once")
ErrMissingPermission = errors.New("missing permission")
)
@@ -38,55 +39,6 @@ type Role struct {
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)
@@ -99,6 +51,7 @@ func resolveGrantKey(key string) (Permission, error) {
}
func ValidateGrants(locked bool, grants []Grant) error {
seen := make(map[string]bool, len(grants))
for _, grant := range grants {
permission, err := resolveGrantKey(grant.Key)
if err != nil {
@@ -107,14 +60,25 @@ func ValidateGrants(locked bool, grants []Grant) error {
if permission.OwnerOnly && !locked {
return ErrOwnerOnlyGrant
}
if !scopeValidFor(permission.Kind, grant.Scope) {
if !scopeValidFor(permission.Kind, grant.Scope, isTypedKey(grant.Key)) {
return ErrInvalidScope
}
if seen[grant.Key] {
return ErrDuplicateGrant
}
seen[grant.Key] = true
}
return nil
}
func scopeValidFor(kind Kind, scope Scope) bool {
func isTypedKey(key string) bool {
return strings.Contains(key, typeSeparator)
}
func scopeValidFor(kind Kind, scope Scope, typed bool) bool {
if typed && scope == ScopeNone {
return true
}
if kind == KindToggle {
return scope == ScopeAll
}
+87 -13
View File
@@ -12,29 +12,103 @@ func (e MissingPermissionError) Is(target error) bool {
return target == ErrMissingPermission
}
type GrantSet map[string]Scope
func NewGrantSet(grants []Grant) GrantSet {
set := make(GrantSet, len(grants))
for _, grant := range grants {
set[grant.Key] = grant.Scope
}
return set
type GrantSet struct {
scopes map[string]Scope
}
func (g GrantSet) Scope(key string) Scope {
scope, present := g[key]
func (r Role) GrantSet() GrantSet {
scopes := make(map[string]Scope, len(r.Grants))
for _, grant := range r.Grants {
permission, err := resolveGrantKey(grant.Key)
if err != nil || permission.OwnerOnly && !r.Locked {
continue
}
scope := effectiveScope(permission.Kind, grant.Scope)
if held, present := scopes[grant.Key]; present {
scope = mostRestrictive(held, scope)
}
scopes[grant.Key] = scope
}
return GrantSet{scopes: scopes}
}
func effectiveScope(kind Kind, scope Scope) Scope {
switch {
case scope == ScopeAll:
return ScopeAll
case scope == ScopeOwn && kind == KindScoped:
return ScopeOwn
default:
return ScopeNone
}
}
func scopeRank(scope Scope) int {
switch scope {
case ScopeAll:
return 2
case ScopeOwn:
return 1
default:
return 0
}
}
func mostRestrictive(left Scope, right Scope) Scope {
if scopeRank(left) <= scopeRank(right) {
return left
}
return right
}
func (g GrantSet) ScopeFor(key string, typeKey string) Scope {
if typeKey == "" {
return g.wildcard(key)
}
composite := key + typeSeparator + typeKey
if _, _, err := ParseTypedKey(composite); err != nil {
return ScopeNone
}
if scope, present := g.scopes[composite]; present {
return scope
}
return g.wildcard(key)
}
func (g GrantSet) wildcard(key string) Scope {
if isTypedKey(key) {
return ScopeNone
}
scope, present := g.scopes[key]
if !present {
return ScopeNone
}
return scope
}
func (g GrantSet) Permits(key string, typeKey string, callerID uint32, createdBy uint32) bool {
return ScopePermits(g.ScopeFor(key, typeKey), callerID, createdBy)
}
func ScopePermits(scope Scope, callerID uint32, createdBy uint32) bool {
switch scope {
case ScopeAll:
return true
case ScopeOwn:
return callerID != 0 && callerID == createdBy
default:
return false
}
}
func (g GrantSet) HoldsFor(key string, typeKey string) bool {
return g.ScopeFor(key, typeKey) != ScopeNone
}
func (g GrantSet) Holds(key string) bool {
scope := g.Scope(key)
return scope == ScopeOwn || scope == ScopeAll
return g.HoldsFor(key, "")
}
func (g GrantSet) HoldsAll(key string) bool {
return g.Scope(key) == ScopeAll
return g.ScopeFor(key, "") == ScopeAll
}
+34 -7
View File
@@ -1,11 +1,38 @@
package role
const (
KeyActivityView = "activity.view"
KeyActivityExport = "activity.export"
KeyAPIKeysView = "apikeys.view"
KeyAPIKeysRevoke = "apikeys.revoke"
KeyAuthorsEdit = "authors.edit"
KeyUsersView = "users.view"
KeyApplyUpdates = "system.apply_updates"
KeyContentRead = "content.read"
KeyContentCreate = "content.create"
KeyContentUpdate = "content.update"
KeyContentDelete = "content.delete"
KeyContentPublish = "content.publish"
KeySchemaView = "schema.view"
KeySchemaEdit = "schema.edit"
KeySchemaDelete = "schema.delete"
KeyMediaView = "media.view"
KeyMediaUpload = "media.upload"
KeyMediaDelete = "media.delete"
KeyAuthorsView = "authors.view"
KeyAuthorsCreate = "authors.create"
KeyAuthorsEdit = "authors.edit"
KeyAuthorsDelete = "authors.delete"
KeyActivityView = "activity.view"
KeyActivityExport = "activity.export"
KeyUsersView = "users.view"
KeyUsersInvite = "users.invite"
KeyUsersChangeRole = "users.change_role"
KeyUsersRemove = "users.remove"
KeySettingsView = "settings.view"
KeySettingsEditGeneral = "settings.edit_general"
KeySettingsManageSites = "settings.manage_sites"
KeySettingsDeleteSite = "settings.delete_site"
KeyAPIKeysView = "apikeys.view"
KeyAPIKeysCreate = "apikeys.create"
KeyAPIKeysRevoke = "apikeys.revoke"
KeyRolesView = "roles.view"
KeyRolesManage = "roles.manage"
KeyRolesEditOwnerOnly = "roles.edit_owner_only"
KeySystemApplyUpdates = "system.apply_updates"
KeySystemEditRetention = "system.edit_retention"
KeySystemTransferOwnership = "system.transfer_ownership"
)
@@ -2,7 +2,9 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
)
@@ -32,11 +34,17 @@ func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
}
func (u *CheckArticleSEOUseCase) Check(id uint32, command CheckArticleSEOCommand) (article.SEOCheck, error) {
func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command CheckArticleSEOCommand) (article.SEOCheck, error) {
if err := caller.requireHolding(role.KeyContentRead, builtintype.KeyArticle); err != nil {
return article.SEOCheck{}, err
}
saved, err := u.articleRepository.Get(id)
if err != nil {
return article.SEOCheck{}, err
}
if !caller.seesArticle(saved) {
return article.SEOCheck{}, article.ErrArticleNotFound
}
if err := u.validate(command); err != nil {
return article.SEOCheck{}, err
}
@@ -14,42 +14,39 @@ func NewCheckPermissionUseCase(roles role.Repository) *CheckPermissionUseCase {
return &CheckPermissionUseCase{roles: roles}
}
func (c *CheckPermissionUseCase) Require(account user.User, key string) (role.GrantSet, error) {
grants, err := c.grantsOf(account, key)
if err != nil {
return nil, err
func (c *CheckPermissionUseCase) Grants(account user.User) (role.GrantSet, error) {
resolved, err := resolveRole(c.roles, account)
if errors.Is(err, role.ErrRoleNotFound) {
return role.GrantSet{}, nil
}
if !grants.Holds(key) {
return nil, role.MissingPermissionError{Key: key}
if err != nil {
return role.GrantSet{}, err
}
return resolved.GrantSet(), nil
}
func (c *CheckPermissionUseCase) Require(account user.User, key string) (role.GrantSet, error) {
return c.RequireFor(account, key, "")
}
func (c *CheckPermissionUseCase) RequireFor(account user.User, key string, typeKey string) (role.GrantSet, error) {
grants, err := c.Grants(account)
if err != nil {
return role.GrantSet{}, err
}
if !grants.HoldsFor(key, typeKey) {
return role.GrantSet{}, role.MissingPermissionError{Key: key}
}
return grants, nil
}
func (c *CheckPermissionUseCase) RequireAll(account user.User, key string) (role.GrantSet, error) {
grants, err := c.grantsOf(account, key)
grants, err := c.Grants(account)
if err != nil {
return nil, err
return role.GrantSet{}, err
}
if !grants.HoldsAll(key) {
return nil, role.MissingPermissionError{Key: key}
return role.GrantSet{}, role.MissingPermissionError{Key: key}
}
return grants, nil
}
func (c *CheckPermissionUseCase) grantsOf(account user.User, key string) (role.GrantSet, error) {
resolved, err := resolveRole(c.roles, account)
if errors.Is(err, role.ErrRoleNotFound) {
return nil, role.MissingPermissionError{Key: key}
}
if err != nil {
return nil, err
}
granted, err := c.roles.ListGrants(resolved.ID)
if errors.Is(err, role.ErrRoleNotFound) {
return nil, role.MissingPermissionError{Key: key}
}
if err != nil {
return nil, err
}
return role.NewGrantSet(granted), nil
}
@@ -4,6 +4,8 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
"context"
"errors"
@@ -18,10 +20,10 @@ type CreateArticleUseCase struct {
}
type CreateArticleCommand struct {
Title string
Body string
Format article.BodyFormat
CreatedBy uint32
Title string
Body string
Format article.BodyFormat
Caller ContentCaller
}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
@@ -48,6 +50,9 @@ func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, e
}
func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
if err := createArticle.Caller.requireHolding(role.KeyContentCreate, builtintype.KeyArticle); err != nil {
return article.Article{}, err
}
event := audit.NewEvent(audit.ActionArticleCreate, audit.CategoryArticles)
if err := article.ValidateTitle(createArticle.Title); err != nil {
recordFailure(ctx, g.recorder, event, err)
@@ -60,7 +65,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err
}
creditedAuthors, err := g.creatorBylines(createArticle.CreatedBy)
creditedAuthors, err := g.creatorBylines(createArticle.Caller.UserID)
if err != nil {
recordFailure(ctx, g.recorder, event, err)
return article.Article{}, err
@@ -71,7 +76,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
CreatedBy: createArticle.CreatedBy,
CreatedBy: createArticle.Caller.UserID,
Authors: creditedAuthors,
SEODismissed: []string{},
})
@@ -1,6 +1,7 @@
package useCases
import (
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"errors"
"time"
@@ -55,6 +56,9 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
}
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
if _, err := builtintype.Find(key); err == nil {
return contenttype.ErrKeyTaken
}
_, err := u.contentTypes.GetByKey(key)
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
return nil
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"time"
)
@@ -17,7 +18,7 @@ type CreateEntryUseCase struct {
type CreateEntryCommand struct {
ContentTypeKey string
Data map[string]any
UserID uint32
Caller ContentCaller
}
func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository, now func() time.Time) *CreateEntryUseCase {
@@ -25,6 +26,9 @@ func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Re
}
func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, error) {
if err := command.Caller.requireHolding(role.KeyContentCreate, command.ContentTypeKey); err != nil {
return TypedEntry{}, err
}
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
if err != nil {
return TypedEntry{}, err
@@ -47,8 +51,8 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
stored, err := u.entries.Create(entry.Entry{
ContentTypeID: owner.ID,
Data: data,
CreatedBy: command.UserID,
UpdatedBy: command.UserID,
CreatedBy: command.Caller.UserID,
UpdatedBy: command.Caller.UserID,
CreatedAt: created,
UpdatedAt: created,
})
@@ -3,6 +3,8 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"context"
)
@@ -15,11 +17,17 @@ func NewDeleteArticleUseCase(articleRepository article.Repository, recorder audi
return &DeleteArticleUseCase{articleRepository, recorder}
}
func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32) error {
func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32, caller ContentCaller) error {
if err := caller.requireHolding(role.KeyContentDelete, builtintype.KeyArticle); err != nil {
return err
}
target, err := g.articleRepository.Get(id)
if err != nil {
return err
}
if err := caller.authorize(role.KeyContentDelete, builtintype.KeyArticle, target.CreatedBy); err != nil {
return err
}
event := audit.NewEvent(audit.ActionArticleDelete, audit.CategoryArticles)
event.Target = articleTarget(target)
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/role"
)
type DeleteEntryUseCase struct {
@@ -14,10 +15,20 @@ func NewDeleteEntryUseCase(contentTypes contenttype.Repository, entries entry.Re
return &DeleteEntryUseCase{contentTypes, entries}
}
func (u *DeleteEntryUseCase) Delete(contentTypeKey string, id uint32) error {
func (u *DeleteEntryUseCase) Delete(contentTypeKey string, id uint32, caller ContentCaller) error {
if err := caller.requireHolding(role.KeyContentDelete, contentTypeKey); err != nil {
return err
}
owner, err := u.contentTypes.GetByKey(contentTypeKey)
if err != nil {
return err
}
found, err := u.entries.Get(owner.ID, id)
if err != nil {
return err
}
if err := caller.authorize(role.KeyContentDelete, contentTypeKey, found.CreatedBy); err != nil {
return err
}
return u.entries.Delete(owner.ID, id)
}
+2 -12
View File
@@ -11,24 +11,14 @@ func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer art
return &GetArticleUseCase{articleRepository, bodyRenderer}
}
func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) {
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat, caller ContentCaller) (article.Article, error) {
art, err := u.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
if !art.IsOnline {
if !caller.seesArticle(art) {
return article.Article{}, article.ErrArticleNotFound
}
return renderArticle(u.bodyRenderer, art, format)
}
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
art, err := u.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
return renderArticle(u.bodyRenderer, art, format)
}
+8 -1
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
)
type GetEntryUseCase struct {
@@ -16,7 +17,10 @@ func NewGetEntryUseCase(contentTypes contenttype.Repository, entries entry.Repos
return &GetEntryUseCase{contentTypes, entries, images}
}
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, error) {
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32, caller ContentCaller) (TypedEntry, error) {
if err := caller.requireHolding(role.KeyContentRead, contentTypeKey); err != nil {
return TypedEntry{}, err
}
owner, err := u.contentTypes.GetByKey(contentTypeKey)
if err != nil {
return TypedEntry{}, err
@@ -25,5 +29,8 @@ func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, err
if err != nil {
return TypedEntry{}, err
}
if !caller.reads(contentTypeKey, found.CreatedBy) {
return TypedEntry{}, entry.ErrEntryNotFound
}
return typedEntry(owner, found, newEntryResolvers(u.images, u.contentTypes, u.entries))
}
@@ -46,24 +46,15 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
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
return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, 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{}
func effectiveGrants(grants role.GrantSet) []role.Grant {
effective := []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})
if scope := grants.ScopeFor(permission.Key, ""); scope != role.ScopeNone {
effective = append(effective, role.Grant{Key: permission.Key, Scope: scope})
}
}
return ordered
return effective
}
@@ -27,7 +27,7 @@ func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository us
}
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32, grants role.GrantSet) ([]APIKeyEntry, error) {
scope := grants.Scope(role.KeyAPIKeysView)
scope := grants.ScopeFor(role.KeyAPIKeysView, "")
if scope == role.ScopeNone {
return nil, role.MissingPermissionError{Key: role.KeyAPIKeysView}
}
+124 -34
View File
@@ -2,6 +2,8 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
"errors"
)
@@ -17,15 +19,15 @@ func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer a
}
type ListArticlesCommand struct {
Format article.BodyFormat
Verified bool
Page int
Size int
Q string
Status string
Sort article.Sort
SiteKey *string
SEO string
Format article.BodyFormat
Caller ContentCaller
Page int
Size int
Q string
Status string
Sort article.Sort
SiteKey *string
SEO string
}
type ArticlePage struct {
@@ -38,20 +40,31 @@ type ArticlePage struct {
}
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
counts, err := u.articleRepository.CountByVisibility(!command.Verified)
switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
case role.ScopeAll:
return u.listVisibleByRepository(command, false)
case role.ScopeOwn:
return u.listOwnedAndOnline(command)
default:
return u.listVisibleByRepository(command, true)
}
}
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, onlineOnly bool) (ArticlePage, error) {
counts, err := u.articleRepository.CountByVisibility(onlineOnly)
if err != nil {
return ArticlePage{}, err
}
summaries, err := u.summarizeVisible(!command.Verified)
summaries, err := u.summarizeVisible(onlineOnly)
if err != nil {
return ArticlePage{}, err
}
counts.Suggestions = countSuggestions(summaries)
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
if !command.Verified && command.Status == article.StatusDraft {
page := emptyArticlePage(command, counts)
if onlineOnly && command.Status == article.StatusDraft {
return page, nil
}
filters := pageFilters(command)
filters := pageFilters(command, onlineOnly)
if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil {
@@ -81,6 +94,99 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
return page, nil
}
func emptyArticlePage(command ListArticlesCommand, counts article.Counts) ArticlePage {
return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
}
func (u *ListArticlesUseCase) listOwnedAndOnline(command ListArticlesCommand) (ArticlePage, error) {
everything, err := u.articleRepository.FindPage(article.Filters{Unpaged: true})
if err != nil {
return ArticlePage{}, err
}
visible := visibleTo(command.Caller, everything.Items)
summaries, err := u.summarize(visible)
if err != nil {
return ArticlePage{}, err
}
counts := tally(visible)
counts.Suggestions = countSuggestions(summaries)
page := emptyArticlePage(command, counts)
filters := pageFilters(command, false)
filters.Unpaged = true
if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil {
return ArticlePage{}, err
}
if !known {
return page, nil
}
filters.SiteID = &siteID
}
found, err := u.articleRepository.FindPage(filters)
if err != nil {
return ArticlePage{}, err
}
matching := visibleTo(command.Caller, found.Items)
if command.SEO != "" {
matching = withSEOStatus(matching, summaries, command.SEO)
}
rendered, err := renderArticles(u.bodyRenderer, pageSlice(matching, command.Page, command.Size), command.Format)
if err != nil {
return ArticlePage{}, err
}
page.Items = rendered
page.Total = len(matching)
page.SEO = summaries
return page, nil
}
func visibleTo(caller ContentCaller, items []article.Article) []article.Article {
visible := make([]article.Article, 0, len(items))
for _, item := range items {
if caller.seesArticle(item) {
visible = append(visible, item)
}
}
return visible
}
func tally(visible []article.Article) article.Counts {
counts := article.Counts{All: len(visible)}
for _, item := range visible {
if item.IsOnline {
counts.Online++
} else {
counts.Draft++
}
}
return counts
}
func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article {
matching := make([]article.Article, 0, len(items))
for _, item := range items {
if summaries[item.ID].Status == status {
matching = append(matching, item)
}
}
return matching
}
func pageSlice[T any](items []T, page int, size int) []T {
size = min(max(size, 0), len(items))
if size == 0 {
return []T{}
}
pages := (len(items) + size - 1) / size
pageIndex := max(page, 1) - 1
if pageIndex >= pages {
return []T{}
}
start := pageIndex * size
return items[start:min(start+size, len(items))]
}
func (u *ListArticlesUseCase) summarizeVisible(onlineOnly bool) (map[uint32]article.SEOSummary, error) {
filters := article.Filters{Unpaged: true}
if onlineOnly {
@@ -105,24 +211,8 @@ func countSuggestions(summaries map[uint32]article.SEOSummary) int {
}
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
matching := make([]article.Article, 0, len(items))
for _, item := range items {
if summaries[item.ID].Status == command.SEO {
matching = append(matching, item)
}
}
size := min(max(command.Size, 0), len(matching))
if size == 0 {
return []article.Article{}, len(matching)
}
pages := (len(matching) + size - 1) / size
pageIndex := max(command.Page, 1) - 1
if pageIndex >= pages {
return []article.Article{}, len(matching)
}
start := pageIndex * size
end := min(start+size, len(matching))
return matching[start:end], len(matching)
matching := withSEOStatus(items, summaries, command.SEO)
return pageSlice(matching, command.Page, command.Size), len(matching)
}
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
@@ -155,9 +245,9 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
return found.ID, true, nil
}
func pageFilters(command ListArticlesCommand) article.Filters {
func pageFilters(command ListArticlesCommand, onlineOnly bool) article.Filters {
filters := article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
if !command.Verified {
if onlineOnly {
online := true
filters.IsOnline = &online
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"strings"
)
@@ -19,6 +20,7 @@ type ListEntriesCommand struct {
Size int
Sort entry.Sort
Query string
Caller ContentCaller
}
type EntryPage struct {
@@ -33,6 +35,9 @@ func NewListEntriesUseCase(contentTypes contenttype.Repository, entries entry.Re
}
func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, error) {
if err := command.Caller.requireHolding(role.KeyContentRead, command.ContentTypeKey); err != nil {
return EntryPage{}, err
}
if err := entry.ValidatePage(command.Page, command.Size); err != nil {
return EntryPage{}, err
}
@@ -61,7 +66,8 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEntriesCommand) (entry.PageResult, error) {
query := strings.TrimSpace(command.Query)
if query == "" {
readScope := command.Caller.Grants.ScopeFor(role.KeyContentRead, command.ContentTypeKey)
if query == "" && readScope == role.ScopeAll {
return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
}
all, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: command.Sort})
@@ -70,13 +76,9 @@ func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEnt
}
matching := make([]entry.Entry, 0, len(all.Items))
for _, record := range all.Items {
if entry.MatchesQuery(owner.Fields, record.Data, query) {
if role.ScopePermits(readScope, command.Caller.UserID, record.CreatedBy) && (query == "" || entry.MatchesQuery(owner.Fields, record.Data, query)) {
matching = append(matching, record)
}
}
pageNumber := max(command.Page, 1)
size := max(command.Size, 0)
start := min((pageNumber-1)*size, len(matching))
end := min(start+size, len(matching))
return entry.PageResult{Items: matching[start:end], Total: len(matching)}, nil
return entry.PageResult{Items: pageSlice(matching, command.Page, command.Size), Total: len(matching)}, nil
}
@@ -32,12 +32,12 @@ func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites sit
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
}
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, verified bool) (article.ResolvedSEO, error) {
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (article.ResolvedSEO, error) {
art, err := u.articleRepository.Get(id)
if err != nil {
return article.ResolvedSEO{}, err
}
if !art.IsOnline && !verified {
if !caller.seesArticle(art) {
return article.ResolvedSEO{}, article.ErrArticleNotFound
}
owner, err := u.sites.Get(art.SiteID)
@@ -19,8 +19,7 @@ func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, now func() time.
}
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32, grants role.GrantSet) error {
scope := grants.Scope(role.KeyAPIKeysRevoke)
if scope == role.ScopeNone {
if !grants.Holds(role.KeyAPIKeysRevoke) {
return role.MissingPermissionError{Key: role.KeyAPIKeysRevoke}
}
@@ -29,7 +28,7 @@ func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint
return err
}
if scope != role.ScopeAll && key.UserID != requesterId {
if !grants.Permits(role.KeyAPIKeysRevoke, "", requesterId, key.UserID) {
return apikey.ErrAPIKeyNotFound
}
@@ -4,7 +4,9 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
"context"
"slices"
@@ -44,15 +46,21 @@ func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images}
}
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
return g.Update(ctx, id, UpdateArticleCommand{Body: &body, Format: format})
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, caller ContentCaller, body string, format article.BodyFormat) (article.Article, error) {
return g.Update(ctx, id, caller, UpdateArticleCommand{Body: &body, Format: format})
}
func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command UpdateArticleCommand) (article.Article, error) {
func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller ContentCaller, command UpdateArticleCommand) (article.Article, error) {
if err := caller.requireHolding(role.KeyContentUpdate, builtintype.KeyArticle); err != nil {
return article.Article{}, err
}
before, err := g.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, before.CreatedBy); err != nil {
return article.Article{}, err
}
destination, err := g.destinationSite(command)
if err != nil {
return article.Article{}, err
@@ -247,11 +255,17 @@ func (g *UpdateArticleUseCase) siteName(id uint32) string {
return found.Name
}
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, caller ContentCaller, isOnline bool, format article.BodyFormat) (article.Article, error) {
if err := caller.requireHolding(role.KeyContentPublish, builtintype.KeyArticle); err != nil {
return article.Article{}, err
}
before, err := g.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
if err := caller.authorize(role.KeyContentPublish, builtintype.KeyArticle, before.CreatedBy); err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
if err != nil {
g.recordFailure(ctx, publishAction(isOnline), before, err)
@@ -93,10 +93,14 @@ func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller,
}
func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
if caller.Grants.HoldsAll(role.KeyAuthorsEdit) {
return nil
var linkedID uint32
if before.UserID != nil {
linkedID = *before.UserID
}
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
if !caller.Grants.Permits(role.KeyAuthorsEdit, "", caller.UserID, linkedID) {
return author.ErrAuthorRights
}
if patch.UserID.Set && !caller.Grants.HoldsAll(role.KeyAuthorsEdit) {
return author.ErrAuthorRights
}
return nil
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"time"
)
@@ -18,7 +19,7 @@ type UpdateEntryCommand struct {
ContentTypeKey string
ID uint32
Data map[string]any
UserID uint32
Caller ContentCaller
}
func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository, now func() time.Time) *UpdateEntryUseCase {
@@ -26,6 +27,9 @@ func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Re
}
func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, error) {
if err := command.Caller.requireHolding(role.KeyContentUpdate, command.ContentTypeKey); err != nil {
return TypedEntry{}, err
}
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
if err != nil {
return TypedEntry{}, err
@@ -34,6 +38,9 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
if err != nil {
return TypedEntry{}, err
}
if err := command.Caller.authorize(role.KeyContentUpdate, command.ContentTypeKey, current.CreatedBy); err != nil {
return TypedEntry{}, err
}
if err := entry.Validate(owner.Fields, command.Data); err != nil {
return TypedEntry{}, err
}
@@ -48,7 +55,7 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
return TypedEntry{}, err
}
current.Data = command.Data
current.UpdatedBy = command.UserID
current.UpdatedBy = command.Caller.UserID
current.UpdatedAt = u.now()
stored, err := u.entries.Update(current)
if err != nil {
@@ -34,7 +34,7 @@ type ActivityQuery struct {
}
func scopeFilterToViewer(filter *audit.Filter, viewerID uint32, grants role.GrantSet) error {
switch grants.Scope(role.KeyActivityView) {
switch grants.ScopeFor(role.KeyActivityView, "") {
case role.ScopeAll:
return nil
case role.ScopeOwn:
@@ -0,0 +1,34 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
)
type ContentCaller struct {
UserID uint32
Grants role.GrantSet
}
func (c ContentCaller) requireHolding(key string, typeKey string) error {
if !c.Grants.HoldsFor(key, typeKey) {
return role.MissingPermissionError{Key: key}
}
return nil
}
func (c ContentCaller) authorize(key string, typeKey string, createdBy uint32) error {
if !c.Grants.Permits(key, typeKey, c.UserID, createdBy) {
return role.MissingPermissionError{Key: key}
}
return nil
}
func (c ContentCaller) reads(typeKey string, createdBy uint32) bool {
return c.Grants.Permits(role.KeyContentRead, typeKey, c.UserID, createdBy)
}
func (c ContentCaller) seesArticle(art article.Article) bool {
return art.IsOnline || c.reads(builtintype.KeyArticle, art.CreatedBy)
}