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

@@ -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)
}