diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index fde7499..c28ddba 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -64,6 +64,7 @@ type UseCases struct { DeleteAuthorUseCase *useCases.DeleteAuthorUseCase GetMyAuthorUseCase *useCases.GetMyAuthorUseCase GetMyAccessUseCase *useCases.GetMyAccessUseCase + CheckPermissionUseCase *useCases.CheckPermissionUseCase ListPermissionsUseCase *useCases.ListPermissionsUseCase ListSitesUseCase *useCases.ListSitesUseCase CreateSiteUseCase *useCases.CreateSiteUseCase @@ -372,6 +373,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo), CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), @@ -426,7 +428,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), - RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder), + RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), diff --git a/api/handlers/activity/activityHandler.go b/api/handlers/activity/activityHandler.go index 7b553b2..1e91585 100644 --- a/api/handlers/activity/activityHandler.go +++ b/api/handlers/activity/activityHandler.go @@ -1,11 +1,12 @@ package activity import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/audit" - "RenewCMS/internal/domain/user" + "RenewCMS/internal/domain/role" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" @@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) { case errors.Is(err, audit.ErrInvalidFilter): http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) case errors.Is(err, audit.ErrActorNotAllowed): - http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden) - case errors.Is(err, user.ErrUserNotFound): - http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + access.WriteMissingPermission(w, role.KeyActivityView) + case access.WriteIfMissingPermission(w, err): default: http.Error(w, "Internal server error", http.StatusInternalServerError) } @@ -156,7 +156,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { activityQuery := toActivityQuery(query.filterQuery) activityQuery.Page, activityQuery.Size = query.Page, query.Size - page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery}) + page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: activityQuery}) if err != nil { writeActivityError(w, err) return @@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { return } - prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query)) + viewerId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + prepared, err := h.ExportUseCase.Prepare(useCases.ExportActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: toActivityQuery(query)}) if err != nil { writeActivityError(w, err) return diff --git a/api/handlers/apikey/apiKeyHandler.go b/api/handlers/apikey/apiKeyHandler.go index b2e3628..e8d3182 100644 --- a/api/handlers/apikey/apiKeyHandler.go +++ b/api/handlers/apikey/apiKeyHandler.go @@ -1,6 +1,7 @@ package apikey import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" @@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { return } - entries, err := h.ListUseCase.ListAPIKeys(userId) + entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r)) if err != nil { + if access.WriteIfMissingPermission(w, err) { + return + } if errors.Is(err, user.ErrUserNotFound) { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) return @@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) { return } - if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil { + if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil { switch { + case access.WriteIfMissingPermission(w, err): case errors.Is(err, domain.ErrAPIKeyNotFound): http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound) case errors.Is(err, user.ErrUserNotFound): diff --git a/api/handlers/author/authorHandler.go b/api/handlers/author/authorHandler.go index c18a9c5..b584b9f 100644 --- a/api/handlers/author/authorHandler.go +++ b/api/handlers/author/authorHandler.go @@ -1,6 +1,7 @@ package author import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" @@ -33,11 +34,10 @@ type Handler struct { CreateMyUseCase *useCases.CreateMyAuthorUseCase UpdateUseCase *useCases.UpdateAuthorUseCase DeleteUseCase *useCases.DeleteAuthorUseCase - GetUserUseCase *useCases.GetUserUseCase } -func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler { - return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser} +func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase) *Handler { + return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase} } type socialLinkRequest struct { @@ -180,6 +180,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { func writeAuthorError(w http.ResponseWriter, err error) { switch { + case access.WriteIfMissingPermission(w, err): case errors.Is(err, domain.ErrAuthorNotFound): http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound) case errors.Is(err, domain.ErrSlugTaken): @@ -226,18 +227,6 @@ func writeValidationError(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusBadRequest) } -func (h *Handler) currentUser(r *http.Request) (user.User, bool) { - userId, ok := claims.UserId(r) - if !ok { - return user.User{}, false - } - current, err := h.GetUserUseCase.GetUser(userId) - if err != nil { - return user.User{}, false - } - return current, true -} - type listQuery struct { Page int `validate:"gte=1"` Size int `validate:"oneof=5 25 50 100"` @@ -277,8 +266,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { return } - current, _ := h.currentUser(r) - page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role}) + page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)}) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return @@ -351,12 +339,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } - current, ok := h.currentUser(r) + callerID, ok := claims.UserId(r) if !ok { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) return } - updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch()) + updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: callerID, Grants: access.Grants(r)}, request.toPatch()) if err != nil { writeAuthorError(w, err) return diff --git a/api/handlers/update/updateHandler.go b/api/handlers/update/updateHandler.go index aacf897..dfa7ebe 100644 --- a/api/handlers/update/updateHandler.go +++ b/api/handlers/update/updateHandler.go @@ -1,11 +1,12 @@ package update import ( + "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/role" domain "RenewCMS/internal/domain/update" - "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" @@ -21,29 +22,23 @@ type Handler struct { CheckForUpdateUseCase *useCases.CheckForUpdateUseCase StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase RequestRestartUseCase *useCases.RequestRestartUseCase - GetUserUseCase *useCases.GetUserUseCase } type restartRequest struct { Kind *string `json:"kind" validate:"omitempty,oneof=update restart"` } -func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler { +func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler { return &Handler{ GetUpdateStatusUseCase: getStatus, CheckForUpdateUseCase: checkForUpdate, StartUpdateDownloadUseCase: startDownload, RequestRestartUseCase: requestRestart, - GetUserUseCase: getUser, } } func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) { - isOwner, ok := h.isOwner(w, r) - if !ok { - return - } - writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner)) + writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r))) } func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { @@ -51,11 +46,7 @@ func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - isOwner, ok := h.isOwner(w, r) - if !ok { - return - } - writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner)) + writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r))) } func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { @@ -105,22 +96,8 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) { return *request.Kind, true } -func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) { - userId, ok := claims.UserId(r) - if !ok { - http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) - return false, false - } - currentUser, err := h.GetUserUseCase.GetUser(userId) - if err != nil { - if errors.Is(err, user.ErrUserNotFound) { - http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) - return false, false - } - http.Error(w, "Internal server error", http.StatusInternalServerError) - return false, false - } - return currentUser.Role == user.RoleOwner, true +func canApply(r *http.Request) bool { + return access.Grants(r).Holds(role.KeyApplyUpdates) } func writeDownloadError(w http.ResponseWriter, err error) { diff --git a/api/middleware/auth.go b/api/middleware/auth.go index c781fa9..29d52b2 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -77,39 +77,3 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht }) } } - -func RequireOwner(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) { - currentUser, ok := resolveCurrentUser(getUser, w, r) - if !ok { - return - } - - if currentUser.Role != user.RoleOwner { - http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }) - } -} - -func RequireOwnerOrAdmin(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) { - currentUser, ok := resolveCurrentUser(getUser, w, r) - if !ok { - return - } - - if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin { - http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }) - } -} diff --git a/api/middleware/permission.go b/api/middleware/permission.go new file mode 100644 index 0000000..612a2bc --- /dev/null +++ b/api/middleware/permission.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "RenewCMS/api/pkg/access" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "net/http" +) + +type grantCheck func(account user.User, key string) (role.GrantSet, error) + +func RequirePermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler { + return enforce(getUser, check.Require, key) +} + +func RequireFullScope(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler { + return enforce(getUser, check.RequireAll, key) +} + +func enforce(getUser *useCases.GetUserUseCase, verify grantCheck, key string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, ok := resolveCurrentUser(getUser, w, r) + if !ok { + return + } + + grants, err := verify(currentUser, key) + if err != nil { + if !access.WriteIfMissingPermission(w, err) { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + next.ServeHTTP(w, r.WithContext(access.NewContext(r.Context(), grants))) + }) + } +} diff --git a/api/pkg/access/access.go b/api/pkg/access/access.go new file mode 100644 index 0000000..ef4330c --- /dev/null +++ b/api/pkg/access/access.go @@ -0,0 +1,34 @@ +package access + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/role" + "context" + "errors" + "net/http" +) + +type grantsContextKey struct{} + +func NewContext(ctx context.Context, grants role.GrantSet) context.Context { + return context.WithValue(ctx, grantsContextKey{}, grants) +} + +func Grants(r *http.Request) role.GrantSet { + grants, _ := r.Context().Value(grantsContextKey{}).(role.GrantSet) + return grants +} + +func WriteMissingPermission(w http.ResponseWriter, key string) { + w.Header().Set(apperr.MissingPermissionHeader, key) + http.Error(w, apperr.MissingPermissionErrorMessage, http.StatusForbidden) +} + +func WriteIfMissingPermission(w http.ResponseWriter, err error) bool { + var missing role.MissingPermissionError + if !errors.As(err, &missing) { + return false + } + WriteMissingPermission(w, missing.Key) + return true +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 8c3432d..03dd68c 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -24,7 +24,6 @@ const InviteNotFoundErrorMessage = "Invite not found" const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted." const InviteInvalidErrorMessage = "This invite link doesn't work." const InviteExpiredErrorMessage = "This invite has expired." -const OwnerRequiredErrorMessage = "Only the owner can do this." const UsernameTakenErrorMessage = "That username is taken. Try another." const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer." const MediaQueryErrorMessage = "The media query is invalid." @@ -39,8 +38,9 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json." const APIKeyNotFoundErrorMessage = "API key not found" const APIKeyExpiryErrorMessage = "The expiry must be a date in the future." +const MissingPermissionErrorMessage = "missing_permission" +const MissingPermissionHeader = "X-Missing-Permission" const SessionRequiredErrorMessage = "This action needs a real session." -const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this." const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again." const PurgeBusyErrorMessage = "A purge is already running." const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365." diff --git a/api/router.go b/api/router.go index da9b1b8..90ea22b 100644 --- a/api/router.go +++ b/api/router.go @@ -58,6 +58,14 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler { return c.apiKeyAuthMiddleware } +func (c *UseCases) require(key string) func(http.Handler) http.Handler { + return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key) +} + +func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler { + return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key) +} + func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler { r := chi.NewRouter() r.Use(container.apiKeyAuth()) @@ -149,10 +157,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.Get("/", inviteHandler.ListUsers) + r.With(container.require("users.view")).Get("/", inviteHandler.ListUsers) r.Get("/lookup", lookupHandler.Lookup) r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(container.require("users.invite")) r.Use(middleware.TrustedProxyRealIP(trustedProxies)) r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) r.Post("/invite", inviteHandler.CreateInvite) @@ -173,6 +181,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.Get("/", settingsHandler.GetSettings) }) r.Group(func(r chi.Router) { @@ -183,7 +192,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(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(container.require("system.edit_retention")) r.Patch("/", settingsHandler.UpdateSettings) }) return r @@ -213,11 +222,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.Use(middleware.RequireOwner(container.GetUserUseCase)) - r.Post("/", siteHandler.Create) - r.Patch("/{id}", siteHandler.Update) - r.Patch("/{id}/default", siteHandler.SetDefault) - r.Delete("/{id}", siteHandler.Delete) + 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) }) return r } @@ -225,7 +233,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler { r := chi.NewRouter() - authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase) + authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase) r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(container.Token)) @@ -233,6 +241,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.Get("/", authorHandler.List) }) r.Group(func(r chi.Router) { @@ -244,12 +253,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.Patch("/{id}", authorHandler.Update) - r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) - r.Post("/", authorHandler.Create) - r.Delete("/{id}", authorHandler.Delete) - }) + 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) }) return r } @@ -268,14 +274,11 @@ 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.Get("/", contentTypeHandler.List) - r.Get("/{key}", contentTypeHandler.Get) - r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) - r.Post("/", contentTypeHandler.Create) - r.Put("/{key}", contentTypeHandler.Update) - r.Delete("/{key}", contentTypeHandler.Delete) - }) + 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) @@ -296,7 +299,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(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) + r.Use(container.require("schema.view")) r.Get("/", builtinTypeHandler.List) r.Get("/{key}", builtinTypeHandler.Get) return r @@ -314,12 +317,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.Get("/", activityHandler.List) - r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) - r.Get("/summary", activityHandler.Summary) - r.Get("/export", activityHandler.Export) - }) + 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) return r } @@ -333,7 +333,7 @@ const ( func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler { r := chi.NewRouter() - updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase) + updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase) r.Use(middleware.RequireSession) r.Use(authtoken.Verifier(container.Token)) @@ -341,19 +341,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.Get("/update", updateHandler.GetStatus) + r.With(container.require("settings.view")).Get("/update", updateHandler.GetStatus) r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(container.require("system.apply_updates")) r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow)) r.Post("/update/check", updateHandler.Check) }) r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(container.require("system.apply_updates")) r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow)) r.Post("/update/download", updateHandler.Download) }) r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(container.require("system.apply_updates")) r.Post("/update/restart", updateHandler.Restart) }) return r @@ -370,9 +370,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.Post("/", apiKeyHandler.Create) - r.Get("/", apiKeyHandler.List) - r.Delete("/{id}", apiKeyHandler.Revoke) + 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) return r } @@ -402,28 +402,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.Group(func(r chi.Router) { - r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) - r.Get("/purge-settings", mediaHandler.GetPurgeSettings) - r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings) - r.Get("/purge-preview", mediaHandler.PreviewPurge) - r.Post("/purge", mediaHandler.RunPurge) - }) + 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.Group(func(r chi.Router) { r.Use(middleware.RejectAPIKey) - r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) - r.Get("/{id}/variants", mediaHandler.ListVariants) - r.Post("/{id}/variants", mediaHandler.GenerateAllVariants) - r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant) - r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant) + 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.Get("/", mediaHandler.List) - r.Post("/", mediaHandler.Upload) - r.Post("/merge", mediaHandler.Merge) - r.Patch("/{id}", mediaHandler.Rename) - r.Delete("/{id}", mediaHandler.Delete) - r.Get("/{id}", mediaHandler.Get) - r.Get("/{id}/usage", mediaHandler.Usage) + 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) return r } @@ -438,15 +434,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.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) - r.Get("/", variantHandler.List) - r.Post("/", variantHandler.Create) - r.Get("/cache", variantHandler.CacheStats) - r.Post("/cache/purge", variantHandler.PurgeCache) - r.Put("/{name}", variantHandler.Update) - r.Patch("/{name}", variantHandler.SetEnabled) - r.Delete("/{name}", variantHandler.Delete) - r.Post("/{name}/purge", variantHandler.Purge) + 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) return r } diff --git a/docs/api.md b/docs/api.md index df34cc4..df51f73 100644 --- a/docs/api.md +++ b/docs/api.md @@ -45,7 +45,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU | Route | Who | Purpose | | --- | --- | --- | -| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." | +| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. | | `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. | | `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. | @@ -59,7 +59,7 @@ are UTC. The list answers `{ items, total, page, size }`, newest first (`created 90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`, `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with `{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and -owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup +needs `system.edit_retention` (403 `missing_permission`); any other value answers 400. The daily cleanup removes events past the age limit, then the oldest events until the log fits the size limit. Whichever limit is reached first wins. @@ -138,8 +138,8 @@ dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale` is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id` is `null` or the id of an image. Read access needs a verified session or an API key; every write is -session only and owner only (403 "Only the owner can do this.", or "This action needs a real -session." with a key). The role is checked before the body. +session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real +session." with a key. The role is checked before the body. | Route | Purpose | | --- | --- | @@ -255,8 +255,8 @@ article is allowed and never blocks publishing. | `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. | | `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. | -The role checks use the existing bodies: 403 "Only owners and admins can do this." for `POST /` and -`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author +The role checks use the existing bodies: 403 `missing_permission` for `POST /` and +`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key. Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`, 409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409 @@ -284,8 +284,7 @@ repoint the avatars of the merged copies and report it as `authors_updated`. ## Image variants Owners and admins manage named variants of the stored images. These routes are session only (no API -key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do -this." Behaviour, caching and the public `?v=` contract are in [image-variants.md](image-variants.md). +key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=` contract are in [image-variants.md](image-variants.md). | Route | Purpose | | --- | --- | @@ -358,6 +357,10 @@ this format." and 500 "Internal server error". 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. +A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the +header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on +every request, so an API key follows the current role of its creator. + ## Notes - Images in `body` use paths like `/static/uploadedImages/.png`. Prefix them with the base URL. - Browsers can call the API only from origins in the server's CORS setting. diff --git a/features/activity-log-backend.feature b/features/activity-log-backend.feature index 782f519..b232674 100644 --- a/features/activity-log-backend.feature +++ b/features/activity-log-backend.feature @@ -1,4 +1,5 @@ Feature: The back-office keeps an append-only activity log that owners can read and retention can prune + 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 site I want every important action recorded with who, what, when and from where diff --git a/features/activity-log-backend.qa.md b/features/activity-log-backend.qa.md index 7c134c6..4a70fc7 100644 --- a/features/activity-log-backend.qa.md +++ b/features/activity-log-backend.qa.md @@ -1,5 +1,7 @@ # QA procedure: activity-log-backend +> 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. + Go only, no SPA. Package `test` (black-box), new files `test/activity_log_domain_test.go`, `test/activity_log_persistence_test.go`, `test/activity_log_http_test.go` and `test/activity_cleanup_test.go`. Fake and helper type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no real sleeping, no `time.Ticker`. No env var is added. Never put a secret value in a test name or log assertion helper beyond the fixed test strings. ## Setup shared by every case diff --git a/features/activity_filter_cards_and_export.feature b/features/activity_filter_cards_and_export.feature index ebec12d..8024b99 100644 --- a/features/activity_filter_cards_and_export.feature +++ b/features/activity_filter_cards_and_export.feature @@ -1,4 +1,5 @@ Feature: Activity screen, filter cards as toggles and the Export menu + 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 or admin I want the Failed actions and Security events cards to toggle their filter, and Export to offer the current view or all activity as CSV diff --git a/features/activity_filter_cards_and_export.qa.md b/features/activity_filter_cards_and_export.qa.md index 035d91a..9a9e7f5 100644 --- a/features/activity_filter_cards_and_export.qa.md +++ b/features/activity_filter_cards_and_export.qa.md @@ -1,5 +1,7 @@ # QA: Activity filter cards as toggles and the Export menu +> 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. + ## Implementation note for coder (informational) SPA slice with one small backend change: accept `range=all` in the list, summary and export range validation (use case range handling and the handler `validate` tag). An omitted range stays 7d. The range table in features/activity-log-backend.feature is updated accordingly. diff --git a/features/activity_home_card_and_retention.feature b/features/activity_home_card_and_retention.feature index 54f3624..07187f8 100644 --- a/features/activity_home_card_and_retention.feature +++ b/features/activity_home_card_and_retention.feature @@ -1,4 +1,5 @@ Feature: Recent activity card on Home and Retention card on Settings + 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 RenewCMS user I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings diff --git a/features/activity_home_card_and_retention.qa.md b/features/activity_home_card_and_retention.qa.md index 099e0ca..43366eb 100644 --- a/features/activity_home_card_and_retention.qa.md +++ b/features/activity_home_card_and_retention.qa.md @@ -1,5 +1,7 @@ # QA: Recent activity card on Home and Retention card on Settings +> 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. + ## Implementation note for coder (informational, not a QA assertion) - Backend: add NextCleanupAt (nullable time) to the audit settings entity with a domain method computing the next 03:00 (audit.CleanupHour) strictly after a given time, in that time's location; nil when RetentionDays is audit.ForeverRetentionDays. GetActivityRetentionUseCase and UpdateActivityRetentionUseCase get an injected `now func() time.Time` (wired in api/dependecyInjection.go) and populate it. settingsResponse in api/handlers/settings gains `activity_next_cleanup_at` (`*time.Time`, RFC 3339, null when nil). No logic in the handler, none in the SPA. diff --git a/features/api_keys.feature b/features/api_keys.feature index faec678..9d66561 100644 --- a/features/api_keys.feature +++ b/features/api_keys.feature @@ -1,4 +1,5 @@ Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie + 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 developer integrating with RenewCMS I want to create named API keys and send them as a Bearer token @@ -115,7 +116,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"} Then the response status is 201 And the stored key belongs to user 2 - And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage + And using that key on POST /v1/user/invite returns 403 with apperr.MissingPermissionErrorMessage Scenario Outline: Key management needs a verified real session Given @@ -186,7 +187,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the Given an active key created by admin 2 When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"} Then the response status is 403 - And the response body is apperr.OwnerRequiredErrorMessage + And the response body is apperr.MissingPermissionErrorMessage And no invite exists Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt diff --git a/features/api_keys.qa.md b/features/api_keys.qa.md index c0dbe16..9691cac 100644 --- a/features/api_keys.qa.md +++ b/features/api_keys.qa.md @@ -1,5 +1,7 @@ # QA procedure: API keys +> 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. ## Setup shared by every case @@ -38,7 +40,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP. "yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`. Each: repository still empty. 12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that - key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite. + key on `POST /v1/user/invite` -> `403` `apperr.MissingPermissionErrorMessage`, no invite. 13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`; stale `token_version` -> `401`. Each: repository empty. @@ -59,7 +61,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP. `200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article` -> `401` `apperr.UnauthorizedErrorMessage`. 18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403` - `apperr.OwnerRequiredErrorMessage`, no invite and no mail. + `apperr.MissingPermissionErrorMessage`, no invite and no mail. 19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only): unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from diff --git a/features/authors_backend.feature b/features/authors_backend.feature index ede01a1..46a276c 100644 --- a/features/authors_backend.feature +++ b/features/authors_backend.feature @@ -1,4 +1,5 @@ Feature: Authors backend, public bylines that articles credit in order + 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 or admin of a RenewCMS installation I want to manage authors and credit them on articles in a chosen order diff --git a/features/authors_backend.qa.md b/features/authors_backend.qa.md index 80992e4..ae3a80f 100644 --- a/features/authors_backend.qa.md +++ b/features/authors_backend.qa.md @@ -1,5 +1,7 @@ # QA procedure: Authors backend +> 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/authors_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. Every error assertion compares the plain-text body to the exact code token or constant named in the spec. ## Setup diff --git a/features/container_update_guidance.feature b/features/container_update_guidance.feature index d9263ed..661c2f2 100644 --- a/features/container_update_guidance.feature +++ b/features/container_update_guidance.feature @@ -1,4 +1,5 @@ Feature: Container-aware update guidance + 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 site running in Docker I want Settings to say "update by pulling a new image" instead of "Not prepared" diff --git a/features/container_update_guidance.qa.md b/features/container_update_guidance.qa.md index 4c84104..d75e0e5 100644 --- a/features/container_update_guidance.qa.md +++ b/features/container_update_guidance.qa.md @@ -1,5 +1,7 @@ # QA: Container update guidance +> 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. + ## Automated (Go, test/, httptest) 1. Fakes: in-memory update status store, fake FeedClient, fake HelperDetector, fake JobStore and restart writer, validator.InitValidator(). Mount NewSystemRouter on a fresh chi.NewRouter() with jwtauth.Verifier + Authenticator and a test jwtauth.New("HS256", ...). 2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes). diff --git a/features/custom_content_types.feature b/features/custom_content_types.feature index cf3e42a..e3c4b34 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_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 I want to define content types made of ordered scalar fields, and then create entries of those types diff --git a/features/custom_content_types.qa.md b/features/custom_content_types.qa.md index 58ef861..2768035 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_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. ## Setup shared by every case diff --git a/features/custom_content_types_support.feature b/features/custom_content_types_support.feature index 8c92854..0a36ace 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_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 I want an icon per content type, a read-only view of the built-in types and the display names of entry authors diff --git a/features/custom_content_types_support.qa.md b/features/custom_content_types_support.qa.md index 5818d96..340df5c 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_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. ## Setup shared by every case diff --git a/features/image-variants.feature b/features/image-variants.feature index 5728800..fbbab4a 100644 --- a/features/image-variants.feature +++ b/features/image-variants.feature @@ -1,4 +1,5 @@ Feature: Image variants are generated on first request, cached, and managed by admins + 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 admin or owner of a RenewCMS site I want to define named variants of the original images and have them generated and cached on demand @@ -48,12 +49,12 @@ Feature: Image variants are generated on first request, cached, and managed by a | GET | /v1/image-variants | no cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/image-variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/image-variants | the cookie of an unverified user| 403 | apperr.VerificationRequiredErrorMessage | - | GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | + | GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | | GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage | Scenario Outline: Owners and admins are both allowed diff --git a/features/image-variants.qa.md b/features/image-variants.qa.md index 67411de..dd970d6 100644 --- a/features/image-variants.qa.md +++ b/features/image-variants.qa.md @@ -1,5 +1,7 @@ # QA procedure: image-variants +> 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. + Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_admin_http_test.go`, `test/image_variants_serving_http_test.go`, `test/image_variants_geometry_test.go`, `test/image_variants_audit_test.go`, `test/image_variants_config_test.go`. Fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no `time.Sleep`. ## Setup shared by every case diff --git a/features/image_variants_cache.feature b/features/image_variants_cache.feature index 7f9ca4e..ef22cd5 100644 --- a/features/image_variants_cache.feature +++ b/features/image_variants_cache.feature @@ -1,4 +1,5 @@ Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen + 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 or admin of a RenewCMS site I want to see what the variant cache holds and to purge all of it, the unused part, one variant or one image @@ -42,8 +43,8 @@ Feature: Image variants cache panel: cache statistics, purge by scope, request t | GET | /v1/image-variants/cache | no cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/image-variants/cache | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/image-variants/cache | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage | - | GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | + | GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | | GET | /v1/image-variants/cache | a valid API key | 401 | apperr.UnauthorizedErrorMessage | | POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage | diff --git a/features/image_variants_cache.qa.md b/features/image_variants_cache.qa.md index fbf285f..a245bb7 100644 --- a/features/image_variants_cache.qa.md +++ b/features/image_variants_cache.qa.md @@ -1,5 +1,7 @@ # QA procedure: image_variants_cache +> 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. + Two parts: Go for the API and tracking, Vitest for the Cache screen. No live server, no real SQLite file, no real SMTP, no `time.Sleep` (use an injected clock and manual flushes). ## Go (package `test`, black-box) diff --git a/features/image_variants_per_image_api.feature b/features/image_variants_per_image_api.feature index aaa85bf..f86c9a7 100644 --- a/features/image_variants_per_image_api.feature +++ b/features/image_variants_per_image_api.feature @@ -1,4 +1,5 @@ Feature: Image variants per image: library fields, per-image routes and generate on upload + 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 or admin of a RenewCMS site I want to see, generate and purge the variants of one image, and to have variants generated when I upload @@ -36,10 +37,10 @@ Feature: Image variants per image: library fields, per-image routes and generate | GET | /v1/media/1/variants | no cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/media/1/variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/media/1/variants | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage | - | GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage | + | GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | + | DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage | | GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage | | POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage | diff --git a/features/image_variants_per_image_api.qa.md b/features/image_variants_per_image_api.qa.md index a7b383f..3d0cd44 100644 --- a/features/image_variants_per_image_api.qa.md +++ b/features/image_variants_per_image_api.qa.md @@ -1,5 +1,7 @@ # QA procedure: image_variants_per_image_api +> 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. + Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_per_image_http_test.go`, `test/image_variants_generate_on_upload_test.go`, `test/media_variants_list_http_test.go`, `test/image_variants_per_image_audit_test.go`. Extend `test/image_variants_support_test.go`; fake type names must stay unique in the package. No live server, no real SQLite file, no real SMTP, no `time.Sleep`. ## Setup shared by every case diff --git a/features/integration.feature b/features/integration.feature index de57a13..2a43e7f 100644 --- a/features/integration.feature +++ b/features/integration.feature @@ -1,4 +1,5 @@ Feature: Integration screen (API reference) + 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 developer building a frontend or an API-key client for a RenewCMS site I want /integration to be a complete, searchable API reference diff --git a/features/integration.qa.md b/features/integration.qa.md index 17caea0..96d1f2c 100644 --- a/features/integration.qa.md +++ b/features/integration.qa.md @@ -1,5 +1,7 @@ # QA: Integration screen (API reference) +> 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. + ## Automated (Vitest + Vue Test Utils, `task test:web`) Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at diff --git a/features/integration_api_key_routes.feature b/features/integration_api_key_routes.feature index f77d7c5..889f840 100644 --- a/features/integration_api_key_routes.feature +++ b/features/integration_api_key_routes.feature @@ -1,4 +1,5 @@ Feature: Integration page documents every route an API key can call + 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 developer building on RenewCMS I want the API reference to cover content types, entries, built-in types, sites, authors and media purge diff --git a/features/integration_api_key_routes.qa.md b/features/integration_api_key_routes.qa.md index a27c587..bce78a7 100644 --- a/features/integration_api_key_routes.qa.md +++ b/features/integration_api_key_routes.qa.md @@ -1,5 +1,7 @@ # QA procedure: Integration page, every API-key route documented +> 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. + No Go test: SPA only, no /v1 call, no API change. Samples and counts follow the real API; where the board differs (purge response, built-in type samples) the API wins. ## Automated SPA (Vitest + Vue Test Utils, `task test:web`) diff --git a/features/invitations.feature b/features/invitations.feature index 400d0e5..45151e1 100644 --- a/features/invitations.feature +++ b/features/invitations.feature @@ -1,4 +1,5 @@ Feature: A verified user can invite another person by email, and the users list shows users and open invites + 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 member of the shared RenewCMS workspace I want to invite someone by email and see who has an account or a pending invite @@ -11,7 +12,7 @@ Feature: A verified user can invite another person by email, and the users list And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that requires a valid jwt cookie, a current token_version and a verified user And POST /v1/user/invite additionally requires the owner role (403 - apperr.OwnerRequiredErrorMessage for an admin); GET /v1/user/ is open to every verified user + apperr.MissingPermissionErrorMessage for an admin); GET /v1/user/ is open to every verified user And wherever a scenario has verified user id 7 post an invite, user 7 is the owner And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at diff --git a/features/invitations.qa.md b/features/invitations.qa.md index 04f6d0c..dc5aff8 100644 --- a/features/invitations.qa.md +++ b/features/invitations.qa.md @@ -1,5 +1,7 @@ # QA procedure: invitations +> 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). New fakes must have unique type names across the package: `mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`), plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher` @@ -50,7 +52,7 @@ plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `st failing send -> `500` and no invite for that address remains. 13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale `token_version` -> `401`; cookie of a verified admin (seeded with `Role: admin`) -> `403`, plain - text body exactly `apperr.OwnerRequiredErrorMessage`. Each asserts no invite and no mail. + text body exactly `apperr.MissingPermissionErrorMessage`. Each asserts no invite and no mail. 14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`, `201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no invite or mail. `GET /` from the same address afterwards is not `429`. diff --git a/features/invite_management.feature b/features/invite_management.feature index bb57db9..c34a2dd 100644 --- a/features/invite_management.feature +++ b/features/invite_management.feature @@ -1,4 +1,5 @@ Feature: The owner can cancel a pending invite and renew a pending or expired one + 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 the shared RenewCMS workspace I want to cancel an invite I no longer want, or renew one that is stale or expired diff --git a/features/invite_management.qa.md b/features/invite_management.qa.md index 2f8fc2a..3f6f8db 100644 --- a/features/invite_management.qa.md +++ b/features/invite_management.qa.md @@ -1,5 +1,7 @@ # QA procedure: invite management +> 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). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo` and `mockInviteRepo`. Extend `mockInviteRepo` with the new renew method and a `failSwitch` entry for it (unique type names across the package). No live server, no real SQLite (except case 27), no real SMTP. @@ -45,7 +47,7 @@ across the package). No live server, no real SQLite (except case 27), no real SM ## Access and ids (table over both endpoints) 22. No cookie -> `401`; stale `token_version` -> `401`; unverified -> `403`; admin -> `403` with body - `apperr.OwnerRequiredErrorMessage`; admin on an unknown id -> `403`. Each: invite unchanged, no mail. + `apperr.MissingPermissionErrorMessage`; admin on an unknown id -> `403`. Each: invite unchanged, no mail. 23. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage` on both endpoints. ## Rate limit diff --git a/features/media_cleanup_spa.feature b/features/media_cleanup_spa.feature index 6336fca..16e9988 100644 --- a/features/media_cleanup_spa.feature +++ b/features/media_cleanup_spa.feature @@ -1,4 +1,5 @@ Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button + 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 RenewCMS owner or admin I want to turn on automatic duplicate purging and to purge duplicates by hand diff --git a/features/media_cleanup_spa.qa.md b/features/media_cleanup_spa.qa.md index abbc179..0ec4bf8 100644 --- a/features/media_cleanup_spa.qa.md +++ b/features/media_cleanup_spa.qa.md @@ -1,5 +1,7 @@ # QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button) +> 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. + No Go test: the API contract is already covered by media_purge.qa.md and media_purge_scheduler.qa.md. This slice has no Go change. The SPA never reaches `persistence`, `mailer` or `gorm`, and every `/v1` call goes through `useApi`. Settled decisions: a non-admin sees the Read-only card with placeholders and no settings GET; Partial is a 200 with `failed_groups > 0`; a 403 on PATCH reverts silently, refreshes the session and switches the card to Read-only; dates use the browser timezone. diff --git a/features/media_purge.feature b/features/media_purge.feature index b7bcb6c..b76b654 100644 --- a/features/media_purge.feature +++ b/features/media_purge.feature @@ -1,4 +1,5 @@ Feature: Owners and admins can merge duplicate images in use, with persisted purge settings + 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 RenewCMS owner or admin I want to see what a duplicate purge would do, run it, and keep the automatic-purge settings @@ -35,7 +36,7 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur RequireVerifiedUser), then a group guarded by the new middleware.RequireOwnerOrAdmin, and registered before the /{id} routes And RequireOwnerOrAdmin is an allowlist: only the roles owner and admin pass; any other value, - including an empty role, answers 403 with apperr.OwnerOrAdminRequiredErrorMessage + including an empty role, answers 403 with apperr.MissingPermissionErrorMessage ("Only owners and admins can do this.") as plain text from http.Error And a stale fingerprint answers 409 with apperr.PurgeConflictErrorMessage ("The library changed, so nothing was purged. Check the new summary and try again.") and a busy run answers 409 with @@ -59,14 +60,14 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur | POST | /v1/media/purge | no cookie | 401 | the existing jwtauth response (status only) | | GET | /v1/media/purge-settings | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage | | GET | /v1/media/purge-settings | cookie of an unverified owner | 403 | apperr.VerificationRequiredErrorMessage | - | GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage | - | POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage | + | GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage | + | PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage | + | GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.MissingPermissionErrorMessage | + | GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage | + | PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage | + | GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage | + | POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage | Scenario Outline: Owners and admins both pass the role guard When the client sends with a valid cookie of a verified diff --git a/features/media_purge.qa.md b/features/media_purge.qa.md index 1adb0c2..4ca14fd 100644 --- a/features/media_purge.qa.md +++ b/features/media_purge.qa.md @@ -1,5 +1,7 @@ # QA procedure: media_purge +> 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). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var. ## Setup shared by every case diff --git a/features/roles_permissions_enforcement.feature b/features/roles_permissions_enforcement.feature new file mode 100644 index 0000000..2867e75 --- /dev/null +++ b/features/roles_permissions_enforcement.feature @@ -0,0 +1,396 @@ +Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates + + As the owner of a RenewCMS site + I want every owner and admin gate replaced by a check of the caller's role permissions + So that built-in roles are enforced by what they hold, API keys follow their creator's live role, + and later slices can add custom roles without touching the routes again + + Background: + Given the spec of record is docs/design/screens/roles.md (catalogue, "Built-in roles", "HTTP status to copy", section 7) + And features/roles_permissions_foundation.feature provides the catalogue, the Owner (34), Admin (29) and Writer (9) roles, + users.role_id, the legacy fallback and GetMyAccessUseCase + And "ok" in a table means the request passes the permission gate. A later 400, 404 or 409 from the handler is not a gate failure + And "403" in a table means status 403, body "missing_permission\n" (http.Error, plain text, like the other snake_case codes + in api/pkg/apperr) and the use case or handler body is not invoked + And the code is apperr.MissingPermissionErrorMessage = "missing_permission" + And the response carries the header X-Missing-Permission with the catalogue key that failed, so the SPA can word + "Your role doesn't include {permission name}" + And api/middleware gains RequirePermission(key) for on/off keys and for the minimum "not none" check of a scoped key. It uses + a use case that depends on domain interfaces only. It resolves the caller's role live on every request, with no cache + And a request that fails the gate answers only after the existing steps: authtoken.Authenticator, RequireCurrentToken (401), + RequireVerifiedUser (403 verification), RequireSession (403 session_required for an API key) and RejectAPIKey (401) + And RequireOwner, RequireOwnerOrAdmin, apperr.OwnerRequiredErrorMessage and apperr.OwnerOrAdminRequiredErrorMessage are removed + And no use case, handler or middleware reads user.Role for an authorization decision. user.Role stays only for the + role_id fallback, for CreateUser and AcceptInvite assigning it, for ListUsersAndInvites output and for migrations + And a scoped decision is taken from the caller's resolved grants. Use cases receive them, not user.Role + And the role is resolved as in GetMyAccessUseCase: role_id first, else the built-in named by the legacy string + And a role that cannot be resolved (role_id unknown, or no role_id and a legacy string other than "owner" or "admin") + is refused with 403 missing_permission on every gated route. A repository failure other than role.ErrRoleNotFound + 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) + 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, + PATCH = settings.edit_general; purge preview and run = media.delete at scope all (own fails closed, images record no creator); + per-image variant mutations and image-variants configuration = settings.edit_general; media variants GET and image-variants + list = media.view; image-variants cache stats = settings.view; builtin types GET = schema.view + And GET /v1/site stays open to any verified user + And settings.manage_sites covers PATCH /v1/site/{id} including site SEO defaults, so Admin may edit them + + Scenario: A refused request carries the missing_permission contract + Given user 9 (Writer) + When the client sends POST /v1/user/invite with body {"email": "new@example.com"} and user 9's cookie + Then the response status is 403 and the body is "missing_permission\n" + And the header X-Missing-Permission is "users.invite" + And no invite is created and no mail is sent + + Scenario: Verification and token checks still come first + Given user 12 is not verified and holds the Admin role + When the client sends POST /v1/user/invite with user 12's cookie + Then the status is 403 and the body is apperr.VerificationRequiredErrorMessage + And a stale token_version gives 401 apperr.UnauthorizedErrorMessage and no cookie gives 401, as before + + Scenario Outline: Users and invites + Given the caller is + When the client sends with that caller's cookie + Then the gate result is + + Examples: + | role | call | key | result | + | Owner | GET /v1/user | users.view | ok | + | Admin | GET /v1/user | users.view | ok | + | Writer | GET /v1/user | users.view | 403 | + | Owner | POST /v1/user/invite | users.invite | ok | + | Admin | POST /v1/user/invite | users.invite | ok | + | Writer | POST /v1/user/invite | users.invite | 403 | + | Admin | DELETE /v1/user/invite/1 | users.invite | ok | + | Writer | DELETE /v1/user/invite/1 | users.invite | 403 | + | Admin | POST /v1/user/invite/1/renew | users.invite | ok | + | Writer | POST /v1/user/invite/1/renew | users.invite | 403 | + + Scenario Outline: Settings, sites and system + Given the caller is + When the client sends with that caller's cookie (and a real session, not an API key, where the route requires one) + Then the gate result is + + Examples: + | role | call | key | result | + | Owner | GET /v1/settings | settings.view | ok | + | Admin | GET /v1/settings | settings.view | ok | + | Writer | GET /v1/settings | settings.view | 403 | + | Owner | PATCH /v1/settings | system.edit_retention | ok | + | Admin | PATCH /v1/settings | system.edit_retention | 403 | + | Writer | PATCH /v1/settings | system.edit_retention | 403 | + | Owner | POST /v1/site | settings.manage_sites | ok | + | Admin | POST /v1/site | settings.manage_sites | ok | + | Writer | POST /v1/site | settings.manage_sites | 403 | + | Admin | PATCH /v1/site/2 | settings.manage_sites | ok | + | Admin | PATCH /v1/site/2/default | settings.manage_sites | ok | + | Writer | PATCH /v1/site/2 | settings.manage_sites | 403 | + | Owner | DELETE /v1/site/2 | settings.delete_site | ok | + | Admin | DELETE /v1/site/2 | settings.delete_site | 403 | + | Writer | DELETE /v1/site/2 | settings.delete_site | 403 | + | Writer | GET /v1/site | (none) | ok | + | Owner | GET /v1/system/update | settings.view | ok | + | Admin | GET /v1/system/update | settings.view | ok | + | Writer | GET /v1/system/update | settings.view | 403 | + | Owner | POST /v1/system/update/check | system.apply_updates | ok | + | Admin | POST /v1/system/update/check | system.apply_updates | 403 | + | Owner | POST /v1/system/update/download | system.apply_updates | ok | + | Admin | POST /v1/system/update/download | system.apply_updates | 403 | + | Owner | POST /v1/system/update/restart | system.apply_updates | ok | + | Admin | POST /v1/system/update/restart | system.apply_updates | 403 | + | Writer | POST /v1/system/update/restart | system.apply_updates | 403 | + + Scenario: The permission gate runs before the update rate limiters + Given the update-check limiter allows 5 attempts per minute per user + When the Admin sends POST /v1/system/update/check 6 times + Then every response is 403 missing_permission and none is 429 + + Scenario: The permission gate runs before the invite rate limiter + Given the invite limiter is exhausted for user 9 + When the Writer sends POST /v1/user/invite + Then the response is 403 missing_permission and none is 429 + + Scenario: An admin reads the update status but cannot apply it + Given the Admin and the Owner each send GET /v1/system/update + Then both get 200 + And the Admin's body has can_apply false + And the Owner's body has can_apply true when the update helper is installed, false otherwise, as before + + Scenario: An admin reads the activity retention but cannot change it + Given the Admin sends GET /v1/settings + Then the status is 200 and the body holds activity_retention_days and activity_retention_max_gb + And PATCH /v1/settings with {"activity_retention_days": 30} gives 403 missing_permission and nothing is stored + + Scenario Outline: Authors, schema, media and API key creation + Given the caller is + When the client sends with that caller's cookie + Then the gate result is + + Examples: + | role | call | key | result | + | Owner | GET /v1/author | authors.view | ok | + | Writer | GET /v1/author | authors.view | ok | + | Admin | POST /v1/author | authors.create | ok | + | Writer | POST /v1/author | authors.create | 403 | + | Admin | DELETE /v1/author/4 | authors.delete | ok | + | Writer | DELETE /v1/author/4 | authors.delete | 403 | + | Writer | POST /v1/author/me | (none) | ok | + | Admin | GET /v1/content-type | schema.view | ok | + | Writer | GET /v1/content-type | schema.view | ok | + | Writer | GET /v1/content-type/events | schema.view | ok | + | Admin | POST /v1/content-type | schema.edit | ok | + | Writer | POST /v1/content-type | schema.edit | 403 | + | Writer | PUT /v1/content-type/events | schema.edit | 403 | + | Admin | DELETE /v1/content-type/events | schema.delete | ok | + | Writer | DELETE /v1/content-type/events | schema.delete | 403 | + | Writer | GET /v1/builtin-type | schema.view | ok | + | Writer | GET /v1/media | media.view | ok | + | Writer | GET /v1/media/1 | media.view | ok | + | Writer | GET /v1/media/1/usage | media.view | ok | + | Writer | POST /v1/media (multipart image) | media.upload | ok | + | Writer | PATCH /v1/media/1 | media.upload | ok | + | Writer | DELETE /v1/media/1 | media.delete | 403 | + | Writer | POST /v1/media/merge | media.delete | 403 | + | Admin | DELETE /v1/media/1 | media.delete | ok | + | Writer | GET /v1/media/purge-settings | settings.view | 403 | + | Writer | PATCH /v1/media/purge-settings | settings.edit_general| 403 | + | Writer | GET /v1/media/purge-preview | media.delete | 403 | + | Writer | POST /v1/media/purge | media.delete | 403 | + | Admin | POST /v1/media/purge | media.delete | ok | + | Writer | GET /v1/media/1/variants | media.view | ok | + | Writer | POST /v1/media/1/variants | settings.edit_general| 403 | + | Writer | DELETE /v1/media/1/variants/hero | settings.edit_general| 403 | + | Writer | GET /v1/image-variants | media.view | ok | + | Writer | GET /v1/image-variants/cache | settings.view | 403 | + | Writer | POST /v1/image-variants | settings.edit_general| 403 | + | Writer | POST /v1/image-variants/cache/purge | settings.edit_general| 403 | + | Admin | PUT /v1/image-variants/hero | settings.edit_general| ok | + | Admin | POST /v1/api-key | apikeys.create | ok | + | Writer | POST /v1/api-key | apikeys.create | 403 | + + Scenario: Routes that keep their old behaviour for API keys + Given a valid API key whose creator is the Admin + Then POST /v1/api-key, GET /v1/activity and POST /v1/system/update/check answer 403 apperr.SessionRequiredErrorMessage + And GET /v1/media/1/variants answers 401 apperr.UnauthorizedErrorMessage (RejectAPIKey runs before the permission check) + + Scenario Outline: Roles are judged by the keys they hold, with no implied keys + Given user 20 has a custom role that grants only + When the client sends with user 20's cookie + Then the gate result is + + Examples: + | grants | call | result | + | schema.edit=all | POST /v1/content-type | ok | + | schema.edit=all | GET /v1/content-type | 403 | + | schema.view=all | PUT /v1/content-type/events | 403 | + | users.invite=all | GET /v1/user | 403 | + | users.view=all | POST /v1/user/invite | 403 | + | settings.manage_sites=all | DELETE /v1/site/2 | 403 | + | settings.delete_site=all | PATCH /v1/site/2 | 403 | + | media.upload=all | GET /v1/media | 403 | + | 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 | + | (no grants) | GET /v1/author | 403 | + + Scenario: A user whose role cannot be resolved is refused everywhere that is gated + Given user 13 has role_id 99 (no such role) and legacy role "" + When user 13 sends GET /v1/user, GET /v1/content-type and POST /v1/media + Then every response is 403 missing_permission + + Scenario: A repository failure while resolving the role is a server error + Given the role repository fails with a non-sentinel error + When user 3 sends GET /v1/user + Then the status is 500 and the body is "Internal server error" + + Scenario Outline: The legacy role string is no longer read for authorization + Given user has legacy role "" and role_id + When the client sends with that user's cookie + Then the gate result is + + Examples: + | id | legacy | role_id | call | result | + | 14 | owner | Writer's id | POST /v1/user/invite | 403 | + | 15 | admin | Writer's id | POST /v1/content-type| 403 | + | 16 | "" | Admin's id | POST /v1/content-type| ok | + | 17 | owner | 0 | POST /v1/user/invite | ok | + | 18 | admin | 0 | POST /v1/content-type| ok | + | 19 | admin | 0 | PATCH /v1/settings | 403 | + + Scenario: A session follows its role live + Given user 9 is on the Writer role and GET /v1/media/purge-settings gives 403 + When Writer's grants are edited in the repository to add settings.view=all + Then the next GET /v1/settings with the same cookie answers ok, with no re-login and no cache + + Scenario: Admin relaxations + Given the Admin + Then the Admin can now call POST /v1/user/invite, DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew + And can now call POST /v1/site, PATCH /v1/site/{id} and PATCH /v1/site/{id}/default + And can now list every API key with created_by_name and revoke any key + And can read every setting: GET /v1/settings, GET /v1/system/update and the retention values + + Scenario: Owner-only items stay owner-only + Given the Admin + Then DELETE /v1/site/{id}, PATCH /v1/settings, POST /v1/system/update/check, /download and /restart answer 403 missing_permission + And the Owner passes all of them + + Scenario: Writer is effective + Given user 9 on the Writer role + Then GET /v1/content-type and GET /v1/content-type/events answer ok + And GET /v1/media, GET /v1/media/1 and POST /v1/media answer ok + And GET /v1/author answers ok and the author items carry no user_email + And GET /v1/user, GET /v1/settings, GET /v1/system/update, POST /v1/user/invite, POST /v1/site and POST /v1/api-key answer 403 + And GET /v1/activity answers ok and returns only the events whose actor is user 9 + And GET /v1/permission, GET /v1/auth/me, PATCH /v1/user/me and POST /v1/author/me answer ok + + Scenario Outline: activity.view scope decides what the list returns + Given events by user 3, user 5 and user 9 exist + And the caller's activity.view grant is + When the client sends GET /v1/activity + Then the result is + + Examples: + | scope | query | result | + | all | | 200, events of users 3, 5 and 9 | + | all | ?actor=user:5 | 200, events of user 5 only | + | own | | 200, only the caller's own events | + | own | ?actor=user:9 | 200, only the caller's own events (self is allowed) | + | own | ?actor=user:3 | 403 missing_permission | + | none | | 403 missing_permission | + + Scenario Outline: Activity summary and export follow the scope + Given the caller holds + When the client sends + Then the result is + + Examples: + | grants | call | result | + | activity.view=all | GET /v1/activity/summary| 200 | + | activity.view=own | GET /v1/activity/summary| 403 missing_permission | + | activity.view=all, activity.export=all | GET /v1/activity/export | 200 CSV of every event | + | activity.view=own, activity.export=all | GET /v1/activity/export | 200 CSV of the caller's own events only | + | activity.view=none, activity.export=all | GET /v1/activity/export | 403 missing_permission | + | activity.view=all | GET /v1/activity/export | 403 missing_permission | + + Scenario Outline: apikeys.view scope decides which keys are listed + Given keys 1 (created by user 3), 2 (user 5) and 3 (user 9) exist + And the caller's apikeys.view grant is + When the client sends GET /v1/api-key + Then the result is + + Examples: + | scope | result | + | all | 200, keys 1, 2 and 3, each with created_by_name | + | own | 200, only the caller's own keys, without created_by_name | + | none | 403 missing_permission | + + Scenario Outline: apikeys.revoke scope decides which keys can be revoked + Given key 2 was created by user 5 + And the caller is user 9 and holds apikeys.revoke = + When the client sends DELETE /v1/api-key/2 + Then the result is + + Examples: + | scope | result | + | all | 204 and key 2 is revoked | + | own | 404 apperr.APIKeyNotFoundErrorMessage and key 2 is not revoked | + | none | 403 missing_permission, key 2 is not revoked and no lookup occurs | + + Scenario: The Admin lists and revokes every key + Given the Admin and a key created by the Owner + When the Admin sends GET /v1/api-key and DELETE /v1/api-key/ + Then the list contains the Owner's key with created_by_name, and the delete returns 204 + + Scenario Outline: authors.edit scope decides who can edit an author + Given author 4 is linked to user 9 and author 5 is linked to user 3 + And the caller is user 9 with authors.edit = + When the client sends PATCH with body + Then the result is + + Examples: + | scope | path | body | result | + | all | /v1/author/5 | {"bio": "x"} | 200 | + | all | /v1/author/5 | {"user_id": 9} | 200 or the existing link errors | + | own | /v1/author/4 | {"bio": "x"} | 200 | + | own | /v1/author/5 | {"bio": "x"} | 403 author_rights (existing contract) | + | own | /v1/author/4 | {"user_id": null} | 403 author_rights (existing contract) | + | none | /v1/author/4 | {"bio": "x"} | 403 missing_permission | + | none | /v1/author/999 | {"bio": "x"} | 403 missing_permission (before any lookup) | + + Scenario Outline: user_email on authors needs users.view + Given author 4 is linked to a user with an email + And the caller holds authors.view and users.view = + When the client sends GET /v1/author + Then user_email is + + Examples: + | users_view | shown | + | all | present | + | none | absent | + + Scenario: An API key resolves its creator's current role on every request + Given a valid API key created by user 5 (Admin) + And GET /v1/content-type with that key answers 200 and POST /v1/user/invite answers ok + When user 5's role_id is changed to the Writer role in the repository + Then the next POST /v1/user/invite with the same key answers 403 missing_permission + And GET /v1/content-type with the same key still answers 200 + And POST /v1/content-type with the same key answers 403 missing_permission + And no new key and no new login is needed for the change to apply + + Scenario: A key grows when its creator's role grows + Given a valid API key created by user 9 (Writer), and POST /v1/content-type with it gives 403 + When user 9's role_id is changed to the Admin role + Then the next POST /v1/content-type with the same key passes the gate + + Scenario: Editing the creator's role grants shrinks the key as well + Given a valid API key created by user 20 whose custom role grants media.view and media.upload + When the role's media.upload grant is removed in the repository + Then POST /v1/media with the key gives 403 missing_permission and GET /v1/media still passes + + Scenario: A key whose creator's role cannot be resolved is refused by the gate + Given a valid API key created by user 13 (role_id 99) + When the client sends GET /v1/media with the key + Then the status is 403 missing_permission + + Scenario: Key authentication failures are unchanged + Then an unknown, revoked or expired key, or one whose creator is deleted or unverified, still gives 401 apperr.UnauthorizedErrorMessage + + Scenario: Public and self-service routes keep no permission gate + Given no cookie + Then GET /v1/article, GET /v1/article/{id}, GET /v1/article/{id}/seo and GET /v1/site/{key}/sitemap answer as before + And user 9 on a role with no grants still gets 200 on GET /v1/auth/me, GET /v1/permission, GET /v1/site and GET /v1/user/lookup?ids=1, and ok on PATCH /v1/user/me + + 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) + + Scenario Outline: Existing owner and admin behaviour does not regress + Given the caller is + When the client sends + Then the status is exactly as before this slice + + Examples: + | role | call | status | + | Owner | GET /v1/user | 200 | + | Admin | GET /v1/user | 200 | + | Owner | POST /v1/content-type | as before | + | Admin | POST /v1/content-type | as before | + | Admin | GET /v1/builtin-type | 200 | + | Admin | GET /v1/activity/summary | 200 | + | Admin | GET /v1/activity/export | 200 CSV | + | Admin | GET /v1/media/purge-settings | 200 | + | Admin | PATCH /v1/media/purge-settings | as before | + | Admin | GET /v1/image-variants | 200 | + | Admin | GET /v1/media/1/variants | as before | + | Owner | PATCH /v1/settings | as before | + | Admin | DELETE /v1/author/4 | as before | + + Scenario: The owner and admin gates are gone from the code + Then api/router.go and api/middleware hold no RequireOwner or RequireOwnerOrAdmin + And no file under api/ or internal/infrastructure/useCases compares a user.Role value for authorization, + apart from the role_id fallback in GetMyAccessUseCase, CreateUser and AcceptInvite assigning a role, and the ListUsersAndInvites output + And the apperr constants OwnerRequiredErrorMessage and OwnerOrAdminRequiredErrorMessage no longer exist diff --git a/features/roles_permissions_enforcement.qa.md b/features/roles_permissions_enforcement.qa.md new file mode 100644 index 0000000..8962afd --- /dev/null +++ b/features/roles_permissions_enforcement.qa.md @@ -0,0 +1,152 @@ +# QA procedure: roles_permissions_enforcement + +Package: `test` (black-box). Name new fakes uniquely, for example `enforcementRoleRepo`, `enforcementUserRepo`. +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. +- 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. + +## Design notes for the user + +- docs/design/screens/roles.md "Owner-only items" disables View roles and Manage roles for an admin. This slice follows the user's decision (Admin has 29 keys). The design needs a fix by the user. +- docs/design/change-list.md item 21 ("Sites: owner only, hidden for other roles") and its SEO "Permissions" paragraph ("only the owner edits site SEO defaults", 403 `owner_only`), plus the `owner_only` clause in routes.md "SEO", disagree with this slice. Admin has `settings.manage_sites`, so an admin can now edit sites and their SEO defaults. +- roles.md is inconsistent about Writer and Schema. The "Writer: Articles list" state hides Schema from the sidebar, while the catalogue, the built-in table and change-list ("Schema read-only for view-only roles") give Writer `schema.view`. +- change-list line 100 mentions a "restricted `user` role" that does not exist. +- The docs name `GET /v1/me`. The implemented route is `GET /v1/auth/me` (resolved in slice 1). +- change-list item 15 and routes.md word activity export as "owner and admin"; it is now `activity.export` plus `activity.view`. +- change-list item 22 says GET /v1/author works for any role; under 2a a role without `authors.view` gets 403. +- The Home activity card may call activity summary, so a Writer would get 403 there; check before the SPA slice. + +## Inventory (route, current gate, new key, Owner / Admin / Writer) + +Routes gated today by RequireOwner or RequireOwnerOrAdmin: + +| Route | New key (scope) | Owner / Admin / Writer | Change | +|---|---|---|---| +| POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew | users.invite | ok / ok / 403 | Admin gains | +| PATCH /v1/settings | system.edit_retention | ok / 403 / 403 | none | +| POST /v1/site, PATCH /v1/site/{id}, PATCH /v1/site/{id}/default | settings.manage_sites | ok / ok / 403 | Admin gains | +| DELETE /v1/site/{id} | settings.delete_site | ok / 403 / 403 | none | +| POST /v1/author | authors.create | ok / ok / 403 | none | +| DELETE /v1/author/{id} | authors.delete | ok / ok / 403 | none | +| POST, PUT /v1/content-type | schema.edit | ok / ok / 403 | none | +| DELETE /v1/content-type/{key} | schema.delete | ok / ok / 403 | none | +| GET /v1/builtin-type, /{key} | schema.view | ok / ok / ok | Writer gains | +| GET /v1/activity/summary | activity.view at all | ok / ok / 403 | none | +| GET /v1/activity/export | activity.export plus activity.view (same scoping as the list) | ok / ok / 403 | none | +| POST /v1/system/update/check, /download, /restart | system.apply_updates | ok / 403 / 403 | none | +| GET /v1/media/purge-settings | settings.view | ok / ok / 403 | none | +| PATCH /v1/media/purge-settings | settings.edit_general | ok / ok / 403 | none | +| GET /v1/media/purge-preview, POST /v1/media/purge | media.delete at all | ok / ok / 403 | none | +| GET /v1/media/{id}/variants (after RejectAPIKey) | media.view | ok / ok / ok | Writer gains | +| POST, DELETE /v1/media/{id}/variants... (after RejectAPIKey) | settings.edit_general | ok / ok / 403 | none | +| GET /v1/image-variants | media.view | ok / ok / ok | Writer gains | +| GET /v1/image-variants/cache | settings.view | ok / ok / 403 | none | +| other image-variants mutations and cache purge | settings.edit_general | ok / ok / 403 | none | + +Gated by a legacy `Role ==` inside a use case or handler: + +| Place | New | +|---|---| +| ListActivity.scopeToViewer, activityHandler.writeActivityError | activity.view: all unfiltered, own forced to self (another actor gives 403 missing_permission), none gives 403 | +| ListAPIKeys | apikeys.view: all lists everything with created_by_name, own only the caller's keys without it, none 403 | +| RevokeAPIKey | apikeys.revoke: all any key, own only the caller's (404 API key not found on another's), none 403 | +| ListAuthors (CallerRole, isAdminRole) | gate authors.view; user_email only if the caller holds users.view | +| UpdateAuthor / AuthorCaller.isAdmin | authors.edit: all any author and the link, own the linked author only (else author_rights), none 403 missing_permission | +| updateHandler.isOwner | can_apply true only with system.apply_updates (and helper installed, as today) | +| middleware.RequireOwner, RequireOwnerOrAdmin | deleted; replaced by RequirePermission(key) and a scope-aware variant resolving the live role | + +Previously ungated or verification-only: + +| Route | New | +|---|---| +| GET /v1/user | users.view | +| GET /v1/settings | settings.view | +| GET /v1/system/update | settings.view | +| GET /v1/author | authors.view | +| GET /v1/content-type, /{key} | schema.view | +| GET /v1/media, /{id}, /{id}/usage | media.view | +| POST /v1/media | media.upload | +| PATCH /v1/media/{id} | media.upload | +| DELETE /v1/media/{id}, POST /v1/media/merge | media.delete at all (own fails closed) | +| POST /v1/api-key | apikeys.create (RequireSession stays) | +| GET /v1/api-key, DELETE /v1/api-key/{id} | apikeys.view, apikeys.revoke (scoped) | +| GET /v1/activity | activity.view (scoped) | +| GET /v1/site, GET /v1/user/lookup, PATCH /v1/user/me, POST /v1/author/me, GET /v1/permission, GET /v1/auth/me | ungated | +| /v1/article/*, /v1/content-type/{key}/entry/*, public reads | ungated in 2a | + +## Test infrastructure first + +1. `enforcementRoleRepo` implements the role `Repository` in memory, seeded with the three built-ins through the same Go function the migration uses. It supports editing grants and a switchable failure. +2. The user fake carries `RoleID`. Add users 3 (Owner), 5 (Admin), 9 (Writer), 12 (unverified Admin), 13 (role_id 99), 14-19 (legacy and role_id combinations from the Outline), 20 (custom role). +3. A helper `mintCookie(userID, tokenVersion)` and a helper to mint API keys backed by the existing `apikey` fakes (see `test/api_keys_http_test.go`). +4. HTTP harness. Call `validator.InitValidator()`. Build each router with the same middleware chain as `api/router.go` for that group, now with the permission middleware, over use cases wired to fakes. + - Follow whichever approach the closest existing HTTP test uses. Existing tests for `NewUserRouter`, `NewSettingsRouter`, `NewSiteRouter`, `NewAuthorRouter`, `NewContentTypeRouter`, `NewBuiltinTypeRouter`, `NewActivityRouter`, `NewSystemRouter`, `NewAPIKeyRouter`, `NewMediaRouter` and `NewImageVariantRouter` need the role repository added to their fixtures. + - Mount `authtoken.Verifier` and `container.apiKeyAuth()` equivalents where the real router has them, so key scenarios traverse the same order. + - No live server, no real SQLite file, no real SMTP, no real update helper. +5. A gate-matrix helper `assertGate(t, rec, want)`. For "ok" assert the status is not the `missing_permission` 403 and the handler or use case spy was reached. For "403" assert status 403, body `missing_permission\n`, header `X-Missing-Permission` equals the key, and the spy was not reached. + +## A. Domain and use cases (fakes only) + +6. Permission check use case (name left to coder, for example `CheckPermission`): table-test owner, admin, writer, a custom role, unresolvable role (expect `role.ErrMissingPermission` via `errors.Is`; the failing key travels in a typed error via `errors.As`), a failing repository (expect a non-sentinel error), and a live grant change between two calls. +7. `ListActivity`: all, own (self filter forced, other actor returns `audit.ErrActorNotAllowed`), none. No `user.Role`. +8. `ListAPIKeys` and `RevokeAPIKey`: all, own, none. `created_by_name` follows scope all. Own on another's key returns `apikey.ErrAPIKeyNotFound`. +9. `ListAuthors` and `UpdateAuthor`: `user_email` follows `users.view`. `authors.edit` own, all, none, including `author.ErrAuthorRights` for own violations. +10. The `can_apply` computation in the update status follows `system.apply_updates`. +11. Hygiene tests, in the style of the existing `*_hygiene_test.go`: + - `api/router.go` and `api/middleware` contain no `RequireOwner`. + - Non-test Go under `api/` and `internal/infrastructure/useCases` has no `user.RoleOwner` or `user.RoleAdmin` comparison apart from the allowed spots listed in the feature. + - `internal/domain/role` still imports only the stdlib. + +## B. HTTP + +12. Gate matrices: run every Scenario Outline row. One test per outline, subtests per row, named after the row. +13. Contract: status 403, body `missing_permission\n`, `X-Missing-Permission`, no side effect (no invite stored, no mail sent, no site created, retention unchanged). +14. Order: stale token 401, no cookie 401, unverified 403 verification, API key on session-only routes 403 `session_required`, API key on `/v1/media/1/variants` 401, all before the permission check. Limiter order: 6 admin update checks give 6 times 403; exhausted invite limiter gives 403 for a Writer. +15. Scoped: activity (list, summary, export), api keys (list, revoke), authors (edit, `user_email`) per the Outlines. +16. API key live role: change `role_id` between requests, edit grants between requests, and unresolvable role. Use the same key value throughout. +17. Session live role: edit grants between two requests with the same cookie. +18. Legacy string: the six rows of the "legacy role string" Outline. +19. Ungated: public routes, `/v1/auth/me`, `/v1/permission`, `/v1/site`, `/v1/user/lookup`, `PATCH /v1/user/me`, `POST /v1/author/me` answer for a user whose role has no grants. Article and entry routes behave as in their existing tests. +20. Regression: run the existing suites for the owner and admin cases in the "does not regress" Outline unchanged, after the assertion edits listed below. + +## Existing tests that must change (found by grep, so recheck with `go vet`) + +Assertions of the removed constants become `missing_permission`, or the expectation flips where Admin gains access. + +| File (lines) | Change | +|---|---| +| test/roles_permissions_foundation_http_test.go (around 320, the "Existing role checks are untouched" Outline) | Superseded. The admin rows for invite and `/system/update/check` move to this slice (invite becomes ok, update check becomes `missing_permission`). Keep the owner rows. | +| test/update_http_test.go:248; test/update_download_http_test.go:187; test/update_restart_http_test.go:263-264 | Admin 403 body becomes `missing_permission` | +| test/activity_log_backend_http_test.go:280, 735; test/activity_filter_cards_and_export_http_test.go:75; test/activity_next_cleanup_test.go:199 | Message becomes `missing_permission`. The "editor" fixtures need a Writer or custom role_id. Admin retention read stays 200. | +| test/user_roles_test.go:280; test/invite_management_test.go:410; test/api_keys_http_test.go:434, 573 | Admin invite is now allowed. Other Owner-required assertions become `missing_permission`. API key list and revoke fixtures need roles. | +| test/seo_output_sitemap_http_test.go:167; test/seo_data_site_http_test.go; test/site_acceptance_test.go; test/authors_backend_http_test.go | Admin PATCH /v1/site/{id} is now allowed. Message assertions for non-admins become `missing_permission`. | +| test/custom_content_types_test.go:131 | "member without a role" gets 403 `missing_permission`. The fixture needs an unresolvable or empty role. | +| test/media_purge_http_test.go:160-167; test/media_purge_qa_test.go:31; test/image_variants_per_image_http_test.go:152-155; test/image_variants_cache_http_test.go:32-33; test/image_variants_admin_http_test.go:42-47 | The "editor", "plain" and "empty role" users need role_ids. Expected body becomes `missing_permission`. | +| every test that builds users with a legacy `Role` and no `RoleID` | Add `RoleID` (or rely on the fallback for "owner" and "admin" only). Users with legacy roles like "editor" or "" are now unresolvable. | + +## Existing features to amend or mark superseded + +Edit the owner/admin 403 clauses in place or add a "superseded by roles_permissions_enforcement" note. + +- roles_permissions_foundation (last Outline) +- user_roles, invite_management, invitations +- sites_backend, sites_authors_integration, seo_data_backend, seo_output_backend, seo_sites_articles_integration +- authors_backend +- activity-log-backend, activity_filter_cards_and_export, activity_home_card_and_retention +- api_keys, integration_api_key_routes, integration +- media_purge, media_cleanup_spa, image-variants, image_variants_cache, image_variants_per_image_api +- updates_download_stage, updates_restart_helper, updates_release_feed, container_update_guidance +- custom_content_types, custom_content_types_support + +Also check the matching `.qa.md` files and these non-test texts: `web/src/data/apiCatalogue.ts` (OWNER_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE), `web/src/views/IntegrationView.vue` ("Owner and admin routes answer 403…") and anything under `internal/infrastructure/llmdocs` that documents the old messages. The SPA stores handle 403 by status only, so they should be unaffected; this is a manual check. + +## Manual verification + +- Start `task dev:api` on a copy of a migrated database. As the admin, check that settings are readable and software and retention updates return 403. As the owner, check everything still works. +- Create an API key as an admin, change that user to Writer by editing `users.role_id` in the database copy, and confirm the same key now gets 403 on a schema write. +- The SPA is untouched, so the admin will still see owner-only UI hidden. That is expected until the SPA slices. diff --git a/features/roles_permissions_foundation.feature b/features/roles_permissions_foundation.feature index a22370c..c15aa83 100644 --- a/features/roles_permissions_foundation.feature +++ b/features/roles_permissions_foundation.feature @@ -1,4 +1,5 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints + 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 site I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope) @@ -287,7 +288,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills Examples: | id | legacy | call | status | | 3 | owner | POST /v1/user/invite | allowed (not 403) | - | 5 | admin | POST /v1/user/invite | 403 apperr.OwnerRequiredErrorMessage | + | 5 | admin | POST /v1/user/invite | allowed (not 403) | | 5 | admin | POST /v1/content-type | allowed (not 403) | - | 5 | admin | PATCH /v1/settings | 403 apperr.OwnerRequiredErrorMessage | - | 5 | admin | POST /v1/system/update/check | 403 apperr.OwnerRequiredErrorMessage | + | 5 | admin | PATCH /v1/settings | 403 apperr.MissingPermissionErrorMessage | + | 5 | admin | POST /v1/system/update/check | 403 apperr.MissingPermissionErrorMessage | diff --git a/features/roles_permissions_foundation.qa.md b/features/roles_permissions_foundation.qa.md index 8964a12..3ea6830 100644 --- a/features/roles_permissions_foundation.qa.md +++ b/features/roles_permissions_foundation.qa.md @@ -1,5 +1,7 @@ # QA procedure: roles_permissions_foundation +> 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). Name new fakes uniquely, for example `mockRoleRepo`, `rolesFoundationUserRepo`. Do not copy the old `Role ==` checks into new code. diff --git a/features/seo_data_backend.feature b/features/seo_data_backend.feature index 0007521..b729b7c 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_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 I want to store search and sharing data on articles and sites, with a slug that is unique per site diff --git a/features/seo_data_backend.qa.md b/features/seo_data_backend.qa.md index 91094fa..f6ab9b5 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_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. ## Divergences from docs/design @@ -7,7 +9,7 @@ Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `t `docs/design` is read-only; the SPA slices (3 and 4) align to the code, not to these design words. - **Site image and robots names:** the API uses `default_social_image_id` and `default_robots` (existing site contract). The design writes `social_image_id` for the site and, in places, `robots`. The SPA uses the code names. -- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.OwnerRequiredErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist. +- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.MissingPermissionErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist. - **Empty-slug fallback:** one rule everywhere. A title with no letter or digit becomes `article`, then `article-2`, `article-3` on conflict, in the API and in the migration. `docs/design/seo-slices.md` (decision 5) says `article-` for the migration; that wording is not followed. - **Slug conflict transport:** the conflict follows the authors slug conflict (plain text `slug_taken`, 409); the suggestion travels in the `X-Slug-Suggestion` response header, not in a JSON body. The `slug.suggestion` of the slice 2 check endpoint is separate. - **Empty slug:** `slug: ""` regenerates the slug from the title (the SPA "Reset to match the title" sends `""`); 409 only if the regenerated slug belongs to another article on the site. diff --git a/features/seo_output_backend.feature b/features/seo_output_backend.feature index 0df9185..3180356 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_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 I want the resolved SEO data of an article, a non-persisting check, an SEO status on the article list and a sitemap per site diff --git a/features/seo_output_backend.qa.md b/features/seo_output_backend.qa.md index 51b13b7..18bc43b 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_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. ## Divergences from docs/design diff --git a/features/seo_sites_articles_integration.feature b/features/seo_sites_articles_integration.feature index 3539b83..aaa15bb 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_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 I want to set the site-wide SEO defaults, see which articles need attention, dismiss suggestions I do not want, and read how the SEO routes work diff --git a/features/seo_sites_articles_integration.qa.md b/features/seo_sites_articles_integration.qa.md index 99da0cf..b1b374c 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_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. Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role. diff --git a/features/sites_authors_integration.feature b/features/sites_authors_integration.feature index c4d1f90..0c85dcd 100644 --- a/features/sites_authors_integration.feature +++ b/features/sites_authors_integration.feature @@ -1,4 +1,5 @@ Feature: Sites and authors in the Editor, Articles and Profile screens + 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 RenewCMS user who can edit articles I want to choose an article's site and bylines in the editor, see them in the articles list, and create my own author profile diff --git a/features/sites_authors_integration.qa.md b/features/sites_authors_integration.qa.md index 262934e..7450cbf 100644 --- a/features/sites_authors_integration.qa.md +++ b/features/sites_authors_integration.qa.md @@ -1,5 +1,7 @@ # QA: Sites and authors in Editor, Articles and Profile +> 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: the API already exists (commits "Sites feature backend" and "Authors feature backend"). A wrong contract is reported as a backend defect. Roles: the domain defines only `owner` and `admin` (`internal/domain/user`). There is no "member" constant. "verified" in the feature and below is a verified account without the owner or admin role, which has no author rights. The earlier `sites_authors_screens` files still say "member" for the same account; that wording is left as is. diff --git a/features/sites_backend.feature b/features/sites_backend.feature index 0f1b970..bf2d76f 100644 --- a/features/sites_backend.feature +++ b/features/sites_backend.feature @@ -1,4 +1,5 @@ Feature: Sites backend, the owner manages sites and every article belongs to exactly one + 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 installation I want to create, edit, delete and choose a default among several sites, and assign each article to one @@ -14,7 +15,7 @@ Feature: Sites backend, the owner manages sites and every article belongs to exa And GET /v1/site/ accepts a session or an API key; every write is session-only and goes through RequireOwner, like PATCH /v1/settings And PATCH /v1/article/{id} with site_id stays callable with an API key And error responses are plain text from http.Error whose body is exactly the code token: site_key_taken, site_has_articles, default_site, key_immutable, invalid_site_url, invalid_locale, site_not_found, image_not_found, use_set_default, invalid_reassign - And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.OwnerRequiredErrorMessage), with no owner_only token + And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.MissingPermissionErrorMessage), with no owner_only token And a request body that cannot be decoded answers 400 with apperr.BodyErrorMessage ("The request cannot be processed due to a mismatch in the format of the body."), a malformed id answers 400 with apperr.IdUint32ErrorMessage, and a validate-tag failure answers 400 with the validator error text, like POST /v1/article And a missing article answers 404 "Article not found" (apperr.ArticleNotFoundErrorMessage) And the roles are "owner" and "admin" only, and a "verified" account is one with is_verified true diff --git a/features/sites_backend.qa.md b/features/sites_backend.qa.md index f2550fc..0490393 100644 --- a/features/sites_backend.qa.md +++ b/features/sites_backend.qa.md @@ -1,5 +1,7 @@ # QA procedure: Sites backend +> 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/sites_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. Every error assertion compares the plain-text body to the exact code token. ## Setup diff --git a/features/updates_download_stage.feature b/features/updates_download_stage.feature index e4ac5ce..12f306f 100644 --- a/features/updates_download_stage.feature +++ b/features/updates_download_stage.feature @@ -1,4 +1,5 @@ Feature: Updates part 2: the Update button (download, verify, stage) + 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 site I want one button that downloads the new release, proves it is genuine, backs up the database and stages it @@ -100,7 +101,7 @@ Feature: Updates part 2: the Update button (download, verify, stage) Scenario: Only the owner When an admin sends POST /v1/system/update/download - Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage + Then the status is 403 and the body is apperr.MissingPermissionErrorMessage And no job starts and the fetcher is not called Scenario Outline: POST is guarded like the other system routes diff --git a/features/updates_download_stage.qa.md b/features/updates_download_stage.qa.md index e233d06..841ab76 100644 --- a/features/updates_download_stage.qa.md +++ b/features/updates_download_stage.qa.md @@ -1,5 +1,7 @@ # QA: Updates part 2 (download, verify, stage) +> 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. + ## Automated Go (`task test`, package `test`, black-box, no live network, no real SMTP) Files: `test/update_download_test.go` (domain, use case), `test/update_download_http_test.go` (HTTP), @@ -29,7 +31,7 @@ the TLS cases, a small size cap and a short timeout. Never relax the policy by e 5. Persistence: JSON job store round trip in a temp dir; "downloading" at startup reads failed/"download"; "ready" with the staged binary deleted reads idle; a missing or corrupt file reads idle without error. The real helper detector reads `/update-helper.json`: absent file gives false, a present valid file gives true. -6. Guards: admin 403 with `apperr.OwnerRequiredErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none start a job. +6. Guards: admin 403 with `apperr.MissingPermissionErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none start a job. 7. Status codes: available false or nothing stored gives 400; detector off gives 409; state downloading, verifying, ready each give 409; after "failed" a new POST gives 202 with a new started_at. Order check: detector off and available false gives 409. 8. Concurrency: two simultaneous POSTs (goroutines) give one 202 and one 409; each asset fetched once (run with `-race`). diff --git a/features/updates_release_feed.feature b/features/updates_release_feed.feature index a4b551b..9c6c513 100644 --- a/features/updates_release_feed.feature +++ b/features/updates_release_feed.feature @@ -1,4 +1,5 @@ Feature: Updates part 1: signed releases, release feed and read-only update notification + 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 site I want to see when a newer signed release exists, and have admins see the same status read-only @@ -142,7 +143,7 @@ Feature: Updates part 1: signed releases, release feed and read-only update noti Scenario: An admin cannot check Given the caller is an admin When the client sends POST /v1/system/update/check - Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage + Then the status is 403 and the body is apperr.MissingPermissionErrorMessage And the feed is not contacted Scenario: Feed failure keeps the last result diff --git a/features/updates_release_feed.qa.md b/features/updates_release_feed.qa.md index 16bf4f6..883293e 100644 --- a/features/updates_release_feed.qa.md +++ b/features/updates_release_feed.qa.md @@ -1,5 +1,7 @@ # QA: Updates part 1 (signed releases, release feed, notification) +> 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. + ## Automated Go (`task test`, package `test`, black-box) Files: `test/update_test.go` (domain, use cases), `test/update_http_test.go` (HTTP), `test/update_feed_test.go` @@ -24,7 +26,7 @@ server, no SQLite, no SMTP. `apperr.VerificationRequiredErrorMessage`. 6. `POST /v1/system/update/check` as owner: 200, store updated with latest, `release_url` and `checked_at`, feed called once, a later GET equals it. Nothing newer: `available` false. Running version "dev": `available` false. -7. POST check as admin: 403, body `apperr.OwnerRequiredErrorMessage+"\n"`, feed not called. +7. POST check as admin: 403, body `apperr.MissingPermissionErrorMessage+"\n"`, feed not called. 8. Feed error: 500, store unchanged (latest and `checked_at` as before), body contains no feed error text. 9. Rate limit: owner A sends 6 checks: first 5 are 200, the 6th is 429 with `apperr.TooManyRequestsErrorMessage+"\n"` and an integer `Retry-After` of at least 1; feed called 5 times; owner B's first check still 200; a login-limiter diff --git a/features/updates_restart_helper.feature b/features/updates_restart_helper.feature index 1c301b7..8fab1e9 100644 --- a/features/updates_restart_helper.feature +++ b/features/updates_restart_helper.feature @@ -1,4 +1,5 @@ Feature: Updates part 3: Restart, the privileged helper and rollback + 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 site I want one Restart button that installs the staged release, or just restarts the site, and puts the old version back if the new one does not come up @@ -122,7 +123,7 @@ Feature: Updates part 3: Restart, the privileged helper and rollback | no jwt cookie | 401 | the existing 401 text | | a stale token version | 401 | the existing 401 text | | an unverified owner | 403 | the existing unverified text | - | an admin | 403 | apperr.OwnerRequiredErrorMessage | + | an admin | 403 | apperr.MissingPermissionErrorMessage | Scenario Outline: Restart is refused with 409 Given the owner and diff --git a/features/updates_restart_helper.qa.md b/features/updates_restart_helper.qa.md index 5d1b0fa..2ad2c80 100644 --- a/features/updates_restart_helper.qa.md +++ b/features/updates_restart_helper.qa.md @@ -1,5 +1,7 @@ # QA: Updates part 3 (restart, helper, rollback) +> 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. + ## Automated Go (`task test`, package `test`, black-box, no live systemd, no real SMTP, no network) Files: `test/update_restart_http_test.go` (HTTP), `test/update_restart_usecase_test.go` (request and status use cases), @@ -41,7 +43,7 @@ plus the fakes where noted. Helper time is driven by `fakeClock`: no `time.Sleep "Restart site never installs": owner A's body `{"kind":"restart"}` after the fake job store turned "ready" gives 409, no marker, staged files untouched. 4. Bad body: `{"kind":"upgrade"}`, `{"kind":""}`, `{"kind":7}`, truncated JSON, `not json` give 400 plain text, no marker. The struct uses `validate:"omitempty,oneof=update restart"` and goes through `validator.Validate.Struct`; an empty body and `{}` are accepted. -5. Guards: admin 403 with `apperr.OwnerRequiredErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none writes a marker or deletes the result. +5. Guards: admin 403 with `apperr.MissingPermissionErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none writes a marker or deletes the result. Order: an admin with a bad body gets 403, an owner with a bad body and can_apply off gets 400, can_apply off and a kind mismatch gives 409. 6. 409 table: can_apply off, downloading, verifying, marker pending, result "running": no second marker, existing files untouched. 7. Concurrency (`-race`): two simultaneous POSTs give one 202, one 409, one marker. diff --git a/features/user_roles.feature b/features/user_roles.feature index ecab29d..af19d70 100644 --- a/features/user_roles.feature +++ b/features/user_roles.feature @@ -1,4 +1,5 @@ Feature: Users have a role, owner or admin, and only the owner can invite + 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 person who registered the workspace I want my account to be the owner and everyone I invite to be an admin @@ -22,7 +23,7 @@ Feature: Users have a role, owner or admin, and only the owner can invite created_at; invite items are unchanged: id, kind, email, status, created_at And POST /v1/user/invite is owner-only: after the jwt, current-token and verified checks, a RequireOwner check loads the user with GetUserUseCase and refuses any role other than - "owner" with 403 and the plain-text body apperr.OwnerRequiredErrorMessage + "owner" with 403 and the plain-text body apperr.MissingPermissionErrorMessage "Only the owner can do this.", before the rate limiter, the body decoding and the validation And GET /v1/user/ stays open to every verified user, owner and admin alike And error responses are plain text from http.Error, success responses are JSON @@ -82,7 +83,7 @@ Feature: Users have a role, owner or admin, and only the owner can invite Given a verified admin id 2 "jonas" and a valid jwt cookie for user id 2 When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"} Then the response status is 403 - And the response body is exactly apperr.OwnerRequiredErrorMessage, as plain text + And the response body is exactly apperr.MissingPermissionErrorMessage, as plain text And the invite repository is still empty And no email was sent diff --git a/features/user_roles.qa.md b/features/user_roles.qa.md index e8c04a6..122560e 100644 --- a/features/user_roles.qa.md +++ b/features/user_roles.qa.md @@ -1,5 +1,7 @@ # QA procedure: user_roles +> 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). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo`. No live server, no real SQLite file, no real SMTP. @@ -34,7 +36,7 @@ No live server, no real SQLite file, no real SMTP. ## POST /v1/user/invite 13. Owner cookie, valid body -> 201, one invite, one "invitation" mail. -14. Admin cookie, valid body -> 403, body exactly `apperr.OwnerRequiredErrorMessage`, `Content-Type` +14. Admin cookie, valid body -> 403, body exactly `apperr.MissingPermissionErrorMessage`, `Content-Type` is not JSON-decoded (plain text), no invite, no mail. 15. Admin cookie with `{"email":"not-an-email"}` and with `not json` -> 403 both. 16. Rate limit 2: admin sends 3 requests -> 403, 403, 403. diff --git a/internal/domain/role/roleDomain.go b/internal/domain/role/roleDomain.go index c119582..cd8db52 100644 --- a/internal/domain/role/roleDomain.go +++ b/internal/domain/role/roleDomain.go @@ -11,6 +11,8 @@ 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") + + ErrMissingPermission = errors.New("missing permission") ) type Scope string diff --git a/internal/domain/role/roleGrantSet.go b/internal/domain/role/roleGrantSet.go new file mode 100644 index 0000000..0c86281 --- /dev/null +++ b/internal/domain/role/roleGrantSet.go @@ -0,0 +1,40 @@ +package role + +type MissingPermissionError struct { + Key string +} + +func (e MissingPermissionError) Error() string { + return ErrMissingPermission.Error() + ": " + e.Key +} + +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 +} + +func (g GrantSet) Scope(key string) Scope { + scope, present := g[key] + if !present { + return ScopeNone + } + return scope +} + +func (g GrantSet) Holds(key string) bool { + scope := g.Scope(key) + return scope == ScopeOwn || scope == ScopeAll +} + +func (g GrantSet) HoldsAll(key string) bool { + return g.Scope(key) == ScopeAll +} diff --git a/internal/domain/role/roleKeys.go b/internal/domain/role/roleKeys.go new file mode 100644 index 0000000..ab2a428 --- /dev/null +++ b/internal/domain/role/roleKeys.go @@ -0,0 +1,11 @@ +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" +) diff --git a/internal/infrastructure/useCases/CheckPermission.go b/internal/infrastructure/useCases/CheckPermission.go new file mode 100644 index 0000000..36b033e --- /dev/null +++ b/internal/infrastructure/useCases/CheckPermission.go @@ -0,0 +1,55 @@ +package useCases + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "errors" +) + +type CheckPermissionUseCase struct { + roles role.Repository +} + +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 + } + if !grants.Holds(key) { + return nil, 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) + if err != nil { + return nil, err + } + if !grants.HoldsAll(key) { + return nil, 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/ExportActivity.go b/internal/infrastructure/useCases/ExportActivity.go index eeefeda..23be27c 100644 --- a/internal/infrastructure/useCases/ExportActivity.go +++ b/internal/infrastructure/useCases/ExportActivity.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/role" "encoding/csv" "io" "strings" @@ -29,11 +30,20 @@ func NewExportActivityUseCase(events audit.Repository, now func() time.Time) *Ex return &ExportActivityUseCase{events, now} } -func (e *ExportActivityUseCase) Prepare(query ActivityQuery) (*ActivityExport, error) { - filter, err := buildActivityFilter(query, e.now()) +type ExportActivityCommand struct { + ViewerID uint32 + Grants role.GrantSet + Query ActivityQuery +} + +func (e *ExportActivityUseCase) Prepare(command ExportActivityCommand) (*ActivityExport, error) { + filter, err := buildActivityFilter(command.Query, e.now()) if err != nil { return nil, err } + if err := scopeFilterToViewer(&filter, command.ViewerID, command.Grants); err != nil { + return nil, err + } return &ActivityExport{events: e.events, filter: filter}, nil } diff --git a/internal/infrastructure/useCases/GetMyAccess.go b/internal/infrastructure/useCases/GetMyAccess.go index 112cac1..3def7ea 100644 --- a/internal/infrastructure/useCases/GetMyAccess.go +++ b/internal/infrastructure/useCases/GetMyAccess.go @@ -23,15 +23,15 @@ var legacyBuiltInNames = map[user.Role]string{ user.RoleAdmin: role.AdminName, } -func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) { +func resolveRole(roles role.Repository, account user.User) (role.Role, error) { if account.RoleID != 0 { - return u.roles.Get(account.RoleID) + return roles.Get(account.RoleID) } name, known := legacyBuiltInNames[account.Role] if !known { return role.Role{}, role.ErrRoleNotFound } - found, err := u.roles.GetByName(name) + found, err := roles.GetByName(name) if err != nil { return role.Role{}, err } @@ -42,7 +42,7 @@ func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) { } func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) { - resolved, err := u.resolveRole(account) + resolved, err := resolveRole(u.roles, account) if err != nil { return Access{}, err } diff --git a/internal/infrastructure/useCases/GetUpdateStatus.go b/internal/infrastructure/useCases/GetUpdateStatus.go index 4ac2ae0..5d9da2d 100644 --- a/internal/infrastructure/useCases/GetUpdateStatus.go +++ b/internal/infrastructure/useCases/GetUpdateStatus.go @@ -19,8 +19,8 @@ func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, h } } -func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus { - status := g.status.build(isOwner && g.helper.Installed()) +func (g *GetUpdateStatusUseCase) GetStatus(canApply bool) update.UpdateStatus { + status := g.status.build(canApply && g.helper.Installed()) status.Container = g.container return status } diff --git a/internal/infrastructure/useCases/ListAPIKeys.go b/internal/infrastructure/useCases/ListAPIKeys.go index 0df0c17..efaea71 100644 --- a/internal/infrastructure/useCases/ListAPIKeys.go +++ b/internal/infrastructure/useCases/ListAPIKeys.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/apikey" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "errors" "sort" @@ -25,13 +26,17 @@ func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository us return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now} } -func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, error) { +func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32, grants role.GrantSet) ([]APIKeyEntry, error) { + scope := grants.Scope(role.KeyAPIKeysView) + if scope == role.ScopeNone { + return nil, role.MissingPermissionError{Key: role.KeyAPIKeysView} + } requester, err := l.userRepository.Get(requesterId) if err != nil { return nil, err } - keys, err := l.keysVisibleTo(requester) + keys, err := l.keysVisibleTo(requester, scope) if err != nil { return nil, err } @@ -43,7 +48,7 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err return keys[i].CreatedAt.After(keys[j].CreatedAt) }) - creatorNames, err := l.creatorNamesFor(requester, keys) + creatorNames, err := l.creatorNamesFor(requester, scope, keys) if err != nil { return nil, err } @@ -51,14 +56,14 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err now := l.now() entries := make([]APIKeyEntry, 0, len(keys)) for _, key := range keys { - entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: requester.Role == user.RoleOwner}) + entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: scope == role.ScopeAll}) } return entries, nil } -func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.APIKey) (map[uint32]string, error) { +func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, scope role.Scope, keys []apikey.APIKey) (map[uint32]string, error) { names := map[uint32]string{} - if requester.Role != user.RoleOwner { + if scope != role.ScopeAll { return names, nil } names[requester.ID] = requester.NameOrUsername() @@ -79,8 +84,8 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey. return names, nil } -func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) { - if requester.Role == user.RoleOwner { +func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User, scope role.Scope) ([]apikey.APIKey, error) { + if scope == role.ScopeAll { return l.apiKeyRepository.ListAll() } return l.apiKeyRepository.ListByUser(requester.ID) diff --git a/internal/infrastructure/useCases/ListActivity.go b/internal/infrastructure/useCases/ListActivity.go index 4cba076..3a6cb5d 100644 --- a/internal/infrastructure/useCases/ListActivity.go +++ b/internal/infrastructure/useCases/ListActivity.go @@ -6,6 +6,7 @@ import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/user" "errors" @@ -26,6 +27,7 @@ type ListActivityUseCase struct { type ListActivityCommand struct { ViewerID uint32 + Grants role.GrantSet Query ActivityQuery } @@ -53,7 +55,7 @@ func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (Activit if filter.Page, filter.Size, err = resolveActivityPaging(command.Query); err != nil { return ActivityPage{}, err } - if err := l.scopeToViewer(&filter, command.ViewerID); err != nil { + if err := scopeFilterToViewer(&filter, command.ViewerID, command.Grants); err != nil { return ActivityPage{}, err } @@ -68,22 +70,6 @@ func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (Activit return ActivityPage{Items: entries, Total: found.Total, Page: filter.Page, Size: filter.Size}, nil } -func (l *ListActivityUseCase) scopeToViewer(filter *audit.Filter, viewerID uint32) error { - viewer, err := l.users.Get(viewerID) - if err != nil { - return err - } - if viewer.Role == user.RoleOwner || viewer.Role == user.RoleAdmin { - return nil - } - own := audit.ActorRef{Type: audit.ActorUser, ID: &viewer.ID} - if filter.Actor != nil && !sameActor(*filter.Actor, own) { - return audit.ErrActorNotAllowed - } - filter.Actor = &own - return nil -} - func sameActor(a audit.ActorRef, b audit.ActorRef) bool { return a.Type == b.Type && a.ID != nil && b.ID != nil && *a.ID == *b.ID } diff --git a/internal/infrastructure/useCases/ListAuthors.go b/internal/infrastructure/useCases/ListAuthors.go index 52ecfe4..e1ed04d 100644 --- a/internal/infrastructure/useCases/ListAuthors.go +++ b/internal/infrastructure/useCases/ListAuthors.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" ) @@ -11,10 +12,10 @@ type ListAuthorsUseCase struct { } type ListAuthorsCommand struct { - Q string - Page int - Size int - CallerRole user.Role + Q string + Page int + Size int + Grants role.GrantSet } type AuthorPage struct { @@ -35,7 +36,7 @@ func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error } items := make([]author.Author, 0, len(found.Items)) for _, item := range found.Items { - if isAdminRole(command.CallerRole) { + if command.Grants.Holds(role.KeyUsersView) { item = withUserEmail(u.users, item) } items = append(items, item) diff --git a/internal/infrastructure/useCases/RevokeAPIKey.go b/internal/infrastructure/useCases/RevokeAPIKey.go index fda380e..f3c4210 100644 --- a/internal/infrastructure/useCases/RevokeAPIKey.go +++ b/internal/infrastructure/useCases/RevokeAPIKey.go @@ -3,34 +3,33 @@ package useCases import ( "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/audit" - "RenewCMS/internal/domain/user" + "RenewCMS/internal/domain/role" "context" "time" ) type RevokeAPIKeyUseCase struct { apiKeyRepository apikey.Repository - userRepository user.Repository now func() time.Time recorder audit.Recorder } -func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase { - return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now, recorder} +func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase { + return &RevokeAPIKeyUseCase{apiKeyRepository, now, recorder} } -func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32) error { +func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32, grants role.GrantSet) error { + scope := grants.Scope(role.KeyAPIKeysRevoke) + if scope == role.ScopeNone { + return role.MissingPermissionError{Key: role.KeyAPIKeysRevoke} + } + key, err := r.apiKeyRepository.Get(keyId) if err != nil { return err } - requester, err := r.userRepository.Get(requesterId) - if err != nil { - return err - } - - if requester.Role != user.RoleOwner && key.UserID != requester.ID { + if scope != role.ScopeAll && key.UserID != requesterId { return apikey.ErrAPIKeyNotFound } diff --git a/internal/infrastructure/useCases/UpdateAuthor.go b/internal/infrastructure/useCases/UpdateAuthor.go index a1d1ce7..8b5a295 100644 --- a/internal/infrastructure/useCases/UpdateAuthor.go +++ b/internal/infrastructure/useCases/UpdateAuthor.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "context" "strings" @@ -21,6 +22,9 @@ func NewUpdateAuthorUseCase(authors author.Repository, users user.Repository, im } func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller AuthorCaller, patch author.Patch) (author.Author, error) { + if !caller.Grants.Holds(role.KeyAuthorsEdit) { + return author.Author{}, role.MissingPermissionError{Key: role.KeyAuthorsEdit} + } before, err := u.authors.Get(id) if err != nil { return author.Author{}, err @@ -50,7 +54,7 @@ func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller Auth } func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author { - if caller.isAdmin() { + if caller.seesUserEmail() { return withUserEmail(u.users, a) } a.UserEmail = nil @@ -89,7 +93,7 @@ func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller, } func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error { - if caller.isAdmin() { + if caller.Grants.HoldsAll(role.KeyAuthorsEdit) { return nil } if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set { diff --git a/internal/infrastructure/useCases/activityQuery.go b/internal/infrastructure/useCases/activityQuery.go index 7458d82..8e78a31 100644 --- a/internal/infrastructure/useCases/activityQuery.go +++ b/internal/infrastructure/useCases/activityQuery.go @@ -2,6 +2,7 @@ package useCases import ( "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/role" "time" ) @@ -32,6 +33,22 @@ type ActivityQuery struct { Size int } +func scopeFilterToViewer(filter *audit.Filter, viewerID uint32, grants role.GrantSet) error { + switch grants.Scope(role.KeyActivityView) { + case role.ScopeAll: + return nil + case role.ScopeOwn: + own := audit.ActorRef{Type: audit.ActorUser, ID: &viewerID} + if filter.Actor != nil && !sameActor(*filter.Actor, own) { + return audit.ErrActorNotAllowed + } + filter.Actor = &own + return nil + default: + return role.MissingPermissionError{Key: role.KeyActivityView} + } +} + type activityWindow struct { from time.Time to time.Time diff --git a/internal/infrastructure/useCases/authorSupport.go b/internal/infrastructure/useCases/authorSupport.go index da06934..2efa548 100644 --- a/internal/infrastructure/useCases/authorSupport.go +++ b/internal/infrastructure/useCases/authorSupport.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "errors" "strconv" @@ -14,15 +15,11 @@ const authorChangeSeparator = ", " type AuthorCaller struct { UserID uint32 - Role user.Role + Grants role.GrantSet } -func isAdminRole(role user.Role) bool { - return role == user.RoleOwner || role == user.RoleAdmin -} - -func (c AuthorCaller) isAdmin() bool { - return isAdminRole(c.Role) +func (c AuthorCaller) seesUserEmail() bool { + return c.Grants.Holds(role.KeyUsersView) } func authorTarget(a author.Author) audit.Target { diff --git a/scripts/mutate.sh b/scripts/mutate.sh index 7287e17..19776df 100644 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -54,6 +54,10 @@ coverpkg="" for scope in "${scopes[@]}"; do coverpkg="${coverpkg:+$coverpkg,}$scope/..." done +memcap=() +if [ "${MUTATE_MEMORY_MAX:-12G}" != "0" ] && command -v systemd-run >/dev/null 2>&1 && systemd-run --user --scope -q true >/dev/null 2>&1; then + memcap=(systemd-run --user --scope -q -p "MemoryMax=${MUTATE_MEMORY_MAX:-12G}" -p MemorySwapMax=0) +fi parts=() i=0 for scope in "${scopes[@]}"; do @@ -73,7 +77,7 @@ for scope in "${scopes[@]}"; do done if [ "$keep" -eq 0 ]; then continue; fi fi - gremlins unleash --integration --tags nofrontend --workers "${MUTATE_WORKERS:-4}" --coverpkg="$coverpkg" --timeout-coefficient 10 ${excludes[@]+"${excludes[@]}"} --output "$(native "$part")" "$scope" || rc=$? + ${memcap[@]+"${memcap[@]}"} gremlins unleash --integration --tags nofrontend --workers "${MUTATE_WORKERS:-4}" --coverpkg="$coverpkg" --timeout-coefficient 10 ${excludes[@]+"${excludes[@]}"} --output "$(native "$part")" "$scope" || rc=$? if [ "$rc" -ge 128 ]; then exit "$rc"; fi if [ "$rc" -ne 0 ]; then code=$rc; fi if [ -f "$part" ]; then parts+=("$(native "$part")"); fi diff --git a/test/activity_filter_cards_and_export_http_test.go b/test/activity_filter_cards_and_export_http_test.go index 0706cf4..d801b0a 100644 --- a/test/activity_filter_cards_and_export_http_test.go +++ b/test/activity_filter_cards_and_export_http_test.go @@ -72,6 +72,6 @@ func TestActivityRangeAll(t *testing.T) { t.Run("A non-owner export with range=all is still 403", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) - assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage) + assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage) }) } diff --git a/test/activity_log_backend_http_test.go b/test/activity_log_backend_http_test.go index 9daed48..48c8b59 100644 --- a/test/activity_log_backend_http_test.go +++ b/test/activity_log_backend_http_test.go @@ -41,6 +41,7 @@ const ( type activityHTTPEnv struct { *activityHarness users *mockUserRepo + roles *mockRoleRepo keys *mockAPIKeyRepo articles *mockArticleRepo mail *mockMailRepo @@ -56,6 +57,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv { e := &activityHTTPEnv{ activityHarness: h, users: newMockUserRepo(), + roles: newSeededMockRoleRepo(), keys: newMockAPIKeyRepo(), articles: newMockArticleRepo(), mail: newMockMailRepo(), @@ -64,7 +66,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv { } e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner} e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleAdmin} - e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor"} + e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor", RoleID: writerRoleID} e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner} e.users.nextID = 4 @@ -73,14 +75,15 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv { container := &api.UseCases{ Token: e.token, GetUserUseCase: useCases.NewGetUserUseCase(e.users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), - GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(e.keys, e.generator, h.clock, h.recorder), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(e.keys, e.users, h.clock), - RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder), + RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, h.clock, h.recorder), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock), GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock), @@ -277,7 +280,7 @@ func TestActivityLogBackendAudienceGates(t *testing.T) { t.Run("A scoped caller cannot read the summary or the export", func(t *testing.T) { for _, path := range []string{"/v1/activity/summary", "/v1/activity/export"} { e := newActivityHTTPEnv(t, 1000) - assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage) + assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage) if e.probe.reads != 0 { t.Error("the audit repository was read") } @@ -670,7 +673,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) { t.Run("The default is 90 days", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) - rec := e.get("/v1/settings", asUser(e, activityEditorID)) + rec := e.get("/v1/settings", asUser(e, activityAdminID)) assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String()) }) @@ -680,7 +683,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) { e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC)) e.settings.settings.LastCleanupRemoved = 142 - body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityEditorID))) + body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityAdminID))) if body["activity_last_cleanup_at"] != "2026-10-06T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) { t.Errorf("unexpected body %v", body) @@ -732,7 +735,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) { rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityAdminID)) - assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) + assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage) if e.settings.settings.RetentionDays != 90 { t.Error("the retention changed") } diff --git a/test/activity_next_cleanup_test.go b/test/activity_next_cleanup_test.go index 4400fd5..e395ec8 100644 --- a/test/activity_next_cleanup_test.go +++ b/test/activity_next_cleanup_test.go @@ -106,7 +106,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) { e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC)) e.settings.settings.LastCleanupRemoved = 142 - rec := e.get("/v1/settings", asUser(e, activityEditorID)) + rec := e.get("/v1/settings", asUser(e, activityAdminID)) body := keys(t, rec.Body.String()) if rec.Code != http.StatusOK || len(body) != 7 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) { @@ -179,13 +179,14 @@ func TestSettingsNextCleanupQAFlow(t *testing.T) { } }) - t.Run("GET is open to owner, admin and editor and refused without a cookie", func(t *testing.T) { + t.Run("GET is open to owner and admin and refused to an editor and without a cookie", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) - for _, id := range []uint32{activityOwnerID, activityAdminID, activityEditorID} { + for _, id := range []uint32{activityOwnerID, activityAdminID} { if rec := e.get("/v1/settings", asUser(e, id)); rec.Code != http.StatusOK { t.Errorf("user %d: expected 200, got %d", id, rec.Code) } } + assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusForbidden, apperr.MissingPermissionErrorMessage) if rec := e.get("/v1/settings"); rec.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", rec.Code) } @@ -196,7 +197,7 @@ func TestSettingsNextCleanupQAFlow(t *testing.T) { rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityEditorID)) - assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) + assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage) if e.settings.retentionOK != 0 || len(e.all(t)) != 0 { t.Error("the editor changed something") } diff --git a/test/activity_retention_size_http_test.go b/test/activity_retention_size_http_test.go index 6a3b166..0dfa2ff 100644 --- a/test/activity_retention_size_http_test.go +++ b/test/activity_retention_size_http_test.go @@ -41,7 +41,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) { e.settings.settings.RetentionMaxGB = c.gb e.probe.sizeBytes = &c.bytes - rec := e.get("/v1/settings", asUser(e, activityEditorID)) + rec := e.get("/v1/settings", asUser(e, activityAdminID)) body := sizeSettingsBody(t, rec.Body.Bytes()) var got []string @@ -61,7 +61,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) { t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) - body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityEditorID)).Body.Bytes()) + body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityAdminID)).Body.Bytes()) if body["activity_retention_days"] != float64(90) || body["activity_retention_max_gb"] != float64(0) || body["activity_log_size_bytes"] != float64(0) { t.Errorf("unexpected body %v", body) @@ -153,7 +153,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) { rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`, asUser(e, activityAdminID)) - assertPlain(t, rec, http.StatusForbidden, "Only the owner can do this.") + assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage) if e.settings.retentionOK != 0 { t.Error("the admin changed something") } @@ -164,7 +164,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.probe.sizeErr = errRepositoryFailure - assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusInternalServerError, "Internal server error") + assertPlain(t, e.get("/v1/settings", asUser(e, activityAdminID)), http.StatusInternalServerError, "Internal server error") }) t.Run("Settings API, the change is audited", func(t *testing.T) { diff --git a/test/activity_support_test.go b/test/activity_support_test.go index e08d18a..dd23d71 100644 --- a/test/activity_support_test.go +++ b/test/activity_support_test.go @@ -26,6 +26,7 @@ type activityProbeRepo struct { deleteErr error deleteCalls int readErr error + maxReads int sizeErr error sizeBytes *int64 oldestErr error @@ -47,6 +48,9 @@ func (p *activityProbeRepo) Append(event audit.Event) (audit.Event, error) { func (p *activityProbeRepo) List(filter audit.Filter) (audit.Page, error) { p.reads++ p.listCalls = append(p.listCalls, filter) + if p.maxReads > 0 && p.reads > p.maxReads { + return audit.Page{}, errors.New("read budget exceeded") + } if p.readErr != nil { return audit.Page{}, p.readErr } diff --git a/test/activity_usecases_test.go b/test/activity_usecases_test.go index 54b93ff..2f6075b 100644 --- a/test/activity_usecases_test.go +++ b/test/activity_usecases_test.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" @@ -36,6 +37,7 @@ func newActivityWorld(t *testing.T) *activityWorld { images: newMockImageRepo(), keys: newMockAPIKeyRepo(), } + w.probe.maxReads = 50 w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner} w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin} w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"} @@ -62,7 +64,7 @@ func TestActivityListUseCase(t *testing.T) { recent := w.seedAt(t, 2*day, nil) w.seedAt(t, 10*day, nil) - page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1}) + page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants}) if err != nil || len(page.Items) != 1 || page.Items[0].Event.ID != recent.ID { t.Fatalf("expected only the recent event, got %+v, %v", page, err) @@ -94,7 +96,7 @@ func TestActivityListUseCase(t *testing.T) { w := newActivityWorld(t) w.seedAt(t, c.ago, nil) - page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: c.query}) + page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: c.query}) if err != nil || (page.Total == 1) != c.found { t.Errorf("expected found=%v, got total %d, %v", c.found, page.Total, err) @@ -115,7 +117,7 @@ func TestActivityListUseCase(t *testing.T) { for i, query := range queries { w := newActivityWorld(t) - _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: query}) + _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: query}) if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 { t.Errorf("query %d (%+v): expected ErrInvalidFilter without reading, got %v after %d reads", i, query, err, w.probe.reads) @@ -128,14 +130,14 @@ func TestActivityListUseCase(t *testing.T) { w.seedAt(t, day, nil) w.seedAt(t, day, func(e *audit.Event) { e.Outcome = audit.OutcomeFailed }) - page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: useCases.ActivityQuery{Outcome: "all"}}) + page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants, Query: useCases.ActivityQuery{Outcome: "all"}}) if page.Total != 2 { t.Errorf("expected both outcomes, got %d", page.Total) } }) - t.Run("A caller who is not owner or admin is scoped to its own events", func(t *testing.T) { + t.Run("A caller with activity.view at own is scoped to its own events", func(t *testing.T) { w := newActivityWorld(t) w.seedAt(t, day, nil) w.seedAt(t, day, func(e *audit.Event) { @@ -143,7 +145,7 @@ func TestActivityListUseCase(t *testing.T) { }) w.seedAt(t, day, func(e *audit.Event) { e.Actor = audit.SystemActor() }) - page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3}) + page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Grants: activityViewOwnGrants}) if err != nil || page.Total != 1 || *page.Items[0].Event.Actor.ID != 3 { t.Fatalf("expected only user 3's events, got %+v, %v", page, err) @@ -155,7 +157,7 @@ func TestActivityListUseCase(t *testing.T) { for actor, allowed := range cases { w := newActivityWorld(t) - _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Query: useCases.ActivityQuery{Actor: actor}}) + _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Grants: activityViewOwnGrants, Query: useCases.ActivityQuery{Actor: actor}}) if allowed && err != nil { t.Errorf("%s: expected success, got %v", actor, err) @@ -166,13 +168,13 @@ func TestActivityListUseCase(t *testing.T) { } }) - t.Run("An unknown viewer is reported", func(t *testing.T) { + t.Run("A caller without activity.view is refused before the repository is read", func(t *testing.T) { w := newActivityWorld(t) - _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 99}) + _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3}) - if !errors.Is(err, user.ErrUserNotFound) { - t.Errorf("expected ErrUserNotFound, got %v", err) + if !errors.Is(err, role.ErrMissingPermission) || w.probe.reads != 0 { + t.Errorf("expected a missing permission without reading, got %v after %d reads", err, w.probe.reads) } }) @@ -195,7 +197,7 @@ func TestActivityListUseCase(t *testing.T) { c.seed(w) w.seedAt(t, day, func(e *audit.Event) { e.Target = c.target }) - page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1}) + page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants}) if err != nil || len(page.Items) != 1 { t.Fatalf("unexpected result %+v, %v", page, err) @@ -209,9 +211,10 @@ func TestActivityListUseCase(t *testing.T) { t.Run("A repository failure is returned", func(t *testing.T) { w := newActivityWorld(t) + w.seedAt(t, 24*time.Hour, func(e *audit.Event) { e.Target = audit.Target{Type: "user", ID: "3", Name: "Marta Alves"} }) w.users.fail.failNext("Get") - _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1}) + _, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Grants: activityViewAllGrants}) if !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) @@ -307,7 +310,7 @@ func TestActivitySummaryUseCase(t *testing.T) { func exportToString(t *testing.T, w *activityWorld, query useCases.ActivityQuery) string { t.Helper() - prepared, err := w.export.Prepare(query) + prepared, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: query}) if err != nil { t.Fatalf("prepare failed: %v", err) } @@ -445,7 +448,7 @@ func TestActivityExportUseCase(t *testing.T) { t.Run("An invalid filter is refused before anything is prepared", func(t *testing.T) { w := newActivityWorld(t) - _, err := w.export.Prepare(useCases.ActivityQuery{Outcome: "maybe"}) + _, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: useCases.ActivityQuery{Outcome: "maybe"}}) if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 { t.Errorf("expected ErrInvalidFilter without reading, got %v", err) diff --git a/test/api_keys_creator_name_http_test.go b/test/api_keys_creator_name_http_test.go index 949e1b7..b44ca22 100644 --- a/test/api_keys_creator_name_http_test.go +++ b/test/api_keys_creator_name_http_test.go @@ -3,6 +3,7 @@ package test import ( "RenewCMS/api" "RenewCMS/internal/domain/apikey" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "net/http" @@ -86,13 +87,15 @@ func TestAPIKeysCreatorNameHttp(t *testing.T) { } }) - t.Run("API: an admin's list is unchanged", func(t *testing.T) { + t.Run("API: an own-scope caller's list is unchanged", func(t *testing.T) { env := newAPIKeyEnv(t) env.nameUsers() - env.issueKey(apiKeyDevId, "a") - env.issueKey(apiKeyDevId, "b") + env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn}) + env.issueKey(6, "a") + env.issueKey(6, "b") + env.issueKey(apiKeyDevId, "other") - rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId) + rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", 6) assertAPIKeyStatus(t, rec, http.StatusOK) list := apiKeyDecodeList(t, rec) @@ -174,10 +177,10 @@ func TestListAPIKeysCreatorName(t *testing.T) { return repo } - t.Run("Use case: ListAPIKeys returns the creator name only to an owner", func(t *testing.T) { + t.Run("Use case: ListAPIKeys returns the creator name only with all scope", func(t *testing.T) { uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock) - ownerEntries, err := uc.ListAPIKeys(1) + ownerEntries, err := uc.ListAPIKeys(1, apiKeyAllScope) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -189,12 +192,12 @@ func TestListAPIKeysCreatorName(t *testing.T) { t.Errorf("unexpected creator names %v", names) } - adminEntries, err := uc.ListAPIKeys(2) + ownScopeEntries, err := uc.ListAPIKeys(2, apiKeyOwnScope) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" { - t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries) + if len(ownScopeEntries) != 1 || ownScopeEntries[0].CreatorName != "" { + t.Errorf("expected one entry with an empty creator name, got %+v", ownScopeEntries) } }) } @@ -218,7 +221,7 @@ func TestListAPIKeysTieKeepsRepositoryOrder(t *testing.T) { }} uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock) - entries, err := uc.ListAPIKeys(1) + entries, err := uc.ListAPIKeys(1, apiKeyAllScope) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 01564a9..2f1a534 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -8,6 +8,7 @@ import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/update" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" @@ -116,6 +117,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder), @@ -145,15 +147,19 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock), - RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder), + RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, clock, recorder), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock), } - router := chi.NewRouter() - router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings)) - env.router = router + env.rebuildRouter() return env } +func (e *apiKeyEnv) rebuildRouter() { + router := chi.NewRouter() + router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings)) + e.router = router +} + func (e *apiKeyEnv) issueKey(userId uint32, name string, mutate ...func(*apikey.APIKey)) (string, uint32) { e.issued++ raw, prefix, hashed := e.generator.Generate() @@ -361,7 +367,7 @@ func TestAPIKeysHttp(t *testing.T) { if len(list) != 1 { t.Fatalf("expected one entry, got %d", len(list)) } - apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at") + apiKeyAssertExactFields(t, list[0], "id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at", "created_by_name") if strings.Contains(rec.Body.String(), raw) || strings.Contains(rec.Body.String(), env.keys.sorted()[0].HashedKey) { t.Errorf("neither the raw key nor its hash may appear in the body") } @@ -431,7 +437,10 @@ func TestAPIKeysHttp(t *testing.T) { } raw := apiKeyDecodeObject(t, rec)["key"].(string) invite := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) - assertAPIKeyMessage(t, invite, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) + assertAPIKeyStatus(t, invite, http.StatusCreated) + if len(env.invites.invites) != 1 || env.invites.invites[1].InvitedBy != apiKeyDevId { + t.Errorf("expected an invite invited by the key's creator, got %+v", env.invites.invites) + } }) t.Run("Key management needs a verified real session", func(t *testing.T) { @@ -564,13 +573,14 @@ func TestAPIKeysHttp(t *testing.T) { } }) - t.Run("An admin's key can not do owner-only things", func(t *testing.T) { + t.Run("A writer's key can not invite", func(t *testing.T) { env := newAPIKeyEnv(t) - raw, _ := env.issueKey(apiKeyDevId, "ci") + env.addUser(5, "", writerRoleID, true) + raw, _ := env.issueKey(5, "ci") rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) - assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) + assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage) if len(env.invites.invites) != 0 { t.Errorf("expected no invite") } @@ -647,7 +657,8 @@ func TestAPIKeysHttp(t *testing.T) { t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) { env := newAPIKeyEnv(t) - raw, _ := env.issueKey(apiKeyPatId, "ci") + env.addUser(5, "", writerRoleID, true) + raw, _ := env.issueKey(5, "ci") invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId}) assertAPIKeyStatus(t, invite, http.StatusForbidden) @@ -854,7 +865,7 @@ func TestAPIKeysHttp(t *testing.T) { } }) - t.Run("Listing as an admin shows only that admin's keys", func(t *testing.T) { + t.Run("Listing as an admin shows every user's keys", func(t *testing.T) { env := newAPIKeyEnv(t) env.issueKey(apiKeyDevId, "a") env.issueKey(apiKeyDevId, "b") @@ -863,8 +874,8 @@ func TestAPIKeysHttp(t *testing.T) { rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId) assertAPIKeyStatus(t, rec, http.StatusOK) - if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "b,a" { - t.Errorf("expected exactly b and a, got %s", got) + if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "c,b,a" { + t.Errorf("expected c, b and a, got %s", got) } }) @@ -957,16 +968,26 @@ func TestAPIKeysHttp(t *testing.T) { } }) - t.Run("An admin can't revoke someone else's key, and learns nothing about it", func(t *testing.T) { + t.Run("An own-scope caller can't revoke someone else's key, and learns nothing about it", func(t *testing.T) { env := newAPIKeyEnv(t) + env.customRoleUser(6, role.Grant{Key: "apikeys.revoke", Scope: role.ScopeOwn}) raw, id := env.issueKey(apiKeyPatId, "ci") - rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId) + rec := env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", 6) assertAPIKeyMessage(t, rec, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage) assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) }) + t.Run("An admin revokes someone else's key", func(t *testing.T) { + env := newAPIKeyEnv(t) + raw, id := env.issueKey(apiKeyPatId, "ci") + + assertAPIKeyStatus(t, env.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", apiKeyDevId), http.StatusNoContent) + + assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusUnauthorized) + }) + t.Run("The owner can revoke any key", func(t *testing.T) { env := newAPIKeyEnv(t) raw, id := env.issueKey(apiKeyPatId, "ci") @@ -1056,8 +1077,9 @@ func TestAPIKeysHttp(t *testing.T) { t.Errorf("expected no stored key") } + env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn}) env.keys.fail.failNext("ListByUser") - assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId), http.StatusInternalServerError, "Internal server error") + assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", 6), http.StatusInternalServerError, "Internal server error") env.keys.fail.failNext("ListAll") assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusInternalServerError, "Internal server error") diff --git a/test/api_keys_test.go b/test/api_keys_test.go index 4242bba..3a78a28 100644 --- a/test/api_keys_test.go +++ b/test/api_keys_test.go @@ -2,6 +2,7 @@ package test import ( "RenewCMS/internal/domain/apikey" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/useCases" @@ -173,6 +174,11 @@ func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey)) return stored } +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}}) +) + func apiKeyUnitUsers() *mockUserRepo { users := newMockUserRepo() users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} @@ -402,10 +408,10 @@ func TestListAPIKeysUseCase(t *testing.T) { return result } - t.Run("An admin lists only their own keys, newest first", func(t *testing.T) { + t.Run("A caller with own scope lists only their own keys, newest first", func(t *testing.T) { repo := newMockAPIKeyRepo() seed(repo) - entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) + entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -414,10 +420,10 @@ func TestListAPIKeysUseCase(t *testing.T) { } }) - t.Run("The owner lists every key, newest first", func(t *testing.T) { + t.Run("A caller with all scope lists every key, newest first", func(t *testing.T) { repo := newMockAPIKeyRepo() seed(repo) - entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1) + entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1, apiKeyAllScope) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -436,7 +442,7 @@ func TestListAPIKeysUseCase(t *testing.T) { for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} { seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same }) } - entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) + entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -455,7 +461,7 @@ func TestListAPIKeysUseCase(t *testing.T) { seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired }) seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked }) - entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) + entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope) statuses := map[string]apikey.Status{} for _, entry := range entries { statuses[entry.Key.Prefix] = entry.Status @@ -468,15 +474,15 @@ func TestListAPIKeysUseCase(t *testing.T) { t.Run("An unknown requester and repository failures are returned", func(t *testing.T) { repo := newMockAPIKeyRepo() uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock) - if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) { + if _, err := uc.ListAPIKeys(99, apiKeyOwnScope); !errors.Is(err, user.ErrUserNotFound) { t.Errorf("expected ErrUserNotFound, got %v", err) } repo.fail.failNext("ListByUser") - if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) { + if _, err := uc.ListAPIKeys(2, apiKeyOwnScope); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } repo.fail.failNext("ListAll") - if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) { + if _, err := uc.ListAPIKeys(1, apiKeyAllScope); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } }) @@ -486,9 +492,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) { t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", nil) - uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) + uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) - if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil { + if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); err != nil { t.Fatalf("unexpected error: %v", err) } if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) { @@ -496,12 +502,12 @@ func TestRevokeAPIKeyUseCase(t *testing.T) { } }) - t.Run("Another admin gets not found and the key stays active", func(t *testing.T) { + t.Run("Another own-scope caller gets not found and the key stays active", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", nil) - uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) + uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) - if err := uc.RevokeAPIKey(testCtx, 3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) { + if err := uc.RevokeAPIKey(testCtx, 3, stored.ID, apiKeyOwnScope); !errors.Is(err, apikey.ErrAPIKeyNotFound) { t.Errorf("expected ErrAPIKeyNotFound, got %v", err) } if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 { @@ -509,12 +515,12 @@ func TestRevokeAPIKeyUseCase(t *testing.T) { } }) - t.Run("The owner revokes any key", func(t *testing.T) { + t.Run("A caller with all scope revokes any key", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 }) - uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) + uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) - if err := uc.RevokeAPIKey(testCtx, 1, stored.ID); err != nil { + if err := uc.RevokeAPIKey(testCtx, 1, stored.ID, apiKeyAllScope); err != nil { t.Fatalf("unexpected error: %v", err) } if repo.keys[stored.ID].RevokedAt == nil { @@ -526,9 +532,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) { repo := newMockAPIKeyRepo() original := apiKeyUnitNow.Add(-time.Hour) stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original }) - uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) + uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) - if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil { + if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); err != nil { t.Fatalf("unexpected error: %v", err) } if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) { @@ -536,19 +542,16 @@ func TestRevokeAPIKeyUseCase(t *testing.T) { } }) - t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) { + t.Run("An unknown key and repository failures are returned", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", nil) - uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) + uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) - if err := uc.RevokeAPIKey(testCtx, 2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) { + if err := uc.RevokeAPIKey(testCtx, 2, 999, apiKeyOwnScope); !errors.Is(err, apikey.ErrAPIKeyNotFound) { t.Errorf("expected ErrAPIKeyNotFound, got %v", err) } - if err := uc.RevokeAPIKey(testCtx, 99, stored.ID); !errors.Is(err, user.ErrUserNotFound) { - t.Errorf("expected ErrUserNotFound, got %v", err) - } repo.fail.failNext("Revoke") - if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); !errors.Is(err, errRepositoryFailure) { + if err := uc.RevokeAPIKey(testCtx, 2, stored.ID, apiKeyOwnScope); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } }) diff --git a/test/author_domain_test.go b/test/author_domain_test.go index d086557..73734e7 100644 --- a/test/author_domain_test.go +++ b/test/author_domain_test.go @@ -127,7 +127,7 @@ func TestAuthorUseCases(t *testing.T) { uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder) bio := "x" - _, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Role: "user"}, author.Patch{Bio: &bio}) + _, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Grants: authorsEditOwnGrants}, author.Patch{Bio: &bio}) if !errors.Is(err, author.ErrAuthorRights) { t.Errorf("err = %v", err) diff --git a/test/authors_backend_http_test.go b/test/authors_backend_http_test.go index b2f213b..edf369f 100644 --- a/test/authors_backend_http_test.go +++ b/test/authors_backend_http_test.go @@ -14,7 +14,7 @@ import ( ) const ( - ownerOrAdminRefusal = "Only owners and admins can do this." + ownerOrAdminRefusal = apperr.MissingPermissionErrorMessage sessionRefusal = "This action needs a real session." ) diff --git a/test/container_update_guidance_http_test.go b/test/container_update_guidance_http_test.go index b9d98c4..1667ee1 100644 --- a/test/container_update_guidance_http_test.go +++ b/test/container_update_guidance_http_test.go @@ -57,6 +57,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co routes := &api.UseCases{ Token: token, GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now), diff --git a/test/custom_content_types_support_http_test.go b/test/custom_content_types_support_http_test.go index 299664a..2855a88 100644 --- a/test/custom_content_types_support_http_test.go +++ b/test/custom_content_types_support_http_test.go @@ -420,6 +420,7 @@ func TestBuiltinTypeHTTP(t *testing.T) { container := &api.UseCases{ Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), diff --git a/test/custom_content_types_support_test.go b/test/custom_content_types_support_test.go index 67d1bc9..618150c 100644 --- a/test/custom_content_types_support_test.go +++ b/test/custom_content_types_support_test.go @@ -266,6 +266,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv { container := &api.UseCases{ Token: env.token, GetUserUseCase: useCases.NewGetUserUseCase(env.users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now), CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now), ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types), diff --git a/test/custom_content_types_test.go b/test/custom_content_types_test.go index 7b875be..ff4ad8b 100644 --- a/test/custom_content_types_test.go +++ b/test/custom_content_types_test.go @@ -2,6 +2,7 @@ package test import ( "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/user" "net/http" "reflect" "strconv" @@ -128,7 +129,7 @@ func TestContentTypeKeysAndAccess(t *testing.T) { {"no credentials", 0, 401, ""}, {"a jwt cookie for user 4", ctUnverifiedId, 403, apperr.VerificationRequiredErrorMessage}, {"a stale token_version cookie", ctStaleId, 401, apperr.UnauthorizedErrorMessage}, - {"a verified member without a role", ctMemberId, 403, apperr.OwnerOrAdminRequiredErrorMessage}, + {"a verified member without a role", ctMemberId, 403, apperr.MissingPermissionErrorMessage}, } for _, example := range examples { t.Run(example.caller, func(t *testing.T) { @@ -160,6 +161,8 @@ func TestContentTypeKeysAndAccess(t *testing.T) { env.createType(t, "recipe", recipeFields()) env.createType(t, "event", dishFields()) + env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: writerRoleID} + list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type", "") expectStatusAndBody(t, list, http.StatusOK, "") diff --git a/test/image_variants_admin_http_test.go b/test/image_variants_admin_http_test.go index 17fb167..4bc03c5 100644 --- a/test/image_variants_admin_http_test.go +++ b/test/image_variants_admin_http_test.go @@ -39,12 +39,12 @@ func TestImageVariantsGuards(t *testing.T) { {"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage}, {"stale cookie", http.MethodGet, "/v1/image-variants", "stale", 401, apperr.UnauthorizedErrorMessage}, {"unverified user", http.MethodGet, "/v1/image-variants", "unverified", 403, apperr.VerificationRequiredErrorMessage}, - {"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, + {"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage}, + {"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage}, + {"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage}, + {"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage}, + {"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage}, + {"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.MissingPermissionErrorMessage}, {"api key", http.MethodGet, "/v1/image-variants", "apikey", 401, apperr.UnauthorizedErrorMessage}, } for _, row := range rows { diff --git a/test/image_variants_cache_http_test.go b/test/image_variants_cache_http_test.go index 51b6d92..2c71bee 100644 --- a/test/image_variants_cache_http_test.go +++ b/test/image_variants_cache_http_test.go @@ -29,8 +29,8 @@ func TestImageVariantsCacheGuards(t *testing.T) { {"no cookie", http.MethodGet, cacheStatsPath, "none", 401, apperr.UnauthorizedErrorMessage}, {"a stale token_version cookie", http.MethodGet, cacheStatsPath, "stale", 401, apperr.UnauthorizedErrorMessage}, {"the cookie of an unverified user", http.MethodGet, cacheStatsPath, "unverified", 403, apperr.VerificationRequiredErrorMessage}, - {"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, + {"the cookie of a plain user", http.MethodGet, cacheStatsPath, "plain", 403, apperr.MissingPermissionErrorMessage}, + {"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.MissingPermissionErrorMessage}, {"a valid API key", http.MethodGet, cacheStatsPath, "apikey", 401, apperr.UnauthorizedErrorMessage}, {"a valid API key on purge", http.MethodPost, cachePurgePath, "apikey", 401, apperr.UnauthorizedErrorMessage}, } diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index 81d592b..cdc9c85 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -113,6 +113,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases return &api.UseCases{ Token: e.token, GetUserUseCase: useCases.NewGetUserUseCase(e.users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage), diff --git a/test/image_variants_per_image_http_test.go b/test/image_variants_per_image_http_test.go index 1eddfc3..e845b71 100644 --- a/test/image_variants_per_image_http_test.go +++ b/test/image_variants_per_image_http_test.go @@ -149,10 +149,10 @@ func TestPerImageVariantRoutesAreGuarded(t *testing.T) { {http.MethodGet, "/v1/media/1/variants", "none", 401, apperr.UnauthorizedErrorMessage}, {http.MethodGet, "/v1/media/1/variants", "stale", 401, apperr.UnauthorizedErrorMessage}, {http.MethodGet, "/v1/media/1/variants", "unverified", 403, apperr.VerificationRequiredErrorMessage}, - {http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, + {http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.MissingPermissionErrorMessage}, {http.MethodGet, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage}, {http.MethodPost, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage}, } diff --git a/test/invite_management_test.go b/test/invite_management_test.go index 29cb5b4..fb47a24 100644 --- a/test/invite_management_test.go +++ b/test/invite_management_test.go @@ -48,7 +48,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement token: authtoken.New([]byte("test-secret")), } f.invites.users = f.users - f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin} + f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, RoleID: writerRoleID} f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin} f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} f.users.nextID = 12 @@ -77,7 +77,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement r.Use(middleware.RequireVerifiedUser(getUser)) r.Get("/", handler.ListUsers) r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwner(getUser)) + r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite")) r.Use(middleware.TrustedProxyRealIP(nil)) r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour)) r.Post("/invite", handler.CreateInvite) @@ -383,7 +383,7 @@ func TestInviteManagementHttp(t *testing.T) { {"no cookie", func(f *inviteManagementFixture) *http.Cookie { return nil }, http.StatusUnauthorized}, {"stale token_version", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 12, 1) }, http.StatusUnauthorized}, {"unverified user", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 11, 0) }, http.StatusForbidden}, - {"admin", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 9, 0) }, http.StatusForbidden}, + {"writer", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 9, 0) }, http.StatusForbidden}, } for _, tc := range cases { for _, call := range []struct{ method, path string }{ @@ -402,12 +402,12 @@ func TestInviteManagementHttp(t *testing.T) { } }) - t.Run("An admin is refused with the owner message, even on an unknown id", func(t *testing.T) { + t.Run("A writer is refused with the missing permission message, even on an unknown id", func(t *testing.T) { f := newInviteManagementFixture(t, 100) rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/999") - if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" { + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" { t.Errorf("unexpected %d %q", rec.Code, rec.Body.String()) } }) @@ -450,7 +450,7 @@ func TestInviteManagementHttp(t *testing.T) { } }) - t.Run("An admin does not use up the quota", func(t *testing.T) { + t.Run("A writer does not use up the quota", func(t *testing.T) { f := newInviteManagementFixture(t, 2) for i := 0; i < 3; i++ { if rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/1"); rec.Code != http.StatusForbidden { diff --git a/test/media_env_test.go b/test/media_env_test.go index 716c7be..ac94157 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -58,8 +58,8 @@ func newMediaEnv(t *testing.T) *mediaEnv { clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC), logs: &bytes.Buffer{}, } - env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true} - env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true} + env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true, RoleID: adminRoleID} + env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true, RoleID: adminRoleID} env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false} env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1} @@ -73,6 +73,7 @@ func newMediaEnv(t *testing.T) *mediaEnv { UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder), GetUserUseCase: useCases.NewGetUserUseCase(env.users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()), diff --git a/test/media_field_type_support_test.go b/test/media_field_type_support_test.go index 7a64bc3..8336b17 100644 --- a/test/media_field_type_support_test.go +++ b/test/media_field_type_support_test.go @@ -4,6 +4,7 @@ import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/entry" imageDomain "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" @@ -62,6 +63,7 @@ type mediaFieldEnv struct { func newMediaFieldEnv(t *testing.T) *mediaFieldEnv { t.Helper() base := newContentTypeEnv(t) + base.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: adminRoleID} env := &mediaFieldEnv{contentTypeEnv: base, articles: newMockArticleRepo(), spy: &mfEntrySpyRepo{mockEntryRepo: base.entries}, lookups: &mfImageSpyRepo{mockImageRepo: base.images}, logs: &bytes.Buffer{}} settings := newMockPurgeSettingsRepo() now := func() time.Time { return base.clock } diff --git a/test/media_get_http_test.go b/test/media_get_http_test.go index 275f6a3..186c62d 100644 --- a/test/media_get_http_test.go +++ b/test/media_get_http_test.go @@ -15,7 +15,7 @@ const mediaGetEditorID = 9 func newMediaGetEnv(t *testing.T) *siteEnv { t.Helper() e := newSiteEnvWithMain(t) - e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor")} + e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID} e.seedImageRecord(5) return e } diff --git a/test/media_merge_hardening_test.go b/test/media_merge_hardening_test.go index b089f12..6af6286 100644 --- a/test/media_merge_hardening_test.go +++ b/test/media_merge_hardening_test.go @@ -106,6 +106,7 @@ func (e *hardeningEnv) mount() { container := &api.UseCases{ Token: e.token, GetUserUseCase: useCases.NewGetUserUseCase(e.users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), MergeMediaUseCase: merge, GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(e.settings), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(e.settings, time.Now), diff --git a/test/media_purge_http_test.go b/test/media_purge_http_test.go index ec65e55..0396d70 100644 --- a/test/media_purge_http_test.go +++ b/test/media_purge_http_test.go @@ -19,6 +19,7 @@ const ( purgeAdminId uint32 = 11 purgeEditorId uint32 = 12 purgeUnverifiedId uint32 = 13 + purgeNoRoleId uint32 = 14 fingerprintLength = 64 internalError = "Internal server error\n" ) @@ -98,6 +99,7 @@ func newPurgeEnv(t *testing.T) *mediaEnv { env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner} env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin} env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")} + env.users.users[purgeNoRoleId] = user.User{ID: purgeNoRoleId, Username: "norole", IsVerified: true} env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner} return env } @@ -157,14 +159,14 @@ func TestPurgeRoutesRefuseCallersThatAreNotAllowed(t *testing.T) { {http.MethodPost, "/v1/media/purge", 0, "no cookie", 401, ""}, {http.MethodGet, "/v1/media/purge-settings", mediaStaleUserId, "a stale token_version cookie", 401, apperr.UnauthorizedErrorMessage}, {http.MethodGet, "/v1/media/purge-settings", purgeUnverifiedId, "cookie of an unverified owner", 403, apperr.VerificationRequiredErrorMessage}, - {http.MethodGet, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodPatch, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodGet, "/v1/media/purge-preview", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodPost, "/v1/media/purge", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodGet, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodPatch, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodGet, "/v1/media/purge-preview", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage}, - {http.MethodPost, "/v1/media/purge", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage}, + {http.MethodGet, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodPatch, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodGet, "/v1/media/purge-preview", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodPost, "/v1/media/purge", purgeEditorId, "editor", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodGet, "/v1/media/purge-settings", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodPatch, "/v1/media/purge-settings", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodGet, "/v1/media/purge-preview", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage}, + {http.MethodPost, "/v1/media/purge", purgeNoRoleId, "empty role", 403, apperr.MissingPermissionErrorMessage}, } for _, example := range examples { diff --git a/test/media_purge_qa_test.go b/test/media_purge_qa_test.go index 0d8d037..fdbe557 100644 --- a/test/media_purge_qa_test.go +++ b/test/media_purge_qa_test.go @@ -28,7 +28,7 @@ func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) { rec := env.send(t, route.method, route.path, "", 20) - expectRaw(t, rec, 403, apperr.OwnerOrAdminRequiredErrorMessage+"\n") + expectRaw(t, rec, 403, apperr.MissingPermissionErrorMessage+"\n") if env.purgeSettings.getCalls != 0 || env.purgeSettings.saveCalls != 0 { t.Errorf("expected no purge setting access") } diff --git a/test/relation_field_type_http_test.go b/test/relation_field_type_http_test.go index 71cc534..670978b 100644 --- a/test/relation_field_type_http_test.go +++ b/test/relation_field_type_http_test.go @@ -3,6 +3,7 @@ package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/entry" + "RenewCMS/internal/domain/user" "encoding/json" "net/http" "net/http/httptest" @@ -31,6 +32,7 @@ func eventRelationFields(venueRequired bool, speakersRequired bool) []map[string func newRelationEnv(t *testing.T, venueRequired bool, speakersRequired bool) *relationEnv { t.Helper() env := &relationEnv{newContentTypeEnv(t)} + env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: adminRoleID} env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, {"key": "city", "label": "City", "type": "text"}}) env.createType(t, "speaker", []map[string]any{{"key": "name", "label": "Name", "type": "text", "required": true}}) env.createType(t, "event", eventRelationFields(venueRequired, speakersRequired)) diff --git a/test/roles_permissions_enforcement_http_test.go b/test/roles_permissions_enforcement_http_test.go new file mode 100644 index 0000000..c6e03ee --- /dev/null +++ b/test/roles_permissions_enforcement_http_test.go @@ -0,0 +1,499 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const ( + enforcementOwnerID uint32 = 3 + enforcementAdminID uint32 = 5 + enforcementWriterID uint32 = 9 + enforcementUnverified uint32 = 12 + enforcementOrphanID uint32 = 13 + enforcementCustomID uint32 = 20 +) + +type gateOutcome struct { + rec *httptest.ResponseRecorder + reached bool +} + +func (g gateOutcome) blocked() bool { + return !g.reached && g.rec.Code == http.StatusForbidden && g.rec.Body.String() == apperr.MissingPermissionErrorMessage+"\n" +} + +func newEnforcementEnv(t *testing.T) *apiKeyEnv { + t.Helper() + e := newAPIKeyEnv(t) + e.addUser(enforcementOwnerID, user.RoleOwner, ownerRoleID, true) + e.addUser(enforcementAdminID, user.RoleAdmin, adminRoleID, true) + e.addUser(enforcementWriterID, "", writerRoleID, true) + e.addUser(enforcementUnverified, user.RoleAdmin, adminRoleID, false) + e.addUser(enforcementOrphanID, "", 99, true) + return e +} + +func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role { + custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants}) + e.addUser(id, "", custom.ID, true) + return custom +} + +func (e *apiKeyEnv) gateRequest(t *testing.T, req *http.Request) gateOutcome { + t.Helper() + outcome := gateOutcome{rec: httptest.NewRecorder()} + func() { + defer func() { + if recover() != nil { + outcome.reached = true + } + }() + e.router.ServeHTTP(outcome.rec, req) + }() + return outcome +} + +func (e *apiKeyEnv) gateAs(t *testing.T, userID uint32, method, path, body string) gateOutcome { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(e.cookie(t, userID, 0)) + return e.gateRequest(t, req) +} + +func (e *apiKeyEnv) gateWithKey(t *testing.T, raw, method, path, body string) gateOutcome { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+raw) + return e.gateRequest(t, req) +} + +func assertGate(t *testing.T, got gateOutcome, wantKey string) { + t.Helper() + if wantKey == "" { + if got.blocked() { + t.Fatalf("expected the gate to pass, got %d %q (key %q)", got.rec.Code, got.rec.Body.String(), got.rec.Header().Get(apperr.MissingPermissionHeader)) + } + return + } + if !got.blocked() { + t.Fatalf("expected missing_permission for %s, got reached=%v %d %q", wantKey, got.reached, got.rec.Code, got.rec.Body.String()) + } + if header := got.rec.Header().Get(apperr.MissingPermissionHeader); header != wantKey { + t.Fatalf("expected %s header %q, got %q", apperr.MissingPermissionHeader, wantKey, header) + } +} + +type gateRow struct { + caller uint32 + method string + path string + body string + key string +} + +func runGateRows(t *testing.T, rows []gateRow) { + t.Helper() + for _, row := range rows { + name := roleNameOf(row.caller) + " " + row.method + " " + row.path + " -> " + resultOf(row.key) + t.Run(name, func(t *testing.T) { + e := newEnforcementEnv(t) + assertGate(t, e.gateAs(t, row.caller, row.method, row.path, row.body), row.key) + }) + } +} + +func roleNameOf(id uint32) string { + switch id { + case enforcementOwnerID: + return "Owner" + case enforcementAdminID: + return "Admin" + case enforcementWriterID: + return "Writer" + } + return "user" +} + +func resultOf(key string) string { + if key == "" { + return "ok" + } + return "403 " + key +} + +func TestRolesPermissionsEnforcementHTTP(t *testing.T) { + const ( + owner = enforcementOwnerID + admin = enforcementAdminID + writer = enforcementWriterID + ) + + t.Run("A refused request carries the missing_permission contract", func(t *testing.T) { + e := newEnforcementEnv(t) + got := e.gateAs(t, writer, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`) + assertGate(t, got, "users.invite") + if got.rec.Header().Get("Content-Type") == "" { + t.Error("expected a content type") + } + if len(e.invites.invites) != 0 || len(e.mail.SentEmails) != 0 { + t.Errorf("a refused invite must have no effect: %d invites, %d mails", len(e.invites.invites), len(e.mail.SentEmails)) + } + }) + + t.Run("Verification and token checks still come first", func(t *testing.T) { + e := newEnforcementEnv(t) + unverified := e.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", cookieUser: enforcementUnverified}) + assertAPIKeyMessage(t, unverified, http.StatusForbidden, apperr.VerificationRequiredErrorMessage) + + stored := e.users.users[writer] + stored.TokenVersion = 4 + e.users.users[writer] = stored + stale := e.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", cookieUser: writer, cookieVersion: 0}) + assertAPIKeyMessage(t, stale, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) + + anonymous := e.anonymous(t, http.MethodPost, "/v1/user/invite", "") + assertAPIKeyMessage(t, anonymous, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) + }) + + t.Run("Users and invites", func(t *testing.T) { + runGateRows(t, []gateRow{ + {owner, http.MethodGet, "/v1/user", "", ""}, + {admin, http.MethodGet, "/v1/user", "", ""}, + {writer, http.MethodGet, "/v1/user", "", "users.view"}, + {owner, http.MethodPost, "/v1/user/invite", "", ""}, + {admin, http.MethodPost, "/v1/user/invite", "", ""}, + {writer, http.MethodPost, "/v1/user/invite", "", "users.invite"}, + {admin, http.MethodDelete, "/v1/user/invite/1", "", ""}, + {writer, http.MethodDelete, "/v1/user/invite/1", "", "users.invite"}, + {admin, http.MethodPost, "/v1/user/invite/1/renew", "", ""}, + {writer, http.MethodPost, "/v1/user/invite/1/renew", "", "users.invite"}, + }) + }) + + t.Run("Settings, sites and system", func(t *testing.T) { + runGateRows(t, []gateRow{ + {owner, http.MethodGet, "/v1/settings", "", ""}, + {admin, http.MethodGet, "/v1/settings", "", ""}, + {writer, http.MethodGet, "/v1/settings", "", "settings.view"}, + {owner, http.MethodPatch, "/v1/settings", "", ""}, + {admin, http.MethodPatch, "/v1/settings", "", "system.edit_retention"}, + {writer, http.MethodPatch, "/v1/settings", "", "system.edit_retention"}, + {owner, http.MethodPost, "/v1/site", "", ""}, + {admin, http.MethodPost, "/v1/site", "", ""}, + {writer, http.MethodPost, "/v1/site", "", "settings.manage_sites"}, + {admin, http.MethodPatch, "/v1/site/2", "", ""}, + {admin, http.MethodPatch, "/v1/site/2/default", "", ""}, + {writer, http.MethodPatch, "/v1/site/2", "", "settings.manage_sites"}, + {owner, http.MethodDelete, "/v1/site/2", "", ""}, + {admin, http.MethodDelete, "/v1/site/2", "", "settings.delete_site"}, + {writer, http.MethodDelete, "/v1/site/2", "", "settings.delete_site"}, + {writer, http.MethodGet, "/v1/site", "", ""}, + {owner, http.MethodGet, "/v1/system/update", "", ""}, + {admin, http.MethodGet, "/v1/system/update", "", ""}, + {writer, http.MethodGet, "/v1/system/update", "", "settings.view"}, + {owner, http.MethodPost, "/v1/system/update/check", "", ""}, + {admin, http.MethodPost, "/v1/system/update/check", "", "system.apply_updates"}, + {owner, http.MethodPost, "/v1/system/update/download", "", ""}, + {admin, http.MethodPost, "/v1/system/update/download", "", "system.apply_updates"}, + {owner, http.MethodPost, "/v1/system/update/restart", "", ""}, + {admin, http.MethodPost, "/v1/system/update/restart", "", "system.apply_updates"}, + {writer, http.MethodPost, "/v1/system/update/restart", "", "system.apply_updates"}, + }) + }) + + t.Run("The permission gate runs before the update rate limiters", func(t *testing.T) { + e := newEnforcementEnv(t) + for attempt := 1; attempt <= 6; attempt++ { + assertGate(t, e.gateAs(t, admin, http.MethodPost, "/v1/system/update/check", ""), "system.apply_updates") + } + }) + + t.Run("The permission gate runs before the invite rate limiter", func(t *testing.T) { + e := newEnforcementEnv(t) + e.settings.AuthRateLimit.MaxAttempts = 1 + e.rebuildRouter() + first := e.gateAs(t, owner, http.MethodPost, "/v1/user/invite", `{"email": "first@example.com"}`) + if first.blocked() { + t.Fatalf("owner invite refused: %q", first.rec.Body.String()) + } + if again := e.gateAs(t, owner, http.MethodPost, "/v1/user/invite", `{"email": "second@example.com"}`); again.rec.Code != http.StatusTooManyRequests { + t.Fatalf("limiter is not exhausted, status %d", again.rec.Code) + } + got := e.gateAs(t, writer, http.MethodPost, "/v1/user/invite", `{"email": "third@example.com"}`) + assertGate(t, got, "users.invite") + }) + + t.Run("An admin reads the update status but cannot apply it", func(t *testing.T) { + e := newEnforcementEnv(t) + e.container.GetUpdateStatusUseCase = newUpdateStatusUseCase(e.updates, &fakeJobStore{}, &fakeHelperDetector{installed: true}, apiKeyRunningVersion) + e.rebuildRouter() + canApply := func(id uint32) bool { + rec := e.withCookie(t, http.MethodGet, "/v1/system/update", "", id) + assertAPIKeyStatus(t, rec, http.StatusOK) + var body struct { + CanApply bool `json:"can_apply"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.CanApply + } + if canApply(admin) { + t.Error("admin must not be able to apply") + } + if !canApply(owner) { + t.Error("owner must be able to apply with the helper installed") + } + }) + + t.Run("An admin reads the activity retention but cannot change it", func(t *testing.T) { + e := newEnforcementEnv(t) + rec := e.withCookie(t, http.MethodGet, "/v1/settings", "", admin) + assertAPIKeyStatus(t, rec, http.StatusOK) + for _, field := range []string{"activity_retention_days", "activity_retention_max_gb"} { + if !strings.Contains(rec.Body.String(), field) { + t.Errorf("settings body lacks %s: %s", field, rec.Body.String()) + } + } + assertGate(t, e.gateAs(t, admin, http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`), "system.edit_retention") + }) + + t.Run("Authors, schema, media and API key creation", func(t *testing.T) { + runGateRows(t, []gateRow{ + {owner, http.MethodGet, "/v1/author", "", ""}, + {writer, http.MethodGet, "/v1/author", "", ""}, + {admin, http.MethodPost, "/v1/author", "", ""}, + {writer, http.MethodPost, "/v1/author", "", "authors.create"}, + {admin, http.MethodDelete, "/v1/author/4", "", ""}, + {writer, http.MethodDelete, "/v1/author/4", "", "authors.delete"}, + {writer, http.MethodPost, "/v1/author/me", "", ""}, + {admin, http.MethodGet, "/v1/content-type", "", ""}, + {writer, http.MethodGet, "/v1/content-type", "", ""}, + {writer, http.MethodGet, "/v1/content-type/events", "", ""}, + {admin, http.MethodPost, "/v1/content-type", "", ""}, + {writer, http.MethodPost, "/v1/content-type", "", "schema.edit"}, + {writer, http.MethodPut, "/v1/content-type/events", "", "schema.edit"}, + {admin, http.MethodDelete, "/v1/content-type/events", "", ""}, + {writer, http.MethodDelete, "/v1/content-type/events", "", "schema.delete"}, + {writer, http.MethodGet, "/v1/builtin-type", "", ""}, + {writer, http.MethodGet, "/v1/media", "", ""}, + {writer, http.MethodGet, "/v1/media/1", "", ""}, + {writer, http.MethodGet, "/v1/media/1/usage", "", ""}, + {writer, http.MethodPost, "/v1/media", "", ""}, + {writer, http.MethodPatch, "/v1/media/1", "", ""}, + {writer, http.MethodDelete, "/v1/media/1", "", "media.delete"}, + {writer, http.MethodPost, "/v1/media/merge", "", "media.delete"}, + {admin, http.MethodDelete, "/v1/media/1", "", ""}, + {writer, http.MethodGet, "/v1/media/purge-settings", "", "settings.view"}, + {writer, http.MethodPatch, "/v1/media/purge-settings", "", "settings.edit_general"}, + {writer, http.MethodGet, "/v1/media/purge-preview", "", "media.delete"}, + {writer, http.MethodPost, "/v1/media/purge", "", "media.delete"}, + {admin, http.MethodPost, "/v1/media/purge", "", ""}, + {writer, http.MethodGet, "/v1/media/1/variants", "", ""}, + {writer, http.MethodPost, "/v1/media/1/variants", "", "settings.edit_general"}, + {writer, http.MethodDelete, "/v1/media/1/variants/hero", "", "settings.edit_general"}, + {writer, http.MethodGet, "/v1/image-variants", "", ""}, + {writer, http.MethodGet, "/v1/image-variants/cache", "", "settings.view"}, + {writer, http.MethodPost, "/v1/image-variants", "", "settings.edit_general"}, + {writer, http.MethodPost, "/v1/image-variants/cache/purge", "", "settings.edit_general"}, + {admin, http.MethodPut, "/v1/image-variants/hero", "", ""}, + {admin, http.MethodPost, "/v1/api-key", `{"name": "ci"}`, ""}, + {writer, http.MethodPost, "/v1/api-key", `{"name": "ci"}`, "apikeys.create"}, + }) + }) + + t.Run("Routes that keep their old behaviour for API keys", func(t *testing.T) { + e := newEnforcementEnv(t) + raw, _ := e.issueKey(admin, "ci") + for _, route := range [][2]string{{http.MethodPost, "/v1/api-key"}, {http.MethodGet, "/v1/activity"}, {http.MethodPost, "/v1/system/update/check"}} { + rec := e.withKey(t, route[0], route[1], "", raw) + assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.SessionRequiredErrorMessage) + } + rec := e.withKey(t, http.MethodGet, "/v1/media/1/variants", "", raw) + assertAPIKeyMessage(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) + }) + + t.Run("Roles are judged by the keys they hold, with no implied keys", func(t *testing.T) { + rows := []struct { + grants []role.Grant + method string + path string + key string + }{ + {grantsOf("schema.edit"), http.MethodPost, "/v1/content-type", ""}, + {grantsOf("schema.edit"), http.MethodGet, "/v1/content-type", "schema.view"}, + {grantsOf("schema.view"), http.MethodPut, "/v1/content-type/events", "schema.edit"}, + {grantsOf("users.invite"), http.MethodGet, "/v1/user", "users.view"}, + {grantsOf("users.view"), http.MethodPost, "/v1/user/invite", "users.invite"}, + {grantsOf("settings.manage_sites"), http.MethodDelete, "/v1/site/2", "settings.delete_site"}, + {grantsOf("settings.delete_site"), http.MethodPatch, "/v1/site/2", "settings.manage_sites"}, + {grantsOf("media.upload"), http.MethodGet, "/v1/media", "media.view"}, + {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", ""}, + {nil, http.MethodGet, "/v1/author", "authors.view"}, + } + for _, row := range rows { + names := []string{} + for _, grant := range row.grants { + names = append(names, grant.Key) + } + t.Run(strings.Join(names, ",")+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) { + e := newEnforcementEnv(t) + e.customRoleUser(enforcementCustomID, row.grants...) + assertGate(t, e.gateAs(t, enforcementCustomID, row.method, row.path, ""), row.key) + }) + } + }) + + t.Run("A user whose role cannot be resolved is refused everywhere that is gated", func(t *testing.T) { + e := newEnforcementEnv(t) + assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodGet, "/v1/user", ""), "users.view") + assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodGet, "/v1/content-type", ""), "schema.view") + assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodPost, "/v1/media", ""), "media.upload") + }) + + t.Run("A repository failure while resolving the role is a server error", func(t *testing.T) { + e := newEnforcementEnv(t) + e.roles.fail.failNext("Get") + rec := e.withCookie(t, http.MethodGet, "/v1/user", "", owner) + assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error") + }) + + t.Run("The legacy role string is no longer read for authorization", func(t *testing.T) { + rows := []struct { + id uint32 + legacy user.Role + roleID uint32 + method string + path string + key string + }{ + {14, user.RoleOwner, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"}, + {15, user.RoleAdmin, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"}, + {16, "", adminRoleID, http.MethodPost, "/v1/content-type", ""}, + {17, user.RoleOwner, 0, http.MethodPost, "/v1/user/invite", ""}, + {18, user.RoleAdmin, 0, http.MethodPost, "/v1/content-type", ""}, + {19, user.RoleAdmin, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"}, + } + for _, row := range rows { + t.Run(string(row.legacy)+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) { + e := newEnforcementEnv(t) + e.addUser(row.id, row.legacy, row.roleID, true) + assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key) + }) + } + }) + + t.Run("A session follows its role live", func(t *testing.T) { + e := newEnforcementEnv(t) + assertGate(t, e.gateAs(t, writer, http.MethodGet, "/v1/media/purge-settings", ""), "settings.view") + stored := e.roles.roles[writerRoleID] + stored.Grants = append(stored.Grants, role.Grant{Key: "settings.view", Scope: role.ScopeAll}) + e.roles.roles[writerRoleID] = stored + rec := e.withCookie(t, http.MethodGet, "/v1/settings", "", writer) + assertAPIKeyStatus(t, rec, http.StatusOK) + }) + + t.Run("Owner-only items stay owner-only", func(t *testing.T) { + e := newEnforcementEnv(t) + for _, route := range [][2]string{{http.MethodDelete, "/v1/site/2"}, {http.MethodPatch, "/v1/settings"}, {http.MethodPost, "/v1/system/update/check"}, {http.MethodPost, "/v1/system/update/download"}, {http.MethodPost, "/v1/system/update/restart"}} { + if !e.gateAs(t, admin, route[0], route[1], "").blocked() { + t.Errorf("admin passed %s %s", route[0], route[1]) + } + if e.gateAs(t, owner, route[0], route[1], "").blocked() { + t.Errorf("owner blocked on %s %s", route[0], route[1]) + } + } + }) + + t.Run("An API key resolves its creator's current role on every request", func(t *testing.T) { + e := newEnforcementEnv(t) + raw, _ := e.issueKey(admin, "ci") + assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/content-type", ""), "") + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/user/invite", `{"email": "k@example.com"}`), "") + + changed := e.users.users[admin] + changed.RoleID = writerRoleID + e.users.users[admin] = changed + + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/user/invite", `{"email": "k2@example.com"}`), "users.invite") + assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/content-type", ""), "") + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "schema.edit") + }) + + t.Run("A key grows when its creator's role grows", func(t *testing.T) { + e := newEnforcementEnv(t) + raw, _ := e.issueKey(writer, "ci") + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "schema.edit") + changed := e.users.users[writer] + changed.RoleID = adminRoleID + e.users.users[writer] = changed + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "") + }) + + t.Run("Editing the creator's role grants shrinks the key as well", func(t *testing.T) { + e := newEnforcementEnv(t) + custom := e.customRoleUser(enforcementCustomID, grantsOf("media.view", "media.upload")...) + raw, _ := e.issueKey(enforcementCustomID, "ci") + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/media", ""), "") + stored := e.roles.roles[custom.ID] + stored.Grants = grantsOf("media.view") + e.roles.roles[custom.ID] = stored + assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/media", ""), "media.upload") + assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/media", ""), "") + }) + + t.Run("A key whose creator's role cannot be resolved is refused by the gate", func(t *testing.T) { + e := newEnforcementEnv(t) + raw, _ := e.issueKey(enforcementOrphanID, "ci") + assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/media", ""), "media.view") + }) + + t.Run("Key authentication failures are unchanged", func(t *testing.T) { + e := newEnforcementEnv(t) + rec := e.withKey(t, http.MethodGet, "/v1/media", "", "rcms_unknownunknownunknownunknownunknownunknown1") + assertAPIKeyMessage(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) + }) + + t.Run("Public and self-service routes keep no permission gate", func(t *testing.T) { + e := newEnforcementEnv(t) + e.customRoleUser(enforcementCustomID) + for _, route := range [][2]string{{http.MethodGet, "/v1/auth/me"}, {http.MethodGet, "/v1/permission"}, {http.MethodGet, "/v1/site"}, {http.MethodGet, "/v1/user/lookup?ids=1"}, {http.MethodPatch, "/v1/user/me"}, {http.MethodPost, "/v1/author/me"}} { + if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() { + t.Errorf("%s %s is gated", route[0], route[1]) + } + } + if e.gateAs(t, enforcementCustomID, http.MethodGet, "/v1/article", "").blocked() { + t.Error("article list is gated") + } + }) + + t.Run("Articles and entries are not gated in this 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]) + } + } + }) +} + +func grantsOf(keys ...string) []role.Grant { + grants := make([]role.Grant, 0, len(keys)) + for _, key := range keys { + grants = append(grants, role.Grant{Key: key, Scope: role.ScopeAll}) + } + return grants +} diff --git a/test/roles_permissions_enforcement_hygiene_test.go b/test/roles_permissions_enforcement_hygiene_test.go new file mode 100644 index 0000000..9232ff2 --- /dev/null +++ b/test/roles_permissions_enforcement_hygiene_test.go @@ -0,0 +1,197 @@ +package test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" + + "RenewCMS/internal/domain/role" +) + +var legacyRoleSpots = map[string]bool{ + "internal/infrastructure/useCases/GetMyAccess.go": true, + "internal/infrastructure/useCases/CreateUser.go": true, + "internal/infrastructure/useCases/AcceptInvite.go": true, +} + +var legacyRoleComparison = regexp.MustCompile(`user\.Role(Owner|Admin)|\.Role\s*(==|!=)`) + +func productionGoFiles(t *testing.T, roots ...string) map[string]string { + t.Helper() + files := map[string]string{} + for _, root := range roots { + err := filepath.WalkDir(filepath.Join("..", 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") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + files[strings.TrimPrefix(filepath.ToSlash(path), "../")] = string(content) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + } + return files +} + +func TestRolesPermissionsEnforcementHygiene(t *testing.T) { + t.Run("The owner and admin gates are gone from the code", func(t *testing.T) { + for name, content := range productionGoFiles(t, "api") { + if strings.Contains(content, "RequireOwner") { + t.Errorf("%s still mentions RequireOwner", name) + } + if strings.Contains(content, "OwnerRequiredErrorMessage") || strings.Contains(content, "OwnerOrAdminRequiredErrorMessage") { + t.Errorf("%s still mentions a removed owner message", name) + } + } + }) + + t.Run("No authorization decision reads the legacy user role", func(t *testing.T) { + for name, content := range productionGoFiles(t, "api", "internal/infrastructure/useCases") { + if legacyRoleSpots[name] { + continue + } + if match := legacyRoleComparison.FindString(content); match != "" { + t.Errorf("%s compares the legacy role: %q", name, match) + } + } + }) + + t.Run("The legacy role is read for resolution only in the allowed spots", func(t *testing.T) { + for name, content := range productionGoFiles(t, "internal/infrastructure/useCases") { + if strings.Contains(content, "CallerRole") || strings.Contains(content, "isAdminRole") { + t.Errorf("%s still carries a role-based caller", name) + } + } + }) +} + +var permissionGateNames = map[string]bool{"require": true, "requireAll": true} + +func roleKeyConstants(t *testing.T) map[string]string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "internal", "domain", "role", "roleKeys.go"), nil, 0) + if err != nil { + t.Fatalf("parse roleKeys.go: %v", err) + } + constants := map[string]string{} + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range spec.Names { + if i >= len(spec.Values) { + continue + } + if literal, ok := spec.Values[i].(*ast.BasicLit); ok && literal.Kind == token.STRING { + value, _ := strconv.Unquote(literal.Value) + constants[name.Name] = value + } + } + return true + }) + return constants +} + +func routerGateKeys(t *testing.T) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "api", "router.go"), nil, 0) + if err != nil { + t.Fatalf("parse router.go: %v", err) + } + constants := roleKeyConstants(t) + found := map[string]bool{} + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) != 1 { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !permissionGateNames[selector.Sel.Name] { + return true + } + switch arg := call.Args[0].(type) { + case *ast.BasicLit: + value, _ := strconv.Unquote(arg.Value) + found[value] = true + case *ast.SelectorExpr: + found[constants[arg.Sel.Name]] = true + default: + t.Errorf("gate %s called with an argument the test cannot resolve", selector.Sel.Name) + } + return true + }) + keys := make([]string, 0, len(found)) + for key := range found { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func unknownCatalogueKeys(keys []string) []string { + var unknown []string + for _, key := range keys { + if _, err := role.Lookup(key); err != nil { + unknown = append(unknown, key) + } + } + return unknown +} + +func TestRouterPermissionGateKeysAreInTheCatalogue(t *testing.T) { + t.Run("Every key passed to a router permission gate is in the catalogue", func(t *testing.T) { + keys := routerGateKeys(t) + if len(keys) == 0 { + t.Fatal("no permission gate keys found in api/router.go") + } + if unknown := unknownCatalogueKeys(keys); len(unknown) > 0 { + t.Errorf("router gates use keys missing from the catalogue: %v", unknown) + } + }) + + t.Run("Chained gates contribute both keys", func(t *testing.T) { + keys := routerGateKeys(t) + for _, want := range []string{"activity.export", "activity.view"} { + if sort.SearchStrings(keys, want) == len(keys) || keys[sort.SearchStrings(keys, want)] != want { + t.Errorf("expected gate key %q to be found", want) + } + } + }) + + t.Run("The checking function catches a misspelled key", func(t *testing.T) { + cases := []struct { + name string + keys []string + want int + }{ + {"valid keys", []string{"activity.view", "activity.export"}, 0}, + {"misspelled key", []string{"activity.veiw"}, 1}, + {"one bad among good", []string{"activity.view", "apikeys.revok"}, 1}, + {"empty key", []string{""}, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := len(unknownCatalogueKeys(tc.keys)); got != tc.want { + t.Errorf("unknown = %d, want %d", got, tc.want) + } + }) + } + }) +} diff --git a/test/roles_permissions_enforcement_scope_test.go b/test/roles_permissions_enforcement_scope_test.go new file mode 100644 index 0000000..b48031c --- /dev/null +++ b/test/roles_permissions_enforcement_scope_test.go @@ -0,0 +1,373 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "context" + "encoding/csv" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +const scopedUserID uint32 = 6 + +func (e *activityHTTPEnv) customUser(id uint32, grants ...role.Grant) { + custom, _ := e.roles.Create(role.Role{Name: "Scoped " + strconv.Itoa(int(id)), Version: 1, Grants: grants}) + e.users.users[id] = user.User{ID: id, Username: "scoped" + strconv.Itoa(int(id)), Email: "scoped@example.com", IsVerified: true, RoleID: custom.ID} +} + +func (e *activityHTTPEnv) seedActorEvents(t *testing.T, ids ...uint32) { + t.Helper() + for _, id := range ids { + e.seed(t, activityUserEvent(id, "actor"+strconv.Itoa(int(id)), e.now.Add(-time.Hour))) + } +} + +func activityActorsOf(t *testing.T, rec *httptest.ResponseRecorder) []uint32 { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String()) + } + var body struct { + Items []struct { + Actor struct { + ID *uint32 `json:"id"` + } `json:"actor"` + } `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + actors := []uint32{} + for _, item := range body.Items { + actors = append(actors, *item.Actor.ID) + } + slices.Sort(actors) + return actors +} + +func csvActorsOf(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String()) + } + rows, err := csv.NewReader(strings.NewReader(rec.Body.String())).ReadAll() + if err != nil { + t.Fatalf("csv: %v", err) + } + actors := []string{} + for _, row := range rows[1:] { + actors = append(actors, row[1]) + } + slices.Sort(actors) + return actors +} + +func scopedGrant(key string, scope role.Scope) role.Grant { + return role.Grant{Key: key, Scope: scope} +} + +func assertMissing(t *testing.T, rec *httptest.ResponseRecorder, key string) { + t.Helper() + assertPlain(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage) + if got := rec.Header().Get(apperr.MissingPermissionHeader); got != key { + t.Errorf("expected %s %q, got %q", apperr.MissingPermissionHeader, key, got) + } +} + +func TestRolesPermissionsEnforcementActivityScope(t *testing.T) { + t.Run("activity.view scope decides what the list returns", func(t *testing.T) { + rows := []struct { + name string + scope role.Scope + query string + actors []uint32 + missing bool + }{ + {"all", role.ScopeAll, "", []uint32{1, 2, scopedUserID}, false}, + {"all with an actor", role.ScopeAll, "?actor=user:2", []uint32{2}, false}, + {"own", role.ScopeOwn, "", []uint32{scopedUserID}, false}, + {"own asking for self", role.ScopeOwn, "?actor=user:6", []uint32{scopedUserID}, false}, + {"own asking for another actor", role.ScopeOwn, "?actor=user:1", nil, true}, + {"none", role.ScopeNone, "", nil, true}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + e := newActivityHTTPEnv(t, 1000) + e.customUser(scopedUserID, scopedGrant(role.KeyActivityView, row.scope)) + e.seedActorEvents(t, 1, 2, scopedUserID) + + rec := e.get("/v1/activity"+row.query, asUser(e, scopedUserID)) + + if row.missing { + assertMissing(t, rec, role.KeyActivityView) + return + } + if got := activityActorsOf(t, rec); !slices.Equal(got, row.actors) { + t.Errorf("expected actors %v, got %v", row.actors, got) + } + }) + } + }) + + t.Run("Activity summary follows the scope", func(t *testing.T) { + e := newActivityHTTPEnv(t, 1000) + e.customUser(scopedUserID, scopedGrant(role.KeyActivityView, role.ScopeAll)) + if rec := e.get("/v1/activity/summary", asUser(e, scopedUserID)); rec.Code != http.StatusOK { + t.Errorf("all: expected 200, got %d", rec.Code) + } + e.customUser(7, scopedGrant(role.KeyActivityView, role.ScopeOwn)) + assertMissing(t, e.get("/v1/activity/summary", asUser(e, 7)), role.KeyActivityView) + }) + + t.Run("Activity export follows the scope", func(t *testing.T) { + rows := []struct { + name string + grants []role.Grant + actors []string + key string + }{ + {"view all and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeAll), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, []string{"actor1", "actor2", "actor6"}, ""}, + {"view own and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeOwn), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, []string{"actor6"}, ""}, + {"view none and export all", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeNone), scopedGrant(role.KeyActivityExport, role.ScopeAll)}, nil, role.KeyActivityView}, + {"view all without export", []role.Grant{scopedGrant(role.KeyActivityView, role.ScopeAll)}, nil, role.KeyActivityExport}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + e := newActivityHTTPEnv(t, 1000) + e.customUser(scopedUserID, row.grants...) + e.seedActorEvents(t, 1, 2, scopedUserID) + + rec := e.get("/v1/activity/export", asUser(e, scopedUserID)) + + if row.key != "" { + assertMissing(t, rec, row.key) + return + } + if got := csvActorsOf(t, rec); !slices.Equal(got, row.actors) { + t.Errorf("expected %v, got %v", row.actors, got) + } + }) + } + }) + + t.Run("Writer is effective on activity", func(t *testing.T) { + e := newActivityHTTPEnv(t, 1000) + e.seedActorEvents(t, 1, 2, activityEditorID) + got := activityActorsOf(t, e.get("/v1/activity", asUser(e, activityEditorID))) + if !slices.Equal(got, []uint32{activityEditorID}) { + t.Errorf("expected only the writer's events, got %v", got) + } + }) +} + +func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) { + seedKeys := func(e *apiKeyEnv) { + e.issueKey(enforcementOwnerID, "owner key") + e.issueKey(enforcementAdminID, "admin key") + e.issueKey(enforcementCustomID, "caller key") + } + + t.Run("apikeys.view scope decides which keys are listed", func(t *testing.T) { + rows := []struct { + name string + scope role.Scope + names []string + named bool + key string + }{ + {"all", role.ScopeAll, []string{"caller key", "admin key", "owner key"}, true, ""}, + {"own", role.ScopeOwn, []string{"caller key"}, false, ""}, + {"none", role.ScopeNone, nil, false, "apikeys.view"}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + e := newEnforcementEnv(t) + e.customRoleUser(enforcementCustomID, scopedGrant("apikeys.view", row.scope)) + seedKeys(e) + + rec := e.withCookie(t, http.MethodGet, "/v1/api-key", "", enforcementCustomID) + + if row.key != "" { + assertGate(t, gateOutcome{rec: rec}, row.key) + return + } + assertAPIKeyStatus(t, rec, http.StatusOK) + if got := apiKeyEntryNames(t, rec); !slices.Equal(got, row.names) { + t.Errorf("expected %v, got %v", row.names, got) + } + for _, entry := range apiKeyDecodeList(t, rec) { + if _, present := entry["created_by_name"]; present != row.named { + t.Errorf("created_by_name present=%v, expected %v", present, row.named) + } + } + }) + } + }) + + t.Run("apikeys.revoke scope decides which keys can be revoked", func(t *testing.T) { + rows := []struct { + name string + scope role.Scope + status int + message string + revoked bool + }{ + {"all", role.ScopeAll, http.StatusNoContent, "", true}, + {"own", role.ScopeOwn, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage, false}, + {"none", role.ScopeNone, http.StatusForbidden, apperr.MissingPermissionErrorMessage, false}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + e := newEnforcementEnv(t) + e.customRoleUser(enforcementCustomID, scopedGrant("apikeys.revoke", row.scope)) + _, id := e.issueKey(enforcementAdminID, "admin key") + + rec := e.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", enforcementCustomID) + + assertAPIKeyMessage(t, rec, row.status, row.message) + if got := e.keys.keys[id].RevokedAt != nil; got != row.revoked { + t.Errorf("revoked=%v, expected %v", got, row.revoked) + } + }) + } + }) + + t.Run("revoke none answers before any lookup", func(t *testing.T) { + repo := newMockAPIKeyRepo() + stored := seedStubKey(repo, "rcms_a", nil) + repo.fail.failNext("Get") + uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder) + + err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.NewGrantSet(nil)) + + if !errors.Is(err, role.ErrMissingPermission) { + t.Errorf("expected a missing permission, got %v", err) + } + }) + + t.Run("The Admin lists and revokes every key", func(t *testing.T) { + e := newEnforcementEnv(t) + _, ownerKey := e.issueKey(enforcementOwnerID, "owner key") + + list := e.withCookie(t, http.MethodGet, "/v1/api-key", "", enforcementAdminID) + assertAPIKeyStatus(t, list, http.StatusOK) + entries := apiKeyListedByName(t, list) + if entries["owner key"]["created_by_name"] == nil { + t.Errorf("the owner's key lacks created_by_name: %v", entries) + } + revoke := e.withCookie(t, http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(ownerKey)), "", enforcementAdminID) + assertAPIKeyStatus(t, revoke, http.StatusNoContent) + }) + + 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) { + t.Errorf("expected a missing permission, got %v", err) + } + }) +} + +func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) { + const writerUser, ownerUser uint32 = 9, 3 + newUseCase := func() (*useCases.UpdateAuthorUseCase, *mockAuthorRepo) { + authors := plainAuthors() + authors.seedLinked(4, "Writer", "writer", writerUser) + authors.seedLinked(5, "Owner", "owner", ownerUser) + users := newMockUserRepo() + users.users[writerUser] = user.User{ID: writerUser, Username: "writer", Email: "w@example.com"} + users.users[ownerUser] = user.User{ID: ownerUser, Username: "owner", Email: "o@example.com"} + return useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder), authors + } + bio := "x" + + t.Run("authors.edit scope decides who can edit an author", func(t *testing.T) { + rows := []struct { + name string + grants role.GrantSet + id uint32 + patch author.Patch + want error + any bool + }{ + {"all edits another author", authorsEditAllGrants, 5, author.Patch{Bio: &bio}, nil, false}, + {"all may relink", authorsEditAllGrants, 5, author.Patch{UserID: author.Nullable[uint32]{Set: true, Value: idPointer(writerUser)}}, nil, true}, + {"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}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + uc, _ := newUseCase() + + _, err := uc.Update(context.Background(), row.id, useCases.AuthorCaller{UserID: writerUser, Grants: row.grants}, row.patch) + + if row.any { + if errors.Is(err, author.ErrAuthorRights) || errors.Is(err, role.ErrMissingPermission) { + t.Errorf("unexpected refusal %v", err) + } + return + } + if !errors.Is(err, row.want) && (row.want != nil || err != nil) { + t.Errorf("expected %v, got %v", row.want, err) + } + }) + } + }) + + 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), + } { + t.Run(name, func(t *testing.T) { + authors := plainAuthors() + authors.seedLinked(4, "Writer", "writer", writerUser) + users := newMockUserRepo() + users.users[writerUser] = user.User{ID: writerUser, Email: "w@example.com"} + + page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants}) + + if err != nil || len(page.Items) != 1 { + t.Fatalf("unexpected %v %+v", err, page) + } + if shown := page.Items[0].UserEmail != nil; shown != grants.Holds(role.KeyUsersView) { + t.Errorf("user_email shown=%v for %s", shown, name) + } + }) + } + }) + + t.Run("Writer sees author items without user_email and the Admin with it", func(t *testing.T) { + e := newAuthorEnv(t) + e.authors.seedLinked(2, "Edith", "edith", authorEditorID) + + writerList := e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "") + adminList := e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "") + + if writerList.Code != http.StatusOK || strings.Contains(writerList.Body.String(), "user_email") { + t.Errorf("writer: %d %s", writerList.Code, writerList.Body.String()) + } + if adminList.Code != http.StatusOK || !strings.Contains(adminList.Body.String(), "edith@example.com") { + t.Errorf("admin: %d %s", adminList.Code, adminList.Body.String()) + } + }) + + t.Run("A role without authors.edit is refused before any lookup", func(t *testing.T) { + e := newEnforcementEnv(t) + e.customRoleUser(enforcementCustomID, scopedGrant("authors.view", role.ScopeAll)) + assertGate(t, e.gateAs(t, enforcementCustomID, http.MethodPatch, "/v1/author/999", `{"bio": "x"}`), "authors.edit") + }) +} diff --git a/test/roles_permissions_enforcement_support_test.go b/test/roles_permissions_enforcement_support_test.go new file mode 100644 index 0000000..89da458 --- /dev/null +++ b/test/roles_permissions_enforcement_support_test.go @@ -0,0 +1,108 @@ +package test + +import ( + "RenewCMS/internal/domain/audit" + "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "context" + "encoding/csv" + "errors" + "strings" + "testing" + "time" +) + +func TestRolesPermissionsEnforcementActivityExportPaging(t *testing.T) { + w := newActivityWorld(t) + for i := 0; i < 501; i++ { + w.seedAt(t, time.Duration(i+1)*time.Second, func(e *audit.Event) { e.Action = "article.edit" }) + } + + w.probe.maxReads = 5 + prepared, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants}) + if err != nil { + t.Fatalf("prepare failed: %v", err) + } + var out bytes.Buffer + if err := prepared.WriteCSV(&out); err != nil { + t.Fatalf("write failed: %v", err) + } + + rows, err := csv.NewReader(strings.NewReader(out.String())).ReadAll() + if err != nil || len(rows) != 502 { + t.Fatalf("expected 502 rows, got %d (%v)", len(rows), err) + } + if len(w.probe.listCalls) != 2 || w.probe.listCalls[0].Page != 1 || w.probe.listCalls[1].Page != 2 { + t.Errorf("expected pages 1 and 2, got %+v", w.probe.listCalls) + } +} + +func TestRolesPermissionsEnforcementAuthorLinking(t *testing.T) { + t.Run("linking an author to an unknown user is refused", func(t *testing.T) { + authors := plainAuthors() + users := newMockUserRepo() + create := useCases.NewCreateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder) + unknown := uint32(77) + + _, err := create.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Ghost", Slug: "ghost", UserID: &unknown}) + + if !errors.Is(err, user.ErrUserNotFound) || len(authors.authors) != 0 { + t.Fatalf("expected ErrUserNotFound and no author, got %v (%d)", err, len(authors.authors)) + } + }) + + t.Run("linking a user who already has an author is refused", func(t *testing.T) { + authors := plainAuthors() + authors.seedLinked(1, "Taken", "taken", 5) + users := newMockUserRepo() + users.users[5] = user.User{ID: 5, Email: "five@example.com"} + create := useCases.NewCreateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder) + five := uint32(5) + + _, err := create.Create(context.Background(), useCases.CreateAuthorCommand{Name: "Second", Slug: "second", UserID: &five}) + + if !errors.Is(err, author.ErrUserAlreadyLinked) { + t.Fatalf("expected ErrUserAlreadyLinked, got %v", err) + } + }) + + t.Run("user_email is set only for a linked author whose user exists", func(t *testing.T) { + authors := plainAuthors() + authors.seed(1, "Unlinked", "unlinked") + authors.seedLinked(2, "Linked", "linked", 5) + 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)}) + + page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants}) + + if err != nil || len(page.Items) != 3 { + t.Fatalf("unexpected %v %+v", err, page) + } + emails := map[uint32]*string{} + for _, item := range page.Items { + emails[item.ID] = item.UserEmail + } + if emails[1] != nil || emails[3] != nil { + t.Errorf("expected no email for unlinked and dangling, got %v %v", emails[1], emails[3]) + } + if emails[2] == nil || *emails[2] != "five@example.com" { + t.Errorf("expected the linked email, got %v", emails[2]) + } + }) +} + +func TestRolesPermissionsEnforcementMissingPermissionMessage(t *testing.T) { + err := role.MissingPermissionError{Key: "users.invite"} + + if got := err.Error(); got != role.ErrMissingPermission.Error()+": users.invite" { + t.Errorf("unexpected message %q", got) + } + if !errors.Is(err, role.ErrMissingPermission) || errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("unexpected Is behaviour") + } +} diff --git a/test/roles_permissions_enforcement_test.go b/test/roles_permissions_enforcement_test.go new file mode 100644 index 0000000..c4c704b --- /dev/null +++ b/test/roles_permissions_enforcement_test.go @@ -0,0 +1,172 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "testing" +) + +func missingKeyOf(t *testing.T, err error) string { + t.Helper() + var missing role.MissingPermissionError + if !errors.As(err, &missing) { + t.Fatalf("expected a MissingPermissionError, got %v", err) + } + return missing.Key +} + +func TestCheckPermissionUseCase(t *testing.T) { + newCheck := func() (*useCases.CheckPermissionUseCase, *mockRoleRepo) { + roles := newSeededMockRoleRepo() + return useCases.NewCheckPermissionUseCase(roles), roles + } + owner := user.User{ID: 3, RoleID: ownerRoleID} + admin := user.User{ID: 5, RoleID: adminRoleID} + writer := user.User{ID: 9, RoleID: writerRoleID} + + t.Run("Require passes for held keys and refuses the others", func(t *testing.T) { + check, _ := newCheck() + cases := []struct { + name string + account user.User + key string + allowed bool + }{ + {"owner holds an owner-only key", owner, "system.apply_updates", true}, + {"admin lacks an owner-only key", admin, "system.apply_updates", false}, + {"admin holds users.invite", admin, "users.invite", true}, + {"writer lacks users.invite", writer, "users.invite", false}, + {"writer holds schema.view", writer, "schema.view", true}, + {"writer holds a scoped key at own", writer, "activity.view", true}, + {"an unknown key is refused", owner, "no.such_key", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := check.Require(c.account, c.key) + if c.allowed && err != nil { + t.Fatalf("expected pass, got %v", err) + } + if !c.allowed { + if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != c.key { + t.Fatalf("expected missing permission for %s, got %v", c.key, err) + } + } + }) + } + }) + + t.Run("RequireAll refuses a scoped key held only at own", func(t *testing.T) { + check, _ := newCheck() + if _, err := check.RequireAll(writer, "activity.view"); !errors.Is(err, role.ErrMissingPermission) { + t.Fatalf("expected refusal, got %v", err) + } + if _, err := check.RequireAll(admin, "activity.view"); err != nil { + t.Fatalf("expected admin to pass, got %v", err) + } + }) + + t.Run("Require returns the resolved grants", func(t *testing.T) { + check, _ := newCheck() + grants, err := check.Require(writer, "authors.view") + if err != nil { + t.Fatal(err) + } + if grants.Scope("authors.edit") != role.ScopeOwn || grants.Scope("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) { + 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, "schema.view"); !errors.Is(err, role.ErrMissingPermission) { + t.Errorf("expected no implied keys, got %v", err) + } + }) + + t.Run("An unresolvable role is refused as a missing permission", func(t *testing.T) { + check, _ := newCheck() + for name, account := range map[string]user.User{ + "unknown role_id": {ID: 13, RoleID: 99}, + "empty legacy and no id": {ID: 14, Role: ""}, + "editor legacy and no id": {ID: 15, Role: "editor"}, + "legacy owner string ignored when role_id names Writer": {ID: 16, Role: user.RoleOwner, RoleID: writerRoleID}, + } { + _, err := check.Require(account, "users.invite") + if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" { + t.Errorf("%s: got %v", name, err) + } + } + }) + + t.Run("The legacy string resolves only owner and admin when there is no role_id", func(t *testing.T) { + check, _ := newCheck() + if _, err := check.Require(user.User{ID: 17, Role: user.RoleOwner}, "system.apply_updates"); err != nil { + t.Errorf("legacy owner: %v", err) + } + if _, err := check.Require(user.User{ID: 18, Role: user.RoleAdmin}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) { + t.Errorf("legacy admin should lack owner-only keys, got %v", err) + } + }) + + t.Run("A repository failure is not a missing permission", func(t *testing.T) { + check, roles := newCheck() + roles.fail.failNext("Get") + _, err := check.Require(owner, "users.view") + 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) { + check, roles := newCheck() + if _, err := check.Require(writer, "settings.view"); !errors.Is(err, role.ErrMissingPermission) { + t.Fatalf("expected refusal first, got %v", err) + } + stored := roles.roles[writerRoleID] + stored.Grants = append(stored.Grants, role.Grant{Key: "settings.view", Scope: role.ScopeAll}) + roles.roles[writerRoleID] = stored + if _, err := check.Require(writer, "settings.view"); err != nil { + t.Fatalf("expected pass after the edit, got %v", err) + } + }) +} + +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") { + 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") { + t.Errorf("unexpected %v", set) + } + }) + t.Run("MissingPermissionError matches the sentinel and carries the key", func(t *testing.T) { + err := error(role.MissingPermissionError{Key: "users.invite"}) + if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" { + t.Errorf("unexpected %v", err) + } + }) +} + +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}}) +) diff --git a/test/roles_permissions_foundation_http_test.go b/test/roles_permissions_foundation_http_test.go index 9c5511b..e10278c 100644 --- a/test/roles_permissions_foundation_http_test.go +++ b/test/roles_permissions_foundation_http_test.go @@ -308,7 +308,7 @@ func TestExistingRoleChecksAreUntouched(t *testing.T) { denied bool }{ {"owner invites", apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false}, - {"admin invites", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, true}, + {"admin invites (allowed since roles_permissions_enforcement)", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false}, {"admin creates a content type", apiKeyDevId, http.MethodPost, "/v1/content-type", `{}`, false}, {"admin patches settings", apiKeyDevId, http.MethodPatch, "/v1/settings", `{}`, true}, {"admin checks for updates", apiKeyDevId, http.MethodPost, "/v1/system/update/check", "", true}, @@ -317,8 +317,8 @@ func TestExistingRoleChecksAreUntouched(t *testing.T) { t.Run(c.name, func(t *testing.T) { e := newAPIKeyEnv(t) rec := e.withCookie(t, c.method, c.path, c.body, c.user) - if c.denied && (rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n") { - t.Errorf("expected owner-required 403, got %d %q", rec.Code, rec.Body.String()) + if c.denied && (rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n") { + t.Errorf("expected missing_permission 403, got %d %q", rec.Code, rec.Body.String()) } if !c.denied && rec.Code == http.StatusForbidden { t.Errorf("unexpected 403 %q", rec.Body.String()) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 6f046be..ece5079 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -136,6 +136,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), GetUserUseCase: useCases.NewGetUserUseCase(userRepo), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), diff --git a/test/seo_data_site_http_test.go b/test/seo_data_site_http_test.go index de9155d..c998697 100644 --- a/test/seo_data_site_http_test.go +++ b/test/seo_data_site_http_test.go @@ -1,6 +1,7 @@ package test import ( + "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/site" "net/http" @@ -217,7 +218,7 @@ func TestSeoDataSiteFields(t *testing.T) { }) t.Run("Other roles and keys are refused", func(t *testing.T) { - const ownerText, sessionText = "Only the owner can do this.", "This action needs a real session." + const ownerText, sessionText = apperr.MissingPermissionErrorMessage, "This action needs a real session." cases := []struct { name string method string @@ -225,10 +226,9 @@ func TestSeoDataSiteFields(t *testing.T) { send func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult text string }{ - {"PATCH with a verified admin", http.MethodPatch, "/v1/site/2", seoAsUser(apiKeyDevId), ownerText}, {"PATCH with a verified account with another role", http.MethodPatch, "/v1/site/2", seoAsUser(seoEditorID), ownerText}, {"PATCH with a valid API key", http.MethodPatch, "/v1/site/2", seoAsKeyOf(apiKeyDevId), sessionText}, - {"POST with a verified admin", http.MethodPost, "/v1/site/", seoAsUser(apiKeyDevId), ownerText}, + {"POST with a verified account with another role", http.MethodPost, "/v1/site/", seoAsUser(seoEditorID), ownerText}, {"POST with a valid API key", http.MethodPost, "/v1/site/", seoAsKeyOf(apiKeyDevId), sessionText}, } for _, c := range cases { @@ -262,9 +262,20 @@ func TestSeoDataSiteFields(t *testing.T) { t.Run("The role is checked before the body", func(t *testing.T) { e := newSeoEnv(t) - rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", "{not json") + rec := e.as(t, seoEditorID, http.MethodPatch, "/v1/site/2", "{not json") - expectStatusAndText(t, rec, http.StatusForbidden, "Only the owner can do this.") + expectStatusAndText(t, rec, http.StatusForbidden, apperr.MissingPermissionErrorMessage) + }) + + t.Run("An admin edits the SEO defaults of a site", func(t *testing.T) { + e := newSeoEnv(t) + + rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", `{"default_robots": "noindex"}`) + + got := decodeSiteBody(t, rec) + if rec.Code != http.StatusOK || got.DefaultRobots != "noindex" { + t.Errorf("status %d site %+v", rec.Code, got) + } }) t.Run("A no-op site edit records nothing", func(t *testing.T) { diff --git a/test/seo_data_support_test.go b/test/seo_data_support_test.go index 6a9efcd..bd9617e 100644 --- a/test/seo_data_support_test.go +++ b/test/seo_data_support_test.go @@ -41,7 +41,7 @@ type seoEnv struct { func newSeoEnv(t *testing.T) *seoEnv { t.Helper() p := newProducersEnv(t) - p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor")} + p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID} p.users.nextID = seoEditorID authors := newMockAuthorRepo(p.articles) sites := newMockSiteRepo(p.articles) diff --git a/test/seo_output_sitemap_http_test.go b/test/seo_output_sitemap_http_test.go index 1ea04d5..9f7572a 100644 --- a/test/seo_output_sitemap_http_test.go +++ b/test/seo_output_sitemap_http_test.go @@ -164,7 +164,7 @@ func TestSeoOutputSitemap(t *testing.T) { t.Run("The route does not disturb the owner-only site routes", func(t *testing.T) { e := newSeoOutEnv(t) - expectStatusAndText(t, e.call(t, "admin", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.OwnerRequiredErrorMessage) + expectStatusAndText(t, e.call(t, "editor", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.MissingPermissionErrorMessage) }) t.Run("The browser can read the total header", func(t *testing.T) { diff --git a/test/session_revocation_http_test.go b/test/session_revocation_http_test.go index b5c316f..b655497 100644 --- a/test/session_revocation_http_test.go +++ b/test/session_revocation_http_test.go @@ -175,6 +175,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv { container := &api.UseCases{ Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))), GetUserUseCase: useCases.NewGetUserUseCase(userRepo), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), diff --git a/test/site_acceptance_test.go b/test/site_acceptance_test.go index f541077..d814751 100644 --- a/test/site_acceptance_test.go +++ b/test/site_acceptance_test.go @@ -4,6 +4,7 @@ import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/site" "net/http" + "net/http/httptest" "reflect" "strconv" "strings" @@ -11,8 +12,9 @@ import ( ) const ( - siteOwnerOnlyBody = "Only the owner can do this." + siteOwnerOnlyBody = apperr.MissingPermissionErrorMessage siteSessionOnlyBody = "This action needs a real session." + siteWriterID = 7 ) func TestSitesReading(t *testing.T) { @@ -637,10 +639,11 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) { {http.MethodDelete, "/v1/site/2", ""}, } for _, request := range requests { - t.Run(request.method+" "+request.path+" with a verified admin's jwt cookie", func(t *testing.T) { + t.Run(request.method+" "+request.path+" with a verified writer's jwt cookie", func(t *testing.T) { e := newSiteEnvWithShop(t) + e.addUser(siteWriterID, "", writerRoleID, true) before := e.sites.snapshot() - rec := e.as(t, apiKeyDevId, request.method, request.path, request.body) + rec := e.as(t, siteWriterID, request.method, request.path, request.body) expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody) if !reflect.DeepEqual(before, e.sites.snapshot()) { t.Errorf("a site changed") @@ -661,9 +664,28 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) { t.Run("The role is checked before the body", func(t *testing.T) { e := newSiteEnvWithMain(t) + e.addUser(siteWriterID, "", writerRoleID, true) - rec := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", "{not json") + rec := e.as(t, siteWriterID, http.MethodPost, "/v1/site/", "{not json") expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody) }) + + t.Run("An admin manages sites but cannot delete one", func(t *testing.T) { + e := newSiteEnvWithShop(t) + + created := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`) + renamed := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`) + madeDefault := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2/default", "") + for name, rec := range map[string]*httptest.ResponseRecorder{"create": created, "update": renamed, "default": madeDefault} { + if rec.Code >= http.StatusBadRequest { + t.Errorf("%s: status %d %s", name, rec.Code, rec.Body.String()) + } + } + before := e.sites.snapshot() + expectSiteStatusAndBody(t, e.as(t, apiKeyDevId, http.MethodDelete, "/v1/site/2", ""), http.StatusForbidden, siteOwnerOnlyBody) + if !reflect.DeepEqual(before, e.sites.snapshot()) { + t.Errorf("a site changed") + } + }) } diff --git a/test/site_activity_acceptance_test.go b/test/site_activity_acceptance_test.go index ddd250e..917f123 100644 --- a/test/site_activity_acceptance_test.go +++ b/test/site_activity_acceptance_test.go @@ -124,7 +124,7 @@ func TestSitesActivityLog(t *testing.T) { {"PATCH /v1/site/999 as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/999", `{"name": "X"}`}, {"DELETE /v1/site/999 as the owner", apiKeyOwnerId, http.MethodDelete, "/v1/site/999", ""}, {"POST /v1/site/ with an unknown image", apiKeyOwnerId, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "other", "base_url": "https://other.example.com", "default_social_image_id": 999}`}, - {"POST /v1/site/ as an admin (403)", apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`}, + {"POST /v1/site/ as a writer (403)", siteWriterID, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`}, {"POST /v1/site/ with no credentials (401)", 0, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`}, {"PATCH /v1/site/abc as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`}, {"POST /v1/site/ with a malformed body", apiKeyOwnerId, http.MethodPost, "/v1/site/", "{not json"}, @@ -132,6 +132,7 @@ func TestSitesActivityLog(t *testing.T) { for _, c := range cases { t.Run(c.request, func(t *testing.T) { e := newSiteEnvWithShop(t) + e.addUser(siteWriterID, "", writerRoleID, true) e.as(t, c.as, c.method, c.path, c.body) e.noEvent(t) }) diff --git a/test/update_download_http_test.go b/test/update_download_http_test.go index d8af4aa..ee39df8 100644 --- a/test/update_download_http_test.go +++ b/test/update_download_http_test.go @@ -38,6 +38,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness { container := &api.UseCases{ Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: rig.status, @@ -184,7 +185,7 @@ func TestStartUpdateDownloadHTTP(t *testing.T) { rec := h.sendAs(t, http.MethodPost, downloadPath, updateAdminId) - if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" { + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 { diff --git a/test/update_http_test.go b/test/update_http_test.go index eb53ec5..4d26078 100644 --- a/test/update_http_test.go +++ b/test/update_http_test.go @@ -63,6 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness { container := &api.UseCases{ Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running), @@ -245,7 +246,7 @@ func TestUpdateCheckHTTP(t *testing.T) { t.Run("An admin cannot check", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) rec := h.sendAs(t, http.MethodPost, "/update/check", updateAdminId) - if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" { + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } if h.feed.calls != 0 { diff --git a/test/update_restart_http_test.go b/test/update_restart_http_test.go index 423e442..0c1a048 100644 --- a/test/update_restart_http_test.go +++ b/test/update_restart_http_test.go @@ -56,10 +56,11 @@ func newRestartHTTP(t *testing.T) *restartHTTP { h.markers = infraUpdate.NewJSONRestartRequestStore(h.markerPath) h.resultFiles = infraUpdate.NewJSONResultStore(h.resultPath) container := &api.UseCases{ - Token: h.token, - GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), - GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), - GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), + Token: h.token, + GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), + GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase( h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now), RequestRestartUseCase: useCases.NewRequestRestartUseCase( @@ -260,8 +261,8 @@ func TestRestartHTTP(t *testing.T) { {"no jwt cookie", 0, 0, http.StatusUnauthorized, ""}, {"a stale token version", updateOwnerId, 1, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage + "\n"}, {"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden, apperr.VerificationRequiredErrorMessage + "\n"}, - {"an admin", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"}, - {"an admin with a bad body", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"}, + {"an admin", updateAdminId, 0, http.StatusForbidden, apperr.MissingPermissionErrorMessage + "\n"}, + {"an admin with a bad body", updateAdminId, 0, http.StatusForbidden, apperr.MissingPermissionErrorMessage + "\n"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/test/user_roles_test.go b/test/user_roles_test.go index 7f308f6..7777ffa 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -86,7 +86,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture { r.Use(middleware.RequireVerifiedUser(getUser)) r.Get("/", inviteHandler.ListUsers) r.Group(func(r chi.Router) { - r.Use(middleware.RequireOwner(getUser)) + r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite")) r.Use(middleware.TrustedProxyRealIP(nil)) r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour)) r.Post("/invite", inviteHandler.CreateInvite) @@ -108,6 +108,13 @@ func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role, } } +func (f *userRolesFixture) seedWriter(id uint32, username string) { + f.seedUser(id, username, "", true) + stored := f.users.users[id] + stored.RoleID = writerRoleID + f.users.users[id] = stored +} + func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, bytes.NewBufferString(body)) @@ -268,25 +275,36 @@ func TestUserRolesHttp(t *testing.T) { } }) - t.Run("An admin cannot invite", func(t *testing.T) { + t.Run("An admin can invite", func(t *testing.T) { f := newUserRolesFixture(t, 100) f.seedUser(2, "jonas", user.RoleAdmin, true) rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2)) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("A writer cannot invite", func(t *testing.T) { + f := newUserRolesFixture(t, 100) + f.seedWriter(2, "jonas") + + rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2)) + if rec.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", rec.Code) } - if rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" { + if rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" { t.Errorf("unexpected body %q", rec.Body.String()) } assertNothingInvited(t, f) }) for _, body := range []string{`{"email": "not-an-email"}`, `not json`} { - t.Run("The owner check comes before validation and the rate limiter: "+body, func(t *testing.T) { + t.Run("The permission check comes before validation and the rate limiter: "+body, func(t *testing.T) { f := newUserRolesFixture(t, 100) - f.seedUser(2, "jonas", user.RoleAdmin, true) + f.seedWriter(2, "jonas") rec := f.do(t, http.MethodPost, "/v1/user/invite", body, f.as(t, 2)) @@ -297,9 +315,9 @@ func TestUserRolesHttp(t *testing.T) { }) } - t.Run("An admin's refused invites do not use up the rate limit", func(t *testing.T) { + t.Run("A writer's refused invites do not use up the rate limit", func(t *testing.T) { f := newUserRolesFixture(t, 2) - f.seedUser(2, "jonas", user.RoleAdmin, true) + f.seedWriter(2, "jonas") for i := 0; i < 3; i++ { rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2)) @@ -314,7 +332,7 @@ func TestUserRolesHttp(t *testing.T) { f.seedUser(2, "jonas", user.RoleOwner, true) cookie := f.as(t, 2) stored := f.users.users[2] - stored.Role = user.RoleAdmin + stored.RoleID = writerRoleID f.users.users[2] = stored rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, cookie) @@ -384,7 +402,7 @@ func TestUserRolesUseCases(t *testing.T) { }) } -func TestRequireOwnerRepositoryFailure(t *testing.T) { +func TestRequirePermissionRepositoryFailure(t *testing.T) { t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) repo := newMockUserRepo() @@ -392,7 +410,7 @@ func TestRequireOwnerRepositoryFailure(t *testing.T) { r := chi.NewRouter() r.Use(authtoken.Verifier(token)) r.Use(authtoken.Authenticator) - r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo))) + r.Use(middleware.RequirePermission(useCases.NewGetUserUseCase(repo), useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite")) r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) repo.fail.failNext("Get") diff --git a/web/src/data/__tests__/apiCatalogue.spec.ts b/web/src/data/__tests__/apiCatalogue.spec.ts index d5e0e08..2018c7a 100644 --- a/web/src/data/__tests__/apiCatalogue.spec.ts +++ b/web/src/data/__tests__/apiCatalogue.spec.ts @@ -430,7 +430,7 @@ describe('Owner or admin purge routes', () => { for (const endpoint of allEndpoints().filter((item) => item.access === 'Owner or admin')) { expect(endpoint.errors).toContainEqual({ status: 403, - caption: 'Only owners and admins can do this.', + caption: 'missing_permission', }) } }) @@ -762,7 +762,7 @@ describe('Per-endpoint error captions use the real strings', () => { ['POST /v1/user/invite', 409, 'This address already has an account.'], ['POST /v1/user/invite', 409, "There's already a pending invite for this address."], ['POST /v1/user/invite/{id}/renew', 409, 'This invite has already been accepted.'], - ['POST /v1/user/invite', 403, 'Only the owner can do this.'], + ['POST /v1/user/invite', 403, 'missing_permission'], ] it.each(captions)('%s lists %i "%s"', (route, status, caption) => { @@ -852,7 +852,6 @@ describe('Errors table', () => { [ SESSION_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE, - OWNER_REQUIRED_MESSAGE, 'You must verify your account to access this resource.', ], [], diff --git a/web/src/data/apiCatalogue.ts b/web/src/data/apiCatalogue.ts index e1d1eed..2e12d0b 100644 --- a/web/src/data/apiCatalogue.ts +++ b/web/src/data/apiCatalogue.ts @@ -93,8 +93,8 @@ export interface EndpointExamples { export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…' export const NO_CONTENT_RESPONSE = '204 No Content' export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.' -export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.' -export const OWNER_OR_ADMIN_MESSAGE = 'Only owners and admins can do this.' +export const OWNER_REQUIRED_MESSAGE = 'missing_permission' +export const OWNER_OR_ADMIN_MESSAGE = 'missing_permission' export const IMAGES_AFTER_GROUP = 'sites' export const FIELD_TYPES_ANCHOR = 'ct-fields' export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.' @@ -1857,12 +1857,7 @@ export const ERROR_ROWS: ErrorRow[] = [ { status: 403, meaning: 'Forbidden', - messages: [ - SESSION_REQUIRED_MESSAGE, - OWNER_OR_ADMIN_MESSAGE, - OWNER_REQUIRED_MESSAGE, - VERIFY_REQUIRED, - ], + messages: [SESSION_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE, VERIFY_REQUIRED], note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', }, { diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 24bbf0e..0fb3dd1 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -961,8 +961,8 @@ export const messages = { 'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.', 'Keys start with `rcms_` and are shown in full once, when you create them.': 'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.', - 'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.': - 'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié. Les routes propriétaire et administrateur répondent 403 à une clé dont le créateur n’est ni l’un ni l’autre.', + 'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.': + 'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié. Les routes qui exigent une permission répondent 403 « missing_permission » à une clé dont le rôle du créateur ne la possède pas.', 'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.': 'Chaque clé peut faire 120 requêtes par minute. Au-delà, l’API répond 429 avec un en-tête `Retry-After`.', 'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.': diff --git a/web/src/views/IntegrationView.vue b/web/src/views/IntegrationView.vue index c989818..0e80cb5 100644 --- a/web/src/views/IntegrationView.vue +++ b/web/src/views/IntegrationView.vue @@ -74,7 +74,7 @@ const authItems = computed(() => { const authNotes = computed(() => [ t( - 'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.', + 'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.', ), t( 'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.', diff --git a/web/src/views/__tests__/IntegrationView.spec.ts b/web/src/views/__tests__/IntegrationView.spec.ts index 27cad2f..2947c81 100644 --- a/web/src/views/__tests__/IntegrationView.spec.ts +++ b/web/src/views/__tests__/IntegrationView.spec.ts @@ -395,7 +395,7 @@ describe('Group scrolled, one card expanded', () => { expect(upload.text()).toContain('the file must be a PNG, JPEG, WEBP, or SVG image') const invite = await expand(wrapper, 'POST /v1/user/invite') - expect(invite.text()).toContain('Only the owner can do this.') + expect(invite.text()).toContain('missing_permission') expect(invite.text()).toContain('This route is rate limited. Too many requests answer 429.') expect(invite.text()).toContain("There's already a pending invite for this address.") })