feat: more role backend and admin/writer roles additions and adjustments

This commit is contained in:
Florian Sylvain committed 2026-10-26 18:15:26 +01:00
1 parent 8c6c6456da
commit de142f6c5f
131 files changed
+2656 -428

No files matched your search

+3 -1
View File
@@ -64,6 +64,7 @@ type UseCases struct {
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
GetMyAccessUseCase *useCases.GetMyAccessUseCase GetMyAccessUseCase *useCases.GetMyAccessUseCase
CheckPermissionUseCase *useCases.CheckPermissionUseCase
ListPermissionsUseCase *useCases.ListPermissionsUseCase ListPermissionsUseCase *useCases.ListPermissionsUseCase
ListSitesUseCase *useCases.ListSitesUseCase ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase CreateSiteUseCase *useCases.CreateSiteUseCase
@@ -372,6 +373,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder), DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), 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), RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), 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), 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), 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), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
+12 -6
View File
@@ -1,11 +1,12 @@
package activity package activity
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) {
case errors.Is(err, audit.ErrInvalidFilter): case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed): case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden) access.WriteMissingPermission(w, role.KeyActivityView)
case errors.Is(err, user.ErrUserNotFound): case access.WriteIfMissingPermission(w, err):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default: default:
http.Error(w, "Internal server error", http.StatusInternalServerError) 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 := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size 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 { if err != nil {
writeActivityError(w, err) writeActivityError(w, err)
return return
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeActivityError(w, err) writeActivityError(w, err)
return return
+7 -2
View File
@@ -1,6 +1,7 @@
package apikey package apikey
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return return
} }
entries, err := h.ListUseCase.ListAPIKeys(userId) entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
if err != nil { if err != nil {
if access.WriteIfMissingPermission(w, err) {
return
}
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return 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 { switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAPIKeyNotFound): case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound): case errors.Is(err, user.ErrUserNotFound):
+7 -19
View File
@@ -1,6 +1,7 @@
package author package author
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
@@ -33,11 +34,10 @@ type Handler struct {
CreateMyUseCase *useCases.CreateMyAuthorUseCase CreateMyUseCase *useCases.CreateMyAuthorUseCase
UpdateUseCase *useCases.UpdateAuthorUseCase UpdateUseCase *useCases.UpdateAuthorUseCase
DeleteUseCase *useCases.DeleteAuthorUseCase 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 { 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, GetUserUseCase: getUser} return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
} }
type socialLinkRequest struct { type socialLinkRequest struct {
@@ -180,6 +180,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
func writeAuthorError(w http.ResponseWriter, err error) { func writeAuthorError(w http.ResponseWriter, err error) {
switch { switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAuthorNotFound): case errors.Is(err, domain.ErrAuthorNotFound):
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSlugTaken): case errors.Is(err, domain.ErrSlugTaken):
@@ -226,18 +227,6 @@ func writeValidationError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest) 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 { type listQuery struct {
Page int `validate:"gte=1"` Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"` Size int `validate:"oneof=5 25 50 100"`
@@ -277,8 +266,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return return
} }
current, _ := h.currentUser(r) page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role})
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
@@ -351,12 +339,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return return
} }
current, ok := h.currentUser(r) callerID, ok := claims.UserId(r)
if !ok { if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return 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 { if err != nil {
writeAuthorError(w, err) writeAuthorError(w, err)
return return
+7 -30
View File
@@ -1,11 +1,12 @@
package update package update
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/update" domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -21,29 +22,23 @@ type Handler struct {
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase RequestRestartUseCase *useCases.RequestRestartUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
type restartRequest struct { type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"` 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{ return &Handler{
GetUpdateStatusUseCase: getStatus, GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate, CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload, StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart, RequestRestartUseCase: requestRestart,
GetUserUseCase: getUser,
} }
} }
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
isOwner, ok := h.isOwner(w, r) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { 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 return *request.Kind, true
} }
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) { func canApply(r *http.Request) bool {
userId, ok := claims.UserId(r) return access.Grants(r).Holds(role.KeyApplyUpdates)
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 writeDownloadError(w http.ResponseWriter, err error) { func writeDownloadError(w http.ResponseWriter, err error) {
-36
View File
@@ -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)
})
}
}
+40
View File
@@ -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)))
})
}
}
+34
View File
@@ -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
}
+2 -2
View File
@@ -24,7 +24,6 @@ const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted." const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
const InviteInvalidErrorMessage = "This invite link doesn't work." const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired." const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another." const UsernameTakenErrorMessage = "That username is taken. Try another."
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer." const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
const MediaQueryErrorMessage = "The media query is invalid." 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 ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found" const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future." 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 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 PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running." const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365." const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
+61 -66
View File
@@ -58,6 +58,14 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware 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 { func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
@@ -149,10 +157,10 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe) r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers) r.With(container.require("users.view")).Get("/", inviteHandler.ListUsers)
r.Get("/lookup", lookupHandler.Lookup) r.Get("/lookup", lookupHandler.Lookup)
r.Group(func(r chi.Router) { 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.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite) r.Post("/invite", inviteHandler.CreateInvite)
@@ -173,6 +181,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require("settings.view"))
r.Get("/", settingsHandler.GetSettings) r.Get("/", settingsHandler.GetSettings)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -183,7 +192,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(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) r.Patch("/", settingsHandler.UpdateSettings)
}) })
return r return r
@@ -213,11 +222,10 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.With(container.require("settings.manage_sites")).Post("/", siteHandler.Create)
r.Post("/", siteHandler.Create) r.With(container.require("settings.manage_sites")).Patch("/{id}", siteHandler.Update)
r.Patch("/{id}", siteHandler.Update) r.With(container.require("settings.manage_sites")).Patch("/{id}/default", siteHandler.SetDefault)
r.Patch("/{id}/default", siteHandler.SetDefault) r.With(container.require("settings.delete_site")).Delete("/{id}", siteHandler.Delete)
r.Delete("/{id}", siteHandler.Delete)
}) })
return r return r
} }
@@ -225,7 +233,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler { func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() 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.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -233,6 +241,7 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require("authors.view"))
r.Get("/", authorHandler.List) r.Get("/", authorHandler.List)
}) })
r.Group(func(r chi.Router) { 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.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/me", authorHandler.CreateMine) r.Post("/me", authorHandler.CreateMine)
r.Patch("/{id}", authorHandler.Update) r.With(container.require("authors.edit")).Patch("/{id}", authorHandler.Update)
r.Group(func(r chi.Router) { r.With(container.require("authors.create")).Post("/", authorHandler.Create)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require("authors.delete")).Delete("/{id}", authorHandler.Delete)
r.Post("/", authorHandler.Create)
r.Delete("/{id}", authorHandler.Delete)
})
}) })
return r return r
} }
@@ -268,14 +274,11 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", contentTypeHandler.List) r.With(container.require("schema.view")).Get("/", contentTypeHandler.List)
r.Get("/{key}", contentTypeHandler.Get) r.With(container.require("schema.view")).Get("/{key}", contentTypeHandler.Get)
r.Group(func(r chi.Router) { r.With(container.require("schema.edit")).Post("/", contentTypeHandler.Create)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require("schema.edit")).Put("/{key}", contentTypeHandler.Update)
r.Post("/", contentTypeHandler.Create) r.With(container.require("schema.delete")).Delete("/{key}", contentTypeHandler.Delete)
r.Put("/{key}", contentTypeHandler.Update)
r.Delete("/{key}", contentTypeHandler.Delete)
})
r.Get("/{key}/entry", entryHandler.List) r.Get("/{key}/entry", entryHandler.List)
r.Post("/{key}/entry", entryHandler.Create) r.Post("/{key}/entry", entryHandler.Create)
r.Get("/{key}/entry/{id}", entryHandler.Get) 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(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(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("/", builtinTypeHandler.List)
r.Get("/{key}", builtinTypeHandler.Get) r.Get("/{key}", builtinTypeHandler.Get)
return r return r
@@ -314,12 +317,9 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", activityHandler.List) r.With(container.require("activity.view")).Get("/", activityHandler.List)
r.Group(func(r chi.Router) { r.With(container.requireAll("activity.view")).Get("/summary", activityHandler.Summary)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require("activity.export"), container.require("activity.view")).Get("/export", activityHandler.Export)
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
return r return r
} }
@@ -333,7 +333,7 @@ const (
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler { func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() 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(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -341,19 +341,19 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(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.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.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check) r.Post("/update/check", updateHandler.Check)
}) })
r.Group(func(r chi.Router) { 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.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download) r.Post("/update/download", updateHandler.Download)
}) })
r.Group(func(r chi.Router) { 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) r.Post("/update/restart", updateHandler.Restart)
}) })
return r return r
@@ -370,9 +370,9 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create) r.With(container.require("apikeys.create")).Post("/", apiKeyHandler.Create)
r.Get("/", apiKeyHandler.List) r.With(container.require("apikeys.view")).Get("/", apiKeyHandler.List)
r.Delete("/{id}", apiKeyHandler.Revoke) r.With(container.require("apikeys.revoke")).Delete("/{id}", apiKeyHandler.Revoke)
return r return r
} }
@@ -402,28 +402,24 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Group(func(r chi.Router) { r.With(container.require("settings.view")).Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require("settings.edit_general")).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-settings", mediaHandler.GetPurgeSettings) r.With(container.requireAll("media.delete")).Get("/purge-preview", mediaHandler.PreviewPurge)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings) r.With(container.requireAll("media.delete")).Post("/purge", mediaHandler.RunPurge)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RejectAPIKey) r.Use(middleware.RejectAPIKey)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require("media.view")).Get("/{id}/variants", mediaHandler.ListVariants)
r.Get("/{id}/variants", mediaHandler.ListVariants) r.With(container.require("settings.edit_general")).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants) r.With(container.require("settings.edit_general")).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant) r.With(container.require("settings.edit_general")).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
}) })
r.Get("/", mediaHandler.List) r.With(container.require("media.view")).Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload) r.With(container.require("media.upload")).Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge) r.With(container.requireAll("media.delete")).Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename) r.With(container.require("media.upload")).Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete) r.With(container.requireAll("media.delete")).Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}", mediaHandler.Get) r.With(container.require("media.view")).Get("/{id}", mediaHandler.Get)
r.Get("/{id}/usage", mediaHandler.Usage) r.With(container.require("media.view")).Get("/{id}/usage", mediaHandler.Usage)
return r return r
} }
@@ -438,15 +434,14 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require("media.view")).Get("/", variantHandler.List)
r.Get("/", variantHandler.List) r.With(container.require("settings.edit_general")).Post("/", variantHandler.Create)
r.Post("/", variantHandler.Create) r.With(container.require("settings.view")).Get("/cache", variantHandler.CacheStats)
r.Get("/cache", variantHandler.CacheStats) r.With(container.require("settings.edit_general")).Post("/cache/purge", variantHandler.PurgeCache)
r.Post("/cache/purge", variantHandler.PurgeCache) r.With(container.require("settings.edit_general")).Put("/{name}", variantHandler.Update)
r.Put("/{name}", variantHandler.Update) r.With(container.require("settings.edit_general")).Patch("/{name}", variantHandler.SetEnabled)
r.Patch("/{name}", variantHandler.SetEnabled) r.With(container.require("settings.edit_general")).Delete("/{name}", variantHandler.Delete)
r.Delete("/{name}", variantHandler.Delete) r.With(container.require("settings.edit_general")).Post("/{name}/purge", variantHandler.Purge)
r.Post("/{name}/purge", variantHandler.Purge)
return r return r
} }
+11 -8
View File
@@ -45,7 +45,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
| Route | Who | Purpose | | 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/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. | | `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`, 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_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 `{"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. removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins. 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` 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 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 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 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. session." with a key. The role is checked before the body.
| Route | Purpose | | 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. | | `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. | | `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 The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author `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. 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`, 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 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 ## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API 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 key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose | | 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 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. 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 ## Notes
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL. - Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
- Browsers can call the API only from origins in the server's CORS setting. - Browsers can call the API only from origins in the server's CORS setting.
+1
View File
@@ -1,4 +1,5 @@
Feature: The back-office keeps an append-only activity log that owners can read and retention can prune 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 As the owner of a RenewCMS site
I want every important action recorded with who, what, when and from where I want every important action recorded with who, what, when and from where
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: activity-log-backend # 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. 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 ## Setup shared by every case
@@ -1,4 +1,5 @@
Feature: Activity screen, filter cards as toggles and the Export menu 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 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 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
@@ -1,5 +1,7 @@
# QA: Activity filter cards as toggles and the Export menu # 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) ## 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. 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.
@@ -1,4 +1,5 @@
Feature: Recent activity card on Home and Retention card on Settings 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 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 I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings
@@ -1,5 +1,7 @@
# QA: Recent activity card on Home and Retention card on Settings # 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) ## 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. - 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.
+3 -2
View File
@@ -1,4 +1,5 @@
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie 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 As a developer integrating with RenewCMS
I want to create named API keys and send them as a Bearer token 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"} When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"}
Then the response status is 201 Then the response status is 201
And the stored key belongs to user 2 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 Scenario Outline: Key management needs a verified real session
Given <caller> Given <caller>
@@ -186,7 +187,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the
Given an active key created by admin 2 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"} When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
Then the response status is 403 Then the response status is 403
And the response body is apperr.OwnerRequiredErrorMessage And the response body is apperr.MissingPermissionErrorMessage
And no invite exists And no invite exists
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
+4 -2
View File
@@ -1,5 +1,7 @@
# QA procedure: API keys # 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. Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
## Setup shared by every case ## 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`. "yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`.
Each: repository still empty. Each: repository still empty.
12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that 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`; 13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
stale `token_version` -> `401`. Each: repository empty. 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` `200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article`
-> `401` `apperr.UnauthorizedErrorMessage`. -> `401` `apperr.UnauthorizedErrorMessage`.
18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403` 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 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): 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 unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from
+1
View File
@@ -1,4 +1,5 @@
Feature: Authors backend, public bylines that articles credit in order 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 As an owner or admin of a RenewCMS installation
I want to manage authors and credit them on articles in a chosen order I want to manage authors and credit them on articles in a chosen order
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: Authors backend # 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. 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 ## Setup
@@ -1,4 +1,5 @@
Feature: Container-aware update guidance 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 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" I want Settings to say "update by pulling a new image" instead of "Not prepared"
+2
View File
@@ -1,5 +1,7 @@
# QA: Container update guidance # 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) ## 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", ...). 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). 2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes).
+1
View File
@@ -1,4 +1,5 @@
Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API 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 As a site owner or admin
I want to define content types made of ordered scalar fields, and then create entries of those types I want to define content types made of ordered scalar fields, and then create entries of those types
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: custom content types (backend core) # 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. Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
## Setup shared by every case ## Setup shared by every case
@@ -1,4 +1,5 @@
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names 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 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 I want an icon per content type, a read-only view of the built-in types and the display names of entry authors
@@ -1,5 +1,7 @@
# QA procedure: custom content types support (icon, built-in read model, user lookup) # 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. Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
## Setup shared by every case ## Setup shared by every case
+7 -6
View File
@@ -1,4 +1,5 @@
Feature: Image variants are generated on first request, cached, and managed by admins 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 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 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 | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants | a stale token_version 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 an unverified user| 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/image-variants | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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 | | GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
Scenario Outline: Owners and admins are both allowed Scenario Outline: Owners and admins are both allowed
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: image-variants # 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`. 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 ## Setup shared by every case
+3 -2
View File
@@ -1,4 +1,5 @@
Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen 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 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 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 | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants/cache | a stale token_version 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 an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/image-variants/cache | 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.OwnerOrAdminRequiredErrorMessage | | 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 | | 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 | | POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: image_variants_cache # 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). 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) ## Go (package `test`, black-box)
@@ -1,4 +1,5 @@
Feature: Image variants per image: library fields, per-image routes and generate on upload 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 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 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 | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/media/1/variants | a stale token_version 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 an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/media/1/variants | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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 | | GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
| POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage | | POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
@@ -1,5 +1,7 @@
# QA procedure: image_variants_per_image_api # 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`. 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 ## Setup shared by every case
+1
View File
@@ -1,4 +1,5 @@
Feature: Integration screen (API reference) 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 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 I want /integration to be a complete, searchable API reference
+2
View File
@@ -1,5 +1,7 @@
# QA: Integration screen (API reference) # 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`) ## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
@@ -1,4 +1,5 @@
Feature: Integration page documents every route an API key can call 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 As a developer building on RenewCMS
I want the API reference to cover content types, entries, built-in types, sites, authors and media purge I want the API reference to cover content types, entries, built-in types, sites, authors and media purge
@@ -1,5 +1,7 @@
# QA procedure: Integration page, every API-key route documented # 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. 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`) ## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
+2 -1
View File
@@ -1,4 +1,5 @@
Feature: A verified user can invite another person by email, and the users list shows users and open invites 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 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 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 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 requires a valid jwt cookie, a current token_version and a verified user
And POST /v1/user/invite additionally requires the owner role (403 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 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 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 its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at
+3 -1
View File
@@ -1,5 +1,7 @@
# QA procedure: invitations # 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: 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`), `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` 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. 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 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 `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`, 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 `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`. invite or mail. `GET /` from the same address afterwards is not `429`.
+1
View File
@@ -1,4 +1,5 @@
Feature: The owner can cancel a pending invite and renew a pending or expired one 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 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 I want to cancel an invite I no longer want, or renew one that is stale or expired
+3 -1
View File
@@ -1,5 +1,7 @@
# QA procedure: invite management # 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`. 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 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. 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) ## Access and ids (table over both endpoints)
22. No cookie -> `401`; stale `token_version` -> `401`; unverified -> `403`; admin -> `403` with body 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. 23. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage` on both endpoints.
## Rate limit ## Rate limit
+1
View File
@@ -1,4 +1,5 @@
Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button 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 As a RenewCMS owner or admin
I want to turn on automatic duplicate purging and to purge duplicates by hand I want to turn on automatic duplicate purging and to purge duplicates by hand
+2
View File
@@ -1,5 +1,7 @@
# QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button) # 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`. 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. 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.
+10 -9
View File
@@ -1,4 +1,5 @@
Feature: Owners and admins can merge duplicate images in use, with persisted purge settings 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 As a RenewCMS owner or admin
I want to see what a duplicate purge would do, run it, and keep the automatic-purge settings 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 RequireVerifiedUser), then a group guarded by the new middleware.RequireOwnerOrAdmin, and
registered before the /{id} routes registered before the /{id} routes
And RequireOwnerOrAdmin is an allowlist: only the roles owner and admin pass; any other value, 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 ("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, 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 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) | | 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 | 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 an unverified owner | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | 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.OwnerOrAdminRequiredErrorMessage | | POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.MissingPermissionErrorMessage |
Scenario Outline: Owners and admins both pass the role guard Scenario Outline: Owners and admins both pass the role guard
When the client sends <method> <path> with a valid cookie of a verified <role> When the client sends <method> <path> with a valid cookie of a verified <role>
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: media_purge # 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. 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 ## Setup shared by every case
@@ -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 <role>
When the client sends <call> with that caller's cookie
Then the gate result is <result>
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 <role>
When the client sends <call> with that caller's cookie (and a real session, not an API key, where the route requires one)
Then the gate result is <result>
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 <role>
When the client sends <call> with that caller's cookie
Then the gate result is <result>
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 <grants>
When the client sends <call> with user 20's cookie
Then the gate result is <result>
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 <id> has legacy role "<legacy>" and role_id <role_id>
When the client sends <call> with that user's cookie
Then the gate result is <result>
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 <scope>
When the client sends GET /v1/activity<query>
Then the result is <result>
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 <grants>
When the client sends <call>
Then the result is <result>
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 <scope>
When the client sends GET /v1/api-key
Then the result is <result>
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 = <scope>
When the client sends DELETE /v1/api-key/2
Then the result is <result>
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/<that id>
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 = <scope>
When the client sends PATCH <path> with body <body>
Then the result is <result>
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 = <users_view>
When the client sends GET /v1/author
Then user_email is <shown>
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 <role>
When the client sends <call>
Then the status is <status> 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
@@ -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.
@@ -1,4 +1,5 @@
Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints 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 As the owner of a RenewCMS site
I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope) 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: Examples:
| id | legacy | call | status | | id | legacy | call | status |
| 3 | owner | POST /v1/user/invite | allowed (not 403) | | 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 | POST /v1/content-type | allowed (not 403) |
| 5 | admin | PATCH /v1/settings | 403 apperr.OwnerRequiredErrorMessage | | 5 | admin | PATCH /v1/settings | 403 apperr.MissingPermissionErrorMessage |
| 5 | admin | POST /v1/system/update/check | 403 apperr.OwnerRequiredErrorMessage | | 5 | admin | POST /v1/system/update/check | 403 apperr.MissingPermissionErrorMessage |
@@ -1,5 +1,7 @@
# QA procedure: roles_permissions_foundation # 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`. Package: `test` (black-box). Name new fakes uniquely, for example `mockRoleRepo`, `rolesFoundationUserRepo`.
Do not copy the old `Role ==` checks into new code. Do not copy the old `Role ==` checks into new code.
+1
View File
@@ -1,4 +1,5 @@
Feature: SEO data backend, article and site search fields, unique slugs and media usage 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 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 I want to store search and sharing data on articles and sites, with a slug that is unique per site
+3 -1
View File
@@ -1,5 +1,7 @@
# QA procedure: SEO data backend (slice 1 of 4) # 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. 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 ## 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. `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. - **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-<id>` for the migration; that wording is not followed. - **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-<id>` 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. - **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. - **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.
+1
View File
@@ -1,4 +1,5 @@
Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap 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 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 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
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: SEO output backend (slice 2 of 4) # 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. 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 ## Divergences from docs/design
@@ -1,4 +1,5 @@
Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration "Search and sharing" and suggestion dismissal 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 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 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
@@ -1,5 +1,7 @@
# QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal) # 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. 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. Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role.
@@ -1,4 +1,5 @@
Feature: Sites and authors in the Editor, Articles and Profile screens 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 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 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
+2
View File
@@ -1,5 +1,7 @@
# QA: Sites and authors in Editor, Articles and Profile # 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. 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. 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.
+2 -1
View File
@@ -1,4 +1,5 @@
Feature: Sites backend, the owner manages sites and every article belongs to exactly one 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 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 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 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 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 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 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 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 And the roles are "owner" and "admin" only, and a "verified" account is one with is_verified true
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: Sites backend # 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. 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 ## Setup
+2 -1
View File
@@ -1,4 +1,5 @@
Feature: Updates part 2: the Update button (download, verify, stage) 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 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 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 Scenario: Only the owner
When an admin sends POST /v1/system/update/download 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 And no job starts and the fetcher is not called
Scenario Outline: POST is guarded like the other system routes Scenario Outline: POST is guarded like the other system routes
+3 -1
View File
@@ -1,5 +1,7 @@
# QA: Updates part 2 (download, verify, stage) # 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) ## 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), 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 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 binary deleted reads idle; a missing or corrupt file reads idle without error. The real helper detector reads
`<data dir>/update-helper.json`: absent file gives false, a present valid file gives true. `<data dir>/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 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. 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`). 8. Concurrency: two simultaneous POSTs (goroutines) give one 202 and one 409; each asset fetched once (run with `-race`).
+2 -1
View File
@@ -1,4 +1,5 @@
Feature: Updates part 1: signed releases, release feed and read-only update notification 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 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 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 Scenario: An admin cannot check
Given the caller is an admin Given the caller is an admin
When the client sends POST /v1/system/update/check 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 And the feed is not contacted
Scenario: Feed failure keeps the last result Scenario: Feed failure keeps the last result
+3 -1
View File
@@ -1,5 +1,7 @@
# QA: Updates part 1 (signed releases, release feed, notification) # 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) ## 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` 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`. `apperr.VerificationRequiredErrorMessage`.
6. `POST /v1/system/update/check` as owner: 200, store updated with latest, `release_url` and `checked_at`, feed called 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. 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. 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"` 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 and an integer `Retry-After` of at least 1; feed called 5 times; owner B's first check still 200; a login-limiter
+2 -1
View File
@@ -1,4 +1,5 @@
Feature: Updates part 3: Restart, the privileged helper and rollback 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 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 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 | | no jwt cookie | 401 | the existing 401 text |
| a stale token version | 401 | the existing 401 text | | a stale token version | 401 | the existing 401 text |
| an unverified owner | 403 | the existing unverified 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 Scenario Outline: Restart is refused with 409
Given the owner and <condition> Given the owner and <condition>
+3 -1
View File
@@ -1,5 +1,7 @@
# QA: Updates part 3 (restart, helper, rollback) # 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) ## 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), 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. "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 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. `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. 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. 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. 7. Concurrency (`-race`): two simultaneous POSTs give one 202, one 409, one marker.
+3 -2
View File
@@ -1,4 +1,5 @@
Feature: Users have a role, owner or admin, and only the owner can invite 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 As the person who registered the workspace
I want my account to be the owner and everyone I invite to be an admin 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 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 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 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 "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 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 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 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"} When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
Then the response status is 403 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 the invite repository is still empty
And no email was sent And no email was sent
+3 -1
View File
@@ -1,5 +1,7 @@
# QA procedure: user_roles # 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`. Package: `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo`.
No live server, no real SQLite file, no real SMTP. 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 ## POST /v1/user/invite
13. Owner cookie, valid body -> 201, one invite, one "invitation" mail. 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. is not JSON-decoded (plain text), no invite, no mail.
15. Admin cookie with `{"email":"not-an-email"}` and with `not json` -> 403 both. 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. 16. Rate limit 2: admin sends 3 requests -> 403, 403, 403.
+2
View File
@@ -11,6 +11,8 @@ var (
ErrRoleConflict = errors.New("role was modified concurrently") ErrRoleConflict = errors.New("role was modified concurrently")
ErrInvalidScope = errors.New("invalid scope for permission") ErrInvalidScope = errors.New("invalid scope for permission")
ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role") ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role")
ErrMissingPermission = errors.New("missing permission")
) )
type Scope string type Scope string
+40
View File
@@ -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
}
+11
View File
@@ -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"
)
@@ -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
}
@@ -2,6 +2,7 @@ package useCases
import ( import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"encoding/csv" "encoding/csv"
"io" "io"
"strings" "strings"
@@ -29,11 +30,20 @@ func NewExportActivityUseCase(events audit.Repository, now func() time.Time) *Ex
return &ExportActivityUseCase{events, now} return &ExportActivityUseCase{events, now}
} }
func (e *ExportActivityUseCase) Prepare(query ActivityQuery) (*ActivityExport, error) { type ExportActivityCommand struct {
filter, err := buildActivityFilter(query, e.now()) 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 { if err != nil {
return nil, err return nil, err
} }
if err := scopeFilterToViewer(&filter, command.ViewerID, command.Grants); err != nil {
return nil, err
}
return &ActivityExport{events: e.events, filter: filter}, nil return &ActivityExport{events: e.events, filter: filter}, nil
} }
@@ -23,15 +23,15 @@ var legacyBuiltInNames = map[user.Role]string{
user.RoleAdmin: role.AdminName, 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 { if account.RoleID != 0 {
return u.roles.Get(account.RoleID) return roles.Get(account.RoleID)
} }
name, known := legacyBuiltInNames[account.Role] name, known := legacyBuiltInNames[account.Role]
if !known { if !known {
return role.Role{}, role.ErrRoleNotFound return role.Role{}, role.ErrRoleNotFound
} }
found, err := u.roles.GetByName(name) found, err := roles.GetByName(name)
if err != nil { if err != nil {
return role.Role{}, err 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) { func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
resolved, err := u.resolveRole(account) resolved, err := resolveRole(u.roles, account)
if err != nil { if err != nil {
return Access{}, err return Access{}, err
} }
@@ -19,8 +19,8 @@ func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, h
} }
} }
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus { func (g *GetUpdateStatusUseCase) GetStatus(canApply bool) update.UpdateStatus {
status := g.status.build(isOwner && g.helper.Installed()) status := g.status.build(canApply && g.helper.Installed())
status.Container = g.container status.Container = g.container
return status return status
} }
@@ -2,6 +2,7 @@ package useCases
import ( import (
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"errors" "errors"
"sort" "sort"
@@ -25,13 +26,17 @@ func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository us
return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now} 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) requester, err := l.userRepository.Get(requesterId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
keys, err := l.keysVisibleTo(requester) keys, err := l.keysVisibleTo(requester, scope)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -43,7 +48,7 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err
return keys[i].CreatedAt.After(keys[j].CreatedAt) return keys[i].CreatedAt.After(keys[j].CreatedAt)
}) })
creatorNames, err := l.creatorNamesFor(requester, keys) creatorNames, err := l.creatorNamesFor(requester, scope, keys)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -51,14 +56,14 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err
now := l.now() now := l.now()
entries := make([]APIKeyEntry, 0, len(keys)) entries := make([]APIKeyEntry, 0, len(keys))
for _, key := range 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 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{} names := map[uint32]string{}
if requester.Role != user.RoleOwner { if scope != role.ScopeAll {
return names, nil return names, nil
} }
names[requester.ID] = requester.NameOrUsername() names[requester.ID] = requester.NameOrUsername()
@@ -79,8 +84,8 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
return names, nil return names, nil
} }
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) { func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User, scope role.Scope) ([]apikey.APIKey, error) {
if requester.Role == user.RoleOwner { if scope == role.ScopeAll {
return l.apiKeyRepository.ListAll() return l.apiKeyRepository.ListAll()
} }
return l.apiKeyRepository.ListByUser(requester.ID) return l.apiKeyRepository.ListByUser(requester.ID)
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"errors" "errors"
@@ -26,6 +27,7 @@ type ListActivityUseCase struct {
type ListActivityCommand struct { type ListActivityCommand struct {
ViewerID uint32 ViewerID uint32
Grants role.GrantSet
Query ActivityQuery Query ActivityQuery
} }
@@ -53,7 +55,7 @@ func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (Activit
if filter.Page, filter.Size, err = resolveActivityPaging(command.Query); err != nil { if filter.Page, filter.Size, err = resolveActivityPaging(command.Query); err != nil {
return ActivityPage{}, err 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 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 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 { func sameActor(a audit.ActorRef, b audit.ActorRef) bool {
return a.Type == b.Type && a.ID != nil && b.ID != nil && *a.ID == *b.ID return a.Type == b.Type && a.ID != nil && b.ID != nil && *a.ID == *b.ID
} }
@@ -2,6 +2,7 @@ package useCases
import ( import (
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
) )
@@ -14,7 +15,7 @@ type ListAuthorsCommand struct {
Q string Q string
Page int Page int
Size int Size int
CallerRole user.Role Grants role.GrantSet
} }
type AuthorPage struct { type AuthorPage struct {
@@ -35,7 +36,7 @@ func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error
} }
items := make([]author.Author, 0, len(found.Items)) items := make([]author.Author, 0, len(found.Items))
for _, item := range found.Items { for _, item := range found.Items {
if isAdminRole(command.CallerRole) { if command.Grants.Holds(role.KeyUsersView) {
item = withUserEmail(u.users, item) item = withUserEmail(u.users, item)
} }
items = append(items, item) items = append(items, item)
@@ -3,34 +3,33 @@ package useCases
import ( import (
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/role"
"context" "context"
"time" "time"
) )
type RevokeAPIKeyUseCase struct { type RevokeAPIKeyUseCase struct {
apiKeyRepository apikey.Repository apiKeyRepository apikey.Repository
userRepository user.Repository
now func() time.Time now func() time.Time
recorder audit.Recorder recorder audit.Recorder
} }
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase { func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase {
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now, recorder} return &RevokeAPIKeyUseCase{apiKeyRepository, now, recorder}
}
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}
} }
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32) error {
key, err := r.apiKeyRepository.Get(keyId) key, err := r.apiKeyRepository.Get(keyId)
if err != nil { if err != nil {
return err return err
} }
requester, err := r.userRepository.Get(requesterId) if scope != role.ScopeAll && key.UserID != requesterId {
if err != nil {
return err
}
if requester.Role != user.RoleOwner && key.UserID != requester.ID {
return apikey.ErrAPIKeyNotFound return apikey.ErrAPIKeyNotFound
} }
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"context" "context"
"strings" "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) { 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) before, err := u.authors.Get(id)
if err != nil { if err != nil {
return author.Author{}, err 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 { func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author {
if caller.isAdmin() { if caller.seesUserEmail() {
return withUserEmail(u.users, a) return withUserEmail(u.users, a)
} }
a.UserEmail = nil 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 { func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
if caller.isAdmin() { if caller.Grants.HoldsAll(role.KeyAuthorsEdit) {
return nil return nil
} }
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set { if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
@@ -2,6 +2,7 @@ package useCases
import ( import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"time" "time"
) )
@@ -32,6 +33,22 @@ type ActivityQuery struct {
Size int 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 { type activityWindow struct {
from time.Time from time.Time
to time.Time to time.Time
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"errors" "errors"
"strconv" "strconv"
@@ -14,15 +15,11 @@ const authorChangeSeparator = ", "
type AuthorCaller struct { type AuthorCaller struct {
UserID uint32 UserID uint32
Role user.Role Grants role.GrantSet
} }
func isAdminRole(role user.Role) bool { func (c AuthorCaller) seesUserEmail() bool {
return role == user.RoleOwner || role == user.RoleAdmin return c.Grants.Holds(role.KeyUsersView)
}
func (c AuthorCaller) isAdmin() bool {
return isAdminRole(c.Role)
} }
func authorTarget(a author.Author) audit.Target { func authorTarget(a author.Author) audit.Target {
+5 -1
View File
@@ -54,6 +54,10 @@ coverpkg=""
for scope in "${scopes[@]}"; do for scope in "${scopes[@]}"; do
coverpkg="${coverpkg:+$coverpkg,}$scope/..." coverpkg="${coverpkg:+$coverpkg,}$scope/..."
done 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=() parts=()
i=0 i=0
for scope in "${scopes[@]}"; do for scope in "${scopes[@]}"; do
@@ -73,7 +77,7 @@ for scope in "${scopes[@]}"; do
done done
if [ "$keep" -eq 0 ]; then continue; fi if [ "$keep" -eq 0 ]; then continue; fi
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" -ge 128 ]; then exit "$rc"; fi
if [ "$rc" -ne 0 ]; then code=$rc; fi if [ "$rc" -ne 0 ]; then code=$rc; fi
if [ -f "$part" ]; then parts+=("$(native "$part")"); fi if [ -f "$part" ]; then parts+=("$(native "$part")"); fi
@@ -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) { t.Run("A non-owner export with range=all is still 403", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000) 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)
}) })
} }
+10 -7
View File
@@ -41,6 +41,7 @@ const (
type activityHTTPEnv struct { type activityHTTPEnv struct {
*activityHarness *activityHarness
users *mockUserRepo users *mockUserRepo
roles *mockRoleRepo
keys *mockAPIKeyRepo keys *mockAPIKeyRepo
articles *mockArticleRepo articles *mockArticleRepo
mail *mockMailRepo mail *mockMailRepo
@@ -56,6 +57,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
e := &activityHTTPEnv{ e := &activityHTTPEnv{
activityHarness: h, activityHarness: h,
users: newMockUserRepo(), users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
keys: newMockAPIKeyRepo(), keys: newMockAPIKeyRepo(),
articles: newMockArticleRepo(), articles: newMockArticleRepo(),
mail: newMockMailRepo(), 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[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[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.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner}
e.users.nextID = 4 e.users.nextID = 4
@@ -73,14 +75,15 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
container := &api.UseCases{ container := &api.UseCases{
Token: e.token, Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", 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), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(e.keys, e.generator, h.clock, h.recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(e.keys, e.generator, h.clock, h.recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(e.keys, e.users, h.clock), 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), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"), GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock), 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) { 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"} { for _, path := range []string{"/v1/activity/summary", "/v1/activity/export"} {
e := newActivityHTTPEnv(t, 1000) 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 { if e.probe.reads != 0 {
t.Error("the audit repository was read") 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) { t.Run("The default is 90 days", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000) 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()) 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.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142 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) { if body["activity_last_cleanup_at"] != "2026-10-06T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
t.Errorf("unexpected body %v", body) 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)) 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 { if e.settings.settings.RetentionDays != 90 {
t.Error("the retention changed") t.Error("the retention changed")
} }
+5 -4
View File
@@ -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.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142 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()) 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) { 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) 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 { if rec := e.get("/v1/settings", asUser(e, id)); rec.Code != http.StatusOK {
t.Errorf("user %d: expected 200, got %d", id, rec.Code) 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 { if rec := e.get("/v1/settings"); rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code) 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)) 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 { if e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Error("the editor changed something") t.Error("the editor changed something")
} }
+4 -4
View File
@@ -41,7 +41,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
e.settings.settings.RetentionMaxGB = c.gb e.settings.settings.RetentionMaxGB = c.gb
e.probe.sizeBytes = &c.bytes 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()) body := sizeSettingsBody(t, rec.Body.Bytes())
var got []string 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) { t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000) 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) { 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) 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)) 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 { if e.settings.retentionOK != 0 {
t.Error("the admin changed something") t.Error("the admin changed something")
} }
@@ -164,7 +164,7 @@ func TestSettingsRetentionSizeHttp(t *testing.T) {
e := newActivityHTTPEnv(t, 1000) e := newActivityHTTPEnv(t, 1000)
e.probe.sizeErr = errRepositoryFailure 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) { t.Run("Settings API, the change is audited", func(t *testing.T) {
+4
View File
@@ -26,6 +26,7 @@ type activityProbeRepo struct {
deleteErr error deleteErr error
deleteCalls int deleteCalls int
readErr error readErr error
maxReads int
sizeErr error sizeErr error
sizeBytes *int64 sizeBytes *int64
oldestErr error 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) { func (p *activityProbeRepo) List(filter audit.Filter) (audit.Page, error) {
p.reads++ p.reads++
p.listCalls = append(p.listCalls, filter) 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 { if p.readErr != nil {
return audit.Page{}, p.readErr return audit.Page{}, p.readErr
} }
+18 -15
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"bytes" "bytes"
@@ -36,6 +37,7 @@ func newActivityWorld(t *testing.T) *activityWorld {
images: newMockImageRepo(), images: newMockImageRepo(),
keys: newMockAPIKeyRepo(), keys: newMockAPIKeyRepo(),
} }
w.probe.maxReads = 50
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner} 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[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"} 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) recent := w.seedAt(t, 2*day, nil)
w.seedAt(t, 10*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 { 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) 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 := newActivityWorld(t)
w.seedAt(t, c.ago, nil) 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 { if err != nil || (page.Total == 1) != c.found {
t.Errorf("expected found=%v, got total %d, %v", c.found, page.Total, err) 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 { for i, query := range queries {
w := newActivityWorld(t) 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 { 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) 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, nil)
w.seedAt(t, day, func(e *audit.Event) { e.Outcome = audit.OutcomeFailed }) 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 { if page.Total != 2 {
t.Errorf("expected both outcomes, got %d", page.Total) 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 := newActivityWorld(t)
w.seedAt(t, day, nil) w.seedAt(t, day, nil)
w.seedAt(t, day, func(e *audit.Event) { 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() }) 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 { 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) 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 { for actor, allowed := range cases {
w := newActivityWorld(t) 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 { if allowed && err != nil {
t.Errorf("%s: expected success, got %v", actor, err) 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) 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) { if !errors.Is(err, role.ErrMissingPermission) || w.probe.reads != 0 {
t.Errorf("expected ErrUserNotFound, got %v", err) 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) c.seed(w)
w.seedAt(t, day, func(e *audit.Event) { e.Target = c.target }) 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 { if err != nil || len(page.Items) != 1 {
t.Fatalf("unexpected result %+v, %v", page, err) 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) { t.Run("A repository failure is returned", func(t *testing.T) {
w := newActivityWorld(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") 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) { if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err) 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 { func exportToString(t *testing.T, w *activityWorld, query useCases.ActivityQuery) string {
t.Helper() t.Helper()
prepared, err := w.export.Prepare(query) prepared, err := w.export.Prepare(useCases.ExportActivityCommand{Grants: activityViewAllGrants, Query: query})
if err != nil { if err != nil {
t.Fatalf("prepare failed: %v", err) 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) { t.Run("An invalid filter is refused before anything is prepared", func(t *testing.T) {
w := newActivityWorld(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 { if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
t.Errorf("expected ErrInvalidFilter without reading, got %v", err) t.Errorf("expected ErrInvalidFilter without reading, got %v", err)
+13 -10
View File
@@ -3,6 +3,7 @@ package test
import ( import (
"RenewCMS/api" "RenewCMS/api"
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"net/http" "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 := newAPIKeyEnv(t)
env.nameUsers() env.nameUsers()
env.issueKey(apiKeyDevId, "a") env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn})
env.issueKey(apiKeyDevId, "b") 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) assertAPIKeyStatus(t, rec, http.StatusOK)
list := apiKeyDecodeList(t, rec) list := apiKeyDecodeList(t, rec)
@@ -174,10 +177,10 @@ func TestListAPIKeysCreatorName(t *testing.T) {
return repo 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) uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock)
ownerEntries, err := uc.ListAPIKeys(1) ownerEntries, err := uc.ListAPIKeys(1, apiKeyAllScope)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -189,12 +192,12 @@ func TestListAPIKeysCreatorName(t *testing.T) {
t.Errorf("unexpected creator names %v", names) t.Errorf("unexpected creator names %v", names)
} }
adminEntries, err := uc.ListAPIKeys(2) ownScopeEntries, err := uc.ListAPIKeys(2, apiKeyOwnScope)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" { if len(ownScopeEntries) != 1 || ownScopeEntries[0].CreatorName != "" {
t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries) 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) uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
entries, err := uc.ListAPIKeys(1) entries, err := uc.ListAPIKeys(1, apiKeyAllScope)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
+38 -16
View File
@@ -8,6 +8,7 @@ import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/update" "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
@@ -116,6 +117,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, 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), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock), 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), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
} }
router := chi.NewRouter() env.rebuildRouter()
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
env.router = router
return env 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) { func (e *apiKeyEnv) issueKey(userId uint32, name string, mutate ...func(*apikey.APIKey)) (string, uint32) {
e.issued++ e.issued++
raw, prefix, hashed := e.generator.Generate() raw, prefix, hashed := e.generator.Generate()
@@ -361,7 +367,7 @@ func TestAPIKeysHttp(t *testing.T) {
if len(list) != 1 { if len(list) != 1 {
t.Fatalf("expected one entry, got %d", len(list)) 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) { 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") 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) raw := apiKeyDecodeObject(t, rec)["key"].(string)
invite := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw) 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) { 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) 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) 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 { if len(env.invites.invites) != 0 {
t.Errorf("expected no invite") 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) { 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) 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}) 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) 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 := newAPIKeyEnv(t)
env.issueKey(apiKeyDevId, "a") env.issueKey(apiKeyDevId, "a")
env.issueKey(apiKeyDevId, "b") env.issueKey(apiKeyDevId, "b")
@@ -863,8 +874,8 @@ func TestAPIKeysHttp(t *testing.T) {
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId) rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
assertAPIKeyStatus(t, rec, http.StatusOK) assertAPIKeyStatus(t, rec, http.StatusOK)
if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "b,a" { if got := strings.Join(apiKeyEntryNames(t, rec), ","); got != "c,b,a" {
t.Errorf("expected exactly b and a, got %s", got) 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 := newAPIKeyEnv(t)
env.customRoleUser(6, role.Grant{Key: "apikeys.revoke", Scope: role.ScopeOwn})
raw, id := env.issueKey(apiKeyPatId, "ci") 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) assertAPIKeyMessage(t, rec, http.StatusNotFound, apperr.APIKeyNotFoundErrorMessage)
assertAPIKeyStatus(t, env.withKey(t, http.MethodGet, "/v1/settings", "", raw), http.StatusOK) 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) { t.Run("The owner can revoke any key", func(t *testing.T) {
env := newAPIKeyEnv(t) env := newAPIKeyEnv(t)
raw, id := env.issueKey(apiKeyPatId, "ci") raw, id := env.issueKey(apiKeyPatId, "ci")
@@ -1056,8 +1077,9 @@ func TestAPIKeysHttp(t *testing.T) {
t.Errorf("expected no stored key") t.Errorf("expected no stored key")
} }
env.customRoleUser(6, role.Grant{Key: "apikeys.view", Scope: role.ScopeOwn})
env.keys.fail.failNext("ListByUser") 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") env.keys.fail.failNext("ListAll")
assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusInternalServerError, "Internal server error") assertAPIKeyMessage(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusInternalServerError, "Internal server error")
+29 -26
View File
@@ -2,6 +2,7 @@ package test
import ( import (
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -173,6 +174,11 @@ func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey))
return stored 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 { func apiKeyUnitUsers() *mockUserRepo {
users := newMockUserRepo() users := newMockUserRepo()
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} 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 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() repo := newMockAPIKeyRepo()
seed(repo) seed(repo)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2, apiKeyOwnScope)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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() repo := newMockAPIKeyRepo()
seed(repo) seed(repo)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1) entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1, apiKeyAllScope)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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"} { for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} {
seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same }) 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 { if err != nil {
t.Fatalf("unexpected error: %v", err) 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_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired })
seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked }) 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{} statuses := map[string]apikey.Status{}
for _, entry := range entries { for _, entry := range entries {
statuses[entry.Key.Prefix] = entry.Status 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) { t.Run("An unknown requester and repository failures are returned", func(t *testing.T) {
repo := newMockAPIKeyRepo() repo := newMockAPIKeyRepo()
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock) 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) t.Errorf("expected ErrUserNotFound, got %v", err)
} }
repo.fail.failNext("ListByUser") 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) t.Errorf("expected the repository failure, got %v", err)
} }
repo.fail.failNext("ListAll") 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) 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) { t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
repo := newMockAPIKeyRepo() repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil) 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) t.Fatalf("unexpected error: %v", err)
} }
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) { 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() repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil) 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) t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
} }
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 { 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() repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 }) 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) t.Fatalf("unexpected error: %v", err)
} }
if repo.keys[stored.ID].RevokedAt == nil { if repo.keys[stored.ID].RevokedAt == nil {
@@ -526,9 +532,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
repo := newMockAPIKeyRepo() repo := newMockAPIKeyRepo()
original := apiKeyUnitNow.Add(-time.Hour) original := apiKeyUnitNow.Add(-time.Hour)
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original }) 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) t.Fatalf("unexpected error: %v", err)
} }
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) { 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() repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil) 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) 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") 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) t.Errorf("expected the repository failure, got %v", err)
} }
}) })
+1 -1
View File
@@ -127,7 +127,7 @@ func TestAuthorUseCases(t *testing.T) {
uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder) uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder)
bio := "x" 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) { if !errors.Is(err, author.ErrAuthorRights) {
t.Errorf("err = %v", err) t.Errorf("err = %v", err)
+1 -1
View File
@@ -14,7 +14,7 @@ import (
) )
const ( const (
ownerOrAdminRefusal = "Only owners and admins can do this." ownerOrAdminRefusal = apperr.MissingPermissionErrorMessage
sessionRefusal = "This action needs a real session." sessionRefusal = "This action needs a real session."
) )
@@ -57,6 +57,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
routes := &api.UseCases{ routes := &api.UseCases{
Token: token, Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
@@ -420,6 +420,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
container := &api.UseCases{ container := &api.UseCases{
Token: token, Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users), GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
@@ -266,6 +266,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now), CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types), ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
+4 -1
View File
@@ -2,6 +2,7 @@ package test
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/user"
"net/http" "net/http"
"reflect" "reflect"
"strconv" "strconv"
@@ -128,7 +129,7 @@ func TestContentTypeKeysAndAccess(t *testing.T) {
{"no credentials", 0, 401, ""}, {"no credentials", 0, 401, ""},
{"a jwt cookie for user 4", ctUnverifiedId, 403, apperr.VerificationRequiredErrorMessage}, {"a jwt cookie for user 4", ctUnverifiedId, 403, apperr.VerificationRequiredErrorMessage},
{"a stale token_version cookie", ctStaleId, 401, apperr.UnauthorizedErrorMessage}, {"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 { for _, example := range examples {
t.Run(example.caller, func(t *testing.T) { 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, "recipe", recipeFields())
env.createType(t, "event", dishFields()) 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", "") list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type", "")
expectStatusAndBody(t, list, http.StatusOK, "") expectStatusAndBody(t, list, http.StatusOK, "")
+6 -6
View File
@@ -39,12 +39,12 @@ func TestImageVariantsGuards(t *testing.T) {
{"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage}, {"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage},
{"stale cookie", http.MethodGet, "/v1/image-variants", "stale", 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}, {"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 list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.MissingPermissionErrorMessage},
{"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"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.OwnerOrAdminRequiredErrorMessage}, {"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.OwnerOrAdminRequiredErrorMessage}, {"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.OwnerOrAdminRequiredErrorMessage}, {"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}, {"api key", http.MethodGet, "/v1/image-variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
} }
for _, row := range rows { for _, row := range rows {
+2 -2
View File
@@ -29,8 +29,8 @@ func TestImageVariantsCacheGuards(t *testing.T) {
{"no cookie", http.MethodGet, cacheStatsPath, "none", 401, apperr.UnauthorizedErrorMessage}, {"no cookie", http.MethodGet, cacheStatsPath, "none", 401, apperr.UnauthorizedErrorMessage},
{"a stale token_version cookie", http.MethodGet, cacheStatsPath, "stale", 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 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", http.MethodGet, cacheStatsPath, "plain", 403, apperr.MissingPermissionErrorMessage},
{"the cookie of a plain user on purge", http.MethodPost, cachePurgePath, "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"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", http.MethodGet, cacheStatsPath, "apikey", 401, apperr.UnauthorizedErrorMessage},
{"a valid API key on purge", http.MethodPost, cachePurgePath, "apikey", 401, apperr.UnauthorizedErrorMessage}, {"a valid API key on purge", http.MethodPost, cachePurgePath, "apikey", 401, apperr.UnauthorizedErrorMessage},
} }
+1
View File
@@ -113,6 +113,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
return &api.UseCases{ return &api.UseCases{
Token: e.token, Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more