diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index f8f17a7..bd066c1 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -1,8 +1,8 @@ package article import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" - "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" domain "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/author" @@ -26,12 +26,11 @@ import ( ) type Handler struct { - CreateUseCase *useCases.CreateArticleUseCase - GetUseCase *useCases.GetArticleUseCase - ListUseCase *useCases.ListArticlesUseCase - DeleteUseCase *useCases.DeleteArticleUseCase - UpdateUseCase *useCases.UpdateArticleUseCase - GetUserUseCase *useCases.GetUserUseCase + CreateUseCase *useCases.CreateArticleUseCase + GetUseCase *useCases.GetArticleUseCase + ListUseCase *useCases.ListArticlesUseCase + DeleteUseCase *useCases.DeleteArticleUseCase + UpdateUseCase *useCases.UpdateArticleUseCase } type PostArticle struct { @@ -39,14 +38,13 @@ type PostArticle struct { Body string `json:"body"` } -func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler { +func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler { return &Handler{ - CreateUseCase: create, - GetUseCase: get, - ListUseCase: list, - DeleteUseCase: delete, - UpdateUseCase: update, - GetUserUseCase: getUser, + CreateUseCase: create, + GetUseCase: get, + ListUseCase: list, + DeleteUseCase: delete, + UpdateUseCase: update, } } @@ -180,12 +178,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { return } - var article domain.Article - if h.callerIsVerified(r) { - article, err = h.GetUseCase.GetArticle(id, format) - } else { - article, err = h.GetUseCase.GetOnlineArticle(id, format) - } + article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r)) if err != nil { if errors.Is(err, domain.ErrArticleNotFound) { http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) @@ -223,12 +216,11 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { return } - creatorId, _ := claims.UserId(r) createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{ - Title: localArticle.Title, - Body: localArticle.Body, - Format: format, - CreatedBy: creatorId, + Title: localArticle.Title, + Body: localArticle.Body, + Format: format, + Caller: access.CallerOf(r), }) if err != nil { if errors.Is(err, domain.ErrInvalidTitle) { @@ -251,6 +243,9 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) { } func writeKnownArticleError(w http.ResponseWriter, err error) bool { + if access.WriteIfMissingPermission(w, err) { + return true + } if errors.Is(err, domain.ErrArticleNotFound) { http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) return true @@ -330,7 +325,7 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b return } - updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format) + updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format) if err != nil { writeArticleErrorResponse(w, err) return @@ -457,7 +452,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) { return } - updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, useCases.UpdateArticleCommand{ + updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{ Body: request.Body, SiteID: request.SiteID, AuthorIDs: request.AuthorIDs, @@ -491,29 +486,6 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) { h.setIsOnline(w, r, false) } -func (h *Handler) callerIsVerified(r *http.Request) bool { - return isVerifiedCaller(r, h.GetUserUseCase) -} - -func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool { - userId, ok := claims.UserId(r) - if !ok { - return false - } - - currentUser, err := getUser.GetUser(userId) - if err != nil { - return false - } - - tokenVersion, ok := claims.TokenVersion(r) - if !ok || tokenVersion != currentUser.TokenVersion { - return false - } - - return currentUser.IsVerified -} - const ( defaultListPage = 1 defaultListSize = 25 @@ -596,15 +568,15 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { } page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{ - Format: format, - Verified: h.callerIsVerified(r), - Page: query.Page, - Size: query.Size, - Q: query.Q, - Status: query.Status, - Sort: parseSort(query.Sort), - SiteKey: query.Site, - SEO: query.SEO, + Format: format, + Caller: access.CallerOf(r), + Page: query.Page, + Size: query.Size, + Q: query.Q, + Status: query.Status, + Sort: parseSort(query.Sort), + SiteKey: query.Site, + SEO: query.SEO, }) if err != nil { http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) @@ -639,11 +611,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) { return } - if err := h.DeleteUseCase.DeleteArticle(r.Context(), id); err != nil { + if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil { if errors.Is(err, domain.ErrArticleNotFound) { http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) return } + if access.WriteIfMissingPermission(w, err) { + return + } http.Error(w, "Failed to delete article", http.StatusInternalServerError) return } diff --git a/api/handlers/article/seoHandler.go b/api/handlers/article/seoHandler.go index ff2b2de..574cf5e 100644 --- a/api/handlers/article/seoHandler.go +++ b/api/handlers/article/seoHandler.go @@ -1,6 +1,7 @@ package article import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/infrastructure/useCases" @@ -14,11 +15,10 @@ import ( type SEOHandler struct { ResolveUseCase *useCases.ResolveArticleSEOUseCase CheckUseCase *useCases.CheckArticleSEOUseCase - GetUserUseCase *useCases.GetUserUseCase } -func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler { - return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser} +func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase) *SEOHandler { + return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check} } type CheckArticleSEOBody struct { @@ -68,7 +68,7 @@ func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) { return } - resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase)) + resolved, err := h.ResolveUseCase.Resolve(id, access.CallerOf(r)) if err != nil { writeArticleErrorResponse(w, err) return @@ -93,7 +93,7 @@ func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) { return } - checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{ + checked, err := h.CheckUseCase.Check(id, access.CallerOf(r), useCases.CheckArticleSEOCommand{ Body: request.Body, Slug: request.Slug, Excerpt: request.Excerpt, diff --git a/api/handlers/entry/entryHandler.go b/api/handlers/entry/entryHandler.go index 4aeb010..914df01 100644 --- a/api/handlers/entry/entryHandler.go +++ b/api/handlers/entry/entryHandler.go @@ -1,8 +1,8 @@ package entry import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" - "RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/response" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/contenttype" @@ -178,6 +178,9 @@ func parseEntryId(r *http.Request) (uint32, error) { } func writeEntryError(w http.ResponseWriter, err error) { + if access.WriteIfMissingPermission(w, err) { + return + } var fieldError domain.FieldError switch { case errors.Is(err, contenttype.ErrContentTypeNotFound): @@ -211,21 +214,13 @@ func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) { return body, true } -func currentUserId(w http.ResponseWriter, r *http.Request) (uint32, bool) { - userId, ok := claims.UserId(r) - if !ok { - http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) - } - return userId, ok -} - func (h *Handler) List(w http.ResponseWriter, r *http.Request) { query, err := readListQuery(r.URL.Query()) if err != nil || validator.Validate.Struct(query) != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } - page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q")}) + page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q"), Caller: access.CallerOf(r)}) if err != nil { writeEntryError(w, err) return @@ -243,7 +238,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } - found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id) + found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id, access.CallerOf(r)) if err != nil { writeEntryError(w, err) return @@ -252,15 +247,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { - userId, ok := currentUserId(w, r) - if !ok { - return - } body, ok := readBody(w, r) if !ok { return } - created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, UserID: userId}) + created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, Caller: access.CallerOf(r)}) if err != nil { writeEntryError(w, err) return @@ -274,15 +265,11 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } - userId, ok := currentUserId(w, r) - if !ok { - return - } body, ok := readBody(w, r) if !ok { return } - updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, UserID: userId}) + updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, Caller: access.CallerOf(r)}) if err != nil { writeEntryError(w, err) return @@ -296,7 +283,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } - if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id); err != nil { + if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id, access.CallerOf(r)); err != nil { writeEntryError(w, err) return } diff --git a/api/handlers/update/updateHandler.go b/api/handlers/update/updateHandler.go index dfa7ebe..e3a3e98 100644 --- a/api/handlers/update/updateHandler.go +++ b/api/handlers/update/updateHandler.go @@ -97,7 +97,7 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) { } func canApply(r *http.Request) bool { - return access.Grants(r).Holds(role.KeyApplyUpdates) + return access.Grants(r).Holds(role.KeySystemApplyUpdates) } func writeDownloadError(w http.ResponseWriter, err error) { diff --git a/api/middleware/auditContext.go b/api/middleware/auditContext.go index 041df22..8425ab9 100644 --- a/api/middleware/auditContext.go +++ b/api/middleware/auditContext.go @@ -1,6 +1,7 @@ package middleware import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/claims" "RenewCMS/internal/domain/audit" "RenewCMS/internal/infrastructure/useCases" @@ -10,6 +11,7 @@ import ( func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = access.WithCurrentUser(r) origin := audit.Origin{ Actor: auditActor(getUser, r), Source: auditSource(r), @@ -29,7 +31,7 @@ func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor { if !ok { return audit.AnonymousActor("") } - current, err := getUser.GetUser(userId) + current, err := access.LoadUser(getUser, r, userId) if err != nil { return audit.AnonymousActor("") } diff --git a/api/middleware/auth.go b/api/middleware/auth.go index 29d52b2..4488c7b 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" "RenewCMS/internal/domain/user" @@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter, return user.User{}, false } - currentUser, err := getUser.GetUser(userId) + currentUser, err := access.LoadUser(getUser, r, userId) if err != nil { if errors.Is(err, user.ErrUserNotFound) { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) @@ -39,6 +40,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter, func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = access.WithCurrentUser(r) currentUser, ok := resolveCurrentUser(getUser, w, r) if !ok { return @@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = access.WithCurrentUser(r) currentUser, ok := resolveCurrentUser(getUser, w, r) if !ok { return diff --git a/api/middleware/optionalAccess.go b/api/middleware/optionalAccess.go new file mode 100644 index 0000000..493f56f --- /dev/null +++ b/api/middleware/optionalAccess.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "RenewCMS/api/pkg/access" + "RenewCMS/api/pkg/claims" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "net/http" +) + +func OptionalAccess(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = access.WithCurrentUser(r) + account, signedIn, err := verifiedAccount(getUser, r) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if !signedIn { + next.ServeHTTP(w, r) + return + } + grants, err := check.Grants(account) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + caller := access.Caller{UserID: account.ID, Grants: grants} + next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller))) + }) + } +} + +func verifiedAccount(getUser *useCases.GetUserUseCase, r *http.Request) (user.User, bool, error) { + userID, ok := claims.UserId(r) + if !ok { + return user.User{}, false, nil + } + account, err := access.LoadUser(getUser, r, userID) + if errors.Is(err, user.ErrUserNotFound) { + return user.User{}, false, nil + } + if err != nil { + return user.User{}, false, err + } + tokenVersion, ok := claims.TokenVersion(r) + if !ok || tokenVersion != account.TokenVersion { + return user.User{}, false, nil + } + return account, account.IsVerified, nil +} diff --git a/api/middleware/permission.go b/api/middleware/permission.go index 612a2bc..3e3e559 100644 --- a/api/middleware/permission.go +++ b/api/middleware/permission.go @@ -2,31 +2,52 @@ package middleware import ( "RenewCMS/api/pkg/access" + "RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" + "github.com/go-chi/chi/v5" "net/http" ) -type grantCheck func(account user.User, key string) (role.GrantSet, error) +type grantCheck func(account user.User, r *http.Request) (role.GrantSet, error) + +func ArticleTypeKey(*http.Request) string { + return builtintype.KeyArticle +} + +func PathTypeKey(r *http.Request) string { + return chi.URLParam(r, "key") +} func RequirePermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler { - return enforce(getUser, check.Require, key) + return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) { + return check.Require(account, key) + }) } func RequireFullScope(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler { - return enforce(getUser, check.RequireAll, key) + return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) { + return check.RequireAll(account, key) + }) } -func enforce(getUser *useCases.GetUserUseCase, verify grantCheck, key string) func(http.Handler) http.Handler { +func RequireTypedPermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string, typeKey func(r *http.Request) string) func(http.Handler) http.Handler { + return enforce(getUser, func(account user.User, r *http.Request) (role.GrantSet, error) { + return check.RequireFor(account, key, typeKey(r)) + }) +} + +func enforce(getUser *useCases.GetUserUseCase, verify grantCheck) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = access.WithCurrentUser(r) currentUser, ok := resolveCurrentUser(getUser, w, r) if !ok { return } - grants, err := verify(currentUser, key) + grants, err := verify(currentUser, r) if err != nil { if !access.WriteIfMissingPermission(w, err) { http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -34,7 +55,8 @@ func enforce(getUser *useCases.GetUserUseCase, verify grantCheck, key string) fu return } - next.ServeHTTP(w, r.WithContext(access.NewContext(r.Context(), grants))) + caller := access.Caller{UserID: currentUser.ID, Grants: grants} + next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller))) }) } } diff --git a/api/pkg/access/access.go b/api/pkg/access/access.go index ef4330c..1a1b0df 100644 --- a/api/pkg/access/access.go +++ b/api/pkg/access/access.go @@ -3,20 +3,27 @@ package access import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/useCases" "context" "errors" "net/http" ) -type grantsContextKey struct{} +type Caller = useCases.ContentCaller -func NewContext(ctx context.Context, grants role.GrantSet) context.Context { - return context.WithValue(ctx, grantsContextKey{}, grants) +type callerContextKey struct{} + +func WithCaller(ctx context.Context, caller Caller) context.Context { + return context.WithValue(ctx, callerContextKey{}, caller) +} + +func CallerOf(r *http.Request) Caller { + caller, _ := r.Context().Value(callerContextKey{}).(Caller) + return caller } func Grants(r *http.Request) role.GrantSet { - grants, _ := r.Context().Value(grantsContextKey{}).(role.GrantSet) - return grants + return CallerOf(r).Grants } func WriteMissingPermission(w http.ResponseWriter, key string) { diff --git a/api/pkg/access/currentUser.go b/api/pkg/access/currentUser.go new file mode 100644 index 0000000..eade002 --- /dev/null +++ b/api/pkg/access/currentUser.go @@ -0,0 +1,37 @@ +package access + +import ( + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "context" + "net/http" +) + +type currentUserContextKey struct{} + +type currentUserHolder struct { + loaded bool + userID uint32 + account user.User + err error +} + +func WithCurrentUser(r *http.Request) *http.Request { + if _, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder); present { + return r + } + return r.WithContext(context.WithValue(r.Context(), currentUserContextKey{}, ¤tUserHolder{})) +} + +func LoadUser(getUser *useCases.GetUserUseCase, r *http.Request, userID uint32) (user.User, error) { + holder, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder) + if !present { + return getUser.GetUser(userID) + } + if !holder.loaded || holder.userID != userID { + holder.account, holder.err = getUser.GetUser(userID) + holder.userID = userID + holder.loaded = true + } + return holder.account, holder.err +} diff --git a/api/router.go b/api/router.go index 90ea22b..cdd356e 100644 --- a/api/router.go +++ b/api/router.go @@ -21,6 +21,7 @@ import ( "RenewCMS/api/handlers/user" "RenewCMS/api/middleware" "RenewCMS/api/pkg/authtoken" + "RenewCMS/internal/domain/role" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/llmdocs" "encoding/json" @@ -62,6 +63,18 @@ func (c *UseCases) require(key string) func(http.Handler) http.Handler { return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key) } +func (c *UseCases) requireTyped(key string, typeKey func(*http.Request) string) func(http.Handler) http.Handler { + return middleware.RequireTypedPermission(c.GetUserUseCase, c.CheckPermissionUseCase, key, typeKey) +} + +func (c *UseCases) requireArticle(key string) func(http.Handler) http.Handler { + return c.requireTyped(key, middleware.ArticleTypeKey) +} + +func (c *UseCases) requireEntry(key string) func(http.Handler) http.Handler { + return c.requireTyped(key, middleware.PathTypeKey) +} + func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler { return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key) } @@ -71,11 +84,12 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler r.Use(container.apiKeyAuth()) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) - articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) + articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase) - seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase, container.GetUserUseCase) + seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase) r.Group(func(r chi.Router) { + r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase)) r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}/seo", seoHandler.Resolve) @@ -84,12 +98,12 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.Post("/", articleHandler.PostArticle) - r.Delete("/{id}", articleHandler.DeleteArticle) - r.Patch("/{id}", articleHandler.UpdateArticleBody) - r.Patch("/{id}/publish", articleHandler.PublishArticle) - r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) - r.Post("/{id}/seo/check", seoHandler.Check) + r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle) + r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle) + r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody) + r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle) + r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/unpublish", articleHandler.UnpublishArticle) + r.With(container.requireArticle(role.KeyContentRead)).Post("/{id}/seo/check", seoHandler.Check) }) return r } @@ -157,10 +171,10 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Patch("/me", userHandler.UpdateMe) - r.With(container.require("users.view")).Get("/", inviteHandler.ListUsers) + r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers) r.Get("/lookup", lookupHandler.Lookup) r.Group(func(r chi.Router) { - r.Use(container.require("users.invite")) + r.Use(container.require(role.KeyUsersInvite)) r.Use(middleware.TrustedProxyRealIP(trustedProxies)) r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) r.Post("/invite", inviteHandler.CreateInvite) @@ -181,7 +195,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.Use(container.require("settings.view")) + r.Use(container.require(role.KeySettingsView)) r.Get("/", settingsHandler.GetSettings) }) r.Group(func(r chi.Router) { @@ -192,7 +206,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.Use(container.require("system.edit_retention")) + r.Use(container.require(role.KeySystemEditRetention)) r.Patch("/", settingsHandler.UpdateSettings) }) return r @@ -222,10 +236,10 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("settings.manage_sites")).Post("/", siteHandler.Create) - r.With(container.require("settings.manage_sites")).Patch("/{id}", siteHandler.Update) - r.With(container.require("settings.manage_sites")).Patch("/{id}/default", siteHandler.SetDefault) - r.With(container.require("settings.delete_site")).Delete("/{id}", siteHandler.Delete) + r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create) + r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update) + r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault) + r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete) }) return r } @@ -241,7 +255,7 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.Use(container.require("authors.view")) + r.Use(container.require(role.KeyAuthorsView)) r.Get("/", authorHandler.List) }) r.Group(func(r chi.Router) { @@ -253,9 +267,9 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Post("/me", authorHandler.CreateMine) - r.With(container.require("authors.edit")).Patch("/{id}", authorHandler.Update) - r.With(container.require("authors.create")).Post("/", authorHandler.Create) - r.With(container.require("authors.delete")).Delete("/{id}", authorHandler.Delete) + r.With(container.require(role.KeyAuthorsEdit)).Patch("/{id}", authorHandler.Update) + r.With(container.require(role.KeyAuthorsCreate)).Post("/", authorHandler.Create) + r.With(container.require(role.KeyAuthorsDelete)).Delete("/{id}", authorHandler.Delete) }) return r } @@ -274,16 +288,16 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("schema.view")).Get("/", contentTypeHandler.List) - r.With(container.require("schema.view")).Get("/{key}", contentTypeHandler.Get) - r.With(container.require("schema.edit")).Post("/", contentTypeHandler.Create) - r.With(container.require("schema.edit")).Put("/{key}", contentTypeHandler.Update) - r.With(container.require("schema.delete")).Delete("/{key}", contentTypeHandler.Delete) - r.Get("/{key}/entry", entryHandler.List) - r.Post("/{key}/entry", entryHandler.Create) - r.Get("/{key}/entry/{id}", entryHandler.Get) - r.Put("/{key}/entry/{id}", entryHandler.Update) - r.Delete("/{key}/entry/{id}", entryHandler.Delete) + r.With(container.require(role.KeySchemaView)).Get("/", contentTypeHandler.List) + r.With(container.require(role.KeySchemaView)).Get("/{key}", contentTypeHandler.Get) + r.With(container.require(role.KeySchemaEdit)).Post("/", contentTypeHandler.Create) + r.With(container.require(role.KeySchemaEdit)).Put("/{key}", contentTypeHandler.Update) + r.With(container.require(role.KeySchemaDelete)).Delete("/{key}", contentTypeHandler.Delete) + r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry", entryHandler.List) + r.With(container.requireEntry(role.KeyContentCreate)).Post("/{key}/entry", entryHandler.Create) + r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry/{id}", entryHandler.Get) + r.With(container.requireEntry(role.KeyContentUpdate)).Put("/{key}/entry/{id}", entryHandler.Update) + r.With(container.requireEntry(role.KeyContentDelete)).Delete("/{key}/entry/{id}", entryHandler.Delete) return r } @@ -299,7 +313,7 @@ func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Han r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.Use(container.require("schema.view")) + r.Use(container.require(role.KeySchemaView)) r.Get("/", builtinTypeHandler.List) r.Get("/{key}", builtinTypeHandler.Get) return r @@ -317,9 +331,9 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("activity.view")).Get("/", activityHandler.List) - r.With(container.requireAll("activity.view")).Get("/summary", activityHandler.Summary) - r.With(container.require("activity.export"), container.require("activity.view")).Get("/export", activityHandler.Export) + r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List) + r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary) + r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export) return r } @@ -341,19 +355,19 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("settings.view")).Get("/update", updateHandler.GetStatus) + r.With(container.require(role.KeySettingsView)).Get("/update", updateHandler.GetStatus) r.Group(func(r chi.Router) { - r.Use(container.require("system.apply_updates")) + r.Use(container.require(role.KeySystemApplyUpdates)) r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow)) r.Post("/update/check", updateHandler.Check) }) r.Group(func(r chi.Router) { - r.Use(container.require("system.apply_updates")) + r.Use(container.require(role.KeySystemApplyUpdates)) r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow)) r.Post("/update/download", updateHandler.Download) }) r.Group(func(r chi.Router) { - r.Use(container.require("system.apply_updates")) + r.Use(container.require(role.KeySystemApplyUpdates)) r.Post("/update/restart", updateHandler.Restart) }) return r @@ -370,9 +384,9 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("apikeys.create")).Post("/", apiKeyHandler.Create) - r.With(container.require("apikeys.view")).Get("/", apiKeyHandler.List) - r.With(container.require("apikeys.revoke")).Delete("/{id}", apiKeyHandler.Revoke) + r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create) + r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List) + r.With(container.require(role.KeyAPIKeysRevoke)).Delete("/{id}", apiKeyHandler.Revoke) return r } @@ -402,24 +416,24 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler { r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("settings.view")).Get("/purge-settings", mediaHandler.GetPurgeSettings) - r.With(container.require("settings.edit_general")).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings) - r.With(container.requireAll("media.delete")).Get("/purge-preview", mediaHandler.PreviewPurge) - r.With(container.requireAll("media.delete")).Post("/purge", mediaHandler.RunPurge) + r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings) + r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings) + r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge) + r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge) r.Group(func(r chi.Router) { r.Use(middleware.RejectAPIKey) - r.With(container.require("media.view")).Get("/{id}/variants", mediaHandler.ListVariants) - r.With(container.require("settings.edit_general")).Post("/{id}/variants", mediaHandler.GenerateAllVariants) - r.With(container.require("settings.edit_general")).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant) - r.With(container.require("settings.edit_general")).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant) + r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants) + r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants) + r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant) + r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant) }) - r.With(container.require("media.view")).Get("/", mediaHandler.List) - r.With(container.require("media.upload")).Post("/", mediaHandler.Upload) - r.With(container.requireAll("media.delete")).Post("/merge", mediaHandler.Merge) - r.With(container.require("media.upload")).Patch("/{id}", mediaHandler.Rename) - r.With(container.requireAll("media.delete")).Delete("/{id}", mediaHandler.Delete) - r.With(container.require("media.view")).Get("/{id}", mediaHandler.Get) - r.With(container.require("media.view")).Get("/{id}/usage", mediaHandler.Usage) + r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List) + r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload) + r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge) + r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename) + r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete) + r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get) + r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage) return r } @@ -434,14 +448,14 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) - r.With(container.require("media.view")).Get("/", variantHandler.List) - r.With(container.require("settings.edit_general")).Post("/", variantHandler.Create) - r.With(container.require("settings.view")).Get("/cache", variantHandler.CacheStats) - r.With(container.require("settings.edit_general")).Post("/cache/purge", variantHandler.PurgeCache) - r.With(container.require("settings.edit_general")).Put("/{name}", variantHandler.Update) - r.With(container.require("settings.edit_general")).Patch("/{name}", variantHandler.SetEnabled) - r.With(container.require("settings.edit_general")).Delete("/{name}", variantHandler.Delete) - r.With(container.require("settings.edit_general")).Post("/{name}/purge", variantHandler.Purge) + r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List) + r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create) + r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats) + r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache) + r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update) + r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled) + r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete) + r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge) return r } diff --git a/docs/api.md b/docs/api.md index df51f73..5e37d2c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,8 +11,8 @@ for example `https://cms.example/v1/article/`. ## Authentication - Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` - and `GET /v1/site/{key}/sitemap`. They return online articles only; the list and the resolved SEO - object also answer drafts to a verified session or API key. + and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read + (see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`. - Everything else needs a signed-in browser session (the `jwt` cookie) or an API key: `Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its creator, with that user's role, and shows in full once. @@ -173,7 +173,7 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend | Route | Who | Purpose | | --- | --- | --- | -| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads drafts | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. | +| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. | | `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `-N` in `suggestion`, never a 409. | | `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. | @@ -353,6 +353,36 @@ disabled variant on `POST`), 413 "The source image is too large to generate this source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for this format." and 500 "Internal server error". +## Content permissions + +Articles and the entries of custom content types are checked against the `content.*` permissions of the +caller's role, which is read on every request (a key follows its creator's role). + +| Route | Permission | Scope check after the lookup | +| --- | --- | --- | +| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` | +| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 | +| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft | +| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller | +| `DELETE /v1/article/{id}` | `content.delete` | same | +| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` | +| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry | +| `POST /v1/content-type/{key}/entry` | `content.create` for `{key}` | `created_by` is the caller | +| `PUT`, `DELETE .../entry/{id}` | `content.update`, `content.delete` for `{key}` | `own` needs `created_by` to be the caller | + +Each action reads its own permission: publish does not imply update, update does not imply read. `own` +means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission +can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that +row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the +wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. + +A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`, +never `content.update:blog`). The permission is checked before the id and the body are read, then the scope +after the lookup, and a refused call writes nothing and records no activity event. The verification and +token checks still come first. Creating a content type whose key is `article`, `author`, `site`, `media` +or `user` is 409 `content_type_key_taken`. Owner-only permissions only count on the locked Owner role: the +same key placed by hand on another role is ignored and `GET /v1/auth/me` does not list it. + ## Errors Every error is a plain-text body with one sentence; the status is in the HTTP status line. The statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page. diff --git a/docs/api/articles.md b/docs/api/articles.md index 0795a2c..e51187b 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -13,7 +13,7 @@ Every route that returns an article accepts the optional `format` query paramete - `PATCH /v1/article/{id}/publish` - `PATCH /v1/article/{id}/unpublish` -Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. +Anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. A verified caller also sees the drafts that `content.read` covers: every draft at `all`, only their own at `own`, and none without the permission. The `all`, `online` and `draft` counts follow the same visible set. ## Pagination, search and counts @@ -121,7 +121,7 @@ Publishing, unpublishing and `noindex` never change a slug. A deleted article fr ## Resolved SEO object -`GET /v1/article/{id}/seo` answers the data a frontend renders in its ``. Online articles are public; a draft needs a verified session or API key and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys: +`GET /v1/article/{id}/seo` answers the data a frontend renders in its ``. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys: `title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`). diff --git a/docs/architecture.md b/docs/architecture.md index a3749a8..3a4f686 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,9 @@ At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMe ### Roles and permissions -`internal/domain/role` holds the permission catalogue, the built-in roles and the pure rules over grants: `ValidateGrants`, `Role.Allows`, `Role.AllowsFor` and `ParseTypedKey`. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`, and an owner-only permission only counts on the locked Owner role. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the specific grant overrides the wildcard, and without an override the wildcard applies. The existence of the type key is not checked in the domain. `role.Repository.Save` is version checked: it updates the row only when the caller's version matches, bumps the version, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. +`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository.Save` is version checked: it updates the row only when the caller's version matches, bumps the version, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. + +Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `ListGrants`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`. Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version. diff --git a/docs/llm/gotchas.md b/docs/llm/gotchas.md index e0e7d0e..5846c83 100644 --- a/docs/llm/gotchas.md +++ b/docs/llm/gotchas.md @@ -51,7 +51,8 @@ Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop. ## Draft versus online - Anonymous callers and unverified users only see online articles. A draft answers `404` with `Article not found`, the same as an id that does not exist. -- A verified session or a key sees drafts as well. In lists, `counts.draft` is `0` for anonymous callers and `status=draft` answers an empty page. +- A verified session or a key sees the drafts its role may read: every draft with `content.read` at all, only its own with `content.read` at own. In lists, `counts.draft` is `0` for anonymous callers and `status=draft` answers an empty page. +- A role without `content.update`, `content.delete` or `content.publish` for an article gets `403` with the body `missing_permission` and the header `X-Missing-Permission`. Saving a draft never publishes it. - `POST /v1/article/` always creates a draft. Publishing is a separate call, `PATCH /v1/article/{id}/publish`. - Sitemaps list online articles only. diff --git a/features/article_body_format.feature b/features/article_body_format.feature index 416c60d..548b7df 100644 --- a/features/article_body_format.feature +++ b/features/article_body_format.feature @@ -1,4 +1,5 @@ Feature: Article body content negotiation on read + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As a headless frontend developer I want article routes to return the body as markdown (default), html or a typed json block array diff --git a/features/article_body_format.qa.md b/features/article_body_format.qa.md index 599e15f..a8c1bb1 100644 --- a/features/article_body_format.qa.md +++ b/features/article_body_format.qa.md @@ -1,5 +1,7 @@ # QA procedure: article_body_format +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP. ## Setup diff --git a/features/article_body_unlimited.feature b/features/article_body_unlimited.feature index 9eef77c..341b9eb 100644 --- a/features/article_body_unlimited.feature +++ b/features/article_body_unlimited.feature @@ -1,4 +1,5 @@ Feature: Article bodies have no length limit and the body is optional + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As a verified RenewCMS owner I want to create an article with only a title and write articles of any length diff --git a/features/article_body_unlimited.qa.md b/features/article_body_unlimited.qa.md index b3a9059..9d49287 100644 --- a/features/article_body_unlimited.qa.md +++ b/features/article_body_unlimited.qa.md @@ -1,5 +1,7 @@ # QA: unlimited, optional article body +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + ## Part A. HTTP (Go, test/article_body_unlimited_http_test.go) Setup: in-memory fake article repository plus the real markdown converter as BodyRenderer (so the "json" format of an empty body is genuinely exercised). Build CreateArticle, GetArticle, UpdateArticle use cases and the article handler via NewHandler(...). Mount on a fresh chi.NewRouter() that mirrors api/router.go: POST /v1/article/ and PATCH /v1/article/{id} inside jwtauth.Verifier + Authenticator with a test jwtauth.New("HS256", ...) token. Call validator.InitValidator(). Use httptest. No live server, no real SQLite, no real SMTP. diff --git a/features/article_publishing.feature b/features/article_publishing.feature index 368b7a5..043e671 100644 --- a/features/article_publishing.feature +++ b/features/article_publishing.feature @@ -1,4 +1,5 @@ Feature: An authenticated user can publish and unpublish an article + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As an authenticated RenewCMS user managing the CMS I want to flip an article's is_online flag from an HTTP endpoint diff --git a/features/article_publishing.qa.md b/features/article_publishing.qa.md index 36dd516..d715155 100644 --- a/features/article_publishing.qa.md +++ b/features/article_publishing.qa.md @@ -1,5 +1,7 @@ # QA procedure: article_publishing +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, extending `article_test.go`). ## Setup shared by every case diff --git a/features/article_title_rules.feature b/features/article_title_rules.feature index 3620714..0fc67c7 100644 --- a/features/article_title_rules.feature +++ b/features/article_title_rules.feature @@ -1,4 +1,5 @@ Feature: The article title rule is the same in the request and the domain + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As the maintainer of RenewCMS I want PostArticle's title validation and article.CreateArticle's title validation to diff --git a/features/article_title_rules.qa.md b/features/article_title_rules.qa.md index 223f702..463b626 100644 --- a/features/article_title_rules.qa.md +++ b/features/article_title_rules.qa.md @@ -1,5 +1,7 @@ # QA procedure: article_title_rules +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, alongside `article_test.go` / `use_case_baseline_test.go` / `not_found_errors_test.go` / the hasher-domain-interface tests). diff --git a/features/articles_list.feature b/features/articles_list.feature index ff5b891..8fb2b0a 100644 --- a/features/articles_list.feature +++ b/features/articles_list.feature @@ -1,4 +1,5 @@ Feature: Articles list screen + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As a verified RenewCMS owner I want /article to list every article with its status, let me search and filter it, and let me diff --git a/features/articles_list.qa.md b/features/articles_list.qa.md index 3096cbf..1e20cfa 100644 --- a/features/articles_list.qa.md +++ b/features/articles_list.qa.md @@ -1,5 +1,7 @@ # QA: Articles list screen +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + ## Implementation note for coder (informational, not a QA assertion) `internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains diff --git a/features/articles_pagination_api.feature b/features/articles_pagination_api.feature index 75dab2b..a58fc85 100644 --- a/features/articles_pagination_api.feature +++ b/features/articles_pagination_api.feature @@ -1,4 +1,5 @@ Feature: Paginated article listing + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As an API consumer or the RenewCMS back-office I want GET /v1/article/ to always answer one page of articles with the total and the tab counts diff --git a/features/articles_pagination_api.qa.md b/features/articles_pagination_api.qa.md index 046a1da..2ff3baa 100644 --- a/features/articles_pagination_api.qa.md +++ b/features/articles_pagination_api.qa.md @@ -1,5 +1,7 @@ # QA: Paginated article listing (API) +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + ## HTTP-level (Go, `test/`, `task test`) Build `article.NewHandler(...)` over the real `ListArticlesUseCase` and a fake `article.Repository` (extend `mockArticleRepo` in `test/article_test.go`, keeping type names unique across files). The fake implements the interface contract: paging (Page/Size 0 = unpaged), q/status filtering, ordering updated_at DESC then id DESC, total, and the visibility counts. Mount on a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only, test `jwtauth.New("HS256", ...)`). Call `validator.InitValidator()`. Use a user fake for the verified, unverified and missing cases, as in `test/public_article_listing_test.go`. No live server, no SQLite file. diff --git a/features/custom_content_types.feature b/features/custom_content_types.feature index e3c4b34..45702d5 100644 --- a/features/custom_content_types.feature +++ b/features/custom_content_types.feature @@ -1,4 +1,5 @@ Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API + Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. As a site owner or admin diff --git a/features/custom_content_types.qa.md b/features/custom_content_types.qa.md index 2768035..0110e88 100644 --- a/features/custom_content_types.qa.md +++ b/features/custom_content_types.qa.md @@ -1,5 +1,7 @@ # QA procedure: custom content types (backend core) +> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". + > Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Package: `test` (black-box). No live server, no real SQLite file, no real SMTP. diff --git a/features/custom_content_types_support.feature b/features/custom_content_types_support.feature index 0a36ace..e31ccf9 100644 --- a/features/custom_content_types_support.feature +++ b/features/custom_content_types_support.feature @@ -1,4 +1,5 @@ Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names + Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. As an owner, admin or verified user working in the SPA diff --git a/features/custom_content_types_support.qa.md b/features/custom_content_types_support.qa.md index 340df5c..eb26c01 100644 --- a/features/custom_content_types_support.qa.md +++ b/features/custom_content_types_support.qa.md @@ -1,5 +1,7 @@ # QA procedure: custom content types support (icon, built-in read model, user lookup) +> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". + > Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Package: `test` (black-box). No live server, no real SQLite file, no real SMTP. diff --git a/features/delete_article_keeps_images.feature b/features/delete_article_keeps_images.feature index b3a5bc7..7e088fd 100644 --- a/features/delete_article_keeps_images.feature +++ b/features/delete_article_keeps_images.feature @@ -1,4 +1,5 @@ Feature: Deleting an article never deletes images + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As an authenticated RenewCMS user I want deleting an article to leave the media library alone diff --git a/features/delete_article_keeps_images.qa.md b/features/delete_article_keeps_images.qa.md index e9858cc..98853ec 100644 --- a/features/delete_article_keeps_images.qa.md +++ b/features/delete_article_keeps_images.qa.md @@ -1,5 +1,7 @@ # QA procedure: delete_article_keeps_images +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box). Wire `useCases.NewDeleteArticleUseCase(articleRepo)` (single argument) with the other article use cases and mount as in the retired `delete_article_cascades_images.qa.md`: `Verifier` plus `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, jwt cookie for user id 1. Seed images 10 and 11 in `mockImageRepo` and the article body with their urls. Call `validator.InitValidator()` first. ## HTTP checks diff --git a/features/edit_article_body.feature b/features/edit_article_body.feature index c0b428c..14e2f38 100644 --- a/features/edit_article_body.feature +++ b/features/edit_article_body.feature @@ -1,4 +1,5 @@ Feature: An authenticated user can edit an article's body + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As an authenticated RenewCMS user managing the CMS I want to change an existing article's body from an HTTP endpoint diff --git a/features/edit_article_body.qa.md b/features/edit_article_body.qa.md index 3ce16ce..3cc3551 100644 --- a/features/edit_article_body.qa.md +++ b/features/edit_article_body.qa.md @@ -1,5 +1,7 @@ # QA procedure: edit_article_body +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, extending `article_test.go`). ## Setup shared by every case diff --git a/features/media_field_type.feature b/features/media_field_type.feature index 0ebf93c..26a8728 100644 --- a/features/media_field_type.feature +++ b/features/media_field_type.feature @@ -1,4 +1,5 @@ Feature: Media field type for custom content types + Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". As a site owner or admin I want a content type field that holds one image from the media library diff --git a/features/media_field_type.qa.md b/features/media_field_type.qa.md index 1118169..e08a32f 100644 --- a/features/media_field_type.qa.md +++ b/features/media_field_type.qa.md @@ -1,5 +1,7 @@ # QA procedure: media field type (slice A, API only) +> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". + Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA. ## Setup shared by every case diff --git a/features/not_found_errors.feature b/features/not_found_errors.feature index 7f5beb1..c799f68 100644 --- a/features/not_found_errors.feature +++ b/features/not_found_errors.feature @@ -1,4 +1,5 @@ Feature: Domain not-found errors instead of raw GORM errors + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As the maintainer of RenewCMS I want a missing article or user reported as a domain not-found error diff --git a/features/not_found_errors.qa.md b/features/not_found_errors.qa.md index ebb5750..0205979 100644 --- a/features/not_found_errors.qa.md +++ b/features/not_found_errors.qa.md @@ -1,5 +1,7 @@ # QA procedure: not_found_errors +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, alongside the existing `article_test.go` / `user_test.go` / `use_case_baseline_test.go`). ## Setup shared by every case diff --git a/features/public_article_listing.feature b/features/public_article_listing.feature index b1676df..30cca34 100644 --- a/features/public_article_listing.feature +++ b/features/public_article_listing.feature @@ -1,4 +1,5 @@ Feature: Public and authenticated article listing + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As a visitor of the public site and as an authenticated RenewCMS user I want GET /v1/article/ to return every online article to anyone, diff --git a/features/public_article_listing.qa.md b/features/public_article_listing.qa.md index 74e8e83..eba29a8 100644 --- a/features/public_article_listing.qa.md +++ b/features/public_article_listing.qa.md @@ -1,5 +1,7 @@ # QA procedure: public_article_listing +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, extending `article_test.go`). ## Setup shared by every case diff --git a/features/public_article_read.feature b/features/public_article_read.feature index 5b4e500..bb37fb4 100644 --- a/features/public_article_read.feature +++ b/features/public_article_read.feature @@ -1,4 +1,5 @@ Feature: Single article read follows the same online/offline visibility rule as the list + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As a visitor of the public site and as an authenticated RenewCMS user I want GET /v1/article/{id} to return an online article to anyone, and an offline article diff --git a/features/public_article_read.qa.md b/features/public_article_read.qa.md index bacb0c1..dc50f36 100644 --- a/features/public_article_read.qa.md +++ b/features/public_article_read.qa.md @@ -1,5 +1,7 @@ # QA procedure: public_article_read +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, extending `article_test.go`, `verified_account_required_http_test.go` and `not_found_errors_test.go`). diff --git a/features/relation_field_type.feature b/features/relation_field_type.feature index c149096..e133b09 100644 --- a/features/relation_field_type.feature +++ b/features/relation_field_type.feature @@ -1,4 +1,5 @@ Feature: Relation field type for custom content types + Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". As a site owner or admin I want a content type field that links an entry to entries of another content type diff --git a/features/relation_field_type.qa.md b/features/relation_field_type.qa.md index da5f01b..69eaaf3 100644 --- a/features/relation_field_type.qa.md +++ b/features/relation_field_type.qa.md @@ -1,5 +1,7 @@ # QA procedure: relation field type (API only) +> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken". + Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA. ## Setup shared by every case diff --git a/features/remove_legacy_article_image_alias.feature b/features/remove_legacy_article_image_alias.feature index 5def563..1a335cf 100644 --- a/features/remove_legacy_article_image_alias.feature +++ b/features/remove_legacy_article_image_alias.feature @@ -1,4 +1,5 @@ Feature: The legacy POST /v1/article/{id}/image alias is removed + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As the maintainer of RenewCMS I want the editor to upload through /v1/media only and the article alias deleted diff --git a/features/remove_legacy_article_image_alias.qa.md b/features/remove_legacy_article_image_alias.qa.md index 281e2b8..3f67381 100644 --- a/features/remove_legacy_article_image_alias.qa.md +++ b/features/remove_legacy_article_image_alias.qa.md @@ -1,5 +1,7 @@ # QA procedure: remove_legacy_article_image_alias +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, httptest, in-memory fakes, no live server). ## Setup diff --git a/features/roles_permissions_content_scope.feature b/features/roles_permissions_content_scope.feature new file mode 100644 index 0000000..74ed0bb --- /dev/null +++ b/features/roles_permissions_content_scope.feature @@ -0,0 +1,464 @@ +Feature: Roles and permissions content scope: own and all enforcement on articles and entries, Publish as its own permission, typed grants + + As the owner of a RenewCMS site + I want articles and entries to be checked against the caller's content permissions, by owner and by content type + So that a Contributor edits only their own work, a Writer cannot publish, and a role can differ per content type + + Background: + Given the spec of record is docs/design/screens/roles.md (catalogue, "Hidden or disabled", "HTTP status to copy", sections 6 and 7) and docs/design/routes.md "Roles and permissions" (created_by, Enforcement) + And features/roles_permissions_foundation.feature provides the catalogue and built-in roles, and features/roles_permissions_enforcement.feature provides the 403 contract: + status 403, body "missing_permission\n", header X-Missing-Permission, decided from the caller's live role on every request, API keys following their creator + And this slice supersedes two clauses of the enforcement feature: owner-only keys are effective only on a Locked role (see "Owner-only keys are effective only on a locked role"), + and articles and entries are no longer ungated + And "ok" means the request passes the gate and the scope check. A later 400, 404 or 409 from the handler is not a refusal + And "403" means status 403, body "missing_permission\n", the header X-Missing-Permission holding the BASE catalogue key (content.update, never content.update:events), + no side effect, no audit event, and the use case writes nothing + And a refusal by scope is a role.MissingPermissionError returned by the use case (errors.Is role.ErrMissingPermission, errors.As for the key), written by access.WriteIfMissingPermission + And the typed key form is content.:. The type key of articles is the built-in key "article". The type key of an entry is the content type key of the URL + And a typed grant replaces the wildcard for that type, whether it lowers or raises it. Only the wildcard applies to a type with no typed row + And typed keys exist only for the 5 content.* keys. A typed row of scope none is an explicit deny for that type + And each action reads its own key, with no implied keys: content.publish does not imply content.update, content.update does not imply content.read, content.read implies nothing + And own means the caller's user id equals CreatedBy. All means any. None means no. created_by 0 (unattributed) is owned by nobody, and caller id 0 owns nothing + And scope is decided AFTER loading the article or entry, by the use case, from the caller's resolved grants passed in by the handler. Use cases never read user.Role + And the handler path for a scoped write is: Authenticator, RequireCurrentToken, RequireVerifiedUser, then a gate that refuses when the typed scope is none (before any lookup), then id/body validation (400), then the use case: + load (404), scope check (403) immediately after the load and before any other lookup or validation, then the write + And an id that does not exist answers 404 even for a caller whose scope is own, because ownership is unknown + And entries have no status. content.publish applies to articles only (PATCH .../publish and .../unpublish). No schedule route exists. A future one must use content.publish + And the type key "article" is shared with entry routes. Creating a content type whose key equals a built-in key (article, author, site, media, user) answers 409 apperr.ContentTypeKeyTakenErrorMessage + And the users (all verified, valid jwt cookie, token_version 0) are the foundation's user 3 Owner, user 5 Admin, user 9 Writer, plus these on custom, unlocked roles: + | user | role | grants | + | 21 | Contributor | content.read=all, content.create=all, content.update=own | + | 22 | Reader own | content.read=own, content.create=all | + | 23 | Editor | content.read=all, content.update=all, content.publish=all, content.delete=own | + | 24 | Publisher own | content.read=all, content.publish=own | + | 25 | Typed A | content.read=all, content.create=all, content.update=all, content.update:events=own, content.update:article=none | + | 26 | Typed B | content.read:events=all, content.create:events=all, content.update:events=own | + | 27 | No content | schema.view=all | + And the articles are: + | id | state | created_by | + | 10 | draft | 21 | + | 11 | draft | 9 | + | 12 | online| 3 | + | 13 | draft | 3 | + | 14 | draft | 0 | + | 15 | online| 21 | + | 16 | draft | 22 | + | 17 | draft | 24 | + And the entries are, in content types "events" and "venues": + | id | type | created_by | + | 1 | events | 21 | + | 2 | events | 9 | + | 3 | events | 0 | + | 4 | events | 25 | + | 5 | venues | 3 | + | 6 | events | 22 | + | 7 | events | 26 | + | 8 | events | 23 | + + Scenario Outline: GrantSet.ScopeFor resolves wildcard and typed keys + Given a role (not locked) with grants + When ScopeFor("", "") is evaluated + Then the scope is + + Examples: + | grants | key | type | scope | + | content.update=all | content.update | | all | + | content.update=all | content.update | blog | all | + | content.update=all, content.update:blog=own | content.update | blog | own | + | content.update=all, content.update:blog=own | content.update | news | all | + | content.update=all, content.update:blog=none | content.update | blog | none | + | content.update=own, content.update:blog=all | content.update | blog | all | + | content.update:blog=all | content.update | blog | all | + | content.update:blog=all | content.update | news | none | + | content.update:blog=all | content.update | | none | + | content.create=all, content.create:blog=none | content.create | blog | none | + | content.create=all, content.create:blog=all | content.create | blog | all | + | content.read=all | content.read | Bad Key | none | + | content.read=all | content.read | (101 characters) | none | + | schema.view=all | schema.view | blog | none | + | schema.view=all | schema.view | | all | + | content.explode=all | content.explode| | none | + | (no grants) | content.read | | none | + + Scenario Outline: One helper decides own and all + Given a role holding content.update at + When Permits("content.update", "blog", caller=, createdBy=) is evaluated + Then the result is + + Examples: + | scope | caller | created_by | allowed | + | all | 9 | 21 | true | + | all | 9 | 0 | true | + | own | 21 | 21 | true | + | own | 9 | 21 | false | + | own | 21 | 0 | false | + | own | 0 | 0 | false | + | none | 21 | 21 | false | + + Scenario: The own and all rule has exactly one implementation + Then internal/domain/role holds one exported helper deciding ownership (GrantSet.Permits, built on GrantSet.ScopeFor) + And no use case, handler or middleware compares a caller id with CreatedBy, or reads a GrantSet entry, for an authorization decision. They call ScopeFor, Permits, Holds or HoldsAll + And the 2a decisions for activity.view, apikeys.view, apikeys.revoke and authors.edit are evaluated through the same helper, with the same results as in the enforcement feature + And for authors.edit own means "the author linked to the caller" (existing rule), derived from ScopeFor, not from CreatedBy + + Scenario: Role.Allows and AllowsFor are removed + Then Role no longer has Allows, AllowsFor or scopeOf + And a role is evaluated only through Role.GrantSet(), the single constructor of a GrantSet. NewGrantSet is unexported or removed + And Role.GrantSet() drops owner-only keys when the role is not Locked, drops unknown keys, drops typed keys on non-content keys or with an invalid type key, + and normalises duplicates (see next scenarios) + And a toggle key stored at scope own, or any key stored at a scope other than none, own or all, is treated as none + + Scenario Outline: Duplicate rows resolve to the most restrictive scope + Given a stored role with the rows in this order + When the GrantSet is built + Then ScopeFor("", "") is + And the result is the same when the rows are in the opposite order + + Examples: + | rows | key | type | scope | + | content.update=own, content.update=all | content.update | | own | + | content.update=all, content.update=own | content.update | | own | + | content.update:blog=all, content.update:blog=own | content.update | blog | own | + | content.update=all, content.update:blog=own | content.update | blog | own | + | content.read=all, content.read=none | content.read | | none | + + Scenario Outline: ValidateGrants rejects duplicates and validates typed keys + Given a role to be written with the grants + When ValidateGrants(locked=false, grants) is evaluated + Then the result is (errors.Is) + + Examples: + | grants | result | + | content.update=all, content.update=own | role.ErrDuplicateGrant | + | content.update:blog=all, content.update:blog=own| role.ErrDuplicateGrant | + | content.update=all, content.update:blog=own | nil | + | content.update:blog=none | nil | + | content.update=none | role.ErrInvalidScope | + | content.create:blog=none | nil | + | content.create:blog=own | role.ErrInvalidScope | + | content.update:Bad Key=all | role.ErrUnknownPermission | + | schema.view:blog=all | role.ErrUnknownPermission | + | settings.delete_site=all | role.ErrOwnerOnlyGrant | + + Scenario: Owner-only keys are effective only on a locked role + Given a custom role, not locked, with the hand-placed grant system.apply_updates=all + When user 20 sends POST /v1/system/update/check + Then the response is 403 missing_permission with X-Missing-Permission "system.apply_updates" + And GET /v1/auth/me for that user does not list system.apply_updates + And the built-in Owner role, which is Locked, still holds all 34 keys + And the same refusal applies to settings.delete_site, roles.edit_owner_only, system.edit_retention and system.transfer_ownership on an unlocked role + + Scenario: The permission check loads the role once and no grants separately + Given a role repository that counts calls and a user repository that counts calls + When a gated request is sent with a session cookie + Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once + And ListGrants is called zero times by the permission check and by GetMyAccessUseCase + And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load) + And the status codes and the order of checks of the enforcement feature are unchanged + And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it + + Scenario: The router uses role.Key constants + Then internal/domain/role/roleKeys.go holds one exported constant per catalogue key (34), named from the key (content.read is KeyContentRead) + And api/router.go and api/middleware contain no string literal equal to a catalogue key + And a drift test fails when a catalogue key has no constant, or a constant has no catalogue key + + Scenario Outline: Reading articles shows what the caller may see + Given the articles of the Background + When sends GET /v1/article/ + Then the result is + + Examples: + | caller | id | result | + | no cookie | 12 | 200 | + | no cookie | 10 | 404 Article not found | + | user 12 unverified, Admin role | 10 | 404 Article not found | + | user 9 Writer (read all) | 10 | 200 | + | user 9 Writer | 14 | 200 | + | user 22 (read own) | 16 | 200 | + | user 22 (read own) | 12 | 200 | + | user 22 (read own) | 10 | 404 Article not found | + | user 22 (read own) | 14 | 404 Article not found | + | user 27 (no content.read) | 12 | 200 | + | user 27 (no content.read) | 10 | 404 Article not found | + | user 26 (typed read on events only) | 10 | 404 Article not found | + | user 13, role_id 99, unresolvable | 12 | 200 | + | user 13, role_id 99, unresolvable | 10 | 404 Article not found | + | API key created by user 9 | 10 | 200 | + | API key created by user 22 | 10 | 404 Article not found | + + Scenario: Public article routes never answer 403 + Given user 27 holds no content.read + When the client sends GET /v1/article, GET /v1/article/12 and GET /v1/article/12/seo with that cookie + Then every response is 200 and none is 403 + And a role repository failure while resolving the caller's role answers 500 "Internal server error" + And a request with no cookie never touches the role repository + + Scenario Outline: The article list is the visible set + Given the articles of the Background + When sends GET /v1/article + Then the ids returned are + And total is and counts are + + Examples: + | caller | query | ids | total | counts | + | no cookie | | 12, 15 | 2 | all 2, online 2, draft 0 | + | user 27 | | 12, 15 | 2 | all 2, online 2, draft 0 | + | user 9 Writer | | 10, 11, 12, 13, 14, 15, 16, 17 | 8 | all 8, online 2, draft 6 | + | user 22 (read own) | | 12, 15, 16 | 3 | all 3, online 2, draft 1 | + | user 22 (read own) | ?status=draft | 16 | 1 | all 3, online 2, draft 1 | + | user 22 (read own) | ?status=online| 12, 15 | 2 | all 3, online 2, draft 1 | + | user 21 (read all) | ?status=draft | 10, 11, 13, 14, 16, 17 | 6 | all 8, online 2, draft 6 | + And counts.suggestions and a ?seo= filter consider only the visible articles + And the ids are ordered by the existing default sort + + Scenario Outline: Reading an article's SEO follows the same visibility + When sends GET /v1/article//seo + Then the result is + + Examples: + | caller | id | result | + | no cookie | 12 | 200 | + | no cookie | 10 | 404 Article not found | + | user 22 (read own) | 16 | 200 | + | user 22 (read own) | 10 | 404 Article not found | + | user 9 Writer | 10 | 200 | + + Scenario Outline: The SEO check is a read of a visible article + When sends POST /v1/article//seo/check with body {"excerpt": "A short summary"} + Then the result is + + Examples: + | caller | id | result | + | user 27 | 12 | 403 missing_permission, header content.read | + | user 22 (read own) | 16 | 200 | + | user 22 (read own) | 12 | 200 | + | user 22 (read own) | 10 | 404 Article not found | + | user 9 Writer | 10 | 200 | + | user 9 Writer | 999 | 404 Article not found | + + Scenario Outline: Creating an article needs content.create for the article type + When sends POST /v1/article with body {"title": "A new draft"} + Then the result is + + Examples: + | caller | result | + | user 21 Contributor | 201, is_online false, stored created_by 21 | + | user 22 Reader own | 201, is_online false, stored created_by 22 | + | user 9 Writer | 201, is_online false, stored created_by 9 | + | user 25 Typed A | 201, update:article=none does not affect create | + | user 24 Publisher own | 403 missing_permission, header content.create, nothing stored | + | user 26 Typed B | 403 missing_permission, header content.create, nothing stored | + | user 27 | 403 missing_permission, header content.create, nothing stored | + + Scenario Outline: Updating and deleting an article follow the scope of the article + Given the articles of the Background + When sends + Then the result is + + Examples: + | caller | call | result | + | user 21 Contributor | PATCH /v1/article/10 {"body": "x"} | 200 | + | user 21 Contributor | PATCH /v1/article/11 {"body": "x"} | 403 content.update, article 11 unchanged | + | user 21 Contributor | PATCH /v1/article/14 {"body": "x"} | 403 content.update | + | user 21 Contributor | PATCH /v1/article/999 {"body": "x"} | 404 Article not found | + | user 21 Contributor | PATCH /v1/article/11 {"nothing": 1} | 400 apperr.BodyErrorMessage, as before | + | user 21 Contributor | DELETE /v1/article/10 | 403 content.delete, before any lookup (also for id 999) | + | user 9 Writer | PATCH /v1/article/13 {"body": "x"} | 200 | + | user 9 Writer | DELETE /v1/article/11 | 403 content.delete | + | user 23 Editor | PATCH /v1/article/13 {"body": "x"} | 200 | + | user 23 Editor | DELETE /v1/article/13 | 403 content.delete (created by user 3) | + | user 5 Admin | DELETE /v1/article/13 | 204 | + | user 3 Owner | PATCH /v1/article/10 {"body": "x"} | 200 | + | user 24 Publisher own | PATCH /v1/article/17 {"body": "x"} | 403 content.update (publish does not imply update), before any lookup | + | user 27 | PATCH /v1/article/12 {"body": "x"} | 403 content.update | + And a refused call changes no field, records no audit event and creates no author or site side effect, including when the body also changes site_id, author_ids or the slug + And an allowed own update keeps created_by unchanged + + Scenario: An own-scope user can delete only their own article + Given user 23 is the Editor with content.delete=own and article 18 (draft) was created by user 23 + When user 23 sends DELETE /v1/article/18 and then DELETE /v1/article/12 + Then the first answers 204 and the article is gone and one article.delete audit event is recorded + And the second answers 403 missing_permission with X-Missing-Permission "content.delete" and article 12 is still stored + + Scenario Outline: Publish is its own permission + Given the articles of the Background + When sends + Then the result is + + Examples: + | caller | call | result | + | user 9 Writer | PATCH /v1/article/11/publish | 403 content.publish, article 11 is still a draft | + | user 9 Writer | PATCH /v1/article/12/unpublish | 403 content.publish, article 12 is still online | + | user 21 Contributor | PATCH /v1/article/10/publish | 403 content.publish (own article, but publish is none) | + | user 23 Editor | PATCH /v1/article/11/publish | 200, is_online true, one article.publish audit event | + | user 23 Editor | PATCH /v1/article/12/unpublish | 200, is_online false, one article.unpublish audit event | + | user 24 Publisher own | PATCH /v1/article/17/publish | 200, is_online true | + | user 24 Publisher own | PATCH /v1/article/17/unpublish | 200, is_online false | + | user 24 Publisher own | PATCH /v1/article/10/publish | 403 content.publish, article 10 unchanged | + | user 24 Publisher own | PATCH /v1/article/999/publish | 404 Article not found | + | user 5 Admin | PATCH /v1/article/10/publish | 200 | + | user 27 | PATCH /v1/article/12/publish | 403 content.publish | + + Scenario: Saving a draft without the publish permission + Given user 9 (Writer: content.update=all, no content.publish) and draft article 11 + When user 9 sends PATCH /v1/article/11 with {"body": "new text"} + Then the status is 200, the body holds the new text and is_online false + And the article is still a draft + And the server answers no copy for this: "Ask an editor to publish." is shown by the SPA, never produced by the API + + Scenario: Update never changes the status + Given user 9 and draft article 11 + When user 9 sends PATCH /v1/article/11 with {"body": "x", "is_online": true} + Then the status is 200 and the article is still a draft + And user 9 sends PATCH /v1/article/12 with {"body": "x"} on the online article and it stays online + + Scenario: A new article is always a draft whatever the role + Given user 3 (Owner) sends POST /v1/article with {"title": "Owner draft", "is_online": true} + Then the status is 201 and is_online is false + + Scenario: A refused publish leaves no trace + Given user 9 sends PATCH /v1/article/11/publish + Then the response is 403 missing_permission and no audit event is recorded and the article's updated_at is unchanged + + Scenario Outline: Entries follow the read scope + When sends + Then the result is + + Examples: + | caller | call | result | + | user 21 Contributor | GET /v1/content-type/events/entry | 200, entries 1, 2, 3, 4, 6, 7, 8, total 7 | + | user 22 (read own) | GET /v1/content-type/events/entry | 200, entry 6 only, total 1 | + | user 22 (read own) | GET /v1/content-type/events/entry?page=1&size=5 | 200, entry 6, total 1, page 1, size 5 | + | user 22 (read own) | GET /v1/content-type/events/entry/6 | 200 | + | user 22 (read own) | GET /v1/content-type/events/entry/1 | 404 entry_not_found | + | user 22 (read own) | GET /v1/content-type/events/entry/3 | 404 entry_not_found (created_by 0) | + | user 27 | GET /v1/content-type/events/entry | 403 content.read, before any lookup | + | user 27 | GET /v1/content-type/events/entry/1 | 403 content.read | + | user 27 | GET /v1/content-type/nope/entry | 403 content.read | + | user 21 Contributor | GET /v1/content-type/nope/entry | 404 content_type_not_found | + | user 5 Admin | GET /v1/content-type/events/entry/2 | 200 | + And under scope own, a ?q= search, total, page and size are computed over the caller's own entries only + + Scenario Outline: Entries follow the create, update and delete scope + When sends + Then the result is + + Examples: + | caller | call | result | + | user 21 Contributor | POST /v1/content-type/events/entry {"data": {...}} | 201, created_by 21 | + | user 27 | POST /v1/content-type/events/entry {"data": {...}} | 403 content.create, nothing stored | + | user 21 Contributor | PUT /v1/content-type/events/entry/1 {"data": {...}} | 200, created_by stays 21, updated_by 21 | + | user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update, entry 2 unchanged | + | user 21 Contributor | PUT /v1/content-type/events/entry/3 {"data": {...}} | 403 content.update | + | user 21 Contributor | PUT /v1/content-type/events/entry/999 {"data": {...}} | 404 entry_not_found | + | user 21 Contributor | PUT /v1/content-type/nope/entry/1 {"data": {...}} | 404 content_type_not_found | + | user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {"title": ""}} | 403 content.update (scope is checked before field validation) | + | user 21 Contributor | DELETE /v1/content-type/events/entry/1 | 403 content.delete, before any lookup, entry 1 stored | + | user 9 Writer | PUT /v1/content-type/events/entry/2 {"data": {...}} | 200 | + | user 9 Writer | DELETE /v1/content-type/events/entry/2 | 403 content.delete | + | user 23 Editor | DELETE /v1/content-type/events/entry/8 | 204 | + | user 23 Editor | DELETE /v1/content-type/events/entry/2 | 403 content.delete, entry 2 stored | + | user 5 Admin | DELETE /v1/content-type/events/entry/2 | 204 | + | user 3 Owner | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 | + And no entry route requires content.publish + + Scenario Outline: Typed grants replace the wildcard for their type + When sends + Then the result is + + Examples: + | caller | call | result | + | user 25 Typed A | PATCH /v1/article/10 {"body": "x"} | 403 content.update (update:article=none replaces update=all), before any lookup | + | user 25 Typed A | PUT /v1/content-type/events/entry/4 {"data": {...}} | 200 (update:events=own, entry created by 25) | + | user 25 Typed A | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update (update:events=own replaces update=all) | + | user 25 Typed A | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 (wildcard update=all applies to venues) | + | user 26 Typed B | GET /v1/content-type/events/entry | 200 | + | user 26 Typed B | GET /v1/content-type/venues/entry | 403 content.read | + | user 26 Typed B | POST /v1/content-type/events/entry {"data": {...}} | 201 | + | user 26 Typed B | POST /v1/content-type/venues/entry {"data": {...}} | 403 content.create | + | user 26 Typed B | PUT /v1/content-type/events/entry/7 {"data": {...}} | 200 | + | user 26 Typed B | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update | + | user 26 Typed B | GET /v1/article/10 | 404 Article not found (no article read grant: public view) | + | user 26 Typed B | GET /v1/article/12 | 200 | + | user 26 Typed B | POST /v1/article {"title": "A new draft"} | 403 content.create | + And the header is always the base key, never "content.update:events" + + Scenario: An unknown type key falls under the wildcard or is refused + Given user 26 (typed grants for events only) + When user 26 sends GET /v1/content-type/venues/entry and GET /v1/content-type/nope/entry + Then both answer 403 content.read, with no lookup of the content type + And user 21, whose wildcard grants apply, gets 404 content_type_not_found on GET /v1/content-type/nope/entry + + Scenario: Creating a content type with a built-in key is refused + Given user 5 (Admin) + When user 5 sends POST /v1/content-type with a valid body whose key is "article" + Then the status is 409 and the body is apperr.ContentTypeKeyTakenErrorMessage and nothing is stored + And the same holds for the keys "author", "site", "media" and "user" + And content types that already exist are not touched + + Scenario: Admin and Owner pass every content check + Given the Admin and the Owner + Then they read, create, update, delete and publish every article and entry whatever its created_by + And they list every article including drafts, with counts all 8, online 2, draft 6 + + Scenario: Writer is effective on content + Given user 9 on the Writer role + Then Writer reads and updates every article and entry, and creates articles and entries + And Writer gets 403 content.delete on DELETE of an article or an entry, and 403 content.publish on publish and unpublish + And editing an online article keeps it online + + Scenario: A session follows the role live for content + Given user 21 gets 403 on PATCH /v1/article/11 {"body": "x"} + When the Contributor role's grant becomes content.update=all in the repository + Then the next PATCH /v1/article/11 with the same cookie answers 200, with no re-login + And when the grant is then removed, the next PATCH answers 403 content.update + + Scenario: An API key follows its creator's role and owns what its creator owns + Given a valid API key created by user 21 + When the key sends POST /v1/article {"title": "Key draft"} + Then the stored article has created_by 21 + And the key's PATCH on that article answers 200 and on article 11 answers 403 content.update + And the key's PATCH /v1/article/11/publish answers 403 content.publish + And when user 21's role_id is changed to the Editor role, the same key's PATCH /v1/article/11/publish answers 200 with no new key + + Scenario: A user whose role cannot be resolved is refused on gated content routes + Given user 13 has role_id 99 + When user 13 sends POST /v1/article, PATCH /v1/article/10 and GET /v1/content-type/events/entry + Then every response is 403 missing_permission + And a role repository failure answers 500 "Internal server error" + + Scenario: Verification and token checks still come first + Given user 12 is not verified and holds the Admin role + When user 12 sends PATCH /v1/article/10 with {"body": "x"} + Then the status is 403 apperr.VerificationRequiredErrorMessage + And a stale token_version gives 401 and no cookie gives 401 on every gated content route + + Scenario Outline: Media routes keep their scope behaviour + When sends + Then the result is + + Examples: + | caller | call | result | + | user 9 Writer | DELETE /v1/media/1 | 403 media.delete | + | role with media.delete=own | DELETE /v1/media/1 | 403 media.delete (images record no creator, own fails closed) | + | role with media.delete=own | POST /v1/media/merge | 403 media.delete | + | role with media.delete=own | POST /v1/media/purge | 403 media.delete | + | role with media.delete=all | DELETE /v1/media/1 | ok | + | user 9 Writer | GET /v1/media/1/usage | ok | + | user 9 Writer | POST /v1/media | ok | + And GET /v1/media/{id}/usage keeps listing every referencing article and entry to any media.view holder, whatever the content scopes (not filtered in this slice) + + Scenario: authors.edit keeps its 2a behaviour on the shared helper + Given the authors.edit Outline of the enforcement feature + Then every row gives the same result as before, now evaluated through GrantSet.ScopeFor + + Scenario: /auth/me lists the effective grants + Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all + When the client sends GET /v1/auth/me + Then access.permissions holds content.update at own and no settings.delete_site + And typed grants are not listed in this slice + + Scenario: Out of scope here + Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route + And typed grants are written by slice 3 and exposed to the SPA by a later slice + And rows of deleted content types in typed grants are cleaned up by slice 3 diff --git a/features/roles_permissions_content_scope.qa.md b/features/roles_permissions_content_scope.qa.md new file mode 100644 index 0000000..0de2f3a --- /dev/null +++ b/features/roles_permissions_content_scope.qa.md @@ -0,0 +1,120 @@ +# QA procedure: roles_permissions_content_scope + +Package: `test` (black-box). Name new fakes uniquely, for example `contentScopeRoleRepo`, `contentScopeArticleRepo`, `contentScopeEntryRepo`. +Reuse the fakes and helpers of `test/roles_permissions_enforcement_support_test.go` (`mintCookie`, `assertGate`, the API key minting helper) where they fit. +Do not copy any `Role.Allows`, `Role ==` or hand-rolled own/all comparison into new code: the tests call the exported domain helper. + +## Deferred to later slices + +- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types. +- 4: role change, invite with role, ownership transfer, removal of the legacy role column. +- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants. +- Not changed: GET /v1/media/{id}/usage still lists articles and entries for any media.view holder. media.delete own fails closed. Entry relation summaries are not gated by the related type. + +## Design notes for the user + +- docs/design is silent on Read=Own. The draft encodes the decision recorded in the feature Background ("visible = online OR covered" for articles, filtered list and 404 for entries). If the user chooses another option, edit the "Reading articles", "The article list is the visible set" and "Entries follow the read scope" tables. +- The design says "Publish hidden for roles without it; status shows Draft with Ask an editor to publish." The API only answers 403 content.publish. The copy is the SPA's. +- roles.md "Escalation blocked" talks about granting Publish: All when the editor holds only Own. That is slice 3. +- A custom content type with a built-in key is now refused at creation (409). Mention it in docs/design when the Schema screens are reviewed. + +## Inventory (route, key, gate, after the load, Owner / Admin / Writer) + +| Route | Key (type) | Gate before load | After the load | Owner / Admin / Writer | +|---|---|---|---|---| +| GET /v1/article | content.read (article) | none, public | visible set = online OR covered, filters items, total, counts, ?status, ?seo | all / all / all | +| GET /v1/article/{id}, /{id}/seo | content.read (article) | none, public | invisible gives 404 Article not found | all / all / all | +| POST /v1/article | content.create (article) | typed not none | created_by = caller, offline | ok / ok / ok | +| PATCH /v1/article/{id} | content.update (article) | typed not none | Permits(created_by) | ok / ok / ok | +| DELETE /v1/article/{id} | content.delete (article) | typed not none | Permits(created_by) | ok / ok / 403 | +| PATCH /v1/article/{id}/publish, /unpublish | content.publish (article) | typed not none | Permits(created_by) | ok / ok / 403 | +| POST /v1/article/{id}/seo/check | content.read (article) | typed not none | visible set, else 404 | ok / ok / ok | +| GET /v1/content-type/{key}/entry | content.read ({key}) | typed not none | own filters list | all / all / all | +| GET /v1/content-type/{key}/entry/{id} | content.read ({key}) | typed not none | own and not creator gives 404 entry_not_found | ok / ok / ok | +| POST /v1/content-type/{key}/entry | content.create ({key}) | typed not none | created_by = caller | ok / ok / ok | +| PUT /v1/content-type/{key}/entry/{id} | content.update ({key}) | typed not none | Permits(created_by) | ok / ok / ok | +| DELETE /v1/content-type/{key}/entry/{id} | content.delete ({key}) | typed not none | Permits(created_by) | ok / ok / 403 | +| POST /v1/content-type | schema.edit | as 2a | creating a key equal to a built-in key gives 409 content_type_key_taken | ok / ok / 403 | +| DELETE /v1/media/{id}, POST /v1/media/merge | media.delete | all only | n/a (no creator on images) | ok / ok / 403 | +| GET /v1/media, /{id}, /{id}/usage | media.view | as 2a | usage not filtered | ok / ok / ok | +| POST /v1/media, PATCH /v1/media/{id} | media.upload | as 2a | none | ok / ok / ok | +| PATCH /v1/author/{id} | authors.edit | as 2a | link-based own | as 2a | + +Routes that no key gates after 2b among the content routes: none. Entry routes need no content.publish. No schedule route exists. + +## Test infrastructure first + +1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `ListGrants`. Its `Get` returns the role with `Grants` filled, like the persistence repository. +2. User fake with a `Get` call counter (existing `mockUserRepo` plus a counter, or a wrapper). Users 3, 5, 9, 12 (unverified Admin), 13 (role_id 99), 20 (hand-placed owner-only), 21-27. +3. Article fake and entry fake seeded with the ids, states and created_by values of the Background. Both record writes, so "article unchanged", "entry stored" and "nothing stored" are asserted on the store. A recording `audit.Recorder` to assert "no audit event". +4. HTTP harness: `validator.InitValidator()`. Build `NewArticleRouter` and `NewContentTypeRouter` equivalents mounted on a fresh `chi.NewRouter()` mirroring `api/router.go`: `authtoken.Verifier`, `container.apiKeyAuth()` equivalent, the new optional-access middleware on the public article group, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, the typed permission gate, and the handlers over real use cases wired to the fakes. No live server, no real SQLite, no real SMTP. +5. `assertGate(t, rec, key)` from 2a for the 403 contract. A helper `assertUnchanged(t, store, id, snapshot)`. +6. A helper `grantsOf("content.update=own", "content.update:events=none", ...)` parsing the row notation of the feature into `[]role.Grant`, so table rows read like the feature. + +## A. Domain (no fakes needed) + +7. `GrantSet.ScopeFor`: run every row of the "ScopeFor resolves wildcard and typed keys" Outline through `Role{Grants: ...}.GrantSet()`. Include the 101-character type key and "Bad Key". +8. `GrantSet.Permits`: the 7 rows of the "One helper decides own and all" Outline. +9. Duplicates: build the set from the rows in both orders, expect the same most restrictive scope. Include a toggle key stored at own (expect none) and an unknown scope string (expect none). +10. `ValidateGrants`: the 10 rows of the Outline; the duplicate error is the new sentinel `role.ErrDuplicateGrant`. +11. Owner-only: `Role{Locked: false, Grants: system.apply_updates=all}.GrantSet()` has no owner-only scope for any of the 5 owner-only keys. `Role{Locked: true}` keeps them. `BuiltInRoles()` Owner keeps all 34, Admin none of the 5. +12. Hygiene tests, in the style of `*_hygiene_test.go`: + - `internal/domain/role` imports only the stdlib. + - `Role` has no `Allows`, `AllowsFor`, `scopeOf` (reflect on method set, or grep the non-test Go). + - Non-test Go under `api/` and `internal/infrastructure/useCases` has no comparison of `CreatedBy` with a caller id, and no direct index into a `GrantSet`, outside `internal/domain/role`. + - `api/router.go` and `api/middleware` hold no string literal equal to a catalogue key (parse the Go file, compare string literals with `role.Catalogue()`). + - Drift: every catalogue key has an exported constant in `roleKeys.go`, and every constant is a catalogue key. + +## B. Use cases (fakes only) + +13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls. +14. Counters: for one `Require` call, `ListGrants` is 0, `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`. +15. Article use cases (get, list, update, update-is-online, delete, create, SEO check): table of caller grants × article × expected error or result, per the feature Outlines. Assert for refusals: error is `role.MissingPermissionError{Key: "content.update"}` and so on, the store is unchanged, the recorder has no event, and no site/author/image lookup happened (counters on those fakes). +16. Entry use cases (list, get, create, update, delete): same, including `created_by` unchanged on update, own-filtered list totals and `?q=` over the own set, and `entry.ErrEntryNotFound` for a non-owned read under own. +17. Order: scope check after the load and before validation: PUT with an invalid body for a non-owned entry gives the scope error, not `FieldError`. +18. `CreateContentType` with each of the 5 built-in keys returns `contenttype.ErrKeyTaken`. +19. `GetMyAccess`: effective grants (duplicates normalised, owner-only dropped on unlocked roles, typed rows not listed). + +## C. HTTP + +20. Gate and scope matrices: run every row of each Scenario Outline of the feature. One test per Outline, subtests named after the row. Reset the stores for each row. +21. Contract: every 403 row via `assertGate` (header is the base key), no side effect, no audit event. +22. Public article routes: no cookie, unverified Admin (user 12), user 27, user 13 (role_id 99), API key. Assert 200/404 per the Outline and that no 403 is ever returned. Role repo failure gives 500; an anonymous request leaves the role repo counters at 0. +23. List: the 7 rows with items, total, counts. Assert `counts.suggestions` and `?seo=` consider only visible articles (add a suggestions-bearing draft owned by someone else, hidden from user 22). +24. Publish: all rows, plus the audit assertions (one `article.publish` / `article.unpublish`, none on refusal), plus "update never changes status" (`is_online` in the PATCH body ignored) and "new article is a draft". +25. Typed grants: the 13 rows, plus unknown type under wildcard (404) and under typed-only grants (403, no content type lookup counter). +26. Live role: edit grants and `role_id` between requests with the same cookie and the same API key. +27. API key: article created through the key has `created_by` of the creator. +28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes. +29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `ListGrants`; with an API key at most 2 user `Get`. Run on one article route and one entry route. +30. Owner-only: user 20 with a hand-placed `system.apply_updates=all` gets 403 on `POST /v1/system/update/check`; `/auth/me` omits it. +31. Media rows of the Outline and the "usage not filtered" assertion. +32. Regression: run the 2a Outline "Existing owner and admin behaviour does not regress", the authors.edit Outline and the activity/api-key scope Outlines unchanged. + +## Existing tests that must change (found by grep; recheck with `go vet -tags nofrontend ./test/...`) + +| File (lines) | Change | +|---|---| +| test/roles_permissions_foundation_test.go:38-53 | `Role.Allows` scenarios move to `GrantSet.ScopeFor`/`Permits`. The "unknown key" row keeps asserting `ErrUnknownPermission` from `Lookup` | +| test/role_hardening_test.go:202-212, 250-277 | `Allows`/`AllowsFor` rows move to `GrantSet`. Line 212 becomes the Owner role's grant set holding `system.apply_updates` | +| test/roles_permissions_enforcement_test.go:37-38, 83-85, 146-171 | The hand-made custom role with `system.apply_updates` now gets `ErrMissingPermission` (line 83-85 flips). `TestGrantSet` uses catalogue keys, not "a", "b", "c". `NewGrantSet` becomes `Role.GrantSet()` | +| test/roles_permissions_enforcement_http_test.go:343 | Custom role `grantsOf("system.apply_updates")` on `POST /v1/system/update/check` flips from ok to 403 | +| test/roles_permissions_enforcement_support_test.go:79; test/roles_permissions_enforcement_scope_test.go:252, 275, 297-346; test/api_keys_test.go:178-179 | `role.NewGrantSet(...)` call sites move to `Role{...}.GrantSet()` (owner-only keys need `Locked: true` in the fixture) | +| tests that build the article or entry routers by hand (test/article_publishing_http_test.go, edit_article_body_http_test.go, articles_list_http_test.go, articles_pagination_api_http_test.go, public_article_read_test.go, public_article_listing_test.go, article_body_format_test.go, article_body_unlimited_http_test.go, article_title_rules_test.go, seo_data_article_http_test.go, authors_backend_articles_http_test.go, delete_article_keeps_images_test.go, remove_legacy_article_image_alias_http_test.go, not_found_errors_test.go, stale_token_invalidation_http_test.go, site_articles_acceptance_test.go, custom_content_types_support_http_test.go, relation_field_type_http_test.go, media_field_type_http_test.go, activity_log_producers_http_test.go) | Mount the new middleware and give the role repository to the fixture. Users with legacy role "" or "editor" now get `missing_permission` on writes: give them a role_id (Writer, Editor-like custom role or Admin). Public draft visibility assertions: unverified and no-grant callers see online only | +| test/custom_content_types_test.go (and usecases) | Add the built-in key rows (409). Entry use case calls take the caller grants | + +## Existing features to amend or mark superseded + +Add a "superseded by roles_permissions_content_scope" note or edit in place: +- roles_permissions_enforcement: the Background clause "enforcement honours any grant it finds, including an owner-only key...", the Outline row `system.apply_updates=all | POST /v1/system/update/check | ok`, the scenario "Articles and entries are not gated in this slice", and its QA "Deferred" 2b line. +- roles_permissions_foundation: the Background line `Role.Allows(key, owned bool)`, the three `Allows` scenarios, and the sentence "per-type overrides ... deferred to slice 3" (the read side is implemented here, writing stays in slice 3). +- article_publishing, articles_list, articles_pagination, public_article_read, public_article_listing, edit_article_body, article_body_format, article_title_rules, delete_article_keeps_images, not_found_errors, seo_data_backend, seo_output_backend, seo_sites_articles_integration (draft visibility now depends on content.read). +- custom_content_types, custom_content_types_support, relation_field_type, media_field_type (entry routes are role-gated, built-in keys reserved). +- Matching `.qa.md` files for each of the above. + +## Manual verification + +- Start `task dev:api` on a copy of a migrated database. As the Writer: publish returns 403, saving a draft works and stays a draft. As the Admin: publish works. +- With two accounts on a custom role holding content.update=own, confirm account B gets 403 on A's article and entry, and A can edit them. +- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. This is the only way to try typed grants until slice 3. +- Confirm no password or secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET). diff --git a/features/roles_permissions_enforcement.feature b/features/roles_permissions_enforcement.feature index 2867e75..bfa32dd 100644 --- a/features/roles_permissions_enforcement.feature +++ b/features/roles_permissions_enforcement.feature @@ -29,6 +29,7 @@ Feature: Roles and permissions enforcement: permission checks replace the owner answers 500 "Internal server error" And enforcement honours any grant it finds, including an owner-only key placed on a custom role by hand. Refusing to grant owner-only keys belongs to slice 3 (ValidateGrants at write time) + (superseded by roles_permissions_content_scope: owner-only keys are effective only on a locked role, so a hand-placed one is ignored) And the users, tokens and cookies are the ones of the foundation feature: user 3 Owner, user 5 Admin, user 9 Writer, all verified with a valid jwt cookie and token_version 0 And routes with no obvious catalogue key reuse existing keys: media rename = media.upload; purge settings GET = settings.view, @@ -194,7 +195,7 @@ Feature: Roles and permissions enforcement: permission checks replace the owner | media.view=all | POST /v1/media | 403 | | apikeys.create=all | GET /v1/api-key | 403 | | settings.edit_general=all | PATCH /v1/media/purge-settings | ok | - | system.apply_updates=all | POST /v1/system/update/check | ok | + | system.apply_updates=all | POST /v1/system/update/check | 403 system.apply_updates (superseded by roles_permissions_content_scope: owner-only keys count only on a locked role) | | (no grants) | GET /v1/author | 403 | Scenario: A user whose role cannot be resolved is refused everywhere that is gated @@ -367,6 +368,7 @@ Feature: Roles and permissions enforcement: permission checks replace the owner Scenario: Articles and entries are not gated in this slice Given user 20 has a custom role with no content grants Then the article and entry routes answer exactly as before this slice (own/all, publish and draft are slice 2b) + And this scenario is superseded by roles_permissions_content_scope: articles and entries are gated by the content.* keys Scenario Outline: Existing owner and admin behaviour does not regress Given the caller is diff --git a/features/roles_permissions_enforcement.qa.md b/features/roles_permissions_enforcement.qa.md index 8962afd..b3e1607 100644 --- a/features/roles_permissions_enforcement.qa.md +++ b/features/roles_permissions_enforcement.qa.md @@ -5,7 +5,7 @@ Do not copy the old `Role ==` checks into new code. ## Deferred to later slices -- 2b: own/all enforcement after loading an article or entry, publish separate from update, Draft behaviour. Articles and entries stay ungated here. +- 2b: own/all enforcement after loading an article or entry, publish separate from update, Draft behaviour. Articles and entries stay ungated here. (Superseded: implemented by roles_permissions_content_scope, which also makes owner-only keys effective only on a locked role.) - 3: /v1/role CRUD, no-escalation, ValidateGrants at write time, per-type override keys. - 4: role change, invite with a role, transfer ownership, removal of the legacy role column. - SPA (sidebar, read-only pages, 403 toasts) is out of scope. It reacts to the status code only. diff --git a/features/roles_permissions_foundation.feature b/features/roles_permissions_foundation.feature index c15aa83..f459c64 100644 --- a/features/roles_permissions_foundation.feature +++ b/features/roles_permissions_foundation.feature @@ -13,7 +13,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills - Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants} - the sentinels ErrRoleNotFound and ErrUnknownPermission - the Repository interface (list, get by id, get by name, list grants, create, save) - - the pure function Role.Allows(key, owned bool) bool + - the pure function Role.Allows(key, owned bool) bool (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits) And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only): | group | key | kind | owner-only | | content | content.read | S | no | @@ -51,7 +51,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills | system | system.edit_retention | B | yes | | system | system.transfer_ownership | B | yes | And content permissions are stored under wildcard keys meaning "all content types"; per-type - overrides of the form content.: are deferred to slice 3 + overrides of the form content.: are deferred to slice 3 (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3) And a role stores one row per granted key, with scope own or all. A key with no row is none or off. An on/off key that is on is stored as scope all. And three built-in roles are seeded by persistence/database (like SeedDefaultSite), idempotently, @@ -75,7 +75,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills authtoken.Verifier, container.apiKeyAuth(), authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser. It has no role check yet - Scenario Outline: Role.Allows evaluates scope and ownership + Scenario Outline: Role.Allows evaluates scope and ownership (superseded by roles_permissions_content_scope: the same rows are decided by GrantSet.Permits) Given a role whose grant for "" is "" When Allows("", owned=) is evaluated Then the result is @@ -90,12 +90,12 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills | content.create | all | false | true | | schema.view | none | false | false | - Scenario: A key absent from the role is not allowed + Scenario: A key absent from the role is not allowed (superseded by roles_permissions_content_scope: decided by GrantSet.Permits) Given a role with no grants When Allows("content.read", owned=true) is evaluated Then the result is false - Scenario: An unknown key is rejected, never silently allowed + Scenario: An unknown key is rejected, never silently allowed (superseded by roles_permissions_content_scope: decided by GrantSet.Permits) Given a role with grants for every catalogue key at all When Allows("content.explode", owned=true) is evaluated Then the result is false diff --git a/features/roles_permissions_foundation.qa.md b/features/roles_permissions_foundation.qa.md index 3ea6830..1fe6ced 100644 --- a/features/roles_permissions_foundation.qa.md +++ b/features/roles_permissions_foundation.qa.md @@ -25,7 +25,7 @@ The "Owner-only items" state in docs/design/screens/roles.md disables View roles ## A. Domain (pure Go, no HTTP, no GORM) -4. Table-test `Role.Allows` with the Examples of "Role.Allows evaluates scope and ownership", plus the absent-key and unknown-key cases. Assert `errors.Is(err, role.ErrUnknownPermission)` for the lookup. +4. (Superseded by roles_permissions_content_scope: the rows now run through `GrantSet.Permits` and `GrantSet.ScopeFor`.) Table-test `Role.Allows` with the Examples of "Role.Allows evaluates scope and ownership", plus the absent-key and unknown-key cases. Assert `errors.Is(err, role.ErrUnknownPermission)` for the lookup. 5. Catalogue test: length 34, group order, key order, the 5 owner-only keys, the 9 scoped keys, and `per_type` only for the 5 content keys. A unique-keys check guards against duplicates. 6. A hygiene test that `internal/domain/role` imports only the stdlib (grep the import list, as in the existing `*_hygiene_test.go` files). diff --git a/features/seo_data_backend.feature b/features/seo_data_backend.feature index b729b7c..571d5ea 100644 --- a/features/seo_data_backend.feature +++ b/features/seo_data_backend.feature @@ -1,4 +1,5 @@ Feature: SEO data backend, article and site search fields, unique slugs and media usage + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. As an editor or the owner of a RenewCMS installation diff --git a/features/seo_data_backend.qa.md b/features/seo_data_backend.qa.md index f6ab9b5..5c00de1 100644 --- a/features/seo_data_backend.qa.md +++ b/features/seo_data_backend.qa.md @@ -1,5 +1,7 @@ # QA procedure: SEO data backend (slice 1 of 4) +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + > Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. The 409 `slug_taken` assertion checks the plain-text body `slug_taken` and the `X-Slug-Suggestion` header. diff --git a/features/seo_output_backend.feature b/features/seo_output_backend.feature index 3180356..8b10ec4 100644 --- a/features/seo_output_backend.feature +++ b/features/seo_output_backend.feature @@ -1,4 +1,5 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. As a frontend developer or the RenewCMS back-office editor diff --git a/features/seo_output_backend.qa.md b/features/seo_output_backend.qa.md index 18bc43b..05bb096 100644 --- a/features/seo_output_backend.qa.md +++ b/features/seo_output_backend.qa.md @@ -1,5 +1,7 @@ # QA procedure: SEO output backend (slice 2 of 4) +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + > Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. Spec: `features/seo_output_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. Success bodies are JSON. Assert exact key sets by decoding into `map[string]any` and comparing sorted key lists. diff --git a/features/seo_sites_articles_integration.feature b/features/seo_sites_articles_integration.feature index aaa15bb..ad4b684 100644 --- a/features/seo_sites_articles_integration.feature +++ b/features/seo_sites_articles_integration.feature @@ -1,4 +1,5 @@ Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration "Search and sharing" and suggestion dismissal + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. As the owner of a RenewCMS install, and as anyone who edits articles or builds a frontend on the API diff --git a/features/seo_sites_articles_integration.qa.md b/features/seo_sites_articles_integration.qa.md index b1b374c..e7a192a 100644 --- a/features/seo_sites_articles_integration.qa.md +++ b/features/seo_sites_articles_integration.qa.md @@ -1,5 +1,7 @@ # QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal) +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + > Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user. SPA slice only. No Go change here: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, and `counts.suggestions` is covered by the httptest part of `features/articles_pagination_api.qa.md`, so this file has no httptest part. A wrong contract is reported as a backend defect. diff --git a/features/stale_token_invalidation.feature b/features/stale_token_invalidation.feature index 05341c9..49d032c 100644 --- a/features/stale_token_invalidation.feature +++ b/features/stale_token_invalidation.feature @@ -1,4 +1,5 @@ Feature: A password reset invalidates every jwt issued before it, and a cancelled registration keeps doing so + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As the maintainer of RenewCMS I want a jwt minted before a user resets their password to stop working the moment the reset diff --git a/features/stale_token_invalidation.qa.md b/features/stale_token_invalidation.qa.md index f12bdf1..e3d1ae8 100644 --- a/features/stale_token_invalidation.qa.md +++ b/features/stale_token_invalidation.qa.md @@ -1,5 +1,7 @@ # QA procedure: stale_token_invalidation +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`) and reusing the router/handler wiring pattern from `test/account_verification_http_test.go` and `test/reset_password_http_test.go` (or equivalent). diff --git a/features/verified_account_required.feature b/features/verified_account_required.feature index 6c8f487..63f81d9 100644 --- a/features/verified_account_required.feature +++ b/features/verified_account_required.feature @@ -1,4 +1,5 @@ Feature: Protected article routes and the offline-inclusive listing require a verified account + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As the maintainer of RenewCMS I want every protected article route, and the "see offline articles too" behavior of diff --git a/features/verified_account_required.qa.md b/features/verified_account_required.qa.md index 1f135dc..5aeb076 100644 --- a/features/verified_account_required.qa.md +++ b/features/verified_account_required.qa.md @@ -1,5 +1,7 @@ # QA procedure: verified_account_required +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box), extending `test/article_test.go` (`mockArticleRepo`) and `test/user_test.go` (`mockUserRepo`). This slice's router mount touches both the article and auth routers, so the HTTP-level checks live in a new `test/verified_account_required_http_test.go`. diff --git a/features/write_not_found_errors.feature b/features/write_not_found_errors.feature index 8f3ad6c..b7defde 100644 --- a/features/write_not_found_errors.feature +++ b/features/write_not_found_errors.feature @@ -1,4 +1,5 @@ Feature: Domain not-found errors on repository writes, image attachment and deletes + Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As the maintainer of RenewCMS I want an update, an image attachment or a delete against an unknown article, user or image id diff --git a/features/write_not_found_errors.qa.md b/features/write_not_found_errors.qa.md index a7ac7d0..fa15a86 100644 --- a/features/write_not_found_errors.qa.md +++ b/features/write_not_found_errors.qa.md @@ -1,5 +1,7 @@ # QA procedure: write_not_found_errors +> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. + Package: `test` (black-box, extending `article_test.go` / `user_test.go` / `image_test.go` / `not_found_errors_test.go`). ## Setup shared by every case diff --git a/internal/domain/role/roleBuiltIn.go b/internal/domain/role/roleBuiltIn.go index 2a3f2b2..f14a8c1 100644 --- a/internal/domain/role/roleBuiltIn.go +++ b/internal/domain/role/roleBuiltIn.go @@ -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}, } } diff --git a/internal/domain/role/roleDomain.go b/internal/domain/role/roleDomain.go index cd8db52..f78d513 100644 --- a/internal/domain/role/roleDomain.go +++ b/internal/domain/role/roleDomain.go @@ -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 } diff --git a/internal/domain/role/roleGrantSet.go b/internal/domain/role/roleGrantSet.go index 0c86281..938d222 100644 --- a/internal/domain/role/roleGrantSet.go +++ b/internal/domain/role/roleGrantSet.go @@ -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 } diff --git a/internal/domain/role/roleKeys.go b/internal/domain/role/roleKeys.go index ab2a428..f5d4642 100644 --- a/internal/domain/role/roleKeys.go +++ b/internal/domain/role/roleKeys.go @@ -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" ) diff --git a/internal/infrastructure/useCases/CheckArticleSEO.go b/internal/infrastructure/useCases/CheckArticleSEO.go index 38c0096..09caa7a 100644 --- a/internal/infrastructure/useCases/CheckArticleSEO.go +++ b/internal/infrastructure/useCases/CheckArticleSEO.go @@ -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 } diff --git a/internal/infrastructure/useCases/CheckPermission.go b/internal/infrastructure/useCases/CheckPermission.go index 36b033e..82a01bc 100644 --- a/internal/infrastructure/useCases/CheckPermission.go +++ b/internal/infrastructure/useCases/CheckPermission.go @@ -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 -} diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index fab1ba7..e1f2739 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -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{}, }) diff --git a/internal/infrastructure/useCases/CreateContentType.go b/internal/infrastructure/useCases/CreateContentType.go index 5c3d975..583718b 100644 --- a/internal/infrastructure/useCases/CreateContentType.go +++ b/internal/infrastructure/useCases/CreateContentType.go @@ -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 diff --git a/internal/infrastructure/useCases/CreateEntry.go b/internal/infrastructure/useCases/CreateEntry.go index 810e6b7..7cf1674 100644 --- a/internal/infrastructure/useCases/CreateEntry.go +++ b/internal/infrastructure/useCases/CreateEntry.go @@ -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, }) diff --git a/internal/infrastructure/useCases/DeleteArticle.go b/internal/infrastructure/useCases/DeleteArticle.go index 51d7767..2ae1a9f 100644 --- a/internal/infrastructure/useCases/DeleteArticle.go +++ b/internal/infrastructure/useCases/DeleteArticle.go @@ -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) diff --git a/internal/infrastructure/useCases/DeleteEntry.go b/internal/infrastructure/useCases/DeleteEntry.go index ca37d3f..19fb4cb 100644 --- a/internal/infrastructure/useCases/DeleteEntry.go +++ b/internal/infrastructure/useCases/DeleteEntry.go @@ -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) } diff --git a/internal/infrastructure/useCases/GetArticle.go b/internal/infrastructure/useCases/GetArticle.go index e4b05f2..391174f 100644 --- a/internal/infrastructure/useCases/GetArticle.go +++ b/internal/infrastructure/useCases/GetArticle.go @@ -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) } diff --git a/internal/infrastructure/useCases/GetEntry.go b/internal/infrastructure/useCases/GetEntry.go index 2468f70..1fe98c1 100644 --- a/internal/infrastructure/useCases/GetEntry.go +++ b/internal/infrastructure/useCases/GetEntry.go @@ -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)) } diff --git a/internal/infrastructure/useCases/GetMyAccess.go b/internal/infrastructure/useCases/GetMyAccess.go index 3def7ea..bf8c724 100644 --- a/internal/infrastructure/useCases/GetMyAccess.go +++ b/internal/infrastructure/useCases/GetMyAccess.go @@ -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 } diff --git a/internal/infrastructure/useCases/ListAPIKeys.go b/internal/infrastructure/useCases/ListAPIKeys.go index efaea71..90011a9 100644 --- a/internal/infrastructure/useCases/ListAPIKeys.go +++ b/internal/infrastructure/useCases/ListAPIKeys.go @@ -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} } diff --git a/internal/infrastructure/useCases/ListArticles.go b/internal/infrastructure/useCases/ListArticles.go index 6b008f7..e7e1066 100644 --- a/internal/infrastructure/useCases/ListArticles.go +++ b/internal/infrastructure/useCases/ListArticles.go @@ -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 } diff --git a/internal/infrastructure/useCases/ListEntries.go b/internal/infrastructure/useCases/ListEntries.go index e70220b..41ad499 100644 --- a/internal/infrastructure/useCases/ListEntries.go +++ b/internal/infrastructure/useCases/ListEntries.go @@ -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 } diff --git a/internal/infrastructure/useCases/ResolveArticleSEO.go b/internal/infrastructure/useCases/ResolveArticleSEO.go index ea8b2f0..fe74956 100644 --- a/internal/infrastructure/useCases/ResolveArticleSEO.go +++ b/internal/infrastructure/useCases/ResolveArticleSEO.go @@ -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) diff --git a/internal/infrastructure/useCases/RevokeAPIKey.go b/internal/infrastructure/useCases/RevokeAPIKey.go index f3c4210..bc34eff 100644 --- a/internal/infrastructure/useCases/RevokeAPIKey.go +++ b/internal/infrastructure/useCases/RevokeAPIKey.go @@ -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 } diff --git a/internal/infrastructure/useCases/UpdateArticle.go b/internal/infrastructure/useCases/UpdateArticle.go index 48627da..7cb802d 100644 --- a/internal/infrastructure/useCases/UpdateArticle.go +++ b/internal/infrastructure/useCases/UpdateArticle.go @@ -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) diff --git a/internal/infrastructure/useCases/UpdateAuthor.go b/internal/infrastructure/useCases/UpdateAuthor.go index 8b5a295..8eae671 100644 --- a/internal/infrastructure/useCases/UpdateAuthor.go +++ b/internal/infrastructure/useCases/UpdateAuthor.go @@ -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 diff --git a/internal/infrastructure/useCases/UpdateEntry.go b/internal/infrastructure/useCases/UpdateEntry.go index 630772a..4e6b7a9 100644 --- a/internal/infrastructure/useCases/UpdateEntry.go +++ b/internal/infrastructure/useCases/UpdateEntry.go @@ -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 { diff --git a/internal/infrastructure/useCases/activityQuery.go b/internal/infrastructure/useCases/activityQuery.go index 8e78a31..24bed57 100644 --- a/internal/infrastructure/useCases/activityQuery.go +++ b/internal/infrastructure/useCases/activityQuery.go @@ -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: diff --git a/internal/infrastructure/useCases/contentCaller.go b/internal/infrastructure/useCases/contentCaller.go new file mode 100644 index 0000000..59f213c --- /dev/null +++ b/internal/infrastructure/useCases/contentCaller.go @@ -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) +} diff --git a/test/activity_log_producers_test.go b/test/activity_log_producers_test.go index 004a642..725ab9d 100644 --- a/test/activity_log_producers_test.go +++ b/test/activity_log_producers_test.go @@ -130,7 +130,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) { repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)} uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo()) - if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil { + if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil { t.Fatal(err) } @@ -149,7 +149,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) { repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"} uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo()) - if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil { + if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), "héllo wörld", article.BodyFormatHTML); err != nil { t.Fatal(err) } @@ -189,7 +189,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) { h := newActivityHarness(t) uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors()) - _, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Title: "ab", Body: "Hello"}) + _, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "ab", Body: "Hello"}) if !errors.Is(err, article.ErrInvalidTitle) { t.Fatalf("err = %v", err) diff --git a/test/api_keys_test.go b/test/api_keys_test.go index 3a78a28..34a5ce0 100644 --- a/test/api_keys_test.go +++ b/test/api_keys_test.go @@ -175,8 +175,8 @@ func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey)) } var ( - apiKeyOwnScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeOwn}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeOwn}}) - apiKeyAllScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeAll}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeAll}}) + apiKeyOwnScope = unlockedGrantSet(role.KeyAPIKeysView+"=own", role.KeyAPIKeysRevoke+"=own") + apiKeyAllScope = unlockedGrantSet(role.KeyAPIKeysView+"=all", role.KeyAPIKeysRevoke+"=all") ) func apiKeyUnitUsers() *mockUserRepo { diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go index eefe612..d958668 100644 --- a/test/article_body_format_test.go +++ b/test/article_body_format_test.go @@ -48,13 +48,13 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) r.Group(func(r chi.Router) { diff --git a/test/article_body_unlimited_http_test.go b/test/article_body_unlimited_http_test.go index 84c6fa9..e23eb12 100644 --- a/test/article_body_unlimited_http_test.go +++ b/test/article_body_unlimited_http_test.go @@ -19,7 +19,8 @@ import ( ) func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux { - getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo()) + routerUsers := newVerifiedUserRepo() + getUserUseCase := useCases.NewGetUserUseCase(routerUsers) renderer := markdown.NewConverter() articleHandler := article.NewHandler( useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), @@ -27,7 +28,6 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() @@ -35,6 +35,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) r.Route("/article", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Group(func(r chi.Router) { r.Use(authtoken.Authenticator) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go index e0a1721..a0f20c0 100644 --- a/test/article_publishing_http_test.go +++ b/test/article_publishing_http_test.go @@ -28,7 +28,6 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() @@ -36,6 +35,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A r.Route("/article", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Get("/", articleHandler.ListArticles) r.Group(func(r chi.Router) { r.Use(authtoken.Authenticator) diff --git a/test/article_test.go b/test/article_test.go index 1721c35..3072147 100644 --- a/test/article_test.go +++ b/test/article_test.go @@ -24,6 +24,7 @@ type mockArticleRepo struct { credits map[uint32][]uint32 authors *mockAuthorRepo reportSlugRace bool + raceCreates int createAlwaysRaces bool afterFindByBody func() updateBodyIDs []uint32 @@ -298,6 +299,10 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) { return article.Article{}, err } if m.createAlwaysRaces { + m.raceCreates++ + if m.raceCreates > 100 { + return article.Article{}, errors.New("create budget exceeded") + } return article.Article{}, article.ErrSlugTaken } if m.slugUsedOn(a.SiteID, a.Slug, 0) { @@ -443,7 +448,7 @@ func TestCreateArticle(t *testing.T) { uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) t.Run("Valid Title", func(t *testing.T) { - cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"} + cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"} res, err := uc.CreateArticle(testCtx, cmd) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -454,7 +459,7 @@ func TestCreateArticle(t *testing.T) { }) t.Run("Short Title", func(t *testing.T) { - cmd := useCases.CreateArticleCommand{Title: "Ab", Body: "Content"} + cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Ab", Body: "Content"} _, err := uc.CreateArticle(testCtx, cmd) if !errors.Is(err, article.ErrInvalidTitle) { t.Errorf("Expected ErrInvalidTitle") @@ -469,14 +474,14 @@ func TestGetOnlineArticle(t *testing.T) { uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) t.Run("Article Online", func(t *testing.T) { - _, err := uc.GetOnlineArticle(1, article.BodyFormatHTML) + _, err := uc.GetArticle(1, article.BodyFormatHTML, useCases.ContentCaller{}) if err != nil { t.Errorf("Expected article to be found") } }) t.Run("Article Offline", func(t *testing.T) { - _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML) + _, err := uc.GetArticle(2, article.BodyFormatHTML, useCases.ContentCaller{}) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("Offline article should return not found") } diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index 404e314..bd59c64 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -31,13 +31,13 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(repo, discardRecorder), useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Use(authtoken.Authenticator) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Route("/article", func(r chi.Router) { @@ -90,7 +90,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) { uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) title := strings.Repeat("\U0001F600", tc.runeCount) - result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) + result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"}) if tc.created { if err != nil { diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go index 98a8029..8ac463f 100644 --- a/test/articles_list_http_test.go +++ b/test/articles_list_http_test.go @@ -25,13 +25,13 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Get("/", articleHandler.ListArticles) }) }) diff --git a/test/articles_pagination_api_http_test.go b/test/articles_pagination_api_http_test.go index ca5b31c..72e71ee 100644 --- a/test/articles_pagination_api_http_test.go +++ b/test/articles_pagination_api_http_test.go @@ -579,7 +579,7 @@ func TestArticlesPaginationUseCase(t *testing.T) { repo := newMockArticleRepo() seedSortArticles(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()) - sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}}) + sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}}) if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 { t.Fatalf("unexpected %v %+v", err, sorted) } @@ -595,7 +595,7 @@ func TestArticlesPaginationUseCase(t *testing.T) { seedPaginationArticles(repo, 3, 0) repo.fail.failNextWith(method, sentinel) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()) - _, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25}) + _, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25}) if !errors.Is(err, sentinel) { t.Fatalf("%s: expected sentinel, got %v", method, err) } diff --git a/test/custom_content_types_support_http_test.go b/test/custom_content_types_support_http_test.go index 2855a88..b6b840d 100644 --- a/test/custom_content_types_support_http_test.go +++ b/test/custom_content_types_support_http_test.go @@ -353,11 +353,10 @@ func TestBuiltinTypeHTTP(t *testing.T) { } }) - t.Run("Reading one built-in type does not collide with a custom type of the same key", func(t *testing.T) { + t.Run("Reading one built-in type does not collide with a custom type", func(t *testing.T) { env := newSupportEnv(t) env.createType(t, "builtin", dishFields()) - env.createType(t, "article", dishFields()) - for _, key := range []string{"builtin", "article"} { + for _, key := range []string{"builtin"} { rec := env.admin(t, http.MethodGet, "/v1/content-type/"+key, "") expectStatusAndBody(t, rec, http.StatusOK, "") if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) { @@ -369,8 +368,8 @@ func TestBuiltinTypeHTTP(t *testing.T) { t.Errorf("expected the built-in article, got %v", item["name"]) } listed := decodeContentArray(t, env.admin(t, http.MethodGet, "/v1/content-type", "")) - if len(listed) != 2 { - t.Errorf("expected 2 custom types, got %d", len(listed)) + if len(listed) != 1 { + t.Errorf("expected 1 custom type, got %d", len(listed)) } }) diff --git a/test/custom_content_types_support_test.go b/test/custom_content_types_support_test.go index 618150c..8950e5a 100644 --- a/test/custom_content_types_support_test.go +++ b/test/custom_content_types_support_test.go @@ -410,3 +410,7 @@ func expectStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status in t.Errorf("expected body %q, got %q", body, rec.Body.String()) } } + +func (e *contentTypeEnv) giveMemberEntryAccess() { + e.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: adminRoleID} +} diff --git a/test/custom_content_types_test.go b/test/custom_content_types_test.go index ff4ad8b..3e3937d 100644 --- a/test/custom_content_types_test.go +++ b/test/custom_content_types_test.go @@ -475,6 +475,7 @@ func TestUniqueEntryValues(t *testing.T) { func TestReadingAndChangingEntries(t *testing.T) { t.Run("Getting an entry", func(t *testing.T) { env := newContentTypeEnv(t) + env.giveMemberEntryAccess() env.createType(t, "recipe", recipeFields()) env.createType(t, "dish", dishFields()) env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`) @@ -509,6 +510,7 @@ func TestReadingAndChangingEntries(t *testing.T) { for _, example := range examples { t.Run(example.method+" "+example.path, func(t *testing.T) { env := newContentTypeEnv(t) + env.giveMemberEntryAccess() env.createType(t, "recipe", recipeFields()) rec := env.as(t, ctMemberId, example.method, example.path, example.body) @@ -554,6 +556,7 @@ func TestReadingAndChangingEntries(t *testing.T) { func TestListingEntries(t *testing.T) { seed := func(t *testing.T) *contentTypeEnv { env := newContentTypeEnv(t) + env.giveMemberEntryAccess() env.createType(t, "recipe", recipeFields()) env.createType(t, "dish", dishFields()) for index := 1; index <= 30; index++ { @@ -662,10 +665,9 @@ func TestEntryRouteAccess(t *testing.T) { t.Run("Built-in resources are not reachable through the generic engine", func(t *testing.T) { env := newContentTypeEnv(t) - env.createType(t, "article", dishFields()) rec := env.admin(t, http.MethodGet, "/v1/content-type/article/entry", "") - expectStatusAndBody(t, rec, http.StatusOK, `{"items":[],"total":0,"page":1,"size":25}`) + expectStatusAndBody(t, rec, http.StatusNotFound, apperr.ContentTypeNotFoundErrorMessage) }) } diff --git a/test/custom_content_types_usecases_test.go b/test/custom_content_types_usecases_test.go index f09b254..1e89e18 100644 --- a/test/custom_content_types_usecases_test.go +++ b/test/custom_content_types_usecases_test.go @@ -66,7 +66,7 @@ func TestCreateEntryUseCase(t *testing.T) { fixture, _ := newContentTypeFixture(t) create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now) - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data, UserID: 2}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data, Caller: ownerCaller(2)}) if !errors.Is(err, entry.ErrInvalidEntry) || fixture.entries.createCalls != 0 { t.Errorf("data %v: err %v, creates %d", data, err, fixture.entries.createCalls) @@ -78,11 +78,11 @@ func TestCreateEntryUseCase(t *testing.T) { fixture, _ := newContentTypeFixture(t) create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now) data := func() map[string]any { return map[string]any{"title": "Pad thai", "difficulty": "easy"} } - if _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2}); err != nil { + if _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), Caller: ownerCaller(2)}); err != nil { t.Fatalf("first create: %v", err) } - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), Caller: ownerCaller(2)}) if !errors.Is(err, entry.ErrUniqueViolation) || fixture.entries.createCalls != 1 { t.Errorf("err %v, creates %d", err, fixture.entries.createCalls) @@ -93,7 +93,7 @@ func TestCreateEntryUseCase(t *testing.T) { fixture, _ := newContentTypeFixture(t) create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now) - created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: map[string]any{"title": "Pad thai", "difficulty": "easy"}, UserID: 2}) + created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: map[string]any{"title": "Pad thai", "difficulty": "easy"}, Caller: ownerCaller(2)}) if err != nil || !created.CreatedAt.Equal(fixture.now()) || !created.UpdatedAt.Equal(fixture.now()) || created.CreatedBy != 2 { t.Errorf("unexpected %+v %v", created, err) @@ -564,7 +564,7 @@ func TestEntryPagingAndEquality(t *testing.T) { {1, 25, entry.Sort{}, entry.ErrInvalidSort}, } for _, c := range cases { - _, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "recipe", Page: c.page, Size: c.size, Sort: c.sort}) + _, err := list.ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "recipe", Page: c.page, Size: c.size, Sort: c.sort}) if !errors.Is(err, c.err) { t.Errorf("page %d size %d sort %v: %v", c.page, c.size, c.sort, err) } diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go index 31b13d7..27e761c 100644 --- a/test/edit_article_body_http_test.go +++ b/test/edit_article_body_http_test.go @@ -29,7 +29,6 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() @@ -37,6 +36,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut r.Route("/article", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Group(func(r chi.Router) { r.Use(authtoken.Authenticator) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) diff --git a/test/media_field_type_test.go b/test/media_field_type_test.go index 0aa4590..163642f 100644 --- a/test/media_field_type_test.go +++ b/test/media_field_type_test.go @@ -107,7 +107,7 @@ func TestMediaFieldUseCases(t *testing.T) { fixture := newMediaFieldFixture(t, false) create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now) - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(999)}, UserID: 2}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(999)}, Caller: ownerCaller(2)}) expectFieldError(t, err, "thumbnail", contenttype.RuleType) if fixture.entries.createCalls != 0 { @@ -119,7 +119,7 @@ func TestMediaFieldUseCases(t *testing.T) { fixture := newMediaFieldFixture(t, false) create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now) - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"thumbnail": float64(999)}, UserID: 2}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"thumbnail": float64(999)}, Caller: ownerCaller(2)}) expectFieldError(t, err, "title", entry.RuleRequired) }) @@ -129,7 +129,7 @@ func TestMediaFieldUseCases(t *testing.T) { fixture.images.fail.failNext("Get") create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now) - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(7)}, UserID: 2}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(7)}, Caller: ownerCaller(2)}) if !errors.Is(err, errRepositoryFailure) || fixture.entries.createCalls != 0 { t.Errorf("err %v", err) @@ -157,7 +157,7 @@ func TestMediaFieldUseCases(t *testing.T) { fixture.entries.createCalls = 0 update := useCases.NewUpdateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now) - _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "thumbnail": example.value}, UserID: 3}) + _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "thumbnail": example.value}, Caller: ownerCaller(3)}) if example.rule == "" { if err != nil || fixture.entries.updateCalls != 1 { @@ -194,7 +194,7 @@ func TestMediaFieldUseCases(t *testing.T) { spy := &mfImageSpyRepo{mockImageRepo: fixture.images} list := useCases.NewListEntriesUseCase(fixture.types, fixture.entries, spy) - page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "event", Page: 1, Size: 25, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}}) + page, err := list.ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "event", Page: 1, Size: 25, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}}) if err != nil || spy.gets != 2 || len(page.Items) != 3 { t.Fatalf("err %v lookups %d", err, spy.gets) @@ -217,7 +217,7 @@ func TestMediaFieldUnknownDimensions(t *testing.T) { } get := useCases.NewGetEntryUseCase(fixture.types, fixture.entries, fixture.images) - found, err := get.Get("event", created.ID) + found, err := get.Get("event", created.ID, ownerCaller(1)) if err != nil { t.Fatalf("get: %v", err) diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 199869c..cc0c8e5 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -131,7 +131,6 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) hasher := hashing.NewBcryptHasher() loginHandler := auth.NewLoginHandler( @@ -145,6 +144,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Get("/{id}", articleHandler.GetArticle) }) r.Route("/auth", func(r chi.Router) { diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index df2713b..9663ede 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -47,13 +47,13 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Group(func(r chi.Router) { r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index 848a7d1..4e48083 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -29,13 +29,13 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) r.Group(func(r chi.Router) { diff --git a/test/relation_field_type_http_test.go b/test/relation_field_type_http_test.go index 670978b..9e29568 100644 --- a/test/relation_field_type_http_test.go +++ b/test/relation_field_type_http_test.go @@ -190,7 +190,7 @@ func TestRelationFieldTypeContentTypes(t *testing.T) { env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text"}}) env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text"}}) for range 12 { - env.createEntry(t, "event", ctMemberId, `{"title":"A"}`) + env.createEntry(t, "event", ctAdminId, `{"title":"A"}`) } fields := []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("venue", "Venue", "venue", "one_to_many", example.required)} diff --git a/test/relation_field_type_test.go b/test/relation_field_type_test.go index 0c8584d..58844d1 100644 --- a/test/relation_field_type_test.go +++ b/test/relation_field_type_test.go @@ -92,13 +92,13 @@ func TestRelationFieldUseCases(t *testing.T) { create := useCases.NewCreateEntryUseCase(env.types, env.entries, images, env.now) update := useCases.NewUpdateEntryUseCase(env.types, env.entries, images, env.now) - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(999)}, Caller: ownerCaller(1)}) expectFieldError(t, err, "link", contenttype.RuleType) - stored, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1}) + stored, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, Caller: ownerCaller(1)}) if err != nil || env.entries.createCalls != 1 { t.Fatalf("create: %v", err) } - _, err = update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1}) + _, err = update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "link": float64(999)}, Caller: ownerCaller(1)}) expectFieldError(t, err, "link", contenttype.RuleType) if env.entries.updateCalls != 0 || env.entries.createCalls != 1 { t.Errorf("writes: %d %d", env.entries.updateCalls, env.entries.createCalls) @@ -141,7 +141,7 @@ func TestRelationFieldUseCases(t *testing.T) { env.entries.entries[9] = entry.Entry{ID: 9, ContentTypeID: 2, Data: map[string]any{"title": "A", "link": float64(3)}} env.entries.failGetOn = 2 - if _, err := get.Get("event", 9); !errors.Is(err, errRepositoryFailure) { + if _, err := get.Get("event", 9, ownerCaller(1)); !errors.Is(err, errRepositoryFailure) { t.Errorf("err %v", err) } }) @@ -169,7 +169,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) { now := func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) } images := newMockImageRepo() - created, err := useCases.NewCreateEntryUseCase(types, entries, images, now).Create(useCases.CreateEntryCommand{ContentTypeKey: "plain", Data: map[string]any{"title": "A"}, UserID: 1}) + created, err := useCases.NewCreateEntryUseCase(types, entries, images, now).Create(useCases.CreateEntryCommand{ContentTypeKey: "plain", Data: map[string]any{"title": "A"}, Caller: ownerCaller(1)}) if err != nil || len(created.Relations) != 0 { t.Fatalf("create: %v %v", err, created.Relations) } @@ -179,7 +179,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) { types.getByKeyCalls = 0 entries.getCalls = 0 - if _, err := useCases.NewGetEntryUseCase(types, entries, images).Get("plain", created.ID); err != nil { + if _, err := useCases.NewGetEntryUseCase(types, entries, images).Get("plain", created.ID, ownerCaller(1)); err != nil { t.Fatalf("get: %v", err) } if types.getByKeyCalls != 1 || entries.getCalls != 1 { @@ -188,7 +188,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) { types.getByKeyCalls = 0 entries.getCalls = 0 - updated, err := useCases.NewUpdateEntryUseCase(types, entries, images, now).Update(useCases.UpdateEntryCommand{ContentTypeKey: "plain", ID: created.ID, Data: map[string]any{"title": "B"}, UserID: 1}) + updated, err := useCases.NewUpdateEntryUseCase(types, entries, images, now).Update(useCases.UpdateEntryCommand{ContentTypeKey: "plain", ID: created.ID, Data: map[string]any{"title": "B"}, Caller: ownerCaller(1)}) if err != nil || len(updated.Relations) != 0 { t.Fatalf("update: %v", err) } @@ -198,7 +198,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) { types.getByKeyCalls = 0 entries.getCalls = 0 - if _, err := useCases.NewListEntriesUseCase(types, entries, images).ListPage(useCases.ListEntriesCommand{ContentTypeKey: "plain", Page: 1, Size: 10, Sort: entry.DefaultSort()}); err != nil { + if _, err := useCases.NewListEntriesUseCase(types, entries, images).ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "plain", Page: 1, Size: 10, Sort: entry.DefaultSort()}); err != nil { t.Fatalf("list: %v", err) } if types.getByKeyCalls != 1 || entries.getCalls != 0 { diff --git a/test/relation_field_type_use_case_hardening_test.go b/test/relation_field_type_use_case_hardening_test.go index 29b9c8c..4042764 100644 --- a/test/relation_field_type_use_case_hardening_test.go +++ b/test/relation_field_type_use_case_hardening_test.go @@ -25,7 +25,7 @@ func TestRelationFieldListQueryClampsPaging(t *testing.T) { } for _, example := range cases { t.Run(example.name, func(t *testing.T) { - page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "venue", Page: example.page, Size: example.size, Sort: entry.DefaultSort(), Query: "a"}) + page, err := list.ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "venue", Page: example.page, Size: example.size, Sort: entry.DefaultSort(), Query: "a"}) if err != nil { t.Fatalf("err %v", err) } @@ -43,6 +43,6 @@ func TestRelationFieldTargetWithoutFieldsIsMissing(t *testing.T) { env.types.types[venue.ID] = venue create := useCases.NewCreateEntryUseCase(env.types, env.entries, newMockImageRepo(), func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) }) - _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1}) + _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, Caller: ownerCaller(1)}) expectFieldError(t, err, "link", contenttype.RuleType) } diff --git a/test/role_hardening_test.go b/test/role_hardening_test.go index f057ebf..0e2ef6b 100644 --- a/test/role_hardening_test.go +++ b/test/role_hardening_test.go @@ -197,19 +197,19 @@ func TestValidateGrants(t *testing.T) { }) } -func TestRoleAllowsKindAndOwnerOnly(t *testing.T) { +func TestRoleGrantSetKindAndOwnerOnly(t *testing.T) { t.Run("A toggle grant at scope own does not count", func(t *testing.T) { - if roleWithGrant("schema.view", role.ScopeOwn).Allows("schema.view", true) { + if roleWithGrant("schema.view", role.ScopeOwn).GrantSet().Holds("schema.view") { t.Errorf("expected false") } }) t.Run("Owner-only grants are ignored on a role that is not locked", func(t *testing.T) { - if roleWithGrant("system.apply_updates", role.ScopeAll).Allows("system.apply_updates", true) { + if roleWithGrant("system.apply_updates", role.ScopeAll).GrantSet().Holds("system.apply_updates") { t.Errorf("expected false") } }) t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) { - if !role.BuiltInRoles()[0].Allows("system.apply_updates", false) { + if !role.BuiltInRoles()[0].GrantSet().Holds("system.apply_updates") { t.Errorf("expected true") } }) @@ -247,34 +247,33 @@ func TestParseTypedKey(t *testing.T) { }) } -func TestRoleAllowsFor(t *testing.T) { +func TestGrantSetTypedDecisions(t *testing.T) { t.Run("A per-type override denies despite the wildcard", func(t *testing.T) { - r := role.Role{Grants: []role.Grant{{Key: "content.update", Scope: role.ScopeAll}, {Key: "content.update:blog", Scope: role.ScopeOwn}}} - if r.AllowsFor("content.update", "blog", false) { + set := unlockedGrantSet("content.update=all", "content.update:blog=own") + if set.Permits("content.update", "blog", 1, 2) { t.Errorf("override should deny a non-owned entry") } - if !r.AllowsFor("content.update", "blog", true) { + if !set.Permits("content.update", "blog", 1, 1) { t.Errorf("override should allow an owned entry") } - if !r.AllowsFor("content.update", "news", false) { + if !set.Permits("content.update", "news", 1, 2) { t.Errorf("other types fall back to the wildcard") } }) t.Run("A per-type override grants without a wildcard", func(t *testing.T) { - r := role.Role{Grants: []role.Grant{{Key: "content.read:blog", Scope: role.ScopeAll}}} - if !r.AllowsFor("content.read", "blog", false) || r.AllowsFor("content.read", "news", false) { + set := unlockedGrantSet("content.read:blog=all") + if !set.Permits("content.read", "blog", 1, 2) || set.Permits("content.read", "news", 1, 2) { t.Errorf("override should apply to blog only") } }) - t.Run("An empty type key behaves like Allows", func(t *testing.T) { - r := roleWithGrant("content.read", role.ScopeAll) - if !r.AllowsFor("content.read", "", false) { + t.Run("An empty type key reads the wildcard", func(t *testing.T) { + if !roleWithGrant("content.read", role.ScopeAll).GrantSet().Permits("content.read", "", 1, 2) { t.Errorf("expected true") } }) t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) { - r := role.BuiltInRoles()[0] - if r.AllowsFor("content.read", "Bad Key", true) || r.AllowsFor("schema.view", "blog", true) { + set := role.BuiltInRoles()[0].GrantSet() + if set.Permits("content.read", "Bad Key", 1, 1) || set.Permits("schema.view", "blog", 1, 1) { t.Errorf("expected false") } }) diff --git a/test/roles_content_scope_domain_test.go b/test/roles_content_scope_domain_test.go new file mode 100644 index 0000000..6cfee47 --- /dev/null +++ b/test/roles_content_scope_domain_test.go @@ -0,0 +1,295 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "errors" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "testing" +) + +func TestGrantSetScopeFor(t *testing.T) { + longKey := strings.Repeat("a", 101) + cases := []struct { + grants []string + key string + typeKey string + want role.Scope + }{ + {[]string{"content.update=all"}, "content.update", "", role.ScopeAll}, + {[]string{"content.update=all"}, "content.update", "blog", role.ScopeAll}, + {[]string{"content.update=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn}, + {[]string{"content.update=all", "content.update:blog=own"}, "content.update", "news", role.ScopeAll}, + {[]string{"content.update=all", "content.update:blog=none"}, "content.update", "blog", role.ScopeNone}, + {[]string{"content.update=own", "content.update:blog=all"}, "content.update", "blog", role.ScopeAll}, + {[]string{"content.update:blog=all"}, "content.update", "blog", role.ScopeAll}, + {[]string{"content.update:blog=all"}, "content.update", "news", role.ScopeNone}, + {[]string{"content.update:blog=all"}, "content.update", "", role.ScopeNone}, + {[]string{"content.create=all", "content.create:blog=none"}, "content.create", "blog", role.ScopeNone}, + {[]string{"content.create=all", "content.create:blog=all"}, "content.create", "blog", role.ScopeAll}, + {[]string{"content.read=all"}, "content.read", "Bad Key", role.ScopeNone}, + {[]string{"content.read=all"}, "content.read", longKey, role.ScopeNone}, + {[]string{"schema.view=all"}, "schema.view", "blog", role.ScopeNone}, + {[]string{"schema.view=all"}, "schema.view", "", role.ScopeAll}, + {[]string{"content.explode=all"}, "content.explode", "", role.ScopeNone}, + {nil, "content.read", "", role.ScopeNone}, + } + for _, c := range cases { + name := strings.Join(c.grants, ", ") + " | " + c.key + " | " + c.typeKey + if len(name) > 90 { + name = name[:90] + } + t.Run(name, func(t *testing.T) { + if got := unlockedGrantSet(c.grants...).ScopeFor(c.key, c.typeKey); got != c.want { + t.Errorf("expected %s, got %s", c.want, got) + } + }) + } + + t.Run("a zero GrantSet resolves everything to none", func(t *testing.T) { + var empty role.GrantSet + if empty.ScopeFor("content.read", "") != role.ScopeNone || empty.ScopeFor("content.read", "blog") != role.ScopeNone || empty.Holds("content.read") || empty.HoldsAll("content.read") || empty.HoldsFor("content.read", "blog") { + t.Error("a zero set must hold nothing") + } + }) + + t.Run("HoldsFor is true for own and all of the typed scope only", func(t *testing.T) { + set := unlockedGrantSet("content.update=all", "content.update:blog=own", "content.update:news=none") + if !set.HoldsFor("content.update", "blog") || !set.HoldsFor("content.update", "other") || set.HoldsFor("content.update", "news") { + t.Errorf("unexpected HoldsFor results") + } + }) +} + +func TestGrantSetPermits(t *testing.T) { + cases := []struct { + scope role.Scope + caller uint32 + createdBy uint32 + want bool + }{ + {role.ScopeAll, 9, 21, true}, + {role.ScopeAll, 9, 0, true}, + {role.ScopeOwn, 21, 21, true}, + {role.ScopeOwn, 9, 21, false}, + {role.ScopeOwn, 21, 0, false}, + {role.ScopeOwn, 0, 0, false}, + {role.ScopeNone, 21, 21, false}, + } + for _, c := range cases { + name := string(c.scope) + " caller " + strconv.Itoa(int(c.caller)) + " created_by " + strconv.Itoa(int(c.createdBy)) + t.Run(name, func(t *testing.T) { + set := unlockedGrantSet("content.update:blog=" + string(c.scope)) + if got := set.Permits("content.update", "blog", c.caller, c.createdBy); got != c.want { + t.Errorf("expected %v, got %v", c.want, got) + } + }) + } + + t.Run("a typed scope replaces the wildcard for Permits", func(t *testing.T) { + set := unlockedGrantSet("content.update=all", "content.update:blog=own") + if set.Permits("content.update", "blog", 9, 21) || !set.Permits("content.update", "news", 9, 21) { + t.Errorf("unexpected Permits results") + } + }) +} + +func TestRoleGrantSetNormalisation(t *testing.T) { + duplicates := []struct { + rows []string + key string + typeKey string + want role.Scope + }{ + {[]string{"content.update=own", "content.update=all"}, "content.update", "", role.ScopeOwn}, + {[]string{"content.update=all", "content.update=own"}, "content.update", "", role.ScopeOwn}, + {[]string{"content.update:blog=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn}, + {[]string{"content.update=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn}, + {[]string{"content.read=all", "content.read=none"}, "content.read", "", role.ScopeNone}, + } + for _, c := range duplicates { + t.Run("duplicates resolve to the most restrictive: "+strings.Join(c.rows, ", "), func(t *testing.T) { + forward := unlockedGrantSet(c.rows...) + reversed := slices.Clone(c.rows) + slices.Reverse(reversed) + backward := unlockedGrantSet(reversed...) + if forward.ScopeFor(c.key, c.typeKey) != c.want || backward.ScopeFor(c.key, c.typeKey) != c.want { + t.Errorf("expected %s both ways, got %s and %s", c.want, forward.ScopeFor(c.key, c.typeKey), backward.ScopeFor(c.key, c.typeKey)) + } + }) + } + + t.Run("a toggle key stored at own is none", func(t *testing.T) { + if got := unlockedGrantSet("schema.view=own").ScopeFor("schema.view", ""); got != role.ScopeNone { + t.Errorf("expected none, got %s", got) + } + }) + t.Run("an unknown scope string is none", func(t *testing.T) { + if got := unlockedGrantSet("content.read=everything").ScopeFor("content.read", ""); got != role.ScopeNone { + t.Errorf("expected none, got %s", got) + } + }) + t.Run("a typed key on a non-content key or with an invalid type key is dropped", func(t *testing.T) { + set := unlockedGrantSet("schema.view:blog=all", "content.read:Bad Key=all", "content.read=all") + if set.ScopeFor("schema.view", "blog") != role.ScopeNone || set.ScopeFor("content.read", "Bad Key") != role.ScopeNone || set.ScopeFor("content.read", "x") != role.ScopeAll { + t.Errorf("unexpected scopes") + } + }) +} + +func TestRoleGrantSetOwnerOnlyKeys(t *testing.T) { + ownerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"} + + t.Run("owner-only keys are dropped on a role that is not locked", func(t *testing.T) { + for _, key := range ownerOnly { + set := role.Role{Locked: false, Grants: grantRows(key + "=all")}.GrantSet() + if set.ScopeFor(key, "") != role.ScopeNone || set.Holds(key) || set.HoldsAll(key) { + t.Errorf("%s must not be effective on an unlocked role", key) + } + } + }) + t.Run("owner-only keys are kept on a locked role", func(t *testing.T) { + for _, key := range ownerOnly { + set := role.Role{Locked: true, Grants: grantRows(key + "=all")}.GrantSet() + if set.ScopeFor(key, "") != role.ScopeAll { + t.Errorf("%s must be effective on a locked role", key) + } + } + }) + t.Run("the built-in roles keep their catalogue", func(t *testing.T) { + builtIns := role.BuiltInRoles() + owner, admin := builtIns[0].GrantSet(), builtIns[1].GrantSet() + for _, permission := range role.Catalogue() { + if owner.ScopeFor(permission.Key, "") != role.ScopeAll { + t.Errorf("Owner lacks %s", permission.Key) + } + if permission.OwnerOnly && admin.ScopeFor(permission.Key, "") != role.ScopeNone { + t.Errorf("Admin holds %s", permission.Key) + } + } + }) +} + +func TestValidateGrantsRows(t *testing.T) { + cases := []struct { + rows []string + want error + }{ + {[]string{"content.update=all", "content.update=own"}, role.ErrDuplicateGrant}, + {[]string{"content.update:blog=all", "content.update:blog=own"}, role.ErrDuplicateGrant}, + {[]string{"content.update=all", "content.update:blog=own"}, nil}, + {[]string{"content.update:blog=none"}, nil}, + {[]string{"content.update=none"}, role.ErrInvalidScope}, + {[]string{"content.create:blog=none"}, nil}, + {[]string{"content.create:blog=own"}, role.ErrInvalidScope}, + {[]string{"content.update:Bad Key=all"}, role.ErrUnknownPermission}, + {[]string{"schema.view:blog=all"}, role.ErrUnknownPermission}, + {[]string{"settings.delete_site=all"}, role.ErrOwnerOnlyGrant}, + } + for _, c := range cases { + t.Run(strings.Join(c.rows, ", "), func(t *testing.T) { + err := role.ValidateGrants(false, grantRows(c.rows...)) + if c.want == nil && err != nil || c.want != nil && !errors.Is(err, c.want) { + t.Errorf("expected %v, got %v", c.want, err) + } + }) + } +} + +func TestRoleHasNoParallelDecisionHelpers(t *testing.T) { + roleType := reflect.TypeOf(role.Role{}) + for _, name := range []string{"Allows", "AllowsFor", "scopeOf"} { + if _, present := roleType.MethodByName(name); present { + t.Errorf("Role must not have %s", name) + } + } + for _, source := range roleDomainSources(t) { + if strings.Contains(source.text, "func NewGrantSet") || strings.Contains(source.text, "scopeOf(") { + t.Errorf("%s still holds a parallel implementation", source.path) + } + } +} + +type goSource struct { + path string + text string +} + +func roleDomainSources(t *testing.T) []goSource { + t.Helper() + files, err := filepath.Glob("../internal/domain/role/*.go") + if err != nil || len(files) == 0 { + t.Fatalf("no role domain files: %v", err) + } + sources := []goSource{} + for _, file := range files { + sources = append(sources, goSource{path: file, text: readTestFile(t, file)}) + } + return sources +} + +func TestRoleKeyConstantsMatchTheCatalogue(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "../internal/domain/role/roleKeys.go", nil, 0) + if err != nil { + t.Fatal(err) + } + constants := map[string]string{} + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.ValueSpec) + if !ok { + return true + } + for index, name := range spec.Names { + literal, isLiteral := spec.Values[index].(*ast.BasicLit) + if !isLiteral || !name.IsExported() { + continue + } + value, _ := strconv.Unquote(literal.Value) + constants[name.Name] = value + } + return true + }) + + catalogueKeys := map[string]bool{} + for _, permission := range role.Catalogue() { + catalogueKeys[permission.Key] = true + } + constantValues := map[string]bool{} + for name, value := range constants { + constantValues[value] = true + if !catalogueKeys[value] { + t.Errorf("constant %s = %q is not a catalogue key", name, value) + } + if want := constantNameOf(value); name != want { + t.Errorf("constant for %q is named %s, want %s", value, name, want) + } + } + for key := range catalogueKeys { + if !constantValues[key] { + t.Errorf("catalogue key %q has no constant", key) + } + } + if len(constants) != len(catalogueKeys) { + t.Errorf("expected %d constants, got %d", len(catalogueKeys), len(constants)) + } +} + +func constantNameOf(key string) string { + group, action, _ := strings.Cut(key, ".") + words := append([]string{group}, strings.Split(action, "_")...) + name := "Key" + for _, word := range words { + if word == "apikeys" { + name += "APIKeys" + continue + } + name += strings.ToUpper(word[:1]) + word[1:] + } + return name +} diff --git a/test/roles_content_scope_http_test.go b/test/roles_content_scope_http_test.go new file mode 100644 index 0000000..4bb5421 --- /dev/null +++ b/test/roles_content_scope_http_test.go @@ -0,0 +1,682 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/user" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "testing" +) + +const articleNotFound = apperr.ArticleNotFoundErrorMessage + +func TestContentScopeReadingArticles(t *testing.T) { + t.Run("Reading articles shows what the caller may see", func(t *testing.T) { + rows := []struct { + caller uint32 + id uint32 + want scopeWant + }{ + {csAnonymous, 12, okWant(200)}, + {csAnonymous, 10, notFoundWant(articleNotFound)}, + {csUnverified, 10, notFoundWant(articleNotFound)}, + {csWriter, 10, okWant(200)}, + {csWriter, 14, okWant(200)}, + {csReaderOwn, 16, okWant(200)}, + {csReaderOwn, 12, okWant(200)}, + {csReaderOwn, 10, notFoundWant(articleNotFound)}, + {csReaderOwn, 14, notFoundWant(articleNotFound)}, + {csNoContent, 12, okWant(200)}, + {csNoContent, 10, notFoundWant(articleNotFound)}, + {csTypedB, 10, notFoundWant(articleNotFound)}, + {csTypedB, 12, okWant(200)}, + {csOrphan, 12, okWant(200)}, + {csOrphan, 10, notFoundWant(articleNotFound)}, + } + for _, row := range rows { + t.Run(callerLabel(row.caller)+" GET article "+strconv.Itoa(int(row.id))+" -> "+row.want.label(), func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, row.caller, http.MethodGet, "/v1/article/"+strconv.Itoa(int(row.id)), ""), row.want) + }) + } + }) + + t.Run("Reading an article with an API key follows the creator's role", func(t *testing.T) { + world := newContentScopeWorld(t) + writerKey, _ := world.issueKey(csWriter, "ci") + readerKey, _ := world.issueKey(csReaderOwn, "ci") + assertScopeWant(t, world.withKey(t, http.MethodGet, "/v1/article/10", "", writerKey), okWant(200)) + assertScopeWant(t, world.withKey(t, http.MethodGet, "/v1/article/10", "", readerKey), notFoundWant(articleNotFound)) + }) + + t.Run("Public article routes never answer 403", func(t *testing.T) { + world := newContentScopeWorld(t) + for _, path := range []string{"/v1/article", "/v1/article/12", "/v1/article/12/seo"} { + rec := world.do(t, csNoContent, http.MethodGet, path, "") + if rec.Code != http.StatusOK { + t.Errorf("%s answered %d", path, rec.Code) + } + } + world.roles.fail.failNext("Get") + rec := world.do(t, csWriter, http.MethodGet, "/v1/article/12", "") + if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Internal server error" { + t.Errorf("expected 500 on a role repository failure, got %d %q", rec.Code, rec.Body.String()) + } + world.userRepo.failGet = errors.New("user store down") + rec = world.do(t, csWriter, http.MethodGet, "/v1/article/12", "") + if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Internal server error" { + t.Errorf("expected 500 on a user repository failure, got %d %q", rec.Code, rec.Body.String()) + } + world.userRepo.failGet = user.ErrUserNotFound + rec = world.do(t, csWriter, http.MethodGet, "/v1/article/12", "") + if rec.Code != http.StatusOK { + t.Errorf("a vanished user reads as anonymous, got %d", rec.Code) + } + world.userRepo.failGet = nil + world.resetCounters() + world.do(t, csAnonymous, http.MethodGet, "/v1/article", "") + world.do(t, csAnonymous, http.MethodGet, "/v1/article/12", "") + world.do(t, csAnonymous, http.MethodGet, "/v1/article/12/seo", "") + if world.roleRepo.touched() != 0 { + t.Errorf("an anonymous request must not touch the role repository, got %d calls", world.roleRepo.touched()) + } + }) +} + +func TestContentScopeArticleList(t *testing.T) { + rows := []struct { + caller uint32 + query string + ids []uint32 + total int + all, on, dr int + }{ + {csAnonymous, "", scopeIDs(12, 15), 2, 2, 2, 0}, + {csNoContent, "", scopeIDs(12, 15), 2, 2, 2, 0}, + {csWriter, "", scopeIDs(10, 11, 12, 13, 14, 15, 16, 17), 8, 8, 2, 6}, + {csReaderOwn, "", scopeIDs(12, 15, 16), 3, 3, 2, 1}, + {csReaderOwn, "?status=draft", scopeIDs(16), 1, 3, 2, 1}, + {csReaderOwn, "?status=online", scopeIDs(12, 15), 2, 3, 2, 1}, + {csContributor, "?status=draft", scopeIDs(10, 11, 13, 14, 16, 17), 6, 8, 2, 6}, + } + t.Run("The article list is the visible set", func(t *testing.T) { + for _, row := range rows { + t.Run(callerLabel(row.caller)+" GET /v1/article"+row.query, func(t *testing.T) { + world := newContentScopeWorld(t) + body := decodeScopeList(t, world.do(t, row.caller, http.MethodGet, "/v1/article"+row.query, "")) + if !sameScopeIDs(body.ids(), row.ids) || body.Total != row.total { + t.Errorf("expected ids %v total %d, got %v total %d", row.ids, row.total, body.ids(), body.Total) + } + if body.Counts.All != row.all || body.Counts.Online != row.on || body.Counts.Draft != row.dr { + t.Errorf("expected counts %d/%d/%d, got %+v", row.all, row.on, row.dr, body.Counts) + } + }) + } + }) + + t.Run("Own scope pages over the visible set", func(t *testing.T) { + world := newContentScopeWorld(t) + body := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, "/v1/article?page=2&size=5", "")) + if len(body.Items) != 0 || body.Total != 3 || body.Page != 2 || body.Size != 5 { + t.Errorf("unexpected page %+v", body) + } + first := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, "/v1/article?page=1&size=5&q=Article+16", "")) + if !sameScopeIDs(first.ids(), scopeIDs(16)) || first.Total != 1 { + t.Errorf("expected the search to run over visible articles, got %v total %d", first.ids(), first.Total) + } + }) + + t.Run("Suggestions and the seo filter consider only the visible articles", func(t *testing.T) { + world := newContentScopeWorld(t) + hidden := world.do(t, csReaderOwn, http.MethodGet, "/v1/article?seo=suggestions", "") + visible := decodeScopeList(t, hidden) + for _, id := range visible.ids() { + if id != 12 && id != 15 && id != 16 { + t.Errorf("article %d leaked through the seo filter", id) + } + } + everything := decodeScopeList(t, world.do(t, csWriter, http.MethodGet, "/v1/article", "")) + reader := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, "/v1/article", "")) + if reader.Counts.Suggestions > reader.Counts.All || reader.Counts.Suggestions >= everything.Counts.Suggestions && everything.Counts.Suggestions != 0 { + t.Errorf("suggestions must count visible articles only: reader %d, writer %d", reader.Counts.Suggestions, everything.Counts.Suggestions) + } + if visible.Total != reader.Counts.Suggestions { + t.Errorf("the seo filter total %d must equal counts.suggestions %d", visible.Total, reader.Counts.Suggestions) + } + }) +} + +func TestContentScopeArticleSEO(t *testing.T) { + t.Run("Reading an article's SEO follows the same visibility", func(t *testing.T) { + runScopeRows(t, []scopeRow{ + {csAnonymous, http.MethodGet, "/v1/article/12/seo", "", okWant(200)}, + {csAnonymous, http.MethodGet, "/v1/article/10/seo", "", notFoundWant(articleNotFound)}, + {csReaderOwn, http.MethodGet, "/v1/article/16/seo", "", okWant(200)}, + {csReaderOwn, http.MethodGet, "/v1/article/10/seo", "", notFoundWant(articleNotFound)}, + {csWriter, http.MethodGet, "/v1/article/10/seo", "", okWant(200)}, + }) + }) + + t.Run("The SEO check is a read of a visible article", func(t *testing.T) { + const body = `{"excerpt": "A short summary"}` + runScopeRows(t, []scopeRow{ + {csNoContent, http.MethodPost, "/v1/article/12/seo/check", body, refusedWant("content.read")}, + {csReaderOwn, http.MethodPost, "/v1/article/16/seo/check", body, okWant(200)}, + {csReaderOwn, http.MethodPost, "/v1/article/12/seo/check", body, okWant(200)}, + {csReaderOwn, http.MethodPost, "/v1/article/10/seo/check", body, notFoundWant(articleNotFound)}, + {csWriter, http.MethodPost, "/v1/article/10/seo/check", body, okWant(200)}, + {csWriter, http.MethodPost, "/v1/article/999/seo/check", body, notFoundWant(articleNotFound)}, + }) + }) +} + +func TestContentScopeArticleWrites(t *testing.T) { + t.Run("Creating an article needs content.create for the article type", func(t *testing.T) { + rows := []struct { + caller uint32 + want scopeWant + createdBy uint32 + }{ + {csContributor, okWant(201), csContributor}, + {csReaderOwn, okWant(201), csReaderOwn}, + {csWriter, okWant(201), csWriter}, + {csTypedA, okWant(201), csTypedA}, + {csPublisherOwn, refusedWant("content.create"), 0}, + {csTypedB, refusedWant("content.create"), 0}, + {csNoContent, refusedWant("content.create"), 0}, + } + for _, row := range rows { + t.Run(callerLabel(row.caller)+" -> "+row.want.label(), func(t *testing.T) { + world := newContentScopeWorld(t) + before := len(world.articles.articles) + rec := world.do(t, row.caller, http.MethodPost, "/v1/article", `{"title": "A new draft"}`) + assertScopeWant(t, rec, row.want) + if row.want.key != "" { + if len(world.articles.articles) != before || len(world.recorder.events) != 0 { + t.Errorf("a refused create must store nothing and record nothing") + } + return + } + var created struct { + ID uint32 `json:"id"` + IsOnline bool `json:"is_online"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if created.IsOnline || world.articles.articles[created.ID].CreatedBy != row.createdBy { + t.Errorf("expected a draft created by %d, got online=%v created_by=%d", row.createdBy, created.IsOnline, world.articles.articles[created.ID].CreatedBy) + } + }) + } + }) + + t.Run("A new article is always a draft whatever the role", func(t *testing.T) { + world := newContentScopeWorld(t) + rec := world.do(t, csOwner, http.MethodPost, "/v1/article", `{"title": "Owner draft", "is_online": true}`) + assertScopeWant(t, rec, okWant(201)) + if strings.Contains(rec.Body.String(), `"is_online":true`) { + t.Errorf("a new article must be a draft: %s", rec.Body.String()) + } + }) + + t.Run("Updating and deleting an article follow the scope of the article", func(t *testing.T) { + runScopeRows(t, []scopeRow{ + {csContributor, http.MethodPatch, "/v1/article/10", csArticlePatch, okWant(200)}, + {csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch, refusedWant("content.update")}, + {csContributor, http.MethodPatch, "/v1/article/14", csArticlePatch, refusedWant("content.update")}, + {csContributor, http.MethodPatch, "/v1/article/999", csArticlePatch, notFoundWant(articleNotFound)}, + {csContributor, http.MethodPatch, "/v1/article/11", `{"nothing": 1}`, scopeWant{status: 400, message: apperr.BodyErrorMessage}}, + {csContributor, http.MethodDelete, "/v1/article/10", "", refusedWant("content.delete")}, + {csContributor, http.MethodDelete, "/v1/article/999", "", refusedWant("content.delete")}, + {csWriter, http.MethodPatch, "/v1/article/13", csArticlePatch, okWant(200)}, + {csWriter, http.MethodDelete, "/v1/article/11", "", refusedWant("content.delete")}, + {csEditor, http.MethodPatch, "/v1/article/13", csArticlePatch, okWant(200)}, + {csEditor, http.MethodDelete, "/v1/article/13", "", refusedWant("content.delete")}, + {csAdmin, http.MethodDelete, "/v1/article/13", "", okWant(204)}, + {csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch, okWant(200)}, + {csPublisherOwn, http.MethodPatch, "/v1/article/17", csArticlePatch, refusedWant("content.update")}, + {csNoContent, http.MethodPatch, "/v1/article/12", csArticlePatch, refusedWant("content.update")}, + }) + }) + + t.Run("A refused update changes nothing and leaves no trace", func(t *testing.T) { + world := newContentScopeWorld(t) + snapshot := world.articles.articles[11] + body := `{"body": "hijacked", "site_id": 2, "author_ids": [7], "slug": "stolen"}` + assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", body), refusedWant("content.update")) + if !sameArticle(world.articles.articles[11], snapshot) { + t.Errorf("article 11 changed: %+v", world.articles.articles[11]) + } + if len(world.recorder.events) != 0 { + t.Errorf("a refused update must record nothing, got %v", world.recorder.actions()) + } + }) + + t.Run("An allowed own update keeps created_by unchanged", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200)) + if world.articles.articles[10].CreatedBy != csContributor { + t.Errorf("created_by changed to %d", world.articles.articles[10].CreatedBy) + } + }) + + t.Run("An own-scope user can delete only their own article", func(t *testing.T) { + world := newContentScopeWorld(t) + world.seedScopedArticle(18, false, csEditor) + assertScopeWant(t, world.do(t, csEditor, http.MethodDelete, "/v1/article/18", ""), okWant(204)) + if _, present := world.articles.articles[18]; present || strings.Join(world.recorder.actions(), ",") != "article.delete" { + t.Errorf("expected article 18 gone and one article.delete event, got %v", world.recorder.actions()) + } + assertScopeWant(t, world.do(t, csEditor, http.MethodDelete, "/v1/article/12", ""), refusedWant("content.delete")) + if _, present := world.articles.articles[12]; !present { + t.Errorf("article 12 must still be stored") + } + }) +} + +func sameArticle(left article.Article, right article.Article) bool { + leftBody, _ := json.Marshal(left) + rightBody, _ := json.Marshal(right) + return string(leftBody) == string(rightBody) +} + +func TestContentScopePublish(t *testing.T) { + t.Run("Publish is its own permission", func(t *testing.T) { + runScopeRows(t, []scopeRow{ + {csWriter, http.MethodPatch, "/v1/article/11/publish", "", refusedWant("content.publish")}, + {csWriter, http.MethodPatch, "/v1/article/12/unpublish", "", refusedWant("content.publish")}, + {csContributor, http.MethodPatch, "/v1/article/10/publish", "", refusedWant("content.publish")}, + {csEditor, http.MethodPatch, "/v1/article/11/publish", "", okWant(200)}, + {csEditor, http.MethodPatch, "/v1/article/12/unpublish", "", okWant(200)}, + {csPublisherOwn, http.MethodPatch, "/v1/article/17/publish", "", okWant(200)}, + {csPublisherOwn, http.MethodPatch, "/v1/article/17/unpublish", "", okWant(200)}, + {csPublisherOwn, http.MethodPatch, "/v1/article/10/publish", "", refusedWant("content.publish")}, + {csPublisherOwn, http.MethodPatch, "/v1/article/999/publish", "", notFoundWant(articleNotFound)}, + {csAdmin, http.MethodPatch, "/v1/article/10/publish", "", okWant(200)}, + {csNoContent, http.MethodPatch, "/v1/article/12/publish", "", refusedWant("content.publish")}, + }) + }) + + t.Run("Publishing records one event and a refusal records none", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csEditor, http.MethodPatch, "/v1/article/11/publish", ""), okWant(200)) + assertScopeWant(t, world.do(t, csEditor, http.MethodPatch, "/v1/article/12/unpublish", ""), okWant(200)) + if got := strings.Join(world.recorder.actions(), ","); got != "article.publish,article.unpublish" { + t.Errorf("unexpected events %q", got) + } + if !world.articles.articles[11].IsOnline || world.articles.articles[12].IsOnline { + t.Errorf("unexpected states") + } + }) + + t.Run("A refused publish leaves no trace", func(t *testing.T) { + world := newContentScopeWorld(t) + snapshot := world.articles.articles[11] + assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/11/publish", ""), refusedWant("content.publish")) + if len(world.recorder.events) != 0 || !sameArticle(snapshot, world.articles.articles[11]) { + t.Errorf("a refused publish must leave no trace") + } + }) + + t.Run("Saving a draft without the publish permission keeps it a draft", func(t *testing.T) { + world := newContentScopeWorld(t) + rec := world.do(t, csWriter, http.MethodPatch, "/v1/article/11", `{"body": "new text"}`) + assertScopeWant(t, rec, okWant(200)) + if !strings.Contains(rec.Body.String(), "new text") || world.articles.articles[11].IsOnline { + t.Errorf("expected the new text and a draft, got %s", rec.Body.String()) + } + }) + + t.Run("Update never changes the status", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/11", `{"body": "x", "is_online": true}`), okWant(200)) + assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/12", csArticlePatch), okWant(200)) + if world.articles.articles[11].IsOnline || !world.articles.articles[12].IsOnline { + t.Errorf("update must not change the status") + } + }) +} + +func TestContentScopeEntries(t *testing.T) { + const entriesPath = "/v1/content-type/events/entry" + + t.Run("Entries follow the read scope", func(t *testing.T) { + runScopeRows(t, []scopeRow{ + {csReaderOwn, http.MethodGet, entriesPath + "/6", "", okWant(200)}, + {csReaderOwn, http.MethodGet, entriesPath + "/1", "", notFoundWant(apperr.EntryNotFoundErrorMessage)}, + {csReaderOwn, http.MethodGet, entriesPath + "/3", "", notFoundWant(apperr.EntryNotFoundErrorMessage)}, + {csNoContent, http.MethodGet, entriesPath, "", refusedWant("content.read")}, + {csNoContent, http.MethodGet, entriesPath + "/1", "", refusedWant("content.read")}, + {csNoContent, http.MethodGet, "/v1/content-type/nope/entry", "", refusedWant("content.read")}, + {csContributor, http.MethodGet, "/v1/content-type/nope/entry", "", notFoundWant(apperr.ContentTypeNotFoundErrorMessage)}, + {csAdmin, http.MethodGet, entriesPath + "/2", "", okWant(200)}, + }) + }) + + t.Run("Entry lists show the caller's entries", func(t *testing.T) { + world := newContentScopeWorld(t) + all := decodeScopeList(t, world.do(t, csContributor, http.MethodGet, entriesPath, "")) + if !sameScopeIDs(all.ids(), scopeIDs(1, 2, 3, 4, 6, 7, 8)) || all.Total != 7 { + t.Errorf("expected seven entries, got %v total %d", all.ids(), all.Total) + } + own := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath, "")) + if !sameScopeIDs(own.ids(), scopeIDs(6)) || own.Total != 1 { + t.Errorf("expected entry 6 only, got %v total %d", own.ids(), own.Total) + } + paged := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath+"?page=1&size=5", "")) + if !sameScopeIDs(paged.ids(), scopeIDs(6)) || paged.Total != 1 || paged.Page != 1 || paged.Size != 5 { + t.Errorf("unexpected paged own list %+v", paged) + } + none := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath+"?q=Entry+1", "")) + if len(none.Items) != 0 || none.Total != 0 { + t.Errorf("a search under own must run over own entries only, got %v total %d", none.ids(), none.Total) + } + found := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath+"?q=Entry+6", "")) + if !sameScopeIDs(found.ids(), scopeIDs(6)) || found.Total != 1 { + t.Errorf("expected the own entry to match, got %v total %d", found.ids(), found.Total) + } + }) + + t.Run("Entries follow the create, update and delete scope", func(t *testing.T) { + runScopeRows(t, []scopeRow{ + {csContributor, http.MethodPost, entriesPath, csEntryBody, okWant(201)}, + {csNoContent, http.MethodPost, entriesPath, csEntryBody, refusedWant("content.create")}, + {csContributor, http.MethodPut, entriesPath + "/1", csEntryBody, okWant(200)}, + {csContributor, http.MethodPut, entriesPath + "/2", csEntryBody, refusedWant("content.update")}, + {csContributor, http.MethodPut, entriesPath + "/3", csEntryBody, refusedWant("content.update")}, + {csContributor, http.MethodPut, entriesPath + "/999", csEntryBody, notFoundWant(apperr.EntryNotFoundErrorMessage)}, + {csContributor, http.MethodPut, "/v1/content-type/nope/entry/1", csEntryBody, notFoundWant(apperr.ContentTypeNotFoundErrorMessage)}, + {csContributor, http.MethodPut, entriesPath + "/2", `{"data": {"title": ""}}`, refusedWant("content.update")}, + {csContributor, http.MethodDelete, entriesPath + "/1", "", refusedWant("content.delete")}, + {csWriter, http.MethodPut, entriesPath + "/2", csEntryBody, okWant(200)}, + {csWriter, http.MethodDelete, entriesPath + "/2", "", refusedWant("content.delete")}, + {csEditor, http.MethodDelete, entriesPath + "/8", "", okWant(204)}, + {csEditor, http.MethodDelete, entriesPath + "/2", "", refusedWant("content.delete")}, + {csAdmin, http.MethodDelete, entriesPath + "/2", "", okWant(204)}, + {csOwner, http.MethodPut, "/v1/content-type/venues/entry/5", csEntryBody, okWant(200)}, + }) + }) + + t.Run("Entry writes keep the owner and record the updater", func(t *testing.T) { + world := newContentScopeWorld(t) + created := world.do(t, csContributor, http.MethodPost, entriesPath, csEntryBody) + var body struct { + ID uint32 `json:"id"` + CreatedBy uint32 `json:"created_by"` + } + _ = json.Unmarshal(created.Body.Bytes(), &body) + if body.CreatedBy != csContributor || world.entries.entries[body.ID].CreatedBy != csContributor { + t.Errorf("expected created_by 21, got %+v", body) + } + assertScopeWant(t, world.do(t, csContributor, http.MethodPut, entriesPath+"/1", csEntryBody), okWant(200)) + stored := world.entries.entries[1] + if stored.CreatedBy != csContributor || stored.UpdatedBy != csContributor { + t.Errorf("expected created_by and updated_by 21, got %+v", stored) + } + }) + + t.Run("A refused entry write leaves the entry untouched", func(t *testing.T) { + world := newContentScopeWorld(t) + before := world.entries.entries[2] + assertScopeWant(t, world.do(t, csContributor, http.MethodPut, entriesPath+"/2", csEntryBody), refusedWant("content.update")) + assertScopeWant(t, world.do(t, csContributor, http.MethodDelete, entriesPath+"/1", ""), refusedWant("content.delete")) + if world.entries.updateCalls != 0 || world.entries.entries[2].UpdatedBy != before.UpdatedBy { + t.Errorf("entry 2 must be unchanged") + } + if _, present := world.entries.entries[1]; !present { + t.Errorf("entry 1 must still be stored") + } + }) +} + +func TestContentScopeTypedGrants(t *testing.T) { + t.Run("Typed grants replace the wildcard for their type", func(t *testing.T) { + runScopeRows(t, []scopeRow{ + {csTypedA, http.MethodPatch, "/v1/article/10", csArticlePatch, refusedWant("content.update")}, + {csTypedA, http.MethodPut, "/v1/content-type/events/entry/4", csEntryBody, okWant(200)}, + {csTypedA, http.MethodPut, "/v1/content-type/events/entry/2", csEntryBody, refusedWant("content.update")}, + {csTypedA, http.MethodPut, "/v1/content-type/venues/entry/5", csEntryBody, okWant(200)}, + {csTypedB, http.MethodGet, "/v1/content-type/events/entry", "", okWant(200)}, + {csTypedB, http.MethodGet, "/v1/content-type/venues/entry", "", refusedWant("content.read")}, + {csTypedB, http.MethodPost, "/v1/content-type/events/entry", csEntryBody, okWant(201)}, + {csTypedB, http.MethodPost, "/v1/content-type/venues/entry", csEntryBody, refusedWant("content.create")}, + {csTypedB, http.MethodPut, "/v1/content-type/events/entry/7", csEntryBody, okWant(200)}, + {csTypedB, http.MethodPut, "/v1/content-type/events/entry/2", csEntryBody, refusedWant("content.update")}, + {csTypedB, http.MethodGet, "/v1/article/10", "", notFoundWant(articleNotFound)}, + {csTypedB, http.MethodGet, "/v1/article/12", "", okWant(200)}, + {csTypedB, http.MethodPost, "/v1/article", `{"title": "A new draft"}`, refusedWant("content.create")}, + }) + }) + + t.Run("An unknown type key falls under the wildcard or is refused", func(t *testing.T) { + world := newContentScopeWorld(t) + world.resetCounters() + assertScopeWant(t, world.do(t, csTypedB, http.MethodGet, "/v1/content-type/venues/entry", ""), refusedWant("content.read")) + assertScopeWant(t, world.do(t, csTypedB, http.MethodGet, "/v1/content-type/nope/entry", ""), refusedWant("content.read")) + if world.typeRepo.lookups != 0 { + t.Errorf("a refused read must not look the content type up, got %d lookups", world.typeRepo.lookups) + } + assertScopeWant(t, world.do(t, csContributor, http.MethodGet, "/v1/content-type/nope/entry", ""), notFoundWant(apperr.ContentTypeNotFoundErrorMessage)) + }) +} + +func TestContentScopeContentTypeKeys(t *testing.T) { + t.Run("Creating a content type with a built-in key is refused", func(t *testing.T) { + for _, key := range []string{"article", "author", "site", "media", "user"} { + t.Run(key, func(t *testing.T) { + world := newContentScopeWorld(t) + before := len(world.typeStore.types) + body := `{"key": "` + key + `", "name": "Taken", "fields": [{"key": "title", "label": "Title", "type": "text"}]}` + assertScopeWant(t, world.do(t, csAdmin, http.MethodPost, "/v1/content-type", body), scopeWant{status: 409, message: apperr.ContentTypeKeyTakenErrorMessage}) + if len(world.typeStore.types) != before { + t.Errorf("nothing may be stored") + } + }) + } + }) + + t.Run("A custom key is still created", func(t *testing.T) { + world := newContentScopeWorld(t) + body := `{"key": "podcasts", "name": "Podcasts", "fields": [{"key": "title", "label": "Title", "type": "text"}]}` + assertScopeWant(t, world.do(t, csAdmin, http.MethodPost, "/v1/content-type", body), okWant(201)) + }) +} + +func TestContentScopeRoles(t *testing.T) { + t.Run("Admin and Owner pass every content check", func(t *testing.T) { + for _, caller := range []uint32{csAdmin, csOwner} { + world := newContentScopeWorld(t) + list := decodeScopeList(t, world.do(t, caller, http.MethodGet, "/v1/article", "")) + if list.Counts.All != 8 || list.Counts.Online != 2 || list.Counts.Draft != 6 { + t.Errorf("caller %d: unexpected counts %+v", caller, list.Counts) + } + assertScopeWant(t, world.do(t, caller, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200)) + assertScopeWant(t, world.do(t, caller, http.MethodPatch, "/v1/article/11/publish", ""), okWant(200)) + assertScopeWant(t, world.do(t, caller, http.MethodPut, "/v1/content-type/events/entry/2", csEntryBody), okWant(200)) + assertScopeWant(t, world.do(t, caller, http.MethodDelete, "/v1/content-type/events/entry/3", ""), okWant(204)) + assertScopeWant(t, world.do(t, caller, http.MethodDelete, "/v1/article/14", ""), okWant(204)) + } + }) + + t.Run("Writer is effective on content", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/12", csArticlePatch), okWant(200)) + if !world.articles.articles[12].IsOnline { + t.Errorf("editing an online article must keep it online") + } + assertScopeWant(t, world.do(t, csWriter, http.MethodPost, "/v1/article", `{"title": "Writer draft"}`), okWant(201)) + assertScopeWant(t, world.do(t, csWriter, http.MethodPost, "/v1/content-type/events/entry", csEntryBody), okWant(201)) + assertScopeWant(t, world.do(t, csWriter, http.MethodDelete, "/v1/article/10", ""), refusedWant("content.delete")) + assertScopeWant(t, world.do(t, csWriter, http.MethodDelete, "/v1/content-type/events/entry/1", ""), refusedWant("content.delete")) + assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/10/publish", ""), refusedWant("content.publish")) + }) + + t.Run("A session follows the role live for content", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch), refusedWant("content.update")) + world.setGrants(csContributor, "content.read=all", "content.update=all") + assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch), okWant(200)) + world.setGrants(csContributor, "content.read=all") + assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch), refusedWant("content.update")) + }) + + t.Run("An API key follows its creator's role and owns what its creator owns", func(t *testing.T) { + world := newContentScopeWorld(t) + raw, _ := world.issueKey(csContributor, "ci") + created := world.withKey(t, http.MethodPost, "/v1/article", `{"title": "Key draft"}`, raw) + assertScopeWant(t, created, okWant(201)) + var body struct { + ID uint32 `json:"id"` + } + _ = json.Unmarshal(created.Body.Bytes(), &body) + if world.articles.articles[body.ID].CreatedBy != csContributor { + t.Errorf("expected created_by 21, got %d", world.articles.articles[body.ID].CreatedBy) + } + assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(body.ID)), csArticlePatch, raw), okWant(200)) + assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/11", csArticlePatch, raw), refusedWant("content.update")) + assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/11/publish", "", raw), refusedWant("content.publish")) + changed := world.users.users[csContributor] + changed.RoleID = world.roleIDs[csEditor] + world.users.users[csContributor] = changed + assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/11/publish", "", raw), okWant(200)) + }) + + t.Run("A user whose role cannot be resolved is refused on gated content routes", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csOrphan, http.MethodPost, "/v1/article", `{"title": "A new draft"}`), refusedWant("content.create")) + assertScopeWant(t, world.do(t, csOrphan, http.MethodPatch, "/v1/article/10", csArticlePatch), refusedWant("content.update")) + assertScopeWant(t, world.do(t, csOrphan, http.MethodGet, "/v1/content-type/events/entry", ""), refusedWant("content.read")) + world.roles.fail.failNext("Get") + rec := world.do(t, csContributor, http.MethodPatch, "/v1/article/10", csArticlePatch) + if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Internal server error" { + t.Errorf("expected 500, got %d %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("Verification and token checks still come first", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csUnverified, http.MethodPatch, "/v1/article/10", csArticlePatch), scopeWant{status: 403, message: apperr.VerificationRequiredErrorMessage}) + stale := world.users.users[csWriter] + stale.TokenVersion = 4 + world.users.users[csWriter] = stale + for _, route := range [][3]string{{http.MethodPatch, "/v1/article/10", csArticlePatch}, {http.MethodDelete, "/v1/article/10", ""}, {http.MethodPost, "/v1/article", `{"title": "A new draft"}`}, {http.MethodGet, "/v1/content-type/events/entry", ""}, {http.MethodPut, "/v1/content-type/events/entry/1", csEntryBody}} { + assertScopeWant(t, world.do(t, csWriter, route[0], route[1], route[2]), scopeWant{status: 401, message: apperr.UnauthorizedErrorMessage}) + assertScopeWant(t, world.do(t, csAnonymous, route[0], route[1], route[2]), scopeWant{status: 401, message: apperr.UnauthorizedErrorMessage}) + } + }) +} + +func TestContentScopeRequestLoads(t *testing.T) { + routes := [][3]string{ + {http.MethodPatch, "/v1/article/11", csArticlePatch}, + {http.MethodGet, "/v1/content-type/events/entry", ""}, + {http.MethodGet, "/v1/article/12", ""}, + } + for _, route := range routes { + t.Run("A session request loads the user and the role once: "+route[0]+" "+route[1], func(t *testing.T) { + world := newContentScopeWorld(t) + world.resetCounters() + rec := world.do(t, csWriter, route[0], route[1], route[2]) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 { + t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN) + } + }) + t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) { + world := newContentScopeWorld(t) + raw, _ := world.issueKey(csWriter, "ci") + world.resetCounters() + rec := world.withKey(t, route[0], route[1], route[2], raw) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 { + t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN) + } + }) + } + + t.Run("A legacy owner is resolved by one GetByName and no ListGrants", func(t *testing.T) { + world := newContentScopeWorld(t) + legacy := world.users.users[csOwner] + legacy.RoleID = 0 + world.users.users[csOwner] = legacy + world.resetCounters() + assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200)) + if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 { + t.Errorf("unexpected role loads %+v", world.roleRepo) + } + }) + + t.Run("GET /auth/me does not call ListGrants", func(t *testing.T) { + world := newContentScopeWorld(t) + world.resetCounters() + rec, _ := world.me(t, csWriter) + if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 { + t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN) + } + }) +} + +func TestContentScopeOwnerOnlyAndMe(t *testing.T) { + t.Run("Owner-only keys are effective only on a locked role", func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPost, "/v1/system/update/check", ""), refusedWant("system.apply_updates")) + _, me := world.me(t, csHandPlaced) + for _, key := range permissionKeys(me.Access.Permissions) { + if key == "system.apply_updates" { + t.Errorf("/auth/me must not list the ineffective key") + } + } + _, owner := world.me(t, csOwner) + if len(owner.Access.Permissions) != 34 { + t.Errorf("the Owner must hold all 34 keys, got %d", len(owner.Access.Permissions)) + } + }) + + t.Run("The other owner-only keys are refused on an unlocked role too", func(t *testing.T) { + world := newContentScopeWorld(t) + world.setGrants(csHandPlaced, "settings.delete_site=all", "roles.edit_owner_only=all", "system.edit_retention=all", "system.transfer_ownership=all") + assertScopeWant(t, world.do(t, csHandPlaced, http.MethodDelete, "/v1/site/2", ""), refusedWant("settings.delete_site")) + assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPatch, "/v1/settings", `{}`), refusedWant("system.edit_retention")) + }) + + t.Run("/auth/me lists the effective grants", func(t *testing.T) { + world := newContentScopeWorld(t) + world.setGrants(csHandPlaced, "content.update=own", "content.update=all", "settings.delete_site=all", "content.update:blog=all") + _, me := world.me(t, csHandPlaced) + got := map[string]string{} + for _, grant := range me.Access.Permissions { + got[grant.Key] = string(grant.Scope) + } + if got["content.update"] != "own" || len(got) != 1 { + t.Errorf("expected only content.update at own, got %v", got) + } + }) +} + +func TestContentScopeMedia(t *testing.T) { + t.Run("Media routes keep their scope behaviour", func(t *testing.T) { + world := newContentScopeWorld(t) + world.setGrants(csHandPlaced, "media.delete=own", "media.view=all", "media.upload=all") + assertScopeWant(t, world.do(t, csWriter, http.MethodDelete, "/v1/media/1", ""), refusedWant("media.delete")) + assertScopeWant(t, world.do(t, csHandPlaced, http.MethodDelete, "/v1/media/1", ""), refusedWant("media.delete")) + assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPost, "/v1/media/merge", `{}`), refusedWant("media.delete")) + assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPost, "/v1/media/purge", ""), refusedWant("media.delete")) + world.setGrants(csHandPlaced, "media.delete=all") + if rec := world.do(t, csHandPlaced, http.MethodDelete, "/v1/media/1", ""); rec.Code == http.StatusForbidden { + t.Errorf("media.delete=all must pass the gate, got %d", rec.Code) + } + if rec := world.do(t, csWriter, http.MethodGet, "/v1/media/1/usage", ""); rec.Code == http.StatusForbidden { + t.Errorf("Writer must pass the usage gate, got %d", rec.Code) + } + if rec := world.do(t, csWriter, http.MethodPost, "/v1/media", ""); rec.Code == http.StatusForbidden { + t.Errorf("Writer must pass the upload gate, got %d", rec.Code) + } + }) +} diff --git a/test/roles_content_scope_hygiene_test.go b/test/roles_content_scope_hygiene_test.go new file mode 100644 index 0000000..20009b7 --- /dev/null +++ b/test/roles_content_scope_hygiene_test.go @@ -0,0 +1,90 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +func nonTestGoFiles(t *testing.T, roots ...string) []string { + t.Helper() + files := []string{} + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } + if len(files) == 0 { + t.Fatalf("no Go files under %v", roots) + } + return files +} + +func parsedGoFile(t *testing.T, path string) *ast.File { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("cannot parse %s: %v", path, err) + } + return file +} + +func isCreatedBySelector(expression ast.Expr) bool { + selector, ok := expression.(*ast.SelectorExpr) + return ok && selector.Sel.Name == "CreatedBy" +} + +func TestOwnAndAllHasOneImplementation(t *testing.T) { + t.Run("No use case, handler or middleware compares CreatedBy for an authorization decision", func(t *testing.T) { + for _, path := range nonTestGoFiles(t, "../api", "../internal/infrastructure/useCases") { + ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool { + comparison, ok := node.(*ast.BinaryExpr) + if ok && (comparison.Op == token.EQL || comparison.Op == token.NEQ) && (isCreatedBySelector(comparison.X) || isCreatedBySelector(comparison.Y)) { + t.Errorf("%s compares CreatedBy directly", path) + } + return true + }) + } + }) + + t.Run("A GrantSet is opaque outside the role domain", func(t *testing.T) { + if reflect.TypeOf(role.GrantSet{}).Kind() != reflect.Struct { + t.Errorf("GrantSet must be a struct so no caller can index it") + } + }) + + t.Run("The router and the middleware hold no catalogue key literal", func(t *testing.T) { + keys := map[string]bool{} + for _, permission := range role.Catalogue() { + keys[permission.Key] = true + } + for _, path := range nonTestGoFiles(t, "../api/middleware", "../api/router.go") { + ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + if value, err := strconv.Unquote(literal.Value); err == nil && keys[value] { + t.Errorf("%s holds the literal %q, use the role.Key constant", path, value) + } + return true + }) + } + }) +} diff --git a/test/roles_content_scope_support_test.go b/test/roles_content_scope_support_test.go new file mode 100644 index 0000000..4a4637b --- /dev/null +++ b/test/roles_content_scope_support_test.go @@ -0,0 +1,70 @@ +package test + +import ( + "RenewCMS/api/pkg/access" + "RenewCMS/api/pkg/claims" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "net/http" + "os" + "strings" + "testing" +) + +func grantRows(rows ...string) []role.Grant { + grants := make([]role.Grant, 0, len(rows)) + for _, row := range rows { + key, scope, found := strings.Cut(row, "=") + if !found { + panic("grant row without a scope: " + row) + } + grants = append(grants, role.Grant{Key: key, Scope: role.Scope(scope)}) + } + return grants +} + +func unlockedGrantSet(rows ...string) role.GrantSet { + return role.Role{Grants: grantRows(rows...)}.GrantSet() +} + +func requireMissingKey(t *testing.T, err error, key string) { + t.Helper() + if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != key { + t.Fatalf("expected missing permission %q, got %v", key, err) + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func ownerCaller(userID uint32) useCases.ContentCaller { + return useCases.ContentCaller{UserID: userID, Grants: role.BuiltInRoles()[0].GrantSet()} +} + +func callerWith(userID uint32, rows ...string) useCases.ContentCaller { + return useCases.ContentCaller{UserID: userID, Grants: unlockedGrantSet(rows...)} +} + +func contentAccess(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { + adminGrants := role.BuiltInRoles()[1].GrantSet() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = access.WithCurrentUser(r) + if userID, ok := claims.UserId(r); ok { + account, err := access.LoadUser(getUser, r, userID) + version, hasVersion := claims.TokenVersion(r) + if err == nil && account.IsVerified && (!hasVersion || version == account.TokenVersion) { + r = r.WithContext(access.WithCaller(r.Context(), access.Caller{UserID: userID, Grants: adminGrants})) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/test/roles_content_scope_usecases_test.go b/test/roles_content_scope_usecases_test.go new file mode 100644 index 0000000..a98b7ba --- /dev/null +++ b/test/roles_content_scope_usecases_test.go @@ -0,0 +1,525 @@ +package test + +import ( + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/contenttype" + "RenewCMS/internal/domain/entry" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "context" + "errors" + "testing" + "time" +) + +func TestContentScopeCheckPermissionUseCase(t *testing.T) { + newCheck := func() (*useCases.CheckPermissionUseCase, *contentScopeRoleRepo, *mockRoleRepo) { + roles := newSeededMockRoleRepo() + counting := &contentScopeRoleRepo{Repository: roles} + return useCases.NewCheckPermissionUseCase(counting), counting, roles + } + owner := user.User{ID: 3, RoleID: ownerRoleID} + admin := user.User{ID: 5, RoleID: adminRoleID} + writer := user.User{ID: 9, RoleID: writerRoleID} + + t.Run("RequireFor judges the typed scope", func(t *testing.T) { + check, _, roles := newCheck() + typed, _ := roles.Create(role.Role{Name: "Typed", Version: 1, Grants: grantRows("content.update=all", "content.update:blog=none", "content.read:blog=own")}) + account := user.User{ID: 30, RoleID: typed.ID} + cases := []struct { + name string + account user.User + key string + typeKey string + allowed bool + }{ + {"owner holds update for any type", owner, "content.update", "blog", true}, + {"admin holds publish for article", admin, "content.publish", "article", true}, + {"writer lacks publish", writer, "content.publish", "article", false}, + {"writer holds update for events", writer, "content.update", "events", true}, + {"a none row refuses despite the wildcard", account, "content.update", "blog", false}, + {"the wildcard applies to another type", account, "content.update", "news", true}, + {"a typed own row without a wildcard holds", account, "content.read", "blog", true}, + {"a typed row does not apply to another type", account, "content.read", "news", false}, + {"an invalid type key is refused", owner, "content.read", "Bad Key", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := check.RequireFor(c.account, c.key, c.typeKey) + if c.allowed && err != nil { + t.Fatalf("expected pass, got %v", err) + } + if !c.allowed { + requireMissingKey(t, err, c.key) + } + }) + } + }) + + t.Run("An unresolvable role is refused and a failing repository is not a refusal", func(t *testing.T) { + check, _, roles := newCheck() + _, err := check.RequireFor(user.User{ID: 13, RoleID: 99}, "content.read", "events") + requireMissingKey(t, err, "content.read") + roles.fail.failNext("Get") + _, err = check.RequireFor(owner, "content.read", "events") + if err == nil || errors.Is(err, role.ErrMissingPermission) { + t.Errorf("expected a plain failure, got %v", err) + } + }) + + t.Run("A grant change applies to the next call", func(t *testing.T) { + check, _, roles := newCheck() + if _, err := check.RequireFor(writer, "content.delete", "article"); !errors.Is(err, role.ErrMissingPermission) { + t.Fatalf("expected a refusal first, got %v", err) + } + stored := roles.roles[writerRoleID] + stored.Grants = append(stored.Grants, role.Grant{Key: "content.delete", Scope: role.ScopeOwn}) + roles.roles[writerRoleID] = stored + if _, err := check.RequireFor(writer, "content.delete", "article"); err != nil { + t.Fatalf("expected a pass after the edit, got %v", err) + } + }) + + t.Run("One check loads the role once and never lists grants", func(t *testing.T) { + check, counting, _ := newCheck() + if _, err := check.Require(writer, "content.read"); err != nil { + t.Fatal(err) + } + if counting.gets != 1 || counting.getByNames != 0 || counting.listGrantsN != 0 { + t.Errorf("unexpected loads %+v", counting) + } + counting.gets = 0 + if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil { + t.Fatal(err) + } + if counting.gets != 0 || counting.getByNames != 1 || counting.listGrantsN != 0 { + t.Errorf("unexpected legacy loads %+v", counting) + } + }) + + t.Run("Grants gives an empty set for an unresolvable role and an error for a failure", func(t *testing.T) { + check, _, roles := newCheck() + empty, err := check.Grants(user.User{ID: 13, RoleID: 99}) + if err != nil || empty.Holds("content.read") { + t.Errorf("expected an empty set, got %v %v", empty, err) + } + roles.fail.failNext("Get") + if _, err := check.Grants(owner); err == nil { + t.Errorf("expected the failure") + } + }) +} + +func TestContentScopeGetMyAccessUseCase(t *testing.T) { + t.Run("The effective grants are normalised and read in one load", func(t *testing.T) { + roles := newSeededMockRoleRepo() + counting := &contentScopeRoleRepo{Repository: roles} + custom, _ := roles.Create(role.Role{Name: "Messy", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "settings.delete_site=all", "content.update:blog=all", "schema.view=own")}) + access, err := useCases.NewGetMyAccessUseCase(counting).Get(user.User{ID: 30, RoleID: custom.ID}) + if err != nil { + t.Fatal(err) + } + if len(access.Permissions) != 1 || access.Permissions[0].Key != "content.update" || access.Permissions[0].Scope != role.ScopeOwn { + t.Errorf("unexpected permissions %+v", access.Permissions) + } + if counting.gets != 1 || counting.listGrantsN != 0 { + t.Errorf("unexpected loads %+v", counting) + } + }) + + t.Run("The Owner holds the 34 keys", func(t *testing.T) { + access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()).Get(user.User{ID: 3, RoleID: ownerRoleID}) + if err != nil || len(access.Permissions) != 34 { + t.Errorf("expected 34 permissions, got %d (%v)", len(access.Permissions), err) + } + }) +} + +type articleCase struct { + name string + caller useCases.ContentCaller + id uint32 + err error + missing string + forbidden bool +} + +func seededScopeArticles() *mockArticleRepo { + repo := newMockArticleRepo() + for _, seed := range contentScopeArticles { + repo.articles[seed.id] = article.Article{ID: seed.id, Title: "Article", Body: "x", IsOnline: seed.online, SiteID: 1, CreatedBy: seed.createdBy} + } + return repo +} + +func TestContentScopeArticleUseCases(t *testing.T) { + newRepos := func() (*mockArticleRepo, *mockSiteRepo, *mockAuthorRepo, *mockImageRepo) { + repo := seededScopeArticles() + return repo, plainSites(), newMockAuthorRepo(repo), newMockImageRepo() + } + + t.Run("GetArticle shows online articles and the articles the caller may read", func(t *testing.T) { + repo, _, _, _ := newRepos() + get := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + cases := []struct { + name string + caller useCases.ContentCaller + id uint32 + found bool + }{ + {"anonymous reads an online article", useCases.ContentCaller{}, 12, true}, + {"anonymous cannot read a draft", useCases.ContentCaller{}, 10, false}, + {"read all reads any draft", callerWith(9, "content.read=all"), 10, true}, + {"read own reads an own draft", callerWith(22, "content.read=own"), 16, true}, + {"read own cannot read a foreign draft", callerWith(22, "content.read=own"), 10, false}, + {"read own cannot read an unattributed draft", callerWith(22, "content.read=own"), 14, false}, + {"a typed read on another type does not show drafts", callerWith(26, "content.read:events=all"), 10, false}, + {"a typed none row hides drafts despite the wildcard", callerWith(9, "content.read=all", "content.read:article=none"), 10, false}, + {"caller 0 owns nothing", callerWith(0, "content.read=own"), 14, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := get.GetArticle(c.id, article.BodyFormatHTML, c.caller) + if c.found && err != nil || !c.found && !errors.Is(err, article.ErrArticleNotFound) { + t.Errorf("found=%v, got %v", c.found, err) + } + }) + } + }) + + t.Run("ListArticlesPage returns the visible set with matching counts", func(t *testing.T) { + repo, sites, _, _ := newRepos() + list := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, sites) + cases := []struct { + name string + caller useCases.ContentCaller + status string + ids []uint32 + counts article.Counts + }{ + {"anonymous", useCases.ContentCaller{}, "", scopeIDs(12, 15), article.Counts{All: 2, Online: 2}}, + {"read all", callerWith(9, "content.read=all"), "", scopeIDs(10, 11, 12, 13, 14, 15, 16, 17), article.Counts{All: 8, Online: 2, Draft: 6}}, + {"read own", callerWith(22, "content.read=own"), "", scopeIDs(12, 15, 16), article.Counts{All: 3, Online: 2, Draft: 1}}, + {"read own, drafts", callerWith(22, "content.read=own"), "draft", scopeIDs(16), article.Counts{All: 3, Online: 2, Draft: 1}}, + {"read own, online", callerWith(22, "content.read=own"), "online", scopeIDs(12, 15), article.Counts{All: 3, Online: 2, Draft: 1}}, + {"typed none", callerWith(9, "content.read=all", "content.read:article=none"), "", scopeIDs(12, 15), article.Counts{All: 2, Online: 2}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + page, err := list.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: c.caller, Page: 1, Size: 25, Status: c.status}) + if err != nil { + t.Fatal(err) + } + got := []uint32{} + for _, item := range page.Items { + got = append(got, item.ID) + } + sortIDs(got) + page.Counts.Suggestions = 0 + if !sameScopeIDs(got, c.ids) || page.Total != len(c.ids) || page.Counts != c.counts { + t.Errorf("expected %v %+v, got %v total %d %+v", c.ids, c.counts, got, page.Total, page.Counts) + } + }) + } + }) + + t.Run("Update, publish and delete decide by the scope of the stored article", func(t *testing.T) { + cases := []struct { + name string + call func(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error + key string + caller useCases.ContentCaller + id uint32 + allowed bool + }{ + {"update own", updateCall, "content.update", callerWith(21, "content.update=own"), 10, true}, + {"update foreign", updateCall, "content.update", callerWith(21, "content.update=own"), 11, false}, + {"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own"), 14, false}, + {"update all", updateCall, "content.update", callerWith(9, "content.update=all"), 14, true}, + {"update refused by a typed none row", updateCall, "content.update", callerWith(25, "content.update=all", "content.update:article=none"), 10, false}, + {"publish own", publishCall, "content.publish", callerWith(24, "content.publish=own"), 17, true}, + {"publish foreign", publishCall, "content.publish", callerWith(24, "content.publish=own"), 10, false}, + {"publish is not implied by update", publishCall, "content.publish", callerWith(9, "content.update=all"), 10, false}, + {"delete own", deleteCall, "content.delete", callerWith(23, "content.delete=own"), 13 + 100, true}, + {"delete foreign", deleteCall, "content.delete", callerWith(23, "content.delete=own"), 13, false}, + {"delete is not implied by update", deleteCall, "content.delete", callerWith(9, "content.update=all"), 11, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + repo, sites, authors, images := newRepos() + repo.articles[113] = article.Article{ID: 113, Title: "Owned", CreatedBy: 23, SiteID: 1} + before := len(repo.articles) + snapshot := repo.articles[c.id] + err := c.call(repo, sites, authors, images, c.caller, c.id) + if c.allowed { + if err != nil { + t.Fatalf("expected success, got %v", err) + } + return + } + requireMissingKey(t, err, c.key) + if len(repo.articles) != before || !sameArticle(repo.articles[c.id], snapshot) { + t.Errorf("a refused call must change nothing") + } + }) + } + }) + + t.Run("A refused update performs no site, author or image lookup and records nothing", func(t *testing.T) { + repo, sites, authors, images := newRepos() + sites.fail.failNext("Get") + authors.fail.failNext("Get") + images.fail.failNext("Get") + recorder := &contentScopeRecorder{} + update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, recorder, sites, authors, images) + missingSite := uint32(5) + _, err := update.Update(testCtx, 11, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{SiteID: &missingSite, AuthorIDs: &[]uint32{7}}) + requireMissingKey(t, err, "content.update") + if sites.fail.method != "Get" || authors.fail.method != "Get" || len(recorder.events) != 0 { + t.Errorf("a refused update must not look anything up or record an event") + } + }) + + t.Run("An unknown id is not found even under own scope", func(t *testing.T) { + repo, sites, authors, images := newRepos() + update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images) + body := "x" + _, err := update.Update(testCtx, 999, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{Body: &body}) + if !errors.Is(err, article.ErrArticleNotFound) { + t.Errorf("expected not found, got %v", err) + } + }) + + t.Run("CreateArticle needs content.create for the article type and stores the caller", func(t *testing.T) { + repo, sites, authors, _ := newRepos() + create := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors) + created, err := create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(25, "content.create=all", "content.update:article=none")}) + if err != nil || created.CreatedBy != 25 || created.IsOnline { + t.Fatalf("expected a draft created by 25, got %+v %v", created, err) + } + before := len(repo.articles) + _, err = create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(26, "content.create:events=all")}) + requireMissingKey(t, err, "content.create") + if len(repo.articles) != before || repo.createCalled && before != len(repo.articles) { + t.Errorf("nothing may be stored") + } + }) + + t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) { + repo, sites, _, images := newRepos() + check := useCases.NewCheckArticleSEOUseCase(repo, sites, images) + resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images) + reader := callerWith(22, "content.read=own") + if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil { + t.Errorf("own draft: %v", err) + } + if _, err := check.Check(10, reader, useCases.CheckArticleSEOCommand{}); !errors.Is(err, article.ErrArticleNotFound) { + t.Errorf("foreign draft: %v", err) + } + _, err := check.Check(12, callerWith(27, "schema.view=all"), useCases.CheckArticleSEOCommand{}) + requireMissingKey(t, err, "content.read") + if _, err := resolve.Resolve(10, useCases.ContentCaller{}); !errors.Is(err, article.ErrArticleNotFound) { + t.Errorf("anonymous draft: %v", err) + } + if _, err := resolve.Resolve(10, reader); !errors.Is(err, article.ErrArticleNotFound) { + t.Errorf("foreign draft: %v", err) + } + if _, err := resolve.Resolve(16, reader); err != nil { + t.Errorf("own draft: %v", err) + } + }) +} + +func sortIDs(ids []uint32) { + for i := range ids { + for j := i + 1; j < len(ids); j++ { + if ids[j] < ids[i] { + ids[i], ids[j] = ids[j], ids[i] + } + } + } +} + +func updateCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error { + body := "new" + _, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).Update(testCtx, id, caller, useCases.UpdateArticleCommand{Body: &body}) + return err +} + +func publishCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error { + _, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).UpdateIsOnline(testCtx, id, caller, true, article.BodyFormatHTML) + return err +} + +func deleteCall(repo *mockArticleRepo, _ *mockSiteRepo, _ *mockAuthorRepo, _ *mockImageRepo, caller useCases.ContentCaller, id uint32) error { + return useCases.NewDeleteArticleUseCase(repo, discardRecorder).DeleteArticle(testCtx, id, caller) +} + +func TestContentScopeEntryUseCases(t *testing.T) { + type entryWorld struct { + types *mockContentTypeRepo + entries *mockEntryRepo + lookups *contentScopeTypeRepo + } + newWorld := func() entryWorld { + world := newContentScopeWorld(t) + return entryWorld{types: world.typeStore, entries: world.entries, lookups: world.typeRepo} + } + clock := func() time.Time { return time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) } + data := map[string]any{"title": "Updated"} + + t.Run("Get shows only the entries the caller may read", func(t *testing.T) { + w := newWorld() + get := useCases.NewGetEntryUseCase(w.lookups, w.entries, newMockImageRepo()) + if _, err := get.Get("events", 6, callerWith(22, "content.read=own")); err != nil { + t.Errorf("own entry: %v", err) + } + for _, id := range []uint32{1, 3} { + if _, err := get.Get("events", id, callerWith(22, "content.read=own")); !errors.Is(err, entry.ErrEntryNotFound) { + t.Errorf("entry %d: expected not found, got %v", id, err) + } + } + w.lookups.lookups = 0 + _, err := get.Get("events", 1, callerWith(27, "schema.view=all")) + requireMissingKey(t, err, "content.read") + if w.lookups.lookups != 0 { + t.Errorf("a refused read must not look the content type up") + } + }) + + t.Run("ListPage filters by scope before totals, search and paging", func(t *testing.T) { + w := newWorld() + list := useCases.NewListEntriesUseCase(w.lookups, w.entries, newMockImageRepo()) + sortByID := entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc} + own, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "events", Page: 1, Size: 5, Sort: sortByID, Caller: callerWith(22, "content.read=own")}) + if err != nil || own.Total != 1 || len(own.Items) != 1 || own.Items[0].ID != 6 { + t.Fatalf("expected entry 6 only, got %+v %v", own, err) + } + all, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "events", Page: 1, Size: 5, Sort: sortByID, Caller: callerWith(21, "content.read=all")}) + if err != nil || all.Total != 7 || len(all.Items) != 5 { + t.Fatalf("expected 7 entries over 5 rows, got %+v %v", all, err) + } + searched, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "events", Page: 1, Size: 5, Sort: sortByID, Query: "Entry 1", Caller: callerWith(22, "content.read=own")}) + if err != nil || searched.Total != 0 { + t.Fatalf("a search under own covers own entries only, got %+v %v", searched, err) + } + w.lookups.lookups = 0 + _, err = list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "nope", Page: 1, Size: 5, Sort: sortByID, Caller: callerWith(26, "content.read:events=all")}) + requireMissingKey(t, err, "content.read") + if w.lookups.lookups != 0 { + t.Errorf("a refused list must not look the content type up") + } + }) + + t.Run("Create stores the caller as creator and needs content.create for the type", func(t *testing.T) { + w := newWorld() + create := useCases.NewCreateEntryUseCase(w.lookups, w.entries, newMockImageRepo(), clock) + created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "events", Data: data, Caller: callerWith(26, "content.create:events=all")}) + if err != nil || created.CreatedBy != 26 || created.UpdatedBy != 26 { + t.Fatalf("expected an entry by 26, got %+v %v", created, err) + } + before := len(w.entries.entries) + _, err = create.Create(useCases.CreateEntryCommand{ContentTypeKey: "venues", Data: data, Caller: callerWith(26, "content.create:events=all")}) + requireMissingKey(t, err, "content.create") + if len(w.entries.entries) != before { + t.Errorf("nothing may be stored") + } + }) + + t.Run("Update checks the scope after the load and before the validation", func(t *testing.T) { + w := newWorld() + update := useCases.NewUpdateEntryUseCase(w.lookups, w.entries, newMockImageRepo(), clock) + contributor := callerWith(21, "content.update=own") + _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "events", ID: 2, Data: map[string]any{"title": ""}, Caller: contributor}) + requireMissingKey(t, err, "content.update") + var fieldErr entry.FieldError + if errors.As(err, &fieldErr) { + t.Errorf("the scope must be refused before the validation") + } + if _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "events", ID: 999, Data: data, Caller: contributor}); !errors.Is(err, entry.ErrEntryNotFound) { + t.Errorf("an unknown id is not found, got %v", err) + } + if _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "nope", ID: 1, Data: data, Caller: contributor}); !errors.Is(err, contenttype.ErrContentTypeNotFound) { + t.Errorf("an unknown type is not found, got %v", err) + } + stored, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "events", ID: 1, Data: data, Caller: contributor}) + if err != nil || stored.CreatedBy != 21 || stored.UpdatedBy != 21 { + t.Errorf("expected an own update keeping the owner, got %+v %v", stored, err) + } + if w.entries.updateCalls != 1 { + t.Errorf("only the allowed update may write, got %d", w.entries.updateCalls) + } + }) + + t.Run("Delete decides by the scope of the stored entry", func(t *testing.T) { + w := newWorld() + remove := useCases.NewDeleteEntryUseCase(w.lookups, w.entries) + editor := callerWith(23, "content.delete=own") + requireMissingKey(t, remove.Delete("events", 2, editor), "content.delete") + if _, present := w.entries.entries[2]; !present { + t.Errorf("entry 2 must be stored") + } + if err := remove.Delete("events", 8, editor); err != nil { + t.Errorf("own entry: %v", err) + } + if err := remove.Delete("events", 999, editor); !errors.Is(err, entry.ErrEntryNotFound) { + t.Errorf("unknown entry: %v", err) + } + w.lookups.lookups = 0 + requireMissingKey(t, remove.Delete("events", 8, callerWith(9, "content.update=all")), "content.delete") + if w.lookups.lookups != 0 { + t.Errorf("a refused delete must not look anything up") + } + }) +} + +func TestContentScopeCreateContentTypeReservesBuiltInKeys(t *testing.T) { + for _, key := range []string{"article", "author", "site", "media", "user"} { + t.Run(key, func(t *testing.T) { + types := newMockContentTypeRepo() + create := useCases.NewCreateContentTypeUseCase(types, time.Now) + _, err := create.Create(useCases.CreateContentTypeCommand{Key: key, Name: "Reserved", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}}) + if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 { + t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls) + } + }) + } +} + +func TestContentScopeCreateArticleAuthorLookupFailure(t *testing.T) { + authors := plainAuthors() + boom := errors.New("author store down") + authors.fail.failNextWith("GetByUserID", boom) + articles := newMockArticleRepo() + create := useCases.NewCreateArticleUseCase(articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), authors) + caller := useCases.ContentCaller{UserID: 9, Grants: role.Role{Grants: grantRows("content.create=all")}.GrantSet()} + _, err := create.CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: "Hello", Body: "x", Caller: caller}) + if !errors.Is(err, boom) { + t.Fatalf("expected the author lookup error, got %v", err) + } + if len(articles.articles) != 0 { + t.Errorf("no article may be stored, got %d", len(articles.articles)) + } +} + +func TestContentScopeListArticlesSiteLookupFailure(t *testing.T) { + boom := errors.New("site store down") + key := "default" + grantsFor := map[string]role.GrantSet{ + "all": role.Role{Grants: grantRows("content.read=all")}.GrantSet(), + "own": role.Role{Grants: grantRows("content.read=own")}.GrantSet(), + "none": role.Role{Grants: grantRows("schema.view=all")}.GrantSet(), + } + for name, grants := range grantsFor { + t.Run(name, func(t *testing.T) { + sites := plainSites() + sites.fail.failNextWith("GetByKey", boom) + list := useCases.NewListArticlesUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, sites) + _, err := list.ListArticlesPage(useCases.ListArticlesCommand{Page: 1, Size: 10, SiteKey: &key, Caller: useCases.ContentCaller{UserID: 9, Grants: grants}}) + if !errors.Is(err, boom) { + t.Fatalf("expected the site lookup error, got %v", err) + } + }) + } +} diff --git a/test/roles_content_scope_world_test.go b/test/roles_content_scope_world_test.go new file mode 100644 index 0000000..18a015c --- /dev/null +++ b/test/roles_content_scope_world_test.go @@ -0,0 +1,374 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/contenttype" + "RenewCMS/internal/domain/entry" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +const ( + csOwner uint32 = 3 + csAdmin uint32 = 5 + csWriter uint32 = 9 + csUnverified uint32 = 12 + csOrphan uint32 = 13 + csHandPlaced uint32 = 20 + csContributor uint32 = 21 + csReaderOwn uint32 = 22 + csEditor uint32 = 23 + csPublisherOwn uint32 = 24 + csTypedA uint32 = 25 + csTypedB uint32 = 26 + csNoContent uint32 = 27 + csAnonymous uint32 = 0 + csEventsTypeID uint32 = 1 + csVenuesTypeID uint32 = 2 + csEntryBody = `{"data": {"title": "Updated"}}` + csArticlePatch = `{"body": "x"}` +) + +type contentScopeRecorder struct { + events []audit.Event +} + +var _ audit.Recorder = &contentScopeRecorder{} + +func (r *contentScopeRecorder) Record(_ context.Context, event audit.Event) { + r.events = append(r.events, event) +} + +func (r *contentScopeRecorder) actions() []string { + actions := []string{} + for _, event := range r.events { + actions = append(actions, event.Action) + } + return actions +} + +type contentScopeRoleRepo struct { + role.Repository + gets int + getByNames int + listGrantsN int +} + +func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) { + c.gets++ + return c.Repository.Get(id) +} + +func (c *contentScopeRoleRepo) GetByName(name string) (role.Role, error) { + c.getByNames++ + return c.Repository.GetByName(name) +} + +func (c *contentScopeRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) { + c.listGrantsN++ + return c.Repository.ListGrants(roleID) +} + +func (c *contentScopeRoleRepo) touched() int { + return c.gets + c.getByNames + c.listGrantsN +} + +type contentScopeUserRepo struct { + user.Repository + gets int + failGet error +} + +func (c *contentScopeUserRepo) Get(id uint32) (user.User, error) { + c.gets++ + if c.failGet != nil { + return user.User{}, c.failGet + } + return c.Repository.Get(id) +} + +type contentScopeTypeRepo struct { + contenttype.Repository + lookups int +} + +func (c *contentScopeTypeRepo) GetByKey(key string) (contenttype.ContentType, error) { + c.lookups++ + return c.Repository.GetByKey(key) +} + +type contentScopeWorld struct { + *apiKeyEnv + recorder *contentScopeRecorder + roleRepo *contentScopeRoleRepo + userRepo *contentScopeUserRepo + typeRepo *contentScopeTypeRepo + typeStore *mockContentTypeRepo + entries *mockEntryRepo + roleIDs map[uint32]uint32 +} + +type customRoleSpec struct { + user uint32 + name string + grants []string +} + +var contentScopeCustomRoles = []customRoleSpec{ + {csContributor, "Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}}, + {csReaderOwn, "Reader own", []string{"content.read=own", "content.create=all"}}, + {csEditor, "Editor", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=own"}}, + {csPublisherOwn, "Publisher own", []string{"content.read=all", "content.publish=own"}}, + {csTypedA, "Typed A", []string{"content.read=all", "content.create=all", "content.update=all", "content.update:events=own", "content.update:article=none"}}, + {csTypedB, "Typed B", []string{"content.read:events=all", "content.create:events=all", "content.update:events=own"}}, + {csNoContent, "No content", []string{"schema.view=all"}}, + {csHandPlaced, "Hand placed", []string{"system.apply_updates=all"}}, +} + +type articleSeed struct { + id uint32 + online bool + createdBy uint32 +} + +var contentScopeArticles = []articleSeed{ + {10, false, 21}, {11, false, 9}, {12, true, 3}, {13, false, 3}, {14, false, 0}, {15, true, 21}, {16, false, 22}, {17, false, 24}, +} + +type entrySeed struct { + id uint32 + typeID uint32 + createdBy uint32 +} + +var contentScopeEntries = []entrySeed{ + {1, csEventsTypeID, 21}, {2, csEventsTypeID, 9}, {3, csEventsTypeID, 0}, {4, csEventsTypeID, 25}, + {5, csVenuesTypeID, 3}, {6, csEventsTypeID, 22}, {7, csEventsTypeID, 26}, {8, csEventsTypeID, 23}, +} + +func newContentScopeWorld(t *testing.T) *contentScopeWorld { + t.Helper() + recorder := &contentScopeRecorder{} + env := newAPIKeyEnvRecording(t, recorder) + world := &contentScopeWorld{ + apiKeyEnv: env, + recorder: recorder, + roleRepo: &contentScopeRoleRepo{Repository: env.roles}, + userRepo: &contentScopeUserRepo{Repository: env.users}, + typeStore: newMockContentTypeRepo(), + entries: newMockEntryRepo(), + roleIDs: map[uint32]uint32{}, + } + world.typeRepo = &contentScopeTypeRepo{Repository: world.typeStore} + world.seedUsers() + world.seedContent() + world.wire() + return world +} + +func (w *contentScopeWorld) seedUsers() { + w.addUser(csOwner, user.RoleOwner, ownerRoleID, true) + w.addUser(csAdmin, user.RoleAdmin, adminRoleID, true) + w.addUser(csWriter, "", writerRoleID, true) + w.addUser(csUnverified, user.RoleAdmin, adminRoleID, false) + w.addUser(csOrphan, "", 99, true) + for _, spec := range contentScopeCustomRoles { + created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)}) + w.roleIDs[spec.user] = created.ID + w.addUser(spec.user, "", created.ID, true) + } +} + +func (w *contentScopeWorld) seedContent() { + now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + for _, seed := range contentScopeArticles { + w.seedScopedArticle(seed.id, seed.online, seed.createdBy) + } + title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}} + w.typeStore.types[csEventsTypeID] = contenttype.ContentType{ID: csEventsTypeID, Key: "events", Name: "Events", Fields: []contenttype.Field{title}} + w.typeStore.types[csVenuesTypeID] = contenttype.ContentType{ID: csVenuesTypeID, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}} + w.typeStore.nextID = 10 + for _, seed := range contentScopeEntries { + w.entries.entries[seed.id] = entry.Entry{ID: seed.id, ContentTypeID: seed.typeID, Data: map[string]any{"title": "Entry " + strconv.Itoa(int(seed.id))}, CreatedBy: seed.createdBy, UpdatedBy: seed.createdBy, CreatedAt: now, UpdatedAt: now} + } + w.entries.nextID = 100 +} + +func (w *contentScopeWorld) seedScopedArticle(id uint32, online bool, createdBy uint32) { + w.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: "x", Slug: "article-" + strconv.Itoa(int(id)), IsOnline: online, SiteID: 1, CreatedBy: createdBy, UpdatedAt: time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC)} +} + +func (w *contentScopeWorld) wire() { + clock := func() time.Time { return w.now } + c := w.container + c.GetUserUseCase = useCases.NewGetUserUseCase(w.userRepo) + c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo) + c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo) + c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock) + c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images) + c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images) + c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, clock) + c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock) + c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images) + c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images) + c.UpdateEntryUseCase = useCases.NewUpdateEntryUseCase(w.typeRepo, w.entries, w.images, clock) + c.DeleteEntryUseCase = useCases.NewDeleteEntryUseCase(w.typeRepo, w.entries) + w.rebuildRouter() +} + +func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder { + t.Helper() + return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller}) +} + +func (w *contentScopeWorld) resetCounters() { + w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.listGrantsN = 0, 0, 0 + w.userRepo.gets = 0 + w.typeRepo.lookups = 0 +} + +func (w *contentScopeWorld) setGrants(userID uint32, rows ...string) { + stored := w.roles.roles[w.roleIDs[userID]] + stored.Grants = grantRows(rows...) + w.roles.roles[stored.ID] = stored +} + +type scopeWant struct { + status int + key string + message string +} + +func okWant(status int) scopeWant { + return scopeWant{status: status} +} + +func refusedWant(key string) scopeWant { + return scopeWant{status: http.StatusForbidden, key: key} +} + +func notFoundWant(message string) scopeWant { + return scopeWant{status: http.StatusNotFound, message: message} +} + +func (w scopeWant) label() string { + if w.key != "" { + return "403 " + w.key + } + text := strconv.Itoa(w.status) + if w.message != "" { + text += " " + w.message + } + return text +} + +func assertScopeWant(t *testing.T, rec *httptest.ResponseRecorder, want scopeWant) { + t.Helper() + if want.key != "" { + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" { + t.Fatalf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String()) + } + if header := rec.Header().Get(apperr.MissingPermissionHeader); header != want.key { + t.Fatalf("expected %s %q, got %q", apperr.MissingPermissionHeader, want.key, header) + } + return + } + if rec.Code != want.status { + t.Fatalf("expected %d, got %d %q", want.status, rec.Code, rec.Body.String()) + } + if rec.Header().Get(apperr.MissingPermissionHeader) != "" { + t.Fatalf("unexpected %s header on a non-refusal", apperr.MissingPermissionHeader) + } + if want.message != "" && strings.TrimSpace(rec.Body.String()) != want.message { + t.Fatalf("expected body %q, got %q", want.message, rec.Body.String()) + } +} + +type scopeRow struct { + caller uint32 + method string + path string + body string + want scopeWant +} + +func callerLabel(caller uint32) string { + if caller == csAnonymous { + return "no cookie" + } + return "user " + strconv.Itoa(int(caller)) +} + +func runScopeRows(t *testing.T, rows []scopeRow) { + t.Helper() + for _, row := range rows { + t.Run(callerLabel(row.caller)+" "+row.method+" "+row.path+" "+row.body+" -> "+row.want.label(), func(t *testing.T) { + world := newContentScopeWorld(t) + assertScopeWant(t, world.do(t, row.caller, row.method, row.path, row.body), row.want) + }) + } +} + +type scopeListBody struct { + Items []struct { + ID uint32 `json:"id"` + } `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Counts struct { + All int `json:"all"` + Online int `json:"online"` + Draft int `json:"draft"` + Suggestions int `json:"suggestions"` + } `json:"counts"` +} + +func decodeScopeList(t *testing.T, rec *httptest.ResponseRecorder) scopeListBody { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String()) + } + var body scopeListBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return body +} + +func (b scopeListBody) ids() []uint32 { + ids := []uint32{} + for _, item := range b.Items { + ids = append(ids, item.ID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func scopeIDs(values ...uint32) []uint32 { + return values +} + +func sameScopeIDs(left []uint32, right []uint32) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/test/roles_permissions_enforcement_http_test.go b/test/roles_permissions_enforcement_http_test.go index c6e03ee..5ac5ed6 100644 --- a/test/roles_permissions_enforcement_http_test.go +++ b/test/roles_permissions_enforcement_http_test.go @@ -340,7 +340,7 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) { {grantsOf("media.view"), http.MethodPost, "/v1/media", "media.upload"}, {grantsOf("apikeys.create"), http.MethodGet, "/v1/api-key", "apikeys.view"}, {grantsOf("settings.edit_general"), http.MethodPatch, "/v1/media/purge-settings", ""}, - {grantsOf("system.apply_updates"), http.MethodPost, "/v1/system/update/check", ""}, + {grantsOf("system.apply_updates"), http.MethodPost, "/v1/system/update/check", "system.apply_updates"}, {nil, http.MethodGet, "/v1/author", "authors.view"}, } for _, row := range rows { @@ -479,13 +479,19 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) { } }) - t.Run("Articles and entries are not gated in this slice", func(t *testing.T) { + t.Run("Articles and entries are gated by the content keys since the content scope slice", func(t *testing.T) { e := newEnforcementEnv(t) e.customRoleUser(enforcementCustomID) - for _, route := range [][2]string{{http.MethodPost, "/v1/article"}, {http.MethodDelete, "/v1/article/1"}, {http.MethodGet, "/v1/content-type/events/entry"}, {http.MethodPost, "/v1/content-type/events/entry"}} { - if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() { - t.Errorf("%s %s is gated", route[0], route[1]) - } + rows := []struct { + method, path, key string + }{ + {http.MethodPost, "/v1/article", "content.create"}, + {http.MethodDelete, "/v1/article/1", "content.delete"}, + {http.MethodGet, "/v1/content-type/events/entry", "content.read"}, + {http.MethodPost, "/v1/content-type/events/entry", "content.create"}, + } + for _, row := range rows { + assertGate(t, e.gateAs(t, enforcementCustomID, row.method, row.path, `{}`), row.key) } }) } diff --git a/test/roles_permissions_enforcement_scope_test.go b/test/roles_permissions_enforcement_scope_test.go index b48031c..ea47049 100644 --- a/test/roles_permissions_enforcement_scope_test.go +++ b/test/roles_permissions_enforcement_scope_test.go @@ -249,7 +249,7 @@ func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) { repo.fail.failNext("Get") uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) - err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.NewGrantSet(nil)) + err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.GrantSet{}) if !errors.Is(err, role.ErrMissingPermission) { t.Errorf("expected a missing permission, got %v", err) @@ -272,7 +272,7 @@ func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) { t.Run("A list with no apikeys.view grant is refused by the use case as well", func(t *testing.T) { uc := useCases.NewListAPIKeysUseCase(newMockAPIKeyRepo(), apiKeyUnitUsers(), apiKeyUnitClock) - if _, err := uc.ListAPIKeys(2, role.NewGrantSet(nil)); !errors.Is(err, role.ErrMissingPermission) { + if _, err := uc.ListAPIKeys(2, role.GrantSet{}); !errors.Is(err, role.ErrMissingPermission) { t.Errorf("expected a missing permission, got %v", err) } }) @@ -305,8 +305,8 @@ func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) { {"own edits its linked author", authorsEditOwnGrants, 4, author.Patch{Bio: &bio}, nil, false}, {"own cannot edit another author", authorsEditOwnGrants, 5, author.Patch{Bio: &bio}, author.ErrAuthorRights, false}, {"own cannot unlink", authorsEditOwnGrants, 4, author.Patch{UserID: author.Nullable[uint32]{Set: true}}, author.ErrAuthorRights, false}, - {"none is refused", role.NewGrantSet(nil), 4, author.Patch{Bio: &bio}, role.ErrMissingPermission, false}, - {"none is refused before any lookup", role.NewGrantSet(nil), 999, author.Patch{Bio: &bio}, role.ErrMissingPermission, false}, + {"none is refused", role.GrantSet{}, 4, author.Patch{Bio: &bio}, role.ErrMissingPermission, false}, + {"none is refused before any lookup", role.GrantSet{}, 999, author.Patch{Bio: &bio}, role.ErrMissingPermission, false}, } for _, row := range rows { t.Run(row.name, func(t *testing.T) { @@ -329,8 +329,8 @@ func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) { t.Run("user_email on authors needs users.view", func(t *testing.T) { for name, grants := range map[string]role.GrantSet{ - "with users.view": role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)}), - "without users.view": role.NewGrantSet(nil), + "with users.view": unlockedGrantSet(role.KeyUsersView + "=all"), + "without users.view": role.GrantSet{}, } { t.Run(name, func(t *testing.T) { authors := plainAuthors() diff --git a/test/roles_permissions_enforcement_support_test.go b/test/roles_permissions_enforcement_support_test.go index 89da458..9baceb0 100644 --- a/test/roles_permissions_enforcement_support_test.go +++ b/test/roles_permissions_enforcement_support_test.go @@ -76,7 +76,7 @@ func TestRolesPermissionsEnforcementAuthorLinking(t *testing.T) { authors.seedLinked(3, "Dangling", "dangling", 99) users := newMockUserRepo() users.users[5] = user.User{ID: 5, Email: "five@example.com"} - grants := role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)}) + grants := unlockedGrantSet(role.KeyUsersView + "=all") page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants}) diff --git a/test/roles_permissions_enforcement_test.go b/test/roles_permissions_enforcement_test.go index c4c704b..04347e6 100644 --- a/test/roles_permissions_enforcement_test.go +++ b/test/roles_permissions_enforcement_test.go @@ -73,17 +73,17 @@ func TestCheckPermissionUseCase(t *testing.T) { if err != nil { t.Fatal(err) } - if grants.Scope("authors.edit") != role.ScopeOwn || grants.Scope("users.view") != role.ScopeNone { + if grants.ScopeFor("authors.edit", "") != role.ScopeOwn || grants.ScopeFor("users.view", "") != role.ScopeNone { t.Errorf("unexpected grants %v", grants) } }) - t.Run("A custom role is judged by its grants only, owner-only keys included", func(t *testing.T) { + t.Run("A custom role is judged by its grants only, owner-only keys excluded", func(t *testing.T) { check, roles := newCheck() custom, _ := roles.Create(role.Role{Name: "Hand made", Version: 1, Grants: []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}}) account := user.User{ID: 20, RoleID: custom.ID} - if _, err := check.Require(account, "system.apply_updates"); err != nil { - t.Errorf("expected the hand-placed owner-only key to be honoured, got %v", err) + if _, err := check.Require(account, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) { + t.Errorf("expected the hand-placed owner-only key to be ignored on an unlocked role, got %v", err) } if _, err := check.Require(account, "schema.view"); !errors.Is(err, role.ErrMissingPermission) { t.Errorf("expected no implied keys, got %v", err) @@ -122,11 +122,6 @@ func TestCheckPermissionUseCase(t *testing.T) { if err == nil || errors.Is(err, role.ErrMissingPermission) { t.Fatalf("expected a plain failure, got %v", err) } - roles.fail.failNext("ListGrants") - _, err = check.Require(owner, "users.view") - if err == nil || errors.Is(err, role.ErrMissingPermission) { - t.Fatalf("expected a plain failure, got %v", err) - } }) t.Run("Grant changes apply to the next call with no cache", func(t *testing.T) { @@ -146,13 +141,13 @@ func TestCheckPermissionUseCase(t *testing.T) { func TestGrantSet(t *testing.T) { t.Run("an absent key is none and a nil set holds nothing", func(t *testing.T) { var empty role.GrantSet - if empty.Scope("users.view") != role.ScopeNone || empty.Holds("users.view") || empty.HoldsAll("users.view") { + if empty.ScopeFor("users.view", "") != role.ScopeNone || empty.Holds("users.view") || empty.HoldsAll("users.view") { t.Error("a nil set must hold nothing") } }) t.Run("Holds is true for own and all, HoldsAll only for all", func(t *testing.T) { - set := role.NewGrantSet([]role.Grant{{Key: "a", Scope: role.ScopeOwn}, {Key: "b", Scope: role.ScopeAll}, {Key: "c", Scope: role.ScopeNone}}) - if !set.Holds("a") || set.HoldsAll("a") || !set.Holds("b") || !set.HoldsAll("b") || set.Holds("c") { + set := unlockedGrantSet("content.update=own", "content.delete=all", "content.read=none") + if !set.Holds("content.update") || set.HoldsAll("content.update") || !set.Holds("content.delete") || !set.HoldsAll("content.delete") || set.Holds("content.read") { t.Errorf("unexpected %v", set) } }) @@ -165,8 +160,8 @@ func TestGrantSet(t *testing.T) { } var ( - activityViewAllGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyActivityView, Scope: role.ScopeAll}}) - activityViewOwnGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyActivityView, Scope: role.ScopeOwn}}) - authorsEditOwnGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyAuthorsEdit, Scope: role.ScopeOwn}}) - authorsEditAllGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyAuthorsEdit, Scope: role.ScopeAll}}) + activityViewAllGrants = unlockedGrantSet(role.KeyActivityView + "=all") + activityViewOwnGrants = unlockedGrantSet(role.KeyActivityView + "=own") + authorsEditOwnGrants = unlockedGrantSet(role.KeyAuthorsEdit + "=own") + authorsEditAllGrants = unlockedGrantSet(role.KeyAuthorsEdit + "=all") ) diff --git a/test/roles_permissions_foundation_test.go b/test/roles_permissions_foundation_test.go index 6cb4e71..287e115 100644 --- a/test/roles_permissions_foundation_test.go +++ b/test/roles_permissions_foundation_test.go @@ -18,7 +18,7 @@ func roleWithGrant(key string, scope role.Scope) role.Role { return role.Role{Name: "Probe", Grants: []role.Grant{{Key: key, Scope: scope}}} } -func TestRoleAllows(t *testing.T) { +func TestRoleGrantSetDecisions(t *testing.T) { cases := []struct { key string scope role.Scope @@ -35,14 +35,18 @@ func TestRoleAllows(t *testing.T) { } for _, c := range cases { t.Run(string(c.scope)+" "+c.key, func(t *testing.T) { - if got := roleWithGrant(c.key, c.scope).Allows(c.key, c.owned); got != c.allowed { + createdBy := uint32(2) + if c.owned { + createdBy = 1 + } + if got := roleWithGrant(c.key, c.scope).GrantSet().Permits(c.key, "", 1, createdBy); got != c.allowed { t.Errorf("owned=%v: expected %v, got %v", c.owned, c.allowed, got) } }) } t.Run("A key absent from the role is not allowed", func(t *testing.T) { - if (role.Role{}).Allows("content.read", true) { + if (role.Role{}).GrantSet().Permits("content.read", "", 1, 1) { t.Errorf("expected false") } }) @@ -50,7 +54,7 @@ func TestRoleAllows(t *testing.T) { t.Run("An unknown key is rejected, never silently allowed", func(t *testing.T) { everything := role.BuiltInRoles()[0] everything.Grants = append(everything.Grants, role.Grant{Key: "content.explode", Scope: role.ScopeAll}) - if everything.Allows("content.explode", true) { + if everything.GrantSet().Permits("content.explode", "", 1, 1) { t.Errorf("expected false") } if _, err := role.Lookup("content.explode"); !errors.Is(err, role.ErrUnknownPermission) { @@ -388,7 +392,7 @@ func TestNewArticlesRecordTheirCreator(t *testing.T) { t.Run("New articles record their creator", func(t *testing.T) { repo := newMockArticleRepo() uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) - created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", CreatedBy: 7}) + created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)}) if err != nil { t.Fatal(err) } diff --git a/test/seo_data_cleaner_test.go b/test/seo_data_cleaner_test.go index b0518c3..a5ca029 100644 --- a/test/seo_data_cleaner_test.go +++ b/test/seo_data_cleaner_test.go @@ -23,6 +23,9 @@ type seoAlwaysRacingArticleRepo struct { func (r *seoAlwaysRacingArticleRepo) Create(a article.Article) (article.Article, error) { r.createCalls++ + if r.createCalls > 100 { + return article.Article{}, errors.New("create budget exceeded") + } return article.Article{}, article.ErrSlugTaken } @@ -69,7 +72,7 @@ func TestSeoDataCleanerSlugRaces(t *testing.T) { racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles} create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors) - _, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Title: "Racing", Body: "x"}) + _, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Racing", Body: "x"}) if !errors.Is(err, article.ErrSlugTaken) || racing.createCalls != 5 { t.Errorf("err %v after %d attempts", err, racing.createCalls) diff --git a/test/seo_filter_performance_test.go b/test/seo_filter_performance_test.go index a16d3f5..6cc1d01 100644 --- a/test/seo_filter_performance_test.go +++ b/test/seo_filter_performance_test.go @@ -40,8 +40,8 @@ func TestSeoFilterPerformance(t *testing.T) { } list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db)) - filtered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()} - unfiltered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, Sort: article.DefaultSort()} + filtered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()} + unfiltered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.DefaultSort()} slowestFiltered, page := slowestOfThree(t, list, filtered) slowestUnfiltered, _ := slowestOfThree(t, list, unfiltered) t.Logf("GET /v1/article/?seo=suggestions over %d articles in one site: slowest of 3 runs %d ms (budget %d ms), total %d, counts %+v", seoPerformanceArticles, slowestFiltered.Milliseconds(), seoPerformanceBudget.Milliseconds(), page.Total, page.Counts) diff --git a/test/seo_output_hardening_test.go b/test/seo_output_hardening_test.go index b38ddc8..3a2a6c1 100644 --- a/test/seo_output_hardening_test.go +++ b/test/seo_output_hardening_test.go @@ -21,7 +21,7 @@ func TestSeoOutputListPagingNeverPanics(t *testing.T) { run := func(page int, size int) useCases.ArticlePage { t.Helper() result, err := e.container.ListArticlesUseCase.ListArticlesPage(useCases.ListArticlesCommand{ - Verified: true, Page: page, Size: size, SEO: article.SEOStatusReady, Sort: article.Sort{Field: "title", Direction: "asc"}, + Caller: ownerCaller(1), Page: page, Size: size, SEO: article.SEOStatusReady, Sort: article.Sort{Field: "title", Direction: "asc"}, }) if err != nil { t.Fatalf("page %d size %d: %v", page, size, err) diff --git a/test/site_domain_test.go b/test/site_domain_test.go index 8158a9f..080d79e 100644 --- a/test/site_domain_test.go +++ b/test/site_domain_test.go @@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) { func TestSiteUseCaseRules(t *testing.T) { t.Run("Creating an article assigns it to the default site", func(t *testing.T) { repo := newMockArticleRepo() - created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"}) + created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"}) if err != nil || created.SiteID != 1 { t.Errorf("created %+v, %v", created, err) } @@ -104,7 +104,7 @@ func TestSiteUseCaseRules(t *testing.T) { sites := newMockSiteRepo(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors()) - _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"}) + _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"}) if !errors.Is(err, site.ErrSiteNotFound) || repo.createCalled { t.Errorf("err = %v, create called = %v", err, repo.createCalled) @@ -119,7 +119,7 @@ func TestSiteUseCaseRules(t *testing.T) { uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo()) missing := uint32(9) - _, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing}) + _, err := uc.Update(testCtx, 1, ownerCaller(1), useCases.UpdateArticleCommand{SiteID: &missing}) if !errors.Is(err, site.ErrSiteNotFound) || repo.articles[1].SiteID != 1 { t.Errorf("err = %v, stored %+v", err, repo.articles[1]) diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index 8349ff8..08bca26 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -28,7 +28,6 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) loginHandler := auth.NewLoginHandler( token, @@ -51,6 +50,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr r.Route("/article", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index cb7d1e3..7f0edc2 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -33,7 +33,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) { uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) title := strings.Repeat("a", tc.length) - result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) + result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"}) if tc.expectValid { if err != nil { @@ -65,7 +65,7 @@ func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) { repo.fail.failNext("Create") uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) - _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"}) + _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Backyard Renovation Update", Body: "Some body text"}) if !errors.Is(err, errRepositoryFailure) { t.Errorf("expected repository failure, got %v", err) @@ -86,7 +86,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetArticle_1", func(t *testing.T) { repo := newRepo() uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) - result, err := uc.GetArticle(1, article.BodyFormatHTML) + result, err := uc.GetArticle(1, article.BodyFormatHTML, ownerCaller(1)) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -98,7 +98,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetArticle_2", func(t *testing.T) { repo := newRepo() uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) - result, err := uc.GetArticle(2, article.BodyFormatHTML) + result, err := uc.GetArticle(2, article.BodyFormatHTML, ownerCaller(1)) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -110,7 +110,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetArticle_9", func(t *testing.T) { repo := newRepo() uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) - _, err := uc.GetArticle(9, article.BodyFormatHTML) + _, err := uc.GetArticle(9, article.BodyFormatHTML, ownerCaller(1)) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -119,7 +119,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetOnlineArticle_1", func(t *testing.T) { repo := newRepo() uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) - result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML) + result, err := uc.GetArticle(1, article.BodyFormatHTML, useCases.ContentCaller{}) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -131,7 +131,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetOnlineArticle_2", func(t *testing.T) { repo := newRepo() uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) - _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML) + _, err := uc.GetArticle(2, article.BodyFormatHTML, useCases.ContentCaller{}) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -140,7 +140,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetOnlineArticle_9", func(t *testing.T) { repo := newRepo() uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) - _, err := uc.GetOnlineArticle(9, article.BodyFormatHTML) + _, err := uc.GetArticle(9, article.BodyFormatHTML, useCases.ContentCaller{}) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -210,7 +210,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) { t.Run("ListArticlesPage verified", func(t *testing.T) { repo := newRepo() uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()) - result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}}) + result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}}) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -222,7 +222,7 @@ func TestListArticlesPageEmptyRepositoryReturnsEmptySlice(t *testing.T) { repo := newMockArticleRepo() uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()) - page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25}) + page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25}) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -245,7 +245,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateBody_id1", func(t *testing.T) { repo := newRepo() uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) - result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML) + result, err := uc.UpdateBody(testCtx, 1, ownerCaller(1), "New body", article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -257,7 +257,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateBody_id9", func(t *testing.T) { repo := newRepo() uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) - _, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML) + _, err := uc.UpdateBody(testCtx, 9, ownerCaller(1), "New body", article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -266,7 +266,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateIsOnline_id1", func(t *testing.T) { repo := newRepo() uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) - result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML) + result, err := uc.UpdateIsOnline(testCtx, 1, ownerCaller(1), true, article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -278,7 +278,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateIsOnline_id9", func(t *testing.T) { repo := newRepo() uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()) - _, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML) + _, err := uc.UpdateIsOnline(testCtx, 9, ownerCaller(1), true, article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -291,7 +291,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) { repo.articles[1] = article.Article{ID: 1} uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder) - err := uc.DeleteArticle(testCtx, 1) + err := uc.DeleteArticle(testCtx, 1, ownerCaller(1)) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -302,7 +302,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) { repo.articles[1] = article.Article{ID: 1} uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder) - err := uc.DeleteArticle(testCtx, 9) + err := uc.DeleteArticle(testCtx, 9, ownerCaller(1)) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -315,7 +315,7 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) { repo.fail.failNext("Delete") uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder) - err := uc.DeleteArticle(testCtx, 1) + err := uc.DeleteArticle(testCtx, 1, ownerCaller(1)) if !errors.Is(err, errRepositoryFailure) { t.Errorf("expected repository failure, got %v", err) } diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index 6279b58..ac7c232 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -26,13 +26,13 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) r.Group(func(r chi.Router) { diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index 1968bbe..bf456ce 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -218,13 +218,13 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), - getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(token)) + r.Use(contentAccess(getUserUseCase)) r.Use(authtoken.Authenticator) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Route("/article", func(r chi.Router) {