feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.
This commit is contained in:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4804
-97
No files matched your search
@@ -76,6 +76,12 @@ type UseCases struct {
|
|||||||
GetContentTypeUseCase *useCases.GetContentTypeUseCase
|
GetContentTypeUseCase *useCases.GetContentTypeUseCase
|
||||||
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
|
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
|
||||||
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
|
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
|
||||||
|
ListRolesUseCase *useCases.ListRolesUseCase
|
||||||
|
GetRoleUseCase *useCases.GetRoleUseCase
|
||||||
|
CreateRoleUseCase *useCases.CreateRoleUseCase
|
||||||
|
UpdateRoleUseCase *useCases.UpdateRoleUseCase
|
||||||
|
DeleteRoleUseCase *useCases.DeleteRoleUseCase
|
||||||
|
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
|
||||||
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
|
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
|
||||||
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
|
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
|
||||||
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
|
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
|
||||||
@@ -380,11 +386,17 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
|||||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||||
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
||||||
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
||||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now),
|
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
|
||||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
|
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
|
||||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
|
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
|
||||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
|
||||||
|
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
|
||||||
|
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
|
||||||
|
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||||
|
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
|
||||||
|
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||||
|
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
|
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package role
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/access"
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
domain "RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
ListUseCase *useCases.ListRolesUseCase
|
||||||
|
GetUseCase *useCases.GetRoleUseCase
|
||||||
|
CreateUseCase *useCases.CreateRoleUseCase
|
||||||
|
UpdateUseCase *useCases.UpdateRoleUseCase
|
||||||
|
DeleteUseCase *useCases.DeleteRoleUseCase
|
||||||
|
ReassignUseCase *useCases.ReassignRoleMembersUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(list *useCases.ListRolesUseCase, get *useCases.GetRoleUseCase, create *useCases.CreateRoleUseCase, update *useCases.UpdateRoleUseCase, deleteUseCase *useCases.DeleteRoleUseCase, reassign *useCases.ReassignRoleMembersUseCase) *Handler {
|
||||||
|
return &Handler{ListUseCase: list, GetUseCase: get, CreateUseCase: create, UpdateUseCase: update, DeleteUseCase: deleteUseCase, ReassignUseCase: reassign}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrantRequest struct {
|
||||||
|
Key string `json:"key" validate:"required,max=100"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostRole struct {
|
||||||
|
Name string `json:"name" validate:"required,max=100"`
|
||||||
|
Description string `json:"description" validate:"max=255"`
|
||||||
|
Grants []GrantRequest `json:"grants" validate:"dive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PutRole struct {
|
||||||
|
Name string `json:"name" validate:"required,max=100"`
|
||||||
|
Description string `json:"description" validate:"max=255"`
|
||||||
|
Grants []GrantRequest `json:"grants" validate:"dive"`
|
||||||
|
Version uint32 `json:"version" validate:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetRole struct {
|
||||||
|
Version uint32 `json:"version" validate:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReassignRole struct {
|
||||||
|
TargetRoleID uint32 `json:"target_role_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toGrants(requested []GrantRequest) []domain.Grant {
|
||||||
|
grants := make([]domain.Grant, 0, len(requested))
|
||||||
|
for _, grant := range requested {
|
||||||
|
grants = append(grants, domain.Grant{Key: grant.Key, Scope: domain.Scope(grant.Scope)})
|
||||||
|
}
|
||||||
|
return grants
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoleId(r *http.Request) (uint32, error) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
return uint32(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRequest(w http.ResponseWriter, r *http.Request, request any) bool {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := validator.Validate.Struct(request); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
|
||||||
|
id, err := parseRoleId(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(body); err != nil {
|
||||||
|
log.Printf("Failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type refusal struct {
|
||||||
|
cause error
|
||||||
|
status int
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
var refusals = []refusal{
|
||||||
|
{domain.ErrRoleNotFound, http.StatusNotFound, apperr.RoleNotFoundErrorMessage},
|
||||||
|
{domain.ErrRoleLocked, http.StatusForbidden, apperr.RoleLockedErrorMessage},
|
||||||
|
{domain.ErrBuiltInRole, http.StatusConflict, apperr.RoleBuiltInErrorMessage},
|
||||||
|
{domain.ErrBuiltInName, http.StatusBadRequest, apperr.BuiltInNameErrorMessage},
|
||||||
|
{domain.ErrNotBuiltIn, http.StatusBadRequest, apperr.NotBuiltInErrorMessage},
|
||||||
|
{domain.ErrOwnRole, http.StatusForbidden, apperr.OwnRoleErrorMessage},
|
||||||
|
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
|
||||||
|
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
|
||||||
|
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
|
||||||
|
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
|
||||||
|
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
|
||||||
|
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
|
||||||
|
{domain.ErrInvalidScope, http.StatusBadRequest, apperr.InvalidScopeErrorMessage},
|
||||||
|
{domain.ErrOwnerOnlyGrant, http.StatusBadRequest, apperr.OwnerOnlyPermissionErrorMessage},
|
||||||
|
{domain.ErrDuplicateGrant, http.StatusBadRequest, apperr.DuplicatePermissionErrorMessage},
|
||||||
|
{domain.ErrUnknownContentType, http.StatusBadRequest, apperr.UnknownContentTypeErrorMessage},
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRoleError(w http.ResponseWriter, err error) {
|
||||||
|
if access.WriteIfMissingPermission(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var escalation domain.EscalationError
|
||||||
|
if errors.As(err, &escalation) {
|
||||||
|
w.Header().Set(apperr.EscalationHeader, escalation.Key)
|
||||||
|
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, candidate := range refusals {
|
||||||
|
if errors.Is(err, candidate.cause) {
|
||||||
|
http.Error(w, candidate.message, candidate.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
listed, err := h.ListUseCase.List()
|
||||||
|
if err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := readId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found, err := h.GetUseCase.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request PostRole
|
||||||
|
if !decodeRequest(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateRoleCommand{
|
||||||
|
Caller: access.CallerOf(r), Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := readId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request PutRole
|
||||||
|
if !decodeRequest(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := h.UpdateUseCase.Update(r.Context(), useCases.UpdateRoleCommand{
|
||||||
|
Caller: access.CallerOf(r), ID: id, Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants), Version: request.Version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := readId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request ResetRole
|
||||||
|
if !decodeRequest(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restored, err := h.UpdateUseCase.Reset(r.Context(), useCases.ResetRoleCommand{Caller: access.CallerOf(r), ID: id, Version: request.Version})
|
||||||
|
if err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := readId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteRoleCommand{Caller: access.CallerOf(r), ID: id}); err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Reassign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := readId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request ReassignRole
|
||||||
|
if !decodeRequest(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moved, err := h.ReassignUseCase.Reassign(r.Context(), useCases.ReassignRoleMembersCommand{Caller: access.CallerOf(r), SourceID: id, TargetID: request.TargetRoleID})
|
||||||
|
if err != nil {
|
||||||
|
writeRoleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"moved": moved})
|
||||||
|
}
|
||||||
@@ -100,3 +100,21 @@ const InvalidFieldErrorMessage = "invalid_field"
|
|||||||
const SchemaIncompatibleErrorMessage = "schema_incompatible"
|
const SchemaIncompatibleErrorMessage = "schema_incompatible"
|
||||||
const EntryNotFoundErrorMessage = "entry_not_found"
|
const EntryNotFoundErrorMessage = "entry_not_found"
|
||||||
const InvalidEntryErrorMessage = "invalid_entry"
|
const InvalidEntryErrorMessage = "invalid_entry"
|
||||||
|
|
||||||
|
const RoleNotFoundErrorMessage = "role_not_found"
|
||||||
|
const RoleLockedErrorMessage = "role_locked"
|
||||||
|
const RoleBuiltInErrorMessage = "role_built_in"
|
||||||
|
const BuiltInNameErrorMessage = "built_in_name"
|
||||||
|
const NotBuiltInErrorMessage = "not_built_in"
|
||||||
|
const OwnRoleErrorMessage = "own_role"
|
||||||
|
const EscalationErrorMessage = "escalation"
|
||||||
|
const EscalationHeader = "X-Escalation-Key"
|
||||||
|
const RoleNameTakenErrorMessage = "role_name_taken"
|
||||||
|
const RoleConflictErrorMessage = "role_conflict"
|
||||||
|
const RoleHasMembersErrorMessage = "role_has_members"
|
||||||
|
const InvalidRoleNameErrorMessage = "invalid_role_name"
|
||||||
|
const UnknownPermissionErrorMessage = "unknown_permission"
|
||||||
|
const InvalidScopeErrorMessage = "invalid_scope"
|
||||||
|
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
|
||||||
|
const DuplicatePermissionErrorMessage = "duplicate_permission"
|
||||||
|
const UnknownContentTypeErrorMessage = "unknown_content_type"
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"RenewCMS/api/handlers/llms"
|
"RenewCMS/api/handlers/llms"
|
||||||
"RenewCMS/api/handlers/media"
|
"RenewCMS/api/handlers/media"
|
||||||
"RenewCMS/api/handlers/permission"
|
"RenewCMS/api/handlers/permission"
|
||||||
|
rolehandler "RenewCMS/api/handlers/role"
|
||||||
"RenewCMS/api/handlers/settings"
|
"RenewCMS/api/handlers/settings"
|
||||||
"RenewCMS/api/handlers/site"
|
"RenewCMS/api/handlers/site"
|
||||||
"RenewCMS/api/handlers/staticimages"
|
"RenewCMS/api/handlers/staticimages"
|
||||||
@@ -244,6 +245,39 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(authtoken.Authenticator)
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Use(container.require(role.KeyRolesView))
|
||||||
|
r.Get("/", roleHandler.List)
|
||||||
|
r.Get("/{id}", roleHandler.Get)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(authtoken.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(middleware.RequireSignedIn)
|
||||||
|
r.Use(authtoken.Authenticator)
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Use(container.require(role.KeyRolesManage))
|
||||||
|
r.Post("/", roleHandler.Create)
|
||||||
|
r.Put("/{id}", roleHandler.Update)
|
||||||
|
r.Delete("/{id}", roleHandler.Delete)
|
||||||
|
r.Post("/{id}/reassign", roleHandler.Reassign)
|
||||||
|
r.Post("/{id}/reset", roleHandler.Reset)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -474,6 +508,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
|
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
|
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
|
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
|
||||||
|
|||||||
+52
-1
@@ -50,7 +50,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
|
|||||||
| `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. |
|
||||||
|
|
||||||
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
|
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
|
||||||
`api_keys`, `auth`, `system`, `sites`, `authors`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
|
`api_keys`, `auth`, `system`, `sites`, `authors`, `roles`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
|
||||||
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
|
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
|
||||||
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
|
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
|
||||||
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
|
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
|
||||||
@@ -353,6 +353,57 @@ disabled variant on `POST`), 413 "The source image is too large to generate this
|
|||||||
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
|
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
|
||||||
this format." and 500 "Internal server error".
|
this format." and 500 "Internal server error".
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Routes under `/v1/role`, behind the verified chain. Reads accept a key; every write is session only (a key answers
|
||||||
|
403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.
|
||||||
|
|
||||||
|
| Route | Permission | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /v1/role` | `roles.view` | Every role by id, built-ins first. |
|
||||||
|
| `GET /v1/role/{id}` | `roles.view` | One role. |
|
||||||
|
| `POST /v1/role` | `roles.manage` | Create a role (201). `built_in` and `locked` in the body are ignored. |
|
||||||
|
| `PUT /v1/role/{id}` | `roles.manage` | Replace name, description and grants. `version` is required and must match. |
|
||||||
|
| `DELETE /v1/role/{id}` | `roles.manage` | Delete a role with no members (204). |
|
||||||
|
| `POST /v1/role/{id}/reassign` | `roles.manage` | Move every member to `{"target_role_id": N}`; answers `{"moved": n}`. |
|
||||||
|
| `POST /v1/role/{id}/reset` | `roles.manage` and `roles.edit_owner_only` | Restore a built-in role to its seeded grants and description; body `{"version": N}`. |
|
||||||
|
|
||||||
|
A role is `{ id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }`.
|
||||||
|
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
|
||||||
|
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
|
||||||
|
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
|
||||||
|
users by their effective role (the legacy `owner` and `admin` strings count for the built-in role of that name).
|
||||||
|
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
|
||||||
|
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
|
||||||
|
|
||||||
|
Refusals are plain text, one snake_case code per body:
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
|
||||||
|
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, the Owner role, unknown). |
|
||||||
|
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
|
||||||
|
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
|
||||||
|
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
|
||||||
|
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
|
||||||
|
| 404 | `role_not_found` | Unknown role id. |
|
||||||
|
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members; deleting or moving members out of a built-in role. |
|
||||||
|
|
||||||
|
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
|
||||||
|
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
|
||||||
|
by the caller, name taken, write. Only the Owner holds `roles.edit_owner_only`, so only the Owner edits the Admin and
|
||||||
|
Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200
|
||||||
|
at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.
|
||||||
|
|
||||||
|
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
|
||||||
|
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
|
||||||
|
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` and is recorded only when
|
||||||
|
`moved` is above 0. A refusal records nothing.
|
||||||
|
|
||||||
|
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
|
||||||
|
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
|
||||||
|
creating one with the key of a deleted type clears any stale row for that key.
|
||||||
|
|
||||||
## Content permissions
|
## Content permissions
|
||||||
|
|
||||||
Articles and the entries of custom content types are checked against the `content.*` permissions of the
|
Articles and the entries of custom content types are checked against the `content.*` permissions of the
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMe
|
|||||||
|
|
||||||
### Roles and permissions
|
### Roles and permissions
|
||||||
|
|
||||||
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository.Save` is version checked: it updates the row only when the caller's version matches, bumps the version, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`.
|
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers` and `RemoveTypedGrants`; there is no separate grant listing. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished.
|
||||||
|
|
||||||
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `ListGrants`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
|
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
|
||||||
|
|
||||||
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
|
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
|
|||||||
34. Secret sweep: after exercising every producer, marshal every stored event, the `/v1/activity` response and the `/v1/activity/export` file; assert none contains the password, hash, verification code, invite code, raw API key, article body, file bytes, asset URL or staging path. The log buffer is free of them too.
|
34. Secret sweep: after exercising every producer, marshal every stored event, the `/v1/activity` response and the `/v1/activity/export` file; assert none contains the password, hash, verification code, invite code, raw API key, article body, file bytes, asset URL or staging path. The log buffer is free of them too.
|
||||||
35. Allowlist: every `Change.Field` is in `{title, online, body, filename, display_name}` and every kind is `text` or `body`.
|
35. Allowlist: every `Change.Field` is in `{title, online, body, filename, display_name}` and every kind is `text` or `body`.
|
||||||
36. Read-back: list by `category=articles|media|users|system` and `outcome=failed`; compare action sets with the feature tables.
|
36. Read-back: list by `category=articles|media|users|system` and `outcome=failed`; compare action sets with the feature tables.
|
||||||
|
Superseded in part by roles_permissions_roles_api: the closed category list also holds `roles` (target `role`) and the actions are `role.create`, `role.edit`, `role.delete` and `role.reassign`, covered in test/roles_api_*_test.go.
|
||||||
|
|
||||||
## G. Mutation guard (gremlins stays clean)
|
## G. Mutation guard (gremlins stays clean)
|
||||||
37. `task mutate` scope is `./internal/infrastructure/useCases`. Each new branch needs a killing assertion: every no-op guard (body, online, filename, display name) with equal and differing values; delta sign and rune-versus-byte count; success versus failed outcome and recorded once after the change (event count and order against the repository call); every `errors.Is` row of the reason map plus the generic fallback; every `ctx` forwarding; the system-actor defaults. Bare-bool conditions produce no mutant, so cover both branches with tests regardless.
|
37. `task mutate` scope is `./internal/infrastructure/useCases`. Each new branch needs a killing assertion: every no-op guard (body, online, filename, display name) with equal and differing values; delta sign and rune-versus-byte count; success versus failed outcome and recorded once after the change (event count and order against the repository call); every `errors.Is` row of the reason map plus the generic fallback; every `ctx` forwarding; the system-actor defaults. Bare-bool conditions produce no mutant, so cover both branches with tests regardless.
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Feature: Roles and permissions content scope: own and all enforcement on article
|
|||||||
Given a role repository that counts calls and a user repository that counts calls
|
Given a role repository that counts calls and a user repository that counts calls
|
||||||
When a gated request is sent with a session cookie
|
When a gated request is sent with a session cookie
|
||||||
Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once
|
Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once
|
||||||
And ListGrants is called zero times by the permission check and by GetMyAccessUseCase
|
And List is called zero times by the permission check and by GetMyAccessUseCase (ListGrants was removed by roles_permissions_roles_api)
|
||||||
And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load)
|
And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load)
|
||||||
And the status codes and the order of checks of the enforcement feature are unchanged
|
And the status codes and the order of checks of the enforcement feature are unchanged
|
||||||
And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it
|
And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it
|
||||||
@@ -456,9 +456,9 @@ Feature: Roles and permissions content scope: own and all enforcement on article
|
|||||||
Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all
|
Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all
|
||||||
When the client sends GET /v1/auth/me
|
When the client sends GET /v1/auth/me
|
||||||
Then access.permissions holds content.update at own and no settings.delete_site
|
Then access.permissions holds content.update at own and no settings.delete_site
|
||||||
And typed grants are not listed in this slice
|
And typed grants are not listed in this slice (superseded by roles_permissions_roles_api: typed entries, scope none included, are listed)
|
||||||
|
|
||||||
Scenario: Out of scope here
|
Scenario: Out of scope here
|
||||||
Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route
|
Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route
|
||||||
And typed grants are written by slice 3 and exposed to the SPA by a later slice
|
And typed grants are written by slice 3 and exposed to the SPA by a later slice (done by roles_permissions_roles_api: the API writes them and /auth/me lists them)
|
||||||
And rows of deleted content types in typed grants are cleaned up by slice 3
|
And rows of deleted content types in typed grants are cleaned up by slice 3 (done by roles_permissions_roles_api)
|
||||||
@@ -6,9 +6,9 @@ Do not copy any `Role.Allows`, `Role ==` or hand-rolled own/all comparison into
|
|||||||
|
|
||||||
## Deferred to later slices
|
## Deferred to later slices
|
||||||
|
|
||||||
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types.
|
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types. Done by roles_permissions_roles_api.
|
||||||
- 4: role change, invite with role, ownership transfer, removal of the legacy role column.
|
- 4: role change, invite with role, ownership transfer, removal of the legacy role column.
|
||||||
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants.
|
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants (the API side is done by roles_permissions_roles_api).
|
||||||
- Not changed: GET /v1/media/{id}/usage still lists articles and entries for any media.view holder. media.delete own fails closed. Entry relation summaries are not gated by the related type.
|
- Not changed: GET /v1/media/{id}/usage still lists articles and entries for any media.view holder. media.delete own fails closed. Entry relation summaries are not gated by the related type.
|
||||||
|
|
||||||
## Design notes for the user
|
## Design notes for the user
|
||||||
@@ -44,7 +44,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
|
|||||||
|
|
||||||
## Test infrastructure first
|
## Test infrastructure first
|
||||||
|
|
||||||
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `ListGrants`. Its `Get` returns the role with `Grants` filled, like the persistence repository.
|
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `List` (ListGrants was removed by roles_permissions_roles_api). Its `Get` returns the role with `Grants` filled, like the persistence repository.
|
||||||
2. User fake with a `Get` call counter (existing `mockUserRepo` plus a counter, or a wrapper). Users 3, 5, 9, 12 (unverified Admin), 13 (role_id 99), 20 (hand-placed owner-only), 21-27.
|
2. User fake with a `Get` call counter (existing `mockUserRepo` plus a counter, or a wrapper). Users 3, 5, 9, 12 (unverified Admin), 13 (role_id 99), 20 (hand-placed owner-only), 21-27.
|
||||||
3. Article fake and entry fake seeded with the ids, states and created_by values of the Background. Both record writes, so "article unchanged", "entry stored" and "nothing stored" are asserted on the store. A recording `audit.Recorder` to assert "no audit event".
|
3. Article fake and entry fake seeded with the ids, states and created_by values of the Background. Both record writes, so "article unchanged", "entry stored" and "nothing stored" are asserted on the store. A recording `audit.Recorder` to assert "no audit event".
|
||||||
4. HTTP harness: `validator.InitValidator()`. Build `NewArticleRouter` and `NewContentTypeRouter` equivalents mounted on a fresh `chi.NewRouter()` mirroring `api/router.go`: `authtoken.Verifier`, `container.apiKeyAuth()` equivalent, the new optional-access middleware on the public article group, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, the typed permission gate, and the handlers over real use cases wired to the fakes. No live server, no real SQLite, no real SMTP.
|
4. HTTP harness: `validator.InitValidator()`. Build `NewArticleRouter` and `NewContentTypeRouter` equivalents mounted on a fresh `chi.NewRouter()` mirroring `api/router.go`: `authtoken.Verifier`, `container.apiKeyAuth()` equivalent, the new optional-access middleware on the public article group, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, the typed permission gate, and the handlers over real use cases wired to the fakes. No live server, no real SQLite, no real SMTP.
|
||||||
@@ -68,7 +68,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
|
|||||||
## B. Use cases (fakes only)
|
## B. Use cases (fakes only)
|
||||||
|
|
||||||
13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls.
|
13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls.
|
||||||
14. Counters: for one `Require` call, `ListGrants` is 0, `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
|
14. Counters: for one `Require` call, `List` is 0 (was `ListGrants`), `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
|
||||||
15. Article use cases (get, list, update, update-is-online, delete, create, SEO check): table of caller grants × article × expected error or result, per the feature Outlines. Assert for refusals: error is `role.MissingPermissionError{Key: "content.update"}` and so on, the store is unchanged, the recorder has no event, and no site/author/image lookup happened (counters on those fakes).
|
15. Article use cases (get, list, update, update-is-online, delete, create, SEO check): table of caller grants × article × expected error or result, per the feature Outlines. Assert for refusals: error is `role.MissingPermissionError{Key: "content.update"}` and so on, the store is unchanged, the recorder has no event, and no site/author/image lookup happened (counters on those fakes).
|
||||||
16. Entry use cases (list, get, create, update, delete): same, including `created_by` unchanged on update, own-filtered list totals and `?q=` over the own set, and `entry.ErrEntryNotFound` for a non-owned read under own.
|
16. Entry use cases (list, get, create, update, delete): same, including `created_by` unchanged on update, own-filtered list totals and `?q=` over the own set, and `entry.ErrEntryNotFound` for a non-owned read under own.
|
||||||
17. Order: scope check after the load and before validation: PUT with an invalid body for a non-owned entry gives the scope error, not `FieldError`.
|
17. Order: scope check after the load and before validation: PUT with an invalid body for a non-owned entry gives the scope error, not `FieldError`.
|
||||||
@@ -86,7 +86,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
|
|||||||
26. Live role: edit grants and `role_id` between requests with the same cookie and the same API key.
|
26. Live role: edit grants and `role_id` between requests with the same cookie and the same API key.
|
||||||
27. API key: article created through the key has `created_by` of the creator.
|
27. API key: article created through the key has `created_by` of the creator.
|
||||||
28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes.
|
28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes.
|
||||||
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `ListGrants`; with an API key at most 2 user `Get`. Run on one article route and one entry route.
|
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `List` (was `ListGrants`); with an API key at most 2 user `Get`. Run on one article route and one entry route.
|
||||||
30. Owner-only: user 20 with a hand-placed `system.apply_updates=all` gets 403 on `POST /v1/system/update/check`; `/auth/me` omits it.
|
30. Owner-only: user 20 with a hand-placed `system.apply_updates=all` gets 403 on `POST /v1/system/update/check`; `/auth/me` omits it.
|
||||||
31. Media rows of the Outline and the "usage not filtered" assertion.
|
31. Media rows of the Outline and the "usage not filtered" assertion.
|
||||||
32. Regression: run the 2a Outline "Existing owner and admin behaviour does not regress", the authors.edit Outline and the activity/api-key scope Outlines unchanged.
|
32. Regression: run the 2a Outline "Existing owner and admin behaviour does not regress", the authors.edit Outline and the activity/api-key scope Outlines unchanged.
|
||||||
@@ -116,5 +116,5 @@ Add a "superseded by roles_permissions_content_scope" note or edit in place:
|
|||||||
|
|
||||||
- Start `task dev:api` on a copy of a migrated database. As the Writer: publish returns 403, saving a draft works and stays a draft. As the Admin: publish works.
|
- Start `task dev:api` on a copy of a migrated database. As the Writer: publish returns 403, saving a draft works and stays a draft. As the Admin: publish works.
|
||||||
- With two accounts on a custom role holding content.update=own, confirm account B gets 403 on A's article and entry, and A can edit them.
|
- With two accounts on a custom role holding content.update=own, confirm account B gets 403 on A's article and entry, and A can edit them.
|
||||||
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. This is the only way to try typed grants until slice 3.
|
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. Typed grants can now also be written through PUT /v1/role/{id} (roles_permissions_roles_api).
|
||||||
- Confirm no password or secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
|
- Confirm no password or secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
|
||||||
@@ -12,7 +12,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
|
|||||||
- Scope with values none, own and all
|
- Scope with values none, own and all
|
||||||
- Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants}
|
- Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants}
|
||||||
- the sentinels ErrRoleNotFound and ErrUnknownPermission
|
- the sentinels ErrRoleNotFound and ErrUnknownPermission
|
||||||
- the Repository interface (list, get by id, get by name, list grants, create, save)
|
- the Repository interface (list, get by id, get by name, create, save; list grants was removed and delete, reassign members and remove typed grants added by roles_permissions_roles_api)
|
||||||
- the pure function Role.Allows(key, owned bool) bool (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits)
|
- the pure function Role.Allows(key, owned bool) bool (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits)
|
||||||
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
|
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
|
||||||
| group | key | kind | owner-only |
|
| group | key | kind | owner-only |
|
||||||
@@ -51,7 +51,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
|
|||||||
| system | system.edit_retention | B | yes |
|
| system | system.edit_retention | B | yes |
|
||||||
| system | system.transfer_ownership | B | yes |
|
| system | system.transfer_ownership | B | yes |
|
||||||
And content permissions are stored under wildcard keys meaning "all content types"; per-type
|
And content permissions are stored under wildcard keys meaning "all content types"; per-type
|
||||||
overrides of the form content.<action>:<type_key> are deferred to slice 3 (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
|
overrides of the form content.<action>:<type_key> are written by slice 3, roles_permissions_roles_api (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
|
||||||
And a role stores one row per granted key, with scope own or all. A key with no row is none or off.
|
And a role stores one row per granted key, with scope own or all. A key with no row is none or off.
|
||||||
An on/off key that is on is stored as scope all.
|
An on/off key that is on is stored as scope all.
|
||||||
And three built-in roles are seeded by persistence/database (like SeedDefaultSite), idempotently,
|
And three built-in roles are seeded by persistence/database (like SeedDefaultSite), idempotently,
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity
|
||||||
|
|
||||||
|
As the owner of a RenewCMS site
|
||||||
|
I want to manage roles over /v1/role with the checks the design asks for
|
||||||
|
So that an admin can build a Contributor role in under a minute, nobody can grant what they do not hold,
|
||||||
|
and every change is audited
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the spec of record is docs/design/screens/roles.md (sections 1, 2, 4, 7, 8, tables "No-escalation feedback" and "HTTP status to copy") and docs/design/routes.md "Roles and permissions"
|
||||||
|
And features/roles_permissions_foundation.feature provides the catalogue, the built-in roles and /auth/me,
|
||||||
|
features/roles_permissions_enforcement.feature provides the 403 contract (body "missing_permission\n", header X-Missing-Permission with the base key),
|
||||||
|
and features/roles_permissions_content_scope.feature provides GrantSet, typed keys and ValidateGrants
|
||||||
|
And "403" means status 403, body "missing_permission\n" and the header X-Missing-Permission, nothing stored, no audit event
|
||||||
|
And every other refusal is plain text from http.Error, one snake_case code per line below. Success bodies are JSON, with snake_case fields
|
||||||
|
And the codes are:
|
||||||
|
| situation | status | body | extra header |
|
||||||
|
| role id unknown | 404 | role_not_found | |
|
||||||
|
| edit or delete the Owner (Locked) role | 403 | role_locked | |
|
||||||
|
| edit-owner-only needed for a built-in role | 403 | missing_permission | X-Missing-Permission: roles.edit_owner_only |
|
||||||
|
| delete or reassign from a built-in role | 409 | role_built_in | |
|
||||||
|
| rename a built-in role | 400 | built_in_name | |
|
||||||
|
| edit, delete or reassign from the caller's own role | 403 | own_role | |
|
||||||
|
| grant or touch more than the caller holds | 403 | escalation | X-Escalation-Key: the offending key |
|
||||||
|
| name already used | 409 | role_name_taken | |
|
||||||
|
| version mismatch on PUT | 409 | role_conflict | |
|
||||||
|
| delete a role that still has members | 409 | role_has_members | |
|
||||||
|
| bad reassign target | 400 | invalid_reassign (existing apperr) | |
|
||||||
|
| blank name after trim | 400 | invalid_role_name | |
|
||||||
|
| unknown or malformed permission key | 400 | unknown_permission | |
|
||||||
|
| scope not valid for the key | 400 | invalid_scope | |
|
||||||
|
| owner-only key on a role that is not Locked | 400 | owner_only_permission | |
|
||||||
|
| the same key twice | 400 | duplicate_permission | |
|
||||||
|
| typed key whose type is not article or a content type | 400 | unknown_content_type | |
|
||||||
|
And an undecodable body is 400 apperr.BodyErrorMessage and a failed validate tag is 400 with the validator text, as in the site handler.
|
||||||
|
A non-numeric id is 400 apperr.IdUint32ErrorMessage
|
||||||
|
And the request structs carry validate tags: name "required,max=100", description "max=255", grants key "required,max=100", version "required,min=1"
|
||||||
|
And the routes are mounted in api/router.go under /v1/role (NewRoleRouter), behind authtoken.Verifier, container.apiKeyAuth(), Authenticator,
|
||||||
|
RequireCurrentToken and RequireVerifiedUser:
|
||||||
|
| route | key (role.Key constant) | session only |
|
||||||
|
| GET /v1/role | roles.view | no |
|
||||||
|
| GET /v1/role/{id} | roles.view | no |
|
||||||
|
| POST /v1/role | roles.manage | yes |
|
||||||
|
| PUT /v1/role/{id} | roles.manage | yes |
|
||||||
|
| DELETE /v1/role/{id} | roles.manage | yes |
|
||||||
|
| POST /v1/role/{id}/reassign | roles.manage | yes |
|
||||||
|
| POST /v1/role/{id}/reset | roles.manage | yes |
|
||||||
|
And the permission gate runs before the id, the body and any lookup. An API key on a write route gets 403 apperr.SessionRequiredErrorMessage
|
||||||
|
And handlers receive use cases only: ListRolesUseCase, GetRoleUseCase, CreateRoleUseCase, UpdateRoleUseCase, DeleteRoleUseCase, ReassignRoleMembersUseCase.
|
||||||
|
They depend on domain interfaces only: role.Repository, user.Repository, contenttype.Repository, audit.Recorder
|
||||||
|
And the role JSON is the domain Role (id, name, description, built_in, locked, version, grants) plus members (number), updated_at and updated_by ({id, name} or null)
|
||||||
|
And grants in every response are the normalised effective list: catalogue order, unknown keys and owner-only keys on unlocked roles dropped,
|
||||||
|
duplicates resolved to the most restrictive scope, a typed entry (content.<action>:<type_key>, scope none included) right after its wildcard in type-key order.
|
||||||
|
A response body is always valid as the grants of a PUT. One domain function produces this list, used by the role responses, the diff and /auth/me
|
||||||
|
And a user's effective role id is role_id, else the built-in named by the legacy owner/admin string. members counts users by that id
|
||||||
|
And the no-escalation comparison is one domain function GrantSet.Covers(other, typeKeys): for every catalogue key, and for each content.* key and each
|
||||||
|
type key in {article} plus the existing content types, the scope of other must not rank above the scope of the caller (none < own < all). The first violation, in catalogue order, names the key
|
||||||
|
And the order of checks on every write is: gate, id (400), body (400), load role (404), locked (403), built-in rules (403/409/400),
|
||||||
|
own role (403), existing role covered by the caller (403 escalation), version (409 role_conflict), ValidateGrants and type keys (400),
|
||||||
|
new grants covered by the caller (403 escalation), name taken (409), write
|
||||||
|
And the roles and users of the Background (all verified, valid jwt cookie, token_version 0):
|
||||||
|
| role | grants | members |
|
||||||
|
| Owner | built-in, Locked, 34 keys | user 3 |
|
||||||
|
| Admin | built-in, 29 keys | user 5, user 10 (role_id 0, legacy admin) |
|
||||||
|
| Writer | built-in, 9 keys | user 9 |
|
||||||
|
| Contributor | content.read=all, content.create, content.update=own | users 41, 42, 43, 44 |
|
||||||
|
| Intern | content.read=own | none |
|
||||||
|
| Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | user 45 |
|
||||||
|
| Role manager | roles.view, roles.manage, content.read=all, content.create, content.update=own, content.publish=own, schema.view | user 31 |
|
||||||
|
| Viewer | roles.view | user 32 |
|
||||||
|
| Typed manager | roles.view, roles.manage, content.read=all, content.update=all, content.update:article=none | user 33 |
|
||||||
|
| Narrow manager | roles.view, roles.manage, content.read:events=all | user 34 |
|
||||||
|
| Events editor | content.read=all, content.update:events=own, content.delete:events=none, content.update:venues=all | user 35 |
|
||||||
|
And user 12 is an unverified Admin, and the content types are "events" and "venues"
|
||||||
|
|
||||||
|
Scenario Outline: The gate decides who reads and who writes roles
|
||||||
|
When <caller> sends <call>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | call | result |
|
||||||
|
| user 3 Owner | GET /v1/role | 200 |
|
||||||
|
| user 5 Admin | GET /v1/role | 200 |
|
||||||
|
| user 32 Viewer | GET /v1/role | 200 |
|
||||||
|
| user 32 Viewer | GET /v1/role/<Writer id> | 200 |
|
||||||
|
| user 9 Writer | GET /v1/role | 403 roles.view |
|
||||||
|
| user 9 Writer | GET /v1/role/abc | 403 roles.view (before the id is read) |
|
||||||
|
| user 32 Viewer | POST /v1/role {"name": "X"} | 403 roles.manage |
|
||||||
|
| user 32 Viewer | PUT /v1/role/999 | 403 roles.manage (before any lookup) |
|
||||||
|
| user 32 Viewer | DELETE /v1/role/999 | 403 roles.manage |
|
||||||
|
| user 32 Viewer | POST /v1/role/999/reassign | 403 roles.manage |
|
||||||
|
| no cookie | GET /v1/role | 401 apperr.UnauthorizedErrorMessage |
|
||||||
|
| user 12 unverified Admin | GET /v1/role | 403 apperr.VerificationRequiredErrorMessage |
|
||||||
|
| API key created by user 5 | GET /v1/role | 200 |
|
||||||
|
| API key created by user 5 | POST /v1/role {"name": "Keyed"} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
|
||||||
|
| stale token_version | GET /v1/role | 401 |
|
||||||
|
And a session follows the role live: after Viewer gains roles.manage in the repository, the same cookie's next POST passes the gate, with no re-login
|
||||||
|
|
||||||
|
Scenario: The list returns every role with grants and member counts
|
||||||
|
When user 5 sends GET /v1/role
|
||||||
|
Then the status is 200 and the body is a JSON array ordered by id, built-ins first
|
||||||
|
And Owner has members 1, Admin 2 (user 10 counted through the legacy fallback), Writer 1, Contributor 4, Intern 0
|
||||||
|
And each item holds its normalised grants, built_in, locked and version
|
||||||
|
And the Owner item holds the 34 keys at all and Admin the 29 keys at all
|
||||||
|
And the permission check and /auth/me do not call List
|
||||||
|
|
||||||
|
Scenario: Reading one role
|
||||||
|
When user 32 sends GET /v1/role/<Contributor id>
|
||||||
|
Then the status is 200, members is 4, version 1 and grants are content.read all, content.create all, content.update own
|
||||||
|
And GET /v1/role/999 is 404 role_not_found and GET /v1/role/abc is 400 apperr.IdUint32ErrorMessage
|
||||||
|
|
||||||
|
Scenario: A role with a hand-placed owner-only key or duplicate rows is returned normalised
|
||||||
|
Given a custom role with the rows content.update=own, content.update=all and a hand-placed system.apply_updates=all
|
||||||
|
When user 5 sends GET /v1/role/<that id>
|
||||||
|
Then grants hold content.update at own and no system.apply_updates
|
||||||
|
|
||||||
|
Scenario: Creating the Contributor role (design flow, section 11)
|
||||||
|
Given user 5 holds roles.manage
|
||||||
|
When user 5 sends POST /v1/role with {"name": "Writer plus", "description": "Writes posts and edits only their own", "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}
|
||||||
|
Then the status is 201 and the body is the role with built_in false, locked false, version 1, members 0
|
||||||
|
And the grants are in catalogue order and the new role appears in GET /v1/role
|
||||||
|
And exactly one role.create audit event is recorded (see "Activity")
|
||||||
|
And a body field built_in or locked sent by the client is ignored
|
||||||
|
|
||||||
|
Scenario: A new role may start with nothing granted
|
||||||
|
When user 5 sends POST /v1/role with {"name": "Empty"} and with {"name": "Empty 2", "grants": []}
|
||||||
|
Then both answer 201 with grants []
|
||||||
|
|
||||||
|
Scenario Outline: Write-time validation of create and edit
|
||||||
|
When user 5 sends POST /v1/role with name "Probe" and grants <grants>
|
||||||
|
Then the result is <result>
|
||||||
|
And nothing is stored and no audit event is recorded when the result is not 201
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| grants | result |
|
||||||
|
| content.update=all | 201 |
|
||||||
|
| content.update:events=own | 201 |
|
||||||
|
| content.update:article=none | 201 |
|
||||||
|
| content.update:events=all, content.update=own | 201 |
|
||||||
|
| content.explode=all | 400 unknown_permission |
|
||||||
|
| schema.view:events=all | 400 unknown_permission |
|
||||||
|
| content.update:Bad Key=all | 400 unknown_permission |
|
||||||
|
| content.update:nope=all | 400 unknown_content_type |
|
||||||
|
| content.update:author=all | 400 unknown_content_type |
|
||||||
|
| content.update=none | 400 invalid_scope |
|
||||||
|
| schema.view=own | 400 invalid_scope |
|
||||||
|
| content.update=banana | 400 invalid_scope |
|
||||||
|
| content.create:events=own | 400 invalid_scope |
|
||||||
|
| settings.delete_site=all | 400 owner_only_permission |
|
||||||
|
| system.apply_updates=all | 400 owner_only_permission |
|
||||||
|
| content.update=all, content.update=own | 400 duplicate_permission |
|
||||||
|
| content.update:events=all, content.update:events=own| 400 duplicate_permission |
|
||||||
|
And the grants are validated by role.ValidateGrants(false, grants) in the use case, then the type keys against the content types plus the built-in key "article"
|
||||||
|
And the owner-only keys are refused on every role a client can write, including a built-in Admin or Writer
|
||||||
|
|
||||||
|
Scenario Outline: Name and description rules
|
||||||
|
When user 5 sends POST /v1/role with name <name> and description <description>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| name | description | result |
|
||||||
|
| "" | "" | 400 (validate tag required) |
|
||||||
|
| " " | "" | 400 invalid_role_name |
|
||||||
|
| (101 characters) | "" | 400 (validate tag max=100) |
|
||||||
|
| (100 characters) | "" | 201 |
|
||||||
|
| " Reviewer " | "" | 201, name stored as "Reviewer" |
|
||||||
|
| "Reviewer 2" | (256 characters) | 400 (validate tag max=255) |
|
||||||
|
| "Writer" | "" | 409 role_name_taken |
|
||||||
|
| "WRITER" | "" | 409 role_name_taken (case-insensitive) |
|
||||||
|
| " contributor " | "" | 409 role_name_taken |
|
||||||
|
And a malformed JSON body is 400 apperr.BodyErrorMessage
|
||||||
|
|
||||||
|
Scenario Outline: No-escalation on create and edit
|
||||||
|
Given the caller edits the role Contributor (read all, create, update own) with the extra grant <grant>
|
||||||
|
When <caller> sends PUT /v1/role/<Contributor id> with the current version and the Contributor grants plus <grant>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | grant | result |
|
||||||
|
| user 31 Role manager | content.publish=own | 200 |
|
||||||
|
| user 31 Role manager | content.publish=all | 403 escalation, X-Escalation-Key content.publish |
|
||||||
|
| user 31 Role manager | content.delete=own | 403 escalation (caller holds none), X-Escalation-Key content.delete |
|
||||||
|
| user 31 Role manager | schema.edit=all | 403 escalation |
|
||||||
|
| user 31 Role manager | content.publish:events=none | 200 (denying never escalates) |
|
||||||
|
| user 31 Role manager | content.publish:events=all | 403 escalation, X-Escalation-Key content.publish:events |
|
||||||
|
| user 31 Role manager | content.update:events=own | 200 |
|
||||||
|
| user 31 Role manager | content.update:events=all | 403 escalation |
|
||||||
|
| user 33 Typed manager | content.update=all | 403 escalation (update would become all on article), X-Escalation-Key content.update:article |
|
||||||
|
| user 33 Typed manager | content.update=all, content.update:article=own | 403 escalation (own is above the caller's none on article), X-Escalation-Key content.update:article |
|
||||||
|
| user 33 Typed manager | content.update=all, content.update:article=none | 200 |
|
||||||
|
| user 34 Narrow manager | (no change) | 403 escalation (Contributor's wildcard read is above a typed-only reader) |
|
||||||
|
| user 5 Admin | content.delete=all | 200 |
|
||||||
|
| user 3 Owner | content.delete=all | 200 |
|
||||||
|
And the same comparison applies on POST /v1/role with the same grants
|
||||||
|
And a refused call changes no field, bumps no version and records no event
|
||||||
|
|
||||||
|
Scenario Outline: A caller cannot edit, delete or move members of a role above their own grants
|
||||||
|
When <caller> sends <call>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | call | result |
|
||||||
|
| user 31 Role manager | PUT /v1/role/<Content lead id> with no change | 403 escalation (Content lead publishes and updates at all) |
|
||||||
|
| user 31 Role manager | DELETE /v1/role/<Content lead id> | 403 escalation |
|
||||||
|
| user 31 Role manager | POST /v1/role/<Content lead id>/reassign {"target_role_id": <Intern id>} | 403 escalation |
|
||||||
|
| user 31 Role manager | PUT /v1/role/<Contributor id> with a harmless change | 200 (Contributor is covered) |
|
||||||
|
| user 5 Admin | PUT /v1/role/<Content lead id> with a harmless change | 200 |
|
||||||
|
|
||||||
|
Scenario Outline: Editing a role
|
||||||
|
Given the roles of the Background, each at version 1
|
||||||
|
When <caller> sends PUT /v1/role/<target> with <body>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | target | body | result |
|
||||||
|
| user 5 Admin | Contributor | name same, grants plus content.delete=own, version 1 | 200, version 2, members 4, grants include content.delete own |
|
||||||
|
| user 5 Admin | Contributor | grants minus content.update, version 1 | 200, content.update absent |
|
||||||
|
| user 5 Admin | Contributor | description changed, version 1 | 200, version 2 |
|
||||||
|
| user 5 Admin | Contributor | nothing changed, version 1 | 200, version 1, no write, no event |
|
||||||
|
| user 5 Admin | Contributor | nothing changed, version 7 | 409 role_conflict |
|
||||||
|
| user 5 Admin | Contributor | body changed, version 7 | 409 role_conflict, role unchanged |
|
||||||
|
| user 5 Admin | Contributor | no version field | 400 (validate tag required) |
|
||||||
|
| user 5 Admin | Contributor | name "writer", version 1 | 409 role_name_taken |
|
||||||
|
| user 5 Admin | Contributor | name "contributor" (case only), version 1 | 200, name stored as sent |
|
||||||
|
| user 5 Admin | Contributor | name "Intern", version 1 | 409 role_name_taken |
|
||||||
|
| user 5 Admin | Contributor | content.explode=all | 400 unknown_permission, role unchanged |
|
||||||
|
| user 5 Admin | 999 | any valid body | 404 role_not_found |
|
||||||
|
| user 3 Owner | Writer | description changed, version 1 | 200, version 2, the role keeps built_in true |
|
||||||
|
| user 3 Owner | Writer | grants plus media.delete=own, version 1 | 200 |
|
||||||
|
| user 3 Owner | Writer | name "Author", version 1 | 400 built_in_name |
|
||||||
|
| user 3 Owner | Admin | grants minus users.remove, version 1 | 200 |
|
||||||
|
| user 3 Owner | Admin | grants plus system.apply_updates=all | 400 owner_only_permission |
|
||||||
|
| user 5 Admin | Writer | description changed, version 1 | 403 missing_permission, X-Missing-Permission roles.edit_owner_only |
|
||||||
|
| user 5 Admin | Admin | description changed, version 1 | 403 missing_permission, X-Missing-Permission roles.edit_owner_only (own role, built-in rule first) |
|
||||||
|
| user 3 Owner | Owner | any body | 403 role_locked |
|
||||||
|
| user 5 Admin | Owner | any body | 403 role_locked |
|
||||||
|
| user 31 Role manager | Role manager | description changed, version 1 | 403 own_role |
|
||||||
|
| user 5 Admin | Contributor | an Admin may edit a role they do not hold | 200 |
|
||||||
|
And a successful edit leaves the id, built_in and locked of the role unchanged
|
||||||
|
And members who hold the edited role see the new grants on their next request, with no re-login, and so do the API keys they created
|
||||||
|
And a stored built-in edit survives the next seed: SeedBuiltInRoles never overwrites a role whose version is not 1
|
||||||
|
|
||||||
|
Scenario: Who saved last
|
||||||
|
Given user 5 saved Contributor at 11:02
|
||||||
|
When user 32 sends GET /v1/role/<Contributor id>
|
||||||
|
Then updated_at is that time (RFC 3339, UTC) and updated_by is {"id": 5, "name": the display name of user 5, else the username}
|
||||||
|
And a role never edited since seeding has updated_by null
|
||||||
|
And when the saving user was deleted updated_by is null
|
||||||
|
|
||||||
|
Scenario: A stale editor gets a conflict, then reads the new state
|
||||||
|
Given user 5 and user 3 both loaded Contributor at version 1
|
||||||
|
When user 3 saves first and user 5 then saves the same version
|
||||||
|
Then user 3 gets 200 with version 2 and user 5 gets 409 role_conflict
|
||||||
|
And the stored role is user 3's
|
||||||
|
And the repository Save is a compare-and-swap on version: two concurrent saves of version 1 give exactly one 200 and one 409
|
||||||
|
|
||||||
|
Scenario Outline: Deleting a role
|
||||||
|
When <caller> sends DELETE /v1/role/<target>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | target | result |
|
||||||
|
| user 3 Owner | Intern | 204, the role and its grants are gone, GET answers 404 role_not_found |
|
||||||
|
| user 5 Admin | Intern | 204 |
|
||||||
|
| user 5 Admin | Contributor | 409 role_has_members, nothing deleted |
|
||||||
|
| user 3 Owner | Writer | 409 role_built_in |
|
||||||
|
| user 3 Owner | Admin | 409 role_built_in |
|
||||||
|
| user 3 Owner | Owner | 403 role_locked |
|
||||||
|
| user 5 Admin | Owner | 403 role_locked |
|
||||||
|
| user 5 Admin | 999 | 404 role_not_found |
|
||||||
|
| user 31 Role manager | Role manager | 403 own_role |
|
||||||
|
| user 31 Role manager | Content lead | 403 escalation |
|
||||||
|
| user 31 Role manager | Intern | 204 |
|
||||||
|
And the Intern row of user 31 is 204, because Role manager holds content.read at all, which covers read own
|
||||||
|
And a delete never touches content: articles and entries created by the role's members keep their created_by
|
||||||
|
And the repository Delete re-checks members inside its transaction and returns role.ErrRoleHasMembers when a member appeared meanwhile, so the race answers 409 role_has_members
|
||||||
|
|
||||||
|
Scenario Outline: Moving members, then deleting
|
||||||
|
Given Contributor has members 41, 42, 43 and 44
|
||||||
|
When <caller> sends POST /v1/role/<source>/reassign with <body>
|
||||||
|
Then the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | source | body | result |
|
||||||
|
| user 5 Admin | Contributor | {"target_role_id": <Writer id>} | 200 {"moved": 4}, users 41-44 now have the Writer role_id, tokens and cookies stay valid |
|
||||||
|
| user 5 Admin | Intern | {"target_role_id": <Writer id>} | 200 {"moved": 0}, no event |
|
||||||
|
| user 31 Role manager | Contributor | {"target_role_id": <Admin id>} | 403 escalation (Admin is above Role manager), nobody moved |
|
||||||
|
| user 31 Role manager | Contributor | {"target_role_id": <Intern id>} | 200 {"moved": 4} |
|
||||||
|
| user 5 Admin | Contributor | {"target_role_id": <Owner id>} | 400 invalid_reassign (there is one owner) |
|
||||||
|
| user 5 Admin | Contributor | {"target_role_id": <Contributor id>} | 400 invalid_reassign |
|
||||||
|
| user 5 Admin | Contributor | {"target_role_id": 999} | 400 invalid_reassign |
|
||||||
|
| user 5 Admin | Contributor | {} | 400 (validate tag required) |
|
||||||
|
| user 5 Admin | Writer | {"target_role_id": <Intern id>} | 409 role_built_in |
|
||||||
|
| user 5 Admin | Owner | {"target_role_id": <Intern id>} | 403 role_locked |
|
||||||
|
| user 5 Admin | 999 | {"target_role_id": <Writer id>} | 404 role_not_found |
|
||||||
|
| user 31 Role manager | Role manager | {"target_role_id": <Intern id>} | 403 own_role |
|
||||||
|
And after the first row, DELETE /v1/role/<Contributor id> by user 5 answers 204 and the role is gone
|
||||||
|
And the move is one statement: when the repository fails, nobody is moved and the answer is 500 "Internal server error" with a failed role.reassign event
|
||||||
|
And moving members changes only role_id. Their passwords, token_version, verification and legacy role string are untouched
|
||||||
|
|
||||||
|
Scenario Outline: Resetting a built-in role to its defaults
|
||||||
|
When <caller> sends POST /v1/role/<role>/reset with {"version": <version>}
|
||||||
|
Then the answer is <result>
|
||||||
|
Examples:
|
||||||
|
| caller | role | version | result |
|
||||||
|
| Owner | Writer, edited | current | 200 with the seeded Writer grants and description, version bumped by one, name and flags unchanged |
|
||||||
|
| Owner | Admin, edited | current | 200 with the seeded Admin grants, version bumped by one |
|
||||||
|
| Owner | Writer, pristine | current | 200, grants unchanged, no version bump, no event |
|
||||||
|
| Owner | Writer, edited | stale | 409 role_conflict |
|
||||||
|
| Owner | Owner | current | 403 role_locked |
|
||||||
|
| Owner | Contributor | current | 400 not_built_in |
|
||||||
|
| Owner | 999 | current | 404 role_not_found |
|
||||||
|
| user 5 Admin | Writer, edited | current | 403 missing_permission, X-Missing-Permission roles.edit_owner_only |
|
||||||
|
| user 32 Viewer | Writer, edited | current | 403 missing_permission, X-Missing-Permission roles.manage |
|
||||||
|
| API key of the Owner | Writer, edited | current | 403 session_required |
|
||||||
|
And the reset replaces the stored grants with BuiltInRoles() in one transaction, so the role is never seen half reset
|
||||||
|
And members of the reset role see the seeded grants on their next request, with no re-login
|
||||||
|
And a successful reset records a role.edit event with the role as target, and a refused one records nothing
|
||||||
|
|
||||||
|
Scenario: Activity events for roles
|
||||||
|
Given the audit recorder is enabled and user 5 is the actor
|
||||||
|
When user 5 creates "Reviewer", then edits it (adds content.delete=own, removes media.view, changes content.update from own to all, renames to "Reviewers"), then deletes it
|
||||||
|
Then three events are recorded in order, with category roles, outcome success, the actor user 5 and the target {"type": "role", "id": the role id, "name": the role name}
|
||||||
|
And role.create has params added = every granted entry
|
||||||
|
And role.edit has changes for name and description (text before and after) and params added [{key, scope}], removed [{key, scope}], changed [{key, from, to}] over the normalised grants,
|
||||||
|
plus members. The diff is computed on (key, scope) entries with an absent entry meaning no entry: absent to present is added, present to absent is removed, a different scope is changed
|
||||||
|
And role.delete has params members 0
|
||||||
|
And the example edit above gives added content.delete own, removed media.view all, changed content.update own to all
|
||||||
|
And the move of members records one role.reassign event with params from (role id), to (role id) and moved, only when moved is greater than 0
|
||||||
|
And a refusal (403, 404, 409, 400) before the write records nothing
|
||||||
|
And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure like the other use cases
|
||||||
|
And GET /v1/activity?category=roles is a valid filter, visible to a caller whose activity.view is all
|
||||||
|
And audit.Categories() gains "roles", audit.TargetRole is "role", and the actions are role.create, role.edit, role.delete, role.reassign
|
||||||
|
|
||||||
|
Scenario: /auth/me exposes typed grants
|
||||||
|
Given user 35 is on the Events editor role
|
||||||
|
When user 35 sends GET /v1/auth/me
|
||||||
|
Then access.permissions is, in order: {"key": "content.read", "scope": "all"}, {"key": "content.update:events", "scope": "own"}, {"key": "content.update:venues", "scope": "all"}, {"key": "content.delete:events", "scope": "none"}
|
||||||
|
And wildcard entries with scope none are still not listed
|
||||||
|
And owner-only keys on an unlocked role and duplicate rows are normalised as before
|
||||||
|
And the roles-content-scope scenario "typed grants are not listed in this slice" is superseded by this one
|
||||||
|
|
||||||
|
Scenario: Deleting a content type removes its typed grants
|
||||||
|
Given the Events editor role at version 1 and the content type "events" has no entries and is not referenced
|
||||||
|
When user 5 sends DELETE /v1/content-type/events
|
||||||
|
Then the status is 204
|
||||||
|
And Events editor no longer holds content.update:events or content.delete:events, still holds content.read all and content.update:venues all, and has version 2
|
||||||
|
And a role that held no typed row for "events" keeps its version
|
||||||
|
And user 35 loses the events override on the next request: content.update on events falls back to the wildcard (none)
|
||||||
|
|
||||||
|
Scenario: A refused content type delete keeps the typed grants
|
||||||
|
Given the content type "events" still has entries
|
||||||
|
When user 5 sends DELETE /v1/content-type/events
|
||||||
|
Then the status is 409 apperr.ContentTypeHasEntriesErrorMessage and the Events editor role is unchanged, version 1
|
||||||
|
|
||||||
|
Scenario: A content type created with the key of a deleted one starts clean
|
||||||
|
Given a stale row content.update:events=none is stored on a custom role although no content type "events" exists
|
||||||
|
When user 5 sends POST /v1/content-type with the key "events" and a valid body
|
||||||
|
Then the status is 201 and that row is removed, so the new type is governed by the wildcard
|
||||||
|
|
||||||
|
Scenario: Content type keys do not change
|
||||||
|
Then PUT /v1/content-type/{key} never writes the key, so no typed row needs renaming
|
||||||
|
And a PUT with a different key in the body leaves the stored key unchanged, as before
|
||||||
|
|
||||||
|
Scenario: ListGrants is removed
|
||||||
|
Then role.Repository no longer has ListGrants
|
||||||
|
And role.Repository holds List (with grants), Get, GetByName, Create, Save (compare-and-swap on version, also writing updated_at and updated_by),
|
||||||
|
Delete(id) (one transaction, role.ErrRoleHasMembers when a user still has the role_id, removes the role_permission rows),
|
||||||
|
ReassignMembers(from, to) (int, error) and RemoveTypedGrants(typeKey) (int, error)
|
||||||
|
And the persistence repository asserts var _ role.Repository = &RoleRepository{} and keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL
|
||||||
|
|
||||||
|
Scenario: The comparison logic lives once, in the domain
|
||||||
|
Then GrantSet.Covers is the only implementation of "does the caller hold at least these grants"
|
||||||
|
And no use case, handler or middleware compares scopes by rank or reads a GrantSet entry
|
||||||
|
And api/router.go uses role.KeyRolesView, role.KeyRolesManage and no string literal equal to a catalogue key
|
||||||
|
And no handler under api/handlers/role imports persistence, mailer, models or gorm
|
||||||
|
|
||||||
|
Scenario: Out of scope here
|
||||||
|
Then there is no PUT /v1/user/{id}/role, no invite with a role, no ownership transfer and no removal of the legacy users.role string (slice 4)
|
||||||
|
And there is no SPA change (slices 5-7)
|
||||||
|
And the pending-invite count in members, and role_has_members for invites, arrive with the invite role in slice 4
|
||||||
|
And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the new routes are only added to its exemption list)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# QA procedure: roles_permissions_roles_api
|
||||||
|
|
||||||
|
Package: `test` (black-box). New fakes need unique names, for example `rolesAPIRoleRepo`, `rolesAPIUserRepo`, `rolesAPITypeRepo`, `rolesAPIRecorder`.
|
||||||
|
Reuse `mintCookie`, `assertGate`, the API key minting helper and the seeded fakes of `test/role_support_test.go` and `test/roles_content_scope_world_test.go` where they fit.
|
||||||
|
Do not copy any scope-rank comparison into tests: call `GrantSet.Covers`, `ScopeFor` and the exported helpers.
|
||||||
|
|
||||||
|
## Deferred to later slices
|
||||||
|
|
||||||
|
- 4: PUT /v1/user/{id}/role, invites with a role (and counting pending invites as members), ownership transfer, removal of the legacy role string.
|
||||||
|
- 5-7: SPA (roles list, editor, preview, delete dialog, role-aware shell). The SPA reacts to status, body code and the X-Missing-Permission / X-Escalation-Key headers.
|
||||||
|
|
||||||
|
## Design notes for the user
|
||||||
|
|
||||||
|
- docs/design says the conflict copy names who saved and when: this slice adds updated_at and updated_by to the role (question 1). The 409 body stays plain text.
|
||||||
|
- docs/design names the events role.created / updated / deleted. The code convention is create / edit / delete (question 8). The SPA activity copy keys on the action, so the design board needs the code names.
|
||||||
|
- docs/design has no code for "built-in role can't be deleted", "own role" and the escalation key: role_built_in, own_role, X-Escalation-Key are new.
|
||||||
|
- The design asks the Owner role to be uneditable: role_locked is checked first, before own_role and the built-in rule.
|
||||||
|
- Agents never edit docs/design: these four items are for you to relay to Claude Design.
|
||||||
|
|
||||||
|
## Inventory (route, key, session only, checks after the load, Owner / Admin / Writer)
|
||||||
|
|
||||||
|
| Route | Key | Session only | After the load | Owner / Admin / Writer |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET /v1/role | roles.view | no | none | ok / ok / 403 |
|
||||||
|
| GET /v1/role/{id} | roles.view | no | 404 role_not_found | ok / ok / 403 |
|
||||||
|
| POST /v1/role | roles.manage | yes | ValidateGrants, type keys, covers, name taken | 201 / 201 / 403 |
|
||||||
|
| PUT /v1/role/{id} | roles.manage | yes | locked, built-in rule, own role, covers existing, version, validate, covers new, name taken | ok / ok (not built-in) / 403 |
|
||||||
|
| POST /v1/role/{id}/reset | roles.manage and roles.edit_owner_only | yes | locked, built-in only, version, restore BuiltInRoles() | 200 / 403 / 403 |
|
||||||
|
| DELETE /v1/role/{id} | roles.manage | yes | locked, built-in, own role, covers existing, members | ok / ok / 403 |
|
||||||
|
| POST /v1/role/{id}/reassign | roles.manage | yes | locked, built-in, own role, covers source, target valid, covers target | ok / ok / 403 |
|
||||||
|
| DELETE /v1/content-type/{key} | schema.delete | as before | removes typed rows after the delete | as before |
|
||||||
|
| POST /v1/content-type | schema.edit | as before | clears stale typed rows for the key | as before |
|
||||||
|
| GET /v1/auth/me | none | as before | lists typed grants | as before |
|
||||||
|
|
||||||
|
## Test infrastructure first
|
||||||
|
|
||||||
|
1. `rolesAPIRoleRepo`: in-memory `role.Repository` seeded with the three built-ins (same Go function the migration uses) plus the Background roles. Implements `List` (with grants), `Get`, `GetByName`, `Create` (new id, version 1), `Save` (compare-and-swap on version, error `role.ErrRoleConflict`, also sets updated_at/updated_by), `Delete` (`role.ErrRoleHasMembers` if a user in the shared user fake has the role_id), `ReassignMembers`, `RemoveTypedGrants`. A switchable failure per method, call counters for `List`, `Get`, `GetByName`, and a hook to run code between `Get` and `Save` (race tests).
|
||||||
|
2. User fake with `GetAll`, a role mover shared with the role fake, and users 3, 5, 9, 10 (role_id 0, legacy admin), 12 (unverified Admin), 31-35, 41-45.
|
||||||
|
3. Content type fake with "events" and "venues". Recording `audit.Recorder` to assert event count, action, category, target, params, changes.
|
||||||
|
4. HTTP harness: `validator.InitValidator()`, a fresh `chi.NewRouter()` mirroring `NewRoleRouter` in api/router.go (Verifier, apiKeyAuth equivalent, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on writes, `RequirePermission(role.KeyRolesView|KeyRolesManage)`, AuditContext) over real use cases and the fakes. No live server, no real SQLite, no real SMTP.
|
||||||
|
5. `assertGate(t, rec, key)` for the 403 contract. New helper `assertRefusal(t, rec, status, code)` for plain-text codes, and `assertEscalation(t, rec, key)` for the `escalation` body plus `X-Escalation-Key`.
|
||||||
|
6. `grantsOf("content.update=own", "content.update:events=none", ...)` from the content-scope tests, so rows read like the feature.
|
||||||
|
|
||||||
|
## A. Domain (no fakes)
|
||||||
|
|
||||||
|
7. `GrantSet.Covers`: table of caller grants x candidate grants x type keys, including typed none, caller `update=all, update:article=none` against candidate `update=all` (violation key `content.update:article`), caller with typed-only read, toggles, and the first-violation order. Symmetry check: Covers(x, x) is always true, Owner covers every role, Admin does not cover Owner.
|
||||||
|
8. Normalised grant list function: duplicates, owner-only on unlocked, unknown keys, typed none kept, order (wildcard then typed by type key).
|
||||||
|
9. New sentinels exist and map with `errors.Is`: role.ErrRoleHasMembers, ErrRoleNameTaken, ErrRoleLocked, ErrBuiltInRole, ErrBuiltInName, ErrOwnRole, ErrInvalidRoleName, ErrUnknownContentType, and the typed `EscalationError{Key}` read with `errors.As`.
|
||||||
|
10. Hygiene tests in the style of `*_hygiene_test.go`: `internal/domain/role` imports the stdlib only; `Repository` has no `ListGrants`; no scope-rank comparison outside `internal/domain/role`; `api/router.go` and `api/handlers/role` hold no catalogue key literal and no import of persistence, models, gorm or mailer; the drift test for role.Key constants still passes.
|
||||||
|
|
||||||
|
## B. Use cases (fakes only)
|
||||||
|
|
||||||
|
11. `ListRoles`/`GetRole`: members through the legacy fallback (user 10 counted in Admin), order by id, normalised grants, updated_by name (display name, else username, null for a deleted user), `List` called once, `ListGrants` does not exist.
|
||||||
|
12. `CreateRole`: every row of the validation Outline, with `errors.Is` on the sentinel; type keys against the content type fake plus "article"; the covers rows; name taken (case-insensitive, trimmed); the stored role is built_in false, locked false, version 1; one `role.create` event with the right target and params.
|
||||||
|
13. `UpdateRole`: every row of "Editing a role", "No-escalation" and "above their own grants" Outlines. Assert the order of checks with counters: for a refusal before the version check the fake's `Save` and the content type `List` are never called. No-op PUT: no `Save`, no event, same version. Race: the hook bumps the version between `Get` and `Save`, expect `role.ErrRoleConflict`. Own role resolved for a legacy user (role_id 0).
|
||||||
|
14. Diff: the example edit gives added/removed/changed exactly as in the feature, plus name and description text changes; created role: added only; deleted role: members param.
|
||||||
|
15. `DeleteRole`, `ReassignRoleMembers`: every row of their Outlines; `moved` counts; no event when moved is 0; repository failure gives a failed event and no partial move; delete race (`Delete` returns `role.ErrRoleHasMembers`).
|
||||||
|
16. `DeleteContentType`: removes typed rows after the type delete, bumps only affected roles, does nothing when the delete is refused (`contenttype.ErrHasEntries`, `ErrReferenced`). `CreateContentType`: clears stale typed rows. Both with the role fake's `RemoveTypedGrants` counter.
|
||||||
|
17. `GetMyAccess`: typed entries incl. none, order, wildcard none omitted.
|
||||||
|
18. Activity: `audit.Categories()` contains "roles"; `audit.Event.Validate()` accepts the new category; the filter accepts `category=roles`.
|
||||||
|
|
||||||
|
## C. HTTP
|
||||||
|
|
||||||
|
19. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key writes get `apperr.SessionRequiredErrorMessage`. Live-role scenario (grant added to Viewer between two requests with the same cookie).
|
||||||
|
20. Each Outline of the feature: validation, name rules, no-escalation, above-own-grants, editing, delete, reassign. Assert status, exact body text, headers (`X-Missing-Permission`, `X-Escalation-Key`), the store after the call, and the recorder.
|
||||||
|
21. Order: for a Writer, `PUT /v1/role/abc` and `DELETE /v1/role/999` are 403 before 400/404; for a Viewer too. For user 31 an unknown key in the body is 400 before the escalation 403; an escalation on the existing role (Content lead) is 403 before a stale version 409.
|
||||||
|
22. Responses: list shape, one-role shape (members, updated_at, updated_by), 201 on create, 204 on delete, `{"moved": n}` on reassign. Content-Type header is JSON for success bodies, errors are plain text.
|
||||||
|
23. Live effect: after PUT on Contributor, user 41's next request on a gated route sees the new grants with the same cookie and an API key created by user 41.
|
||||||
|
24. `/auth/me`: the Events editor example.
|
||||||
|
25. Content type delete/create through the real content type router with the role fake attached (rows removed, versions bumped, refused delete leaves rows).
|
||||||
|
26. Loads per request: a gated request on a role route still loads the user at most once and the role once (`Get`), `List` 0 times by the permission check.
|
||||||
|
27. Regression: the 2a and 2b suites (enforcement, content scope) stay green after the interface change.
|
||||||
|
|
||||||
|
## D. Persistence (existing repository-test style, in-memory migrated DB helper `openMigratedRolesDB`)
|
||||||
|
|
||||||
|
28. `RoleRepository`: `List` returns grants for every role in one pass; `Save` compare-and-swap (two saves of version 1: one wins), writes updated_at and updated_by; `Delete` removes rows and refuses with `ErrRoleHasMembers`; `ReassignMembers` returns the count and touches only role_id; `RemoveTypedGrants` removes the five `content.*:<key>` forms only, bumps version once per affected role, leaves wildcard and other types; failure paths (DB closed) for every new method; `Create` of a role with typed rows; seed idempotency unchanged (72 rows, edited built-in kept).
|
||||||
|
29. `role` and `role_permission` columns: updated_at and updated_by exist after `Migrate` (AutoMigrate list in `persistence/database/migrate.go`), seeded roles have updated_by NULL.
|
||||||
|
|
||||||
|
## Existing tests that must change (recheck with `go vet -tags nofrontend ./test/...`)
|
||||||
|
|
||||||
|
| File (lines) | Change |
|
||||||
|
|---|---|
|
||||||
|
| test/role_support_test.go:85 | `mockRoleRepo.ListGrants` removed. Add `Delete`, `ReassignMembers`, `RemoveTypedGrants`; `List` returns grants; `Save` keeps its conflict behaviour |
|
||||||
|
| test/roles_content_scope_world_test.go:79-81 and test/roles_content_scope_http_test.go:587, 599, 604, 616, 621 | `listGrantsN` and the `ListGrants` wrapper go away. Replace "0 ListGrants" with "0 List calls by the permission check and /auth/me" |
|
||||||
|
| test/role_repository_gaps_test.go:45-48 | The `ListGrants` failure case becomes a `List` failure case |
|
||||||
|
| test/roles_content_scope_usecases_test.go:481, test/roles_content_scope_world_test.go:222, test/custom_content_types_support_test.go:271, 275, test/custom_content_types_support_http_test.go:220, 426, test/custom_content_types_usecases_test.go:154, 596, test/relation_field_type_test.go:127, 234 | `NewCreateContentTypeUseCase` and `NewDeleteContentTypeUseCase` take the role repository (clearing typed rows). Pass a role fake or a no-op one |
|
||||||
|
| test/activity_domain_test.go:59 | Categories list gains "roles" |
|
||||||
|
| test/roles_content_scope_http_test.go (the /auth/me typed-grants test) and the feature scenario "/auth/me lists the effective grants" | "typed grants are not listed" is superseded by typed entries incl. none |
|
||||||
|
| test/api_catalogue_drift_test.go:52 | Add the 6 new routes (GET /v1/role, GET /v1/role/{id}, POST /v1/role, PUT /v1/role/{id}, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign) to `routesNotYetInTheCatalogue`. The existing failure of `TestRouterRoutesAreInTheCatalogue` is not this slice's: report, do not fix |
|
||||||
|
| any test that lists `role.Repository` method sets or builds a `role.Role` literal with positional fields | recheck after adding UpdatedAt and UpdatedBy |
|
||||||
|
|
||||||
|
## Existing features and docs to amend
|
||||||
|
|
||||||
|
- features/roles_permissions_content_scope.feature: the scenario "/auth/me lists the effective grants" last line ("typed grants are not listed in this slice") and "Out of scope here" (typed grants written by slice 3: now done); add a "superseded by roles_permissions_roles_api" note in place. Same for its `.qa.md` "Deferred" line 3 and the manual item about editing `role_permission` by hand.
|
||||||
|
- features/roles_permissions_foundation.feature: the Background sentence "per-type overrides ... deferred to slice 3" and the Repository interface line (list, get by id, get by name, list grants, create, save).
|
||||||
|
- features/activity-log-producers.qa.md: add the roles category and the four actions.
|
||||||
|
- docs/api.md: new "Roles" section (routes, codes, headers, response shapes), the `/v1/auth/me` typed entries, the activity category and actions, and the content-type delete/create side effect. docs/architecture.md: role repository methods.
|
||||||
|
- docs/design: for you to relay, not edited by agents (see Design notes).
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
- Edit the Writer role, then POST /v1/role/{id}/reset as Owner with the current version: grants are back to the seed and a second reset is a no-op.
|
||||||
|
|
||||||
|
- `task dev:api` on a copy of a migrated database. As the Owner: create "Contributor" (Write own preset grants), edit it, move a member to Writer, delete it. Check the activity log shows role.create, role.edit (with added/removed/changed), role.reassign and role.delete.
|
||||||
|
- As an Admin: editing Writer answers 403 with X-Missing-Permission roles.edit_owner_only; editing Owner answers role_locked.
|
||||||
|
- With a role manager who publishes only own entries: granting publish all answers escalation with the key in X-Escalation-Key.
|
||||||
|
- Open two sessions on the same role, save from both: the second gets role_conflict.
|
||||||
|
- Restart the server after editing Writer: the edit is still there (seed does not overwrite).
|
||||||
|
- Delete a content type that has a typed row and check the role_permission rows are gone in the database copy.
|
||||||
|
- No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
|
||||||
@@ -34,6 +34,7 @@ const (
|
|||||||
CategorySystem Category = "system"
|
CategorySystem Category = "system"
|
||||||
CategorySites Category = "sites"
|
CategorySites Category = "sites"
|
||||||
CategoryAuthors Category = "authors"
|
CategoryAuthors Category = "authors"
|
||||||
|
CategoryRoles Category = "roles"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Outcome string
|
type Outcome string
|
||||||
@@ -80,6 +81,10 @@ const (
|
|||||||
ActionAuthorCreate = "author.create"
|
ActionAuthorCreate = "author.create"
|
||||||
ActionAuthorEdit = "author.edit"
|
ActionAuthorEdit = "author.edit"
|
||||||
ActionAuthorDelete = "author.delete"
|
ActionAuthorDelete = "author.delete"
|
||||||
|
ActionRoleCreate = "role.create"
|
||||||
|
ActionRoleEdit = "role.edit"
|
||||||
|
ActionRoleDelete = "role.delete"
|
||||||
|
ActionRoleReassign = "role.reassign"
|
||||||
ActionMediaUpload = "media.upload"
|
ActionMediaUpload = "media.upload"
|
||||||
ActionMediaRename = "media.rename"
|
ActionMediaRename = "media.rename"
|
||||||
ActionMediaDelete = "media.delete"
|
ActionMediaDelete = "media.delete"
|
||||||
@@ -118,6 +123,7 @@ const (
|
|||||||
TargetRelease = "release"
|
TargetRelease = "release"
|
||||||
TargetSite = "site"
|
TargetSite = "site"
|
||||||
TargetAuthor = "author"
|
TargetAuthor = "author"
|
||||||
|
TargetRole = "role"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -147,6 +153,10 @@ const (
|
|||||||
ParamFormat = "format"
|
ParamFormat = "format"
|
||||||
ParamQuality = "quality"
|
ParamQuality = "quality"
|
||||||
ParamTag = "tag"
|
ParamTag = "tag"
|
||||||
|
ParamAdded = "added"
|
||||||
|
ParamChanged = "changed"
|
||||||
|
ParamMembers = "members"
|
||||||
|
ParamMoved = "moved"
|
||||||
FieldTitle = "title"
|
FieldTitle = "title"
|
||||||
FieldOnline = "online"
|
FieldOnline = "online"
|
||||||
FieldBody = "body"
|
FieldBody = "body"
|
||||||
@@ -158,6 +168,7 @@ const (
|
|||||||
FieldFormat = "format"
|
FieldFormat = "format"
|
||||||
FieldQuality = "quality"
|
FieldQuality = "quality"
|
||||||
FieldName = "name"
|
FieldName = "name"
|
||||||
|
FieldDescription = "description"
|
||||||
FieldBaseURL = "base_url"
|
FieldBaseURL = "base_url"
|
||||||
FieldDefaultRobots = "default_robots"
|
FieldDefaultRobots = "default_robots"
|
||||||
FieldLocale = "locale"
|
FieldLocale = "locale"
|
||||||
@@ -193,7 +204,7 @@ func ActorTypes() []ActorType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Categories() []Category {
|
func Categories() []Category {
|
||||||
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors}
|
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors, CategoryRoles}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Outcomes() []Outcome {
|
func Outcomes() []Outcome {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package role
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -14,6 +15,18 @@ var (
|
|||||||
ErrDuplicateGrant = errors.New("permission granted more than once")
|
ErrDuplicateGrant = errors.New("permission granted more than once")
|
||||||
|
|
||||||
ErrMissingPermission = errors.New("missing permission")
|
ErrMissingPermission = errors.New("missing permission")
|
||||||
|
|
||||||
|
ErrRoleHasMembers = errors.New("role still has members")
|
||||||
|
ErrRoleNameTaken = errors.New("role name already used")
|
||||||
|
ErrRoleLocked = errors.New("role is locked")
|
||||||
|
ErrBuiltInRole = errors.New("role is built in")
|
||||||
|
ErrBuiltInName = errors.New("built-in role cannot be renamed")
|
||||||
|
ErrOwnRole = errors.New("cannot change the role you hold")
|
||||||
|
ErrInvalidRoleName = errors.New("invalid role name")
|
||||||
|
ErrUnknownContentType = errors.New("unknown content type")
|
||||||
|
ErrNotBuiltIn = errors.New("role is not built in")
|
||||||
|
ErrInvalidReassign = errors.New("invalid reassign target")
|
||||||
|
ErrEscalation = errors.New("grants more than the caller holds")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Scope string
|
type Scope string
|
||||||
@@ -37,6 +50,8 @@ type Role struct {
|
|||||||
Locked bool `json:"locked"`
|
Locked bool `json:"locked"`
|
||||||
Version uint32 `json:"version"`
|
Version uint32 `json:"version"`
|
||||||
Grants []Grant `json:"grants"`
|
Grants []Grant `json:"grants"`
|
||||||
|
UpdatedAt time.Time `json:"-"`
|
||||||
|
UpdatedBy *uint32 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveGrantKey(key string) (Permission, error) {
|
func resolveGrantKey(key string) (Permission, error) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package role
|
||||||
|
|
||||||
|
type GrantChange struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
From Scope `json:"from"`
|
||||||
|
To Scope `json:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrantDiff struct {
|
||||||
|
Added []Grant `json:"added"`
|
||||||
|
Removed []Grant `json:"removed"`
|
||||||
|
Changed []GrantChange `json:"changed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d GrantDiff) Empty() bool {
|
||||||
|
return len(d.Added) == 0 && len(d.Removed) == 0 && len(d.Changed) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiffGrants(before []Grant, after []Grant) GrantDiff {
|
||||||
|
diff := GrantDiff{Added: []Grant{}, Removed: []Grant{}, Changed: []GrantChange{}}
|
||||||
|
previous := scopesByKey(before)
|
||||||
|
current := scopesByKey(after)
|
||||||
|
for _, grant := range after {
|
||||||
|
held, present := previous[grant.Key]
|
||||||
|
if !present {
|
||||||
|
diff.Added = append(diff.Added, grant)
|
||||||
|
} else if held != grant.Scope {
|
||||||
|
diff.Changed = append(diff.Changed, GrantChange{Key: grant.Key, From: held, To: grant.Scope})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, grant := range before {
|
||||||
|
if _, present := current[grant.Key]; !present {
|
||||||
|
diff.Removed = append(diff.Removed, grant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopesByKey(grants []Grant) map[string]Scope {
|
||||||
|
scopes := make(map[string]Scope, len(grants))
|
||||||
|
for _, grant := range grants {
|
||||||
|
scopes[grant.Key] = grant.Scope
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package role
|
package role
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type MissingPermissionError struct {
|
type MissingPermissionError struct {
|
||||||
Key string
|
Key string
|
||||||
}
|
}
|
||||||
@@ -33,14 +38,13 @@ func (r Role) GrantSet() GrantSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func effectiveScope(kind Kind, scope Scope) Scope {
|
func effectiveScope(kind Kind, scope Scope) Scope {
|
||||||
switch {
|
if scope == ScopeAll {
|
||||||
case scope == ScopeAll:
|
|
||||||
return ScopeAll
|
return ScopeAll
|
||||||
case scope == ScopeOwn && kind == KindScoped:
|
|
||||||
return ScopeOwn
|
|
||||||
default:
|
|
||||||
return ScopeNone
|
|
||||||
}
|
}
|
||||||
|
if scope == ScopeOwn && kind == KindScoped {
|
||||||
|
return ScopeOwn
|
||||||
|
}
|
||||||
|
return ScopeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
func scopeRank(scope Scope) int {
|
func scopeRank(scope Scope) int {
|
||||||
@@ -112,3 +116,66 @@ func (g GrantSet) Holds(key string) bool {
|
|||||||
func (g GrantSet) HoldsAll(key string) bool {
|
func (g GrantSet) HoldsAll(key string) bool {
|
||||||
return g.ScopeFor(key, "") == ScopeAll
|
return g.ScopeFor(key, "") == ScopeAll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EscalationError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EscalationError) Error() string {
|
||||||
|
return ErrEscalation.Error() + ": " + e.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EscalationError) Is(target error) bool {
|
||||||
|
return target == ErrEscalation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GrantSet) Covers(other GrantSet, typeKeys []string) error {
|
||||||
|
sortedTypeKeys := slices.Sorted(slices.Values(typeKeys))
|
||||||
|
for _, permission := range catalogue {
|
||||||
|
if scopeRank(other.ScopeFor(permission.Key, "")) > scopeRank(g.ScopeFor(permission.Key, "")) {
|
||||||
|
return EscalationError{Key: permission.Key}
|
||||||
|
}
|
||||||
|
if !permission.PerType() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, typeKey := range sortedTypeKeys {
|
||||||
|
if scopeRank(other.ScopeFor(permission.Key, typeKey)) > scopeRank(g.ScopeFor(permission.Key, typeKey)) {
|
||||||
|
return EscalationError{Key: permission.Key + typeSeparator + typeKey}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GrantSet) Effective() []Grant {
|
||||||
|
effective := []Grant{}
|
||||||
|
for _, permission := range catalogue {
|
||||||
|
if scope := g.wildcard(permission.Key); scope != ScopeNone {
|
||||||
|
effective = append(effective, Grant{Key: permission.Key, Scope: scope})
|
||||||
|
}
|
||||||
|
if permission.PerType() {
|
||||||
|
effective = append(effective, g.typedGrants(permission.Key)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effective
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GrantSet) typedGrants(key string) []Grant {
|
||||||
|
prefix := key + typeSeparator
|
||||||
|
composites := []string{}
|
||||||
|
for composite := range g.scopes {
|
||||||
|
if strings.HasPrefix(composite, prefix) {
|
||||||
|
composites = append(composites, composite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(composites)
|
||||||
|
typed := make([]Grant, 0, len(composites))
|
||||||
|
for _, composite := range composites {
|
||||||
|
typed = append(typed, Grant{Key: composite, Scope: g.scopes[composite]})
|
||||||
|
}
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Role) EffectiveGrants() []Grant {
|
||||||
|
return r.GrantSet().Effective()
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ type Repository interface {
|
|||||||
List() ([]Role, error)
|
List() ([]Role, error)
|
||||||
Get(id uint32) (Role, error)
|
Get(id uint32) (Role, error)
|
||||||
GetByName(name string) (Role, error)
|
GetByName(name string) (Role, error)
|
||||||
ListGrants(roleID uint32) ([]Grant, error)
|
|
||||||
Create(created Role) (Role, error)
|
Create(created Role) (Role, error)
|
||||||
Save(saved Role) (Role, error)
|
Save(saved Role) (Role, error)
|
||||||
|
Delete(id uint32) error
|
||||||
|
ReassignMembers(from uint32, to uint32) (int, error)
|
||||||
|
RemoveTypedGrants(typeKey string) (int, error)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package role
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizeName(name string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", ErrInvalidRoleName
|
||||||
|
}
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SameName(left string, right string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateGrantTypes(grants []Grant, allowedTypeKeys []string) error {
|
||||||
|
for _, grant := range grants {
|
||||||
|
if !isTypedKey(grant.Key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, typeKey, err := ParseTypedKey(grant.Key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Contains(allowedTypeKeys, typeKey) {
|
||||||
|
return ErrUnknownContentType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TypedKeys(typeKey string) []string {
|
||||||
|
keys := []string{}
|
||||||
|
for _, permission := range catalogue {
|
||||||
|
if permission.PerType() {
|
||||||
|
keys = append(keys, permission.Key+typeSeparator+typeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
package mappers
|
package mappers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"RenewCMS/internal/domain/role"
|
"RenewCMS/internal/domain/role"
|
||||||
"RenewCMS/internal/infrastructure/persistence/models"
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
|
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
|
||||||
|
var editedAt time.Time
|
||||||
|
if m.EditedAt != nil {
|
||||||
|
editedAt = m.EditedAt.UTC()
|
||||||
|
}
|
||||||
return role.Role{
|
return role.Role{
|
||||||
|
UpdatedAt: editedAt,
|
||||||
|
UpdatedBy: m.EditedBy,
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
Description: m.Description,
|
Description: m.Description,
|
||||||
@@ -18,7 +26,14 @@ func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RoleToModel(r role.Role) models.Role {
|
func RoleToModel(r role.Role) models.Role {
|
||||||
|
var editedAt *time.Time
|
||||||
|
if !r.UpdatedAt.IsZero() {
|
||||||
|
stamp := r.UpdatedAt
|
||||||
|
editedAt = &stamp
|
||||||
|
}
|
||||||
return models.Role{
|
return models.Role{
|
||||||
|
EditedAt: editedAt,
|
||||||
|
EditedBy: r.UpdatedBy,
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type Role struct {
|
type Role struct {
|
||||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `gorm:"not null;size:100;index"`
|
Name string `gorm:"not null;size:100;index"`
|
||||||
@@ -7,6 +9,8 @@ type Role struct {
|
|||||||
BuiltIn bool `gorm:"not null;default:false"`
|
BuiltIn bool `gorm:"not null;default:false"`
|
||||||
Locked bool `gorm:"not null;default:false"`
|
Locked bool `gorm:"not null;default:false"`
|
||||||
Version uint32 `gorm:"not null;default:1"`
|
Version uint32 `gorm:"not null;default:1"`
|
||||||
|
EditedAt *time.Time `gorm:"column:updated_at"`
|
||||||
|
EditedBy *uint32 `gorm:"column:updated_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RolePermission struct {
|
type RolePermission struct {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package persistence
|
package persistence
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"RenewCMS/internal/domain/role"
|
"RenewCMS/internal/domain/role"
|
||||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RoleRepository struct {
|
type RoleRepository struct {
|
||||||
@@ -37,9 +40,17 @@ func (r *RoleRepository) List() ([]role.Role, error) {
|
|||||||
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
|
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var rows []entity.RolePermission
|
||||||
|
if err := r.db.Order("id asc").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rowsByRole := make(map[uint32][]entity.RolePermission, len(found))
|
||||||
|
for _, row := range rows {
|
||||||
|
rowsByRole[row.RoleID] = append(rowsByRole[row.RoleID], row)
|
||||||
|
}
|
||||||
roles := make([]role.Role, 0, len(found))
|
roles := make([]role.Role, 0, len(found))
|
||||||
for _, row := range found {
|
for _, row := range found {
|
||||||
roles = append(roles, mappers.RoleToDomain(row, nil))
|
roles = append(roles, mappers.RoleToDomain(row, rowsByRole[row.ID]))
|
||||||
}
|
}
|
||||||
return roles, nil
|
return roles, nil
|
||||||
}
|
}
|
||||||
@@ -60,18 +71,28 @@ func (r *RoleRepository) GetByName(name string) (role.Role, error) {
|
|||||||
return r.withGrants(found)
|
return r.withGrants(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
|
func ensureNameUnused(tx *gorm.DB, name string, excludedID uint32) error {
|
||||||
rows, err := r.grantRows(roleID)
|
var taken int64
|
||||||
if err != nil {
|
query := tx.Model(&entity.Role{}).Where("LOWER(name) = LOWER(?)", strings.TrimSpace(name))
|
||||||
return nil, err
|
if excludedID != 0 {
|
||||||
|
query = query.Where("id <> ?", excludedID)
|
||||||
}
|
}
|
||||||
return mappers.GrantsToDomain(rows), nil
|
if err := query.Count(&taken).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taken > 0 {
|
||||||
|
return role.ErrRoleNameTaken
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoleRepository) Create(created role.Role) (role.Role, error) {
|
func (r *RoleRepository) Create(created role.Role) (role.Role, error) {
|
||||||
model := mappers.RoleToModel(created)
|
model := mappers.RoleToModel(created)
|
||||||
model.ID = 0
|
model.ID = 0
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := ensureNameUnused(tx, created.Name, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Create(&model).Error; err != nil {
|
if err := tx.Create(&model).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -92,9 +113,12 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
|
|||||||
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
|
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
|
||||||
return mapNotFoundErr(err, role.ErrRoleNotFound)
|
return mapNotFoundErr(err, role.ErrRoleNotFound)
|
||||||
}
|
}
|
||||||
|
if err := ensureNameUnused(tx, saved.Name, saved.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
update := tx.Model(&entity.Role{}).
|
update := tx.Model(&entity.Role{}).
|
||||||
Where("id = ? AND version = ?", saved.ID, saved.Version).
|
Where("id = ? AND version = ?", saved.ID, saved.Version).
|
||||||
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1})
|
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1, "updated_at": mappers.RoleToModel(saved).EditedAt, "updated_by": saved.UpdatedBy})
|
||||||
if update.Error != nil {
|
if update.Error != nil {
|
||||||
return update.Error
|
return update.Error
|
||||||
}
|
}
|
||||||
@@ -116,4 +140,66 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
|
|||||||
return r.Get(saved.ID)
|
return r.Get(saved.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *RoleRepository) Delete(id uint32) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil {
|
||||||
|
return mapNotFoundErr(err, role.ErrRoleNotFound)
|
||||||
|
}
|
||||||
|
var members int64
|
||||||
|
if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&members).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if members > 0 {
|
||||||
|
return role.ErrRoleHasMembers
|
||||||
|
}
|
||||||
|
if err := tx.Where("role_id = ?", id).Delete(&entity.RolePermission{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Delete(&entity.Role{}, id).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) {
|
||||||
|
moved := r.db.Model(&entity.User{}).
|
||||||
|
Where("role_id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", from, to).
|
||||||
|
UpdateColumn("role_id", to)
|
||||||
|
if moved.Error != nil {
|
||||||
|
return 0, moved.Error
|
||||||
|
}
|
||||||
|
if moved.RowsAffected > 0 {
|
||||||
|
return int(moved.RowsAffected), nil
|
||||||
|
}
|
||||||
|
var targets int64
|
||||||
|
if err := r.db.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if targets == 0 {
|
||||||
|
return 0, role.ErrInvalidReassign
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) {
|
||||||
|
affected := 0
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var roleIDs []uint32
|
||||||
|
doomed := role.TypedKeys(typeKey)
|
||||||
|
if err := tx.Model(&entity.RolePermission{}).Where("permission_key IN ?", doomed).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(roleIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Where("permission_key IN ?", doomed).Delete(&entity.RolePermission{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected = len(roleIDs)
|
||||||
|
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("version", gorm.Expr("version + 1")).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
var _ role.Repository = &RoleRepository{}
|
var _ role.Repository = &RoleRepository{}
|
||||||
@@ -3,12 +3,14 @@ package useCases
|
|||||||
import (
|
import (
|
||||||
"RenewCMS/internal/domain/builtintype"
|
"RenewCMS/internal/domain/builtintype"
|
||||||
"RenewCMS/internal/domain/contenttype"
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateContentTypeUseCase struct {
|
type CreateContentTypeUseCase struct {
|
||||||
contentTypes contenttype.Repository
|
contentTypes contenttype.Repository
|
||||||
|
roles role.Repository
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +21,8 @@ type CreateContentTypeCommand struct {
|
|||||||
Fields []contenttype.Field
|
Fields []contenttype.Field
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase {
|
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, roles role.Repository, now func() time.Time) *CreateContentTypeUseCase {
|
||||||
return &CreateContentTypeUseCase{contentTypes, now}
|
return &CreateContentTypeUseCase{contentTypes, roles, now}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
|
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
|
||||||
@@ -45,7 +47,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
|||||||
return contenttype.ContentType{}, err
|
return contenttype.ContentType{}, err
|
||||||
}
|
}
|
||||||
created := u.now()
|
created := u.now()
|
||||||
return u.contentTypes.Create(contenttype.ContentType{
|
made, err := u.contentTypes.Create(contenttype.ContentType{
|
||||||
Key: command.Key,
|
Key: command.Key,
|
||||||
Name: command.Name,
|
Name: command.Name,
|
||||||
Icon: icon,
|
Icon: icon,
|
||||||
@@ -53,6 +55,13 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
|||||||
CreatedAt: created,
|
CreatedAt: created,
|
||||||
UpdatedAt: created,
|
UpdatedAt: created,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return contenttype.ContentType{}, err
|
||||||
|
}
|
||||||
|
if _, err := u.roles.RemoveTypedGrants(command.Key); err != nil {
|
||||||
|
return contenttype.ContentType{}, err
|
||||||
|
}
|
||||||
|
return made, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
|
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateRoleUseCase struct {
|
||||||
|
roles role.Repository
|
||||||
|
contentTypes contenttype.Repository
|
||||||
|
recorder audit.Recorder
|
||||||
|
viewer roleViewer
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRoleCommand struct {
|
||||||
|
Caller ContentCaller
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Grants []role.Grant
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *CreateRoleUseCase {
|
||||||
|
return &CreateRoleUseCase{roles: roles, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *CreateRoleUseCase) Create(ctx context.Context, command CreateRoleCommand) (RoleView, error) {
|
||||||
|
name, err := role.NormalizeName(command.Name)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
draft := role.Role{Name: name, Description: command.Description, Version: 1, Grants: normalizedGrants(command.Grants)}
|
||||||
|
if err := ensureCovered(command.Caller, u.contentTypes, draft); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if err := ensureNameFree(u.roles, name, 0); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
|
||||||
|
event.Target = audit.Target{Type: audit.TargetRole, Name: name}
|
||||||
|
created, err := u.roles.Create(draft)
|
||||||
|
if err != nil {
|
||||||
|
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
event.Target = roleTarget(created)
|
||||||
|
event.Params[audit.ParamAdded] = created.EffectiveGrants()
|
||||||
|
u.recorder.Record(ctx, event)
|
||||||
|
return u.viewer.one(created, 0)
|
||||||
|
}
|
||||||
@@ -3,15 +3,17 @@ package useCases
|
|||||||
import (
|
import (
|
||||||
"RenewCMS/internal/domain/contenttype"
|
"RenewCMS/internal/domain/contenttype"
|
||||||
"RenewCMS/internal/domain/entry"
|
"RenewCMS/internal/domain/entry"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeleteContentTypeUseCase struct {
|
type DeleteContentTypeUseCase struct {
|
||||||
contentTypes contenttype.Repository
|
contentTypes contenttype.Repository
|
||||||
entries entry.Repository
|
entries entry.Repository
|
||||||
|
roles role.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase {
|
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, roles role.Repository) *DeleteContentTypeUseCase {
|
||||||
return &DeleteContentTypeUseCase{contentTypes, entries}
|
return &DeleteContentTypeUseCase{contentTypes, entries, roles}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *DeleteContentTypeUseCase) Delete(key string) error {
|
func (u *DeleteContentTypeUseCase) Delete(key string) error {
|
||||||
@@ -35,5 +37,9 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error {
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
return contenttype.ErrHasEntries
|
return contenttype.ErrHasEntries
|
||||||
}
|
}
|
||||||
return u.contentTypes.Delete(target.ID)
|
if err := u.contentTypes.Delete(target.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = u.roles.RemoveTypedGrants(target.Key)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeleteRoleUseCase struct {
|
||||||
|
roles role.Repository
|
||||||
|
users user.Repository
|
||||||
|
contentTypes contenttype.Repository
|
||||||
|
recorder audit.Recorder
|
||||||
|
viewer roleViewer
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteRoleCommand struct {
|
||||||
|
Caller ContentCaller
|
||||||
|
ID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeleteRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *DeleteRoleUseCase {
|
||||||
|
return &DeleteRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *DeleteRoleUseCase) Delete(ctx context.Context, command DeleteRoleCommand) error {
|
||||||
|
target, err := u.roles.Get(command.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.check(command.Caller, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
event := audit.NewEvent(audit.ActionRoleDelete, audit.CategoryRoles)
|
||||||
|
event.Target = roleTarget(target)
|
||||||
|
event.Params[audit.ParamMembers] = 0
|
||||||
|
if err := u.roles.Delete(target.ID); err != nil {
|
||||||
|
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.recorder.Record(ctx, event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *DeleteRoleUseCase) check(caller ContentCaller, target role.Role) error {
|
||||||
|
if err := ensureEditable(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNotBuiltIn(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNotOwnRole(u.roles, u.users, caller, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureCovered(caller, u.contentTypes, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
members, err := u.viewer.members(target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if members > 0 {
|
||||||
|
return role.ErrRoleHasMembers
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -46,15 +46,5 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Access{}, err
|
return Access{}, err
|
||||||
}
|
}
|
||||||
return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, nil
|
return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil
|
||||||
}
|
|
||||||
|
|
||||||
func effectiveGrants(grants role.GrantSet) []role.Grant {
|
|
||||||
effective := []role.Grant{}
|
|
||||||
for _, permission := range role.Catalogue() {
|
|
||||||
if scope := grants.ScopeFor(permission.Key, ""); scope != role.ScopeNone {
|
|
||||||
effective = append(effective, role.Grant{Key: permission.Key, Scope: scope})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return effective
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetRoleUseCase struct {
|
||||||
|
roles role.Repository
|
||||||
|
viewer roleViewer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *GetRoleUseCase {
|
||||||
|
return &GetRoleUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *GetRoleUseCase) Get(id uint32) (RoleView, error) {
|
||||||
|
found, err := u.roles.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
members, err := u.viewer.members(found.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
return u.viewer.one(found, members)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListRolesUseCase struct {
|
||||||
|
roles role.Repository
|
||||||
|
viewer roleViewer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListRolesUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *ListRolesUseCase {
|
||||||
|
return &ListRolesUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ListRolesUseCase) List() ([]RoleView, error) {
|
||||||
|
found, err := u.roles.List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u.viewer.views(found)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReassignRoleMembersUseCase struct {
|
||||||
|
roles role.Repository
|
||||||
|
users user.Repository
|
||||||
|
contentTypes contenttype.Repository
|
||||||
|
recorder audit.Recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReassignRoleMembersCommand struct {
|
||||||
|
Caller ContentCaller
|
||||||
|
SourceID uint32
|
||||||
|
TargetID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReassignRoleMembersUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ReassignRoleMembersUseCase {
|
||||||
|
return &ReassignRoleMembersUseCase{roles, users, contentTypes, recorder}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command ReassignRoleMembersCommand) (int, error) {
|
||||||
|
source, err := u.roles.Get(command.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := u.checkSource(command.Caller, source); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := u.checkTarget(command, source); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
event := audit.NewEvent(audit.ActionRoleReassign, audit.CategoryRoles)
|
||||||
|
event.Target = roleTarget(source)
|
||||||
|
event.Params[audit.ParamFrom] = source.ID
|
||||||
|
event.Params[audit.ParamTo] = command.TargetID
|
||||||
|
event.Params[audit.ParamMoved] = 0
|
||||||
|
moved, err := u.roles.ReassignMembers(source.ID, command.TargetID)
|
||||||
|
if err != nil {
|
||||||
|
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if moved > 0 {
|
||||||
|
event.Params[audit.ParamMoved] = moved
|
||||||
|
u.recorder.Record(ctx, event)
|
||||||
|
}
|
||||||
|
return moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ReassignRoleMembersUseCase) checkSource(caller ContentCaller, source role.Role) error {
|
||||||
|
if err := ensureEditable(source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNotBuiltIn(source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNotOwnRole(u.roles, u.users, caller, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ensureCovered(caller, u.contentTypes, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) error {
|
||||||
|
if command.TargetID == 0 || command.TargetID == source.ID {
|
||||||
|
return role.ErrInvalidReassign
|
||||||
|
}
|
||||||
|
target, err := u.roles.Get(command.TargetID)
|
||||||
|
if errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
return role.ErrInvalidReassign
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target.Locked {
|
||||||
|
return role.ErrInvalidReassign
|
||||||
|
}
|
||||||
|
return ensureCovered(command.Caller, u.contentTypes, target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateRoleUseCase struct {
|
||||||
|
roles role.Repository
|
||||||
|
users user.Repository
|
||||||
|
contentTypes contenttype.Repository
|
||||||
|
recorder audit.Recorder
|
||||||
|
now func() time.Time
|
||||||
|
viewer roleViewer
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRoleCommand struct {
|
||||||
|
Caller ContentCaller
|
||||||
|
ID uint32
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Grants []role.Grant
|
||||||
|
Version uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetRoleCommand struct {
|
||||||
|
Caller ContentCaller
|
||||||
|
ID uint32
|
||||||
|
Version uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder, now func() time.Time) *UpdateRoleUseCase {
|
||||||
|
return &UpdateRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, now: now, viewer: roleViewer{roles, users, contentTypes}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UpdateRoleUseCase) Update(ctx context.Context, command UpdateRoleCommand) (RoleView, error) {
|
||||||
|
target, err := u.roles.Get(command.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if err := u.checkTarget(command, target); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
name, err := role.NormalizeName(command.Name)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
after := target
|
||||||
|
after.Name = name
|
||||||
|
after.Description = command.Description
|
||||||
|
after.Grants = normalizedGrants(command.Grants)
|
||||||
|
if err := ensureCovered(command.Caller, u.contentTypes, after); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if err := ensureNameFree(u.roles, name, target.ID); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
return u.write(ctx, command.Caller, target, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UpdateRoleUseCase) Reset(ctx context.Context, command ResetRoleCommand) (RoleView, error) {
|
||||||
|
target, err := u.roles.Get(command.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if err := ensureEditable(target); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if !target.BuiltIn {
|
||||||
|
return RoleView{}, role.ErrNotBuiltIn
|
||||||
|
}
|
||||||
|
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
if command.Version != target.Version {
|
||||||
|
return RoleView{}, role.ErrRoleConflict
|
||||||
|
}
|
||||||
|
seeded, found := seededRole(target.Name)
|
||||||
|
if !found {
|
||||||
|
return RoleView{}, role.ErrNotBuiltIn
|
||||||
|
}
|
||||||
|
after := target
|
||||||
|
after.Description = seeded.Description
|
||||||
|
after.Grants = seeded.EffectiveGrants()
|
||||||
|
return u.write(ctx, command.Caller, target, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UpdateRoleUseCase) checkTarget(command UpdateRoleCommand, target role.Role) error {
|
||||||
|
if err := ensureEditable(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target.BuiltIn {
|
||||||
|
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if renamesBuiltIn(command.Name, target) {
|
||||||
|
return role.ErrBuiltInName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ensureNotOwnRole(u.roles, u.users, command.Caller, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureCovered(command.Caller, u.contentTypes, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if command.Version != target.Version {
|
||||||
|
return role.ErrRoleConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UpdateRoleUseCase) write(ctx context.Context, caller ContentCaller, before role.Role, after role.Role) (RoleView, error) {
|
||||||
|
members, err := u.viewer.members(before.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
live, err := u.viewer.liveGrants(before.EffectiveGrants())
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
diff := role.DiffGrants(live, after.Grants)
|
||||||
|
if diff.Empty() && before.Name == after.Name && before.Description == after.Description {
|
||||||
|
return u.viewer.one(before, members)
|
||||||
|
}
|
||||||
|
|
||||||
|
event := audit.NewEvent(audit.ActionRoleEdit, audit.CategoryRoles)
|
||||||
|
event.Target = roleTarget(before)
|
||||||
|
editor := caller.UserID
|
||||||
|
after.UpdatedAt = u.now().UTC()
|
||||||
|
after.UpdatedBy = &editor
|
||||||
|
saved, err := u.roles.Save(after)
|
||||||
|
if err != nil {
|
||||||
|
recordRoleWriteFailure(ctx, u.recorder, event, err)
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
event.Target = roleTarget(saved)
|
||||||
|
event.Params = grantsParams(diff, members)
|
||||||
|
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldName, before.Name, saved.Name)
|
||||||
|
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldDescription, before.Description, saved.Description)
|
||||||
|
u.recorder.Record(ctx, event)
|
||||||
|
return u.viewer.one(saved, members)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireOwnerOnlyEditing(caller ContentCaller) error {
|
||||||
|
if !caller.Grants.Holds(role.KeyRolesEditOwnerOnly) {
|
||||||
|
return role.MissingPermissionError{Key: role.KeyRolesEditOwnerOnly}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renamesBuiltIn(requested string, target role.Role) bool {
|
||||||
|
trimmed, err := role.NormalizeName(requested)
|
||||||
|
return err != nil || trimmed != target.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func seededRole(name string) (role.Role, bool) {
|
||||||
|
for _, seeded := range role.BuiltInRoles() {
|
||||||
|
if seeded.Name == name {
|
||||||
|
return seeded, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return role.Role{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/builtintype"
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func roleTarget(found role.Role) audit.Target {
|
||||||
|
return audit.NewTarget(audit.TargetRole, found.ID, found.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedTypeKeys(contentTypes contenttype.Repository) ([]string, error) {
|
||||||
|
types, err := contentTypes.List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := []string{builtintype.KeyArticle}
|
||||||
|
for _, contentType := range types {
|
||||||
|
keys = append(keys, contentType.Key)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, candidate role.Role) error {
|
||||||
|
typeKeys, err := allowedTypeKeys(contentTypes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return caller.Grants.Covers(candidate.GrantSet(), typeKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error {
|
||||||
|
account, err := users.Get(caller.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
own, err := resolveRole(roles, account)
|
||||||
|
if errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if own.ID == target.ID {
|
||||||
|
return role.ErrOwnRole
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureEditable(target role.Role) error {
|
||||||
|
if target.Locked {
|
||||||
|
return role.ErrRoleLocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNotBuiltIn(target role.Role) error {
|
||||||
|
if target.BuiltIn {
|
||||||
|
return role.ErrBuiltInRole
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNameFree(roles role.Repository, name string, excludedID uint32) error {
|
||||||
|
existing, err := roles.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, other := range existing {
|
||||||
|
if other.ID != excludedID && role.SameName(other.Name, name) {
|
||||||
|
return role.ErrRoleNameTaken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRoleGrants(contentTypes contenttype.Repository, grants []role.Grant) error {
|
||||||
|
if err := role.ValidateGrants(false, grants); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
typeKeys, err := allowedTypeKeys(contentTypes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return role.ValidateGrantTypes(grants, typeKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedGrants(grants []role.Grant) []role.Grant {
|
||||||
|
return role.Role{Grants: grants}.EffectiveGrants()
|
||||||
|
}
|
||||||
|
|
||||||
|
var writeRefusals = []error{role.ErrRoleConflict, role.ErrRoleHasMembers, role.ErrRoleNotFound, role.ErrRoleNameTaken, role.ErrInvalidReassign}
|
||||||
|
|
||||||
|
func recordRoleWriteFailure(ctx context.Context, recorder audit.Recorder, event audit.Event, err error) {
|
||||||
|
for _, refusal := range writeRefusals {
|
||||||
|
if errors.Is(err, refusal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordFailure(ctx, recorder, event, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantsParams(diff role.GrantDiff, members int) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
audit.ParamAdded: diff.Added,
|
||||||
|
audit.ParamRemoved: diff.Removed,
|
||||||
|
audit.ParamChanged: diff.Changed,
|
||||||
|
audit.ParamMembers: members,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleEditor struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleView struct {
|
||||||
|
role.Role
|
||||||
|
Members int `json:"members"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at"`
|
||||||
|
UpdatedBy *RoleEditor `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roleViewer struct {
|
||||||
|
roles role.Repository
|
||||||
|
users user.Repository
|
||||||
|
contentTypes contenttype.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) {
|
||||||
|
typeKeys, err := allowedTypeKeys(v.contentTypes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
live := make([]role.Grant, 0, len(grants))
|
||||||
|
for _, grant := range grants {
|
||||||
|
if _, typeKey, parseErr := role.ParseTypedKey(grant.Key); parseErr == nil && !slices.Contains(typeKeys, typeKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live = append(live, grant)
|
||||||
|
}
|
||||||
|
return live, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v roleViewer) views(found []role.Role) ([]RoleView, error) {
|
||||||
|
counts, err := memberCounts(v.roles, v.users.GetAll())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
editors, err := v.editors(found)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]RoleView, 0, len(found))
|
||||||
|
for _, item := range found {
|
||||||
|
view := toRoleView(item, counts[item.ID], editors)
|
||||||
|
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views = append(views, view)
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v roleViewer) one(found role.Role, members int) (RoleView, error) {
|
||||||
|
editors, err := v.editors([]role.Role{found})
|
||||||
|
if err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
view := toRoleView(found, members, editors)
|
||||||
|
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
|
||||||
|
return RoleView{}, err
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v roleViewer) members(roleID uint32) (int, error) {
|
||||||
|
counts, err := memberCounts(v.roles, v.users.GetAll())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return counts[roleID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v roleViewer) editors(found []role.Role) (map[uint32]string, error) {
|
||||||
|
ids := []uint32{}
|
||||||
|
for _, item := range found {
|
||||||
|
if item.UpdatedBy != nil {
|
||||||
|
ids = append(ids, *item.UpdatedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := map[uint32]string{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
editors, err := v.users.FindByIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, editor := range editors {
|
||||||
|
names[editor.ID] = editor.NameOrUsername()
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRoleView(found role.Role, members int, editors map[uint32]string) RoleView {
|
||||||
|
view := RoleView{Role: found, Members: members}
|
||||||
|
view.Grants = found.EffectiveGrants()
|
||||||
|
if !found.UpdatedAt.IsZero() {
|
||||||
|
stamp := found.UpdatedAt.UTC()
|
||||||
|
view.UpdatedAt = &stamp
|
||||||
|
}
|
||||||
|
if found.UpdatedBy != nil {
|
||||||
|
if name, present := editors[*found.UpdatedBy]; present {
|
||||||
|
view.UpdatedBy = &RoleEditor{ID: *found.UpdatedBy, Name: name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func memberCounts(roles role.Repository, accounts []user.User) (map[uint32]int, error) {
|
||||||
|
counts := map[uint32]int{}
|
||||||
|
legacyIDs := map[user.Role]uint32{}
|
||||||
|
for _, account := range accounts {
|
||||||
|
id := account.RoleID
|
||||||
|
if id == 0 {
|
||||||
|
resolved, cached := legacyIDs[account.Role]
|
||||||
|
if !cached {
|
||||||
|
found, err := resolveRole(roles, account)
|
||||||
|
if err != nil && !errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved = found.ID
|
||||||
|
legacyIDs[account.Role] = resolved
|
||||||
|
}
|
||||||
|
id = resolved
|
||||||
|
}
|
||||||
|
if id != 0 {
|
||||||
|
counts[id]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) {
|
|||||||
wanted []string
|
wanted []string
|
||||||
}{
|
}{
|
||||||
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
|
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
|
||||||
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "media", "sites", "system", "users"}},
|
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "media", "roles", "sites", "system", "users"}},
|
||||||
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
|
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
|
||||||
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
|
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
|
||||||
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
|
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ var routesNotYetInTheCatalogue = []string{
|
|||||||
"DELETE /v1/content-type/{key}/entry/{id}",
|
"DELETE /v1/content-type/{key}/entry/{id}",
|
||||||
"DELETE /v1/image-variants/{name}",
|
"DELETE /v1/image-variants/{name}",
|
||||||
"DELETE /v1/media/{id}/variants/{name}",
|
"DELETE /v1/media/{id}/variants/{name}",
|
||||||
|
"DELETE /v1/role/{id}",
|
||||||
"GET /v1/builtin-type",
|
"GET /v1/builtin-type",
|
||||||
"GET /v1/builtin-type/{key}",
|
"GET /v1/builtin-type/{key}",
|
||||||
"GET /v1/content-type",
|
"GET /v1/content-type",
|
||||||
@@ -65,6 +66,8 @@ var routesNotYetInTheCatalogue = []string{
|
|||||||
"GET /v1/media/{id}/variants",
|
"GET /v1/media/{id}/variants",
|
||||||
"GET /v1/user/lookup",
|
"GET /v1/user/lookup",
|
||||||
"GET /v1/permission",
|
"GET /v1/permission",
|
||||||
|
"GET /v1/role",
|
||||||
|
"GET /v1/role/{id}",
|
||||||
"PATCH /v1/image-variants/{name}",
|
"PATCH /v1/image-variants/{name}",
|
||||||
"POST /v1/content-type",
|
"POST /v1/content-type",
|
||||||
"POST /v1/content-type/{key}/entry",
|
"POST /v1/content-type/{key}/entry",
|
||||||
@@ -73,9 +76,13 @@ var routesNotYetInTheCatalogue = []string{
|
|||||||
"POST /v1/image-variants/{name}/purge",
|
"POST /v1/image-variants/{name}/purge",
|
||||||
"POST /v1/media/{id}/variants",
|
"POST /v1/media/{id}/variants",
|
||||||
"POST /v1/media/{id}/variants/{name}",
|
"POST /v1/media/{id}/variants/{name}",
|
||||||
|
"POST /v1/role",
|
||||||
|
"POST /v1/role/{id}/reassign",
|
||||||
|
"POST /v1/role/{id}/reset",
|
||||||
"PUT /v1/content-type/{key}",
|
"PUT /v1/content-type/{key}",
|
||||||
"PUT /v1/content-type/{key}/entry/{id}",
|
"PUT /v1/content-type/{key}/entry/{id}",
|
||||||
"PUT /v1/image-variants/{name}",
|
"PUT /v1/image-variants/{name}",
|
||||||
|
"PUT /v1/role/{id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
|
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func TestContentTypeIconUseCase(t *testing.T) {
|
|||||||
repo := newMockContentTypeRepo()
|
repo := newMockContentTypeRepo()
|
||||||
repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields}
|
repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields}
|
||||||
bad := icon
|
bad := icon
|
||||||
_, err := useCases.NewCreateContentTypeUseCase(repo, now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
|
_, err := useCases.NewCreateContentTypeUseCase(repo, newMockRoleRepo(), now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
|
||||||
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.createCalls != 0 {
|
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.createCalls != 0 {
|
||||||
t.Errorf("create: err %v, calls %d", err, repo.createCalls)
|
t.Errorf("create: err %v, calls %d", err, repo.createCalls)
|
||||||
}
|
}
|
||||||
@@ -423,7 +423,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
|||||||
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(),
|
||||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now),
|
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, newMockRoleRepo(), now),
|
||||||
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, newMockImageRepo(), now),
|
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, newMockImageRepo(), now),
|
||||||
}
|
}
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
|
|||||||
@@ -268,11 +268,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
|
|||||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
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, newMockRoleRepo(), now),
|
||||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
|
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
|
||||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
|
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
|
||||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
|
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
|
||||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
|
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()),
|
||||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
|
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
|
||||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func TestDeleteContentTypeUseCase(t *testing.T) {
|
|||||||
t.Run("Use case: DeleteContentType with entries returns ErrHasEntries and nothing is deleted", func(t *testing.T) {
|
t.Run("Use case: DeleteContentType with entries returns ErrHasEntries and nothing is deleted", func(t *testing.T) {
|
||||||
fixture, stored := newContentTypeFixture(t)
|
fixture, stored := newContentTypeFixture(t)
|
||||||
_, _ = fixture.entries.Create(entry.Entry{ContentTypeID: stored.ID, Data: map[string]any{}})
|
_, _ = fixture.entries.Create(entry.Entry{ContentTypeID: stored.ID, Data: map[string]any{}})
|
||||||
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries)
|
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries, newMockRoleRepo())
|
||||||
|
|
||||||
err := remove.Delete("recipe")
|
err := remove.Delete("recipe")
|
||||||
|
|
||||||
@@ -593,7 +593,7 @@ func TestContentTypeNameRule(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("The use cases enforce it for non-HTTP callers", func(t *testing.T) {
|
t.Run("The use cases enforce it for non-HTTP callers", func(t *testing.T) {
|
||||||
fixture, existing := newContentTypeFixture(t)
|
fixture, existing := newContentTypeFixture(t)
|
||||||
create := useCases.NewCreateContentTypeUseCase(fixture.types, fixture.now)
|
create := useCases.NewCreateContentTypeUseCase(fixture.types, newMockRoleRepo(), fixture.now)
|
||||||
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
|
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
|
||||||
if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
|
if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
|
||||||
t.Errorf("create blank: %v", err)
|
t.Errorf("create blank: %v", err)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func TestRelationFieldUseCases(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) {
|
t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) {
|
||||||
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
|
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
|
||||||
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries)
|
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo())
|
||||||
|
|
||||||
if err := remove.Delete("venue"); !errors.Is(err, contenttype.ErrReferenced) {
|
if err := remove.Delete("venue"); !errors.Is(err, contenttype.ErrReferenced) {
|
||||||
t.Errorf("venue: %v", err)
|
t.Errorf("venue: %v", err)
|
||||||
@@ -231,7 +231,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
|
|||||||
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
|
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
|
||||||
env.types.Create(contenttype.ContentType{Key: "node", Name: "Node", Fields: []contenttype.Field{{Key: "parent", Type: contenttype.TypeRelation, Relation: &contenttype.Relation{ContentType: "node", Kind: contenttype.RelationOneToMany}}}})
|
env.types.Create(contenttype.ContentType{Key: "node", Name: "Node", Fields: []contenttype.Field{{Key: "parent", Type: contenttype.TypeRelation, Relation: &contenttype.Relation{ContentType: "node", Kind: contenttype.RelationOneToMany}}}})
|
||||||
|
|
||||||
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries).Delete("node"); err != nil {
|
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()).Delete("node"); err != nil {
|
||||||
t.Errorf("err %v", err)
|
t.Errorf("err %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ func TestRoleRepositoryFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ListGrants and role loading fail when grants cannot be read", func(t *testing.T) {
|
t.Run("List and role loading fail when grants cannot be read", func(t *testing.T) {
|
||||||
repo := broken(t, "role_permissions")
|
repo := broken(t, "role_permissions")
|
||||||
if _, err := repo.ListGrants(1); err == nil {
|
if _, err := repo.List(); err == nil {
|
||||||
t.Error("ListGrants: expected error")
|
t.Error("List: expected error")
|
||||||
}
|
}
|
||||||
if _, err := repo.Get(1); err == nil {
|
if _, err := repo.Get(1); err == nil {
|
||||||
t.Error("Get: expected error")
|
t.Error("Get: expected error")
|
||||||
|
|||||||
+88
-12
@@ -15,10 +15,13 @@ type mockRoleRepo struct {
|
|||||||
roles map[uint32]role.Role
|
roles map[uint32]role.Role
|
||||||
nextID uint32
|
nextID uint32
|
||||||
fail failSwitch
|
fail failSwitch
|
||||||
|
users *mockUserRepo
|
||||||
|
calls map[string]int
|
||||||
|
beforeSave func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockRoleRepo() *mockRoleRepo {
|
func newMockRoleRepo() *mockRoleRepo {
|
||||||
return &mockRoleRepo{roles: map[uint32]role.Role{}}
|
return &mockRoleRepo{roles: map[uint32]role.Role{}, calls: map[string]int{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSeededMockRoleRepo() *mockRoleRepo {
|
func newSeededMockRoleRepo() *mockRoleRepo {
|
||||||
@@ -44,6 +47,7 @@ func (m *mockRoleRepo) sortedIDs() []uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockRoleRepo) List() ([]role.Role, error) {
|
func (m *mockRoleRepo) List() ([]role.Role, error) {
|
||||||
|
m.calls["List"]++
|
||||||
if err := m.fail.trigger("List"); err != nil {
|
if err := m.fail.trigger("List"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,7 @@ func (m *mockRoleRepo) List() ([]role.Role, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
|
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
|
||||||
|
m.calls["Get"]++
|
||||||
if err := m.fail.trigger("Get"); err != nil {
|
if err := m.fail.trigger("Get"); err != nil {
|
||||||
return role.Role{}, err
|
return role.Role{}, err
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,7 @@ func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
|
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
|
||||||
|
m.calls["GetByName"]++
|
||||||
if err := m.fail.trigger("GetByName"); err != nil {
|
if err := m.fail.trigger("GetByName"); err != nil {
|
||||||
return role.Role{}, err
|
return role.Role{}, err
|
||||||
}
|
}
|
||||||
@@ -82,18 +88,8 @@ func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
|
|||||||
return *match, nil
|
return *match, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
|
|
||||||
if err := m.fail.trigger("ListGrants"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
found, ok := m.roles[roleID]
|
|
||||||
if !ok {
|
|
||||||
return nil, role.ErrRoleNotFound
|
|
||||||
}
|
|
||||||
return slices.Clone(found.Grants), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
|
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
|
||||||
|
m.calls["Create"]++
|
||||||
if err := m.fail.trigger("Create"); err != nil {
|
if err := m.fail.trigger("Create"); err != nil {
|
||||||
return role.Role{}, err
|
return role.Role{}, err
|
||||||
}
|
}
|
||||||
@@ -104,9 +100,15 @@ func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
|
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
|
||||||
|
m.calls["Save"]++
|
||||||
if err := m.fail.trigger("Save"); err != nil {
|
if err := m.fail.trigger("Save"); err != nil {
|
||||||
return role.Role{}, err
|
return role.Role{}, err
|
||||||
}
|
}
|
||||||
|
if m.beforeSave != nil {
|
||||||
|
hook := m.beforeSave
|
||||||
|
m.beforeSave = nil
|
||||||
|
hook()
|
||||||
|
}
|
||||||
stored, ok := m.roles[saved.ID]
|
stored, ok := m.roles[saved.ID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return role.Role{}, role.ErrRoleNotFound
|
return role.Role{}, role.ErrRoleNotFound
|
||||||
@@ -118,8 +120,82 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
|
|||||||
stored.Description = saved.Description
|
stored.Description = saved.Description
|
||||||
stored.Version++
|
stored.Version++
|
||||||
stored.Grants = slices.Clone(saved.Grants)
|
stored.Grants = slices.Clone(saved.Grants)
|
||||||
|
stored.UpdatedAt = saved.UpdatedAt
|
||||||
|
stored.UpdatedBy = saved.UpdatedBy
|
||||||
m.roles[saved.ID] = stored
|
m.roles[saved.ID] = stored
|
||||||
return stored, nil
|
return stored, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockRoleRepo) memberCount(roleID uint32) int {
|
||||||
|
if m.users == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, member := range m.users.users {
|
||||||
|
if member.RoleID == roleID {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRoleRepo) Delete(id uint32) error {
|
||||||
|
m.calls["Delete"]++
|
||||||
|
if err := m.fail.trigger("Delete"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := m.roles[id]; !ok {
|
||||||
|
return role.ErrRoleNotFound
|
||||||
|
}
|
||||||
|
if m.memberCount(id) > 0 {
|
||||||
|
return role.ErrRoleHasMembers
|
||||||
|
}
|
||||||
|
delete(m.roles, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
|
||||||
|
m.calls["ReassignMembers"]++
|
||||||
|
if err := m.fail.trigger("ReassignMembers"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if m.users == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
moved := 0
|
||||||
|
for id, member := range m.users.users {
|
||||||
|
if member.RoleID == from {
|
||||||
|
member.RoleID = to
|
||||||
|
m.users.users[id] = member
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRoleRepo) RemoveTypedGrants(typeKey string) (int, error) {
|
||||||
|
m.calls["RemoveTypedGrants"]++
|
||||||
|
if err := m.fail.trigger("RemoveTypedGrants"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
doomed := role.TypedKeys(typeKey)
|
||||||
|
affected := 0
|
||||||
|
for id, stored := range m.roles {
|
||||||
|
kept := []role.Grant{}
|
||||||
|
for _, grant := range stored.Grants {
|
||||||
|
if !slices.Contains(doomed, grant.Key) {
|
||||||
|
kept = append(kept, grant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == len(stored.Grants) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stored.Grants = kept
|
||||||
|
stored.Version++
|
||||||
|
m.roles[id] = stored
|
||||||
|
affected++
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
var _ role.Repository = &mockRoleRepo{}
|
var _ role.Repository = &mockRoleRepo{}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func escalationKeyOf(t *testing.T, err error) string {
|
||||||
|
t.Helper()
|
||||||
|
var escalation role.EscalationError
|
||||||
|
if !errors.As(err, &escalation) || !errors.Is(err, role.ErrEscalation) {
|
||||||
|
t.Fatalf("expected an EscalationError, got %v", err)
|
||||||
|
}
|
||||||
|
return escalation.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantSetCovers(t *testing.T) {
|
||||||
|
typeKeys := []string{"article", "events", "venues"}
|
||||||
|
builtIns := role.BuiltInRoles()
|
||||||
|
ownerSet, adminSet, writerSet := builtIns[0].GrantSet(), builtIns[1].GrantSet(), builtIns[2].GrantSet()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
caller role.GrantSet
|
||||||
|
candidate role.GrantSet
|
||||||
|
wantKey string
|
||||||
|
}{
|
||||||
|
{"owner covers admin", ownerSet, adminSet, ""},
|
||||||
|
{"owner covers writer", ownerSet, writerSet, ""},
|
||||||
|
{"admin does not cover owner and names the first owner-only key", adminSet, ownerSet, role.KeySettingsDeleteSite},
|
||||||
|
{"admin covers writer", adminSet, writerSet, ""},
|
||||||
|
{"writer does not cover admin", writerSet, adminSet, role.KeyContentDelete},
|
||||||
|
{"own covers own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=own"), ""},
|
||||||
|
{"own does not cover all", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=all"), role.KeyContentPublish},
|
||||||
|
{"none does not cover own", unlockedGrantSet("content.read=all"), unlockedGrantSet("content.delete=own"), role.KeyContentDelete},
|
||||||
|
{"denying never escalates", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=none"), ""},
|
||||||
|
{"typed all above wildcard own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=all"), "content.publish:events"},
|
||||||
|
{"typed own under wildcard own", unlockedGrantSet("content.update=own"), unlockedGrantSet("content.update:events=own"), ""},
|
||||||
|
{"wildcard all would raise article", unlockedGrantSet("content.update=all", "content.update:article=none"), unlockedGrantSet("content.update=all"), "content.update:article"},
|
||||||
|
{"typed-only reader does not cover wildcard read", unlockedGrantSet("content.read:events=all"), unlockedGrantSet("content.read=all"), role.KeyContentRead},
|
||||||
|
{"toggle missing", unlockedGrantSet("content.read=all"), unlockedGrantSet("schema.edit=all"), role.KeySchemaEdit},
|
||||||
|
{"first violation follows the catalogue order", unlockedGrantSet(), unlockedGrantSet("content.delete=all", "content.read=all"), role.KeyContentRead},
|
||||||
|
{"wildcard is checked before its typed keys", unlockedGrantSet(), unlockedGrantSet("content.read:events=all", "content.read=all"), role.KeyContentRead},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
err := c.caller.Covers(c.candidate, typeKeys)
|
||||||
|
if c.wantKey == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected the grants to be covered, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := escalationKeyOf(t, err); got != c.wantKey {
|
||||||
|
t.Fatalf("expected the key %q, got %q", c.wantKey, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a set always covers itself", func(t *testing.T) {
|
||||||
|
for _, set := range []role.GrantSet{ownerSet, adminSet, writerSet, unlockedGrantSet(), unlockedGrantSet("content.update=all", "content.update:article=none", "content.delete:events=none")} {
|
||||||
|
if err := set.Covers(set, typeKeys); err != nil {
|
||||||
|
t.Fatalf("expected a set to cover itself, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantSetEffectiveGrants(t *testing.T) {
|
||||||
|
t.Run("the events editor lists typed entries after the wildcard in catalogue then type order", func(t *testing.T) {
|
||||||
|
got := role.Role{Grants: grantRows("content.delete:events=none", "content.update:venues=all", "content.read=all", "content.update:events=own")}.EffectiveGrants()
|
||||||
|
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicates resolve to the most restrictive scope and unknown keys are dropped", func(t *testing.T) {
|
||||||
|
got := role.Role{Grants: grantRows("content.update=all", "content.update=own", "content.explode=all", "content.update:Bad Key=all")}.EffectiveGrants()
|
||||||
|
want := grantRows("content.update=own")
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner-only keys are dropped on an unlocked role and kept on a locked one", func(t *testing.T) {
|
||||||
|
grants := grantRows("system.apply_updates=all", "content.read=all")
|
||||||
|
unlocked := role.Role{Grants: grants}.EffectiveGrants()
|
||||||
|
if !reflect.DeepEqual(unlocked, grantRows("content.read=all")) {
|
||||||
|
t.Fatalf("unexpected unlocked grants %v", unlocked)
|
||||||
|
}
|
||||||
|
locked := role.Role{Locked: true, Grants: grants}.EffectiveGrants()
|
||||||
|
if !reflect.DeepEqual(locked, grantRows("content.read=all", "system.apply_updates=all")) {
|
||||||
|
t.Fatalf("unexpected locked grants %v", locked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a role with nothing granted lists an empty slice, never nil", func(t *testing.T) {
|
||||||
|
if got := (role.Role{}).EffectiveGrants(); got == nil || len(got) != 0 {
|
||||||
|
t.Fatalf("expected an empty non-nil slice, got %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wildcard entries without effect are not listed", func(t *testing.T) {
|
||||||
|
if got := (role.Role{Grants: grantRows("content.create=own", "content.read=none")}).EffectiveGrants(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected nothing, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the effective list is valid as the grants of a write", func(t *testing.T) {
|
||||||
|
listed := role.Role{Grants: grantRows("content.update=own", "content.update=all", "content.delete:events=none", "media.view=all")}.EffectiveGrants()
|
||||||
|
if err := role.ValidateGrants(false, listed); err != nil {
|
||||||
|
t.Fatalf("expected the list to validate, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffGrants(t *testing.T) {
|
||||||
|
t.Run("the example edit gives added, removed and changed", func(t *testing.T) {
|
||||||
|
before := grantRows("content.read=all", "content.update=own", "media.view=all")
|
||||||
|
after := grantRows("content.read=all", "content.update=all", "content.delete=own")
|
||||||
|
diff := role.DiffGrants(before, after)
|
||||||
|
if !reflect.DeepEqual(diff.Added, grantRows("content.delete=own")) {
|
||||||
|
t.Errorf("unexpected added %v", diff.Added)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(diff.Removed, grantRows("media.view=all")) {
|
||||||
|
t.Errorf("unexpected removed %v", diff.Removed)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(diff.Changed, []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
|
||||||
|
t.Errorf("unexpected changed %v", diff.Changed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an absent typed entry against a typed none is an addition", func(t *testing.T) {
|
||||||
|
diff := role.DiffGrants(nil, grantRows("content.delete:events=none"))
|
||||||
|
if len(diff.Added) != 1 || len(diff.Removed) != 0 || len(diff.Changed) != 0 {
|
||||||
|
t.Fatalf("unexpected diff %+v", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unchanged list gives empty non-nil slices", func(t *testing.T) {
|
||||||
|
diff := role.DiffGrants(grantRows("content.read=all"), grantRows("content.read=all"))
|
||||||
|
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil || !diff.Empty() {
|
||||||
|
t.Fatalf("expected empty non-nil slices, got %+v", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleWriteRules(t *testing.T) {
|
||||||
|
t.Run("the new sentinels are distinct and match with errors.Is", func(t *testing.T) {
|
||||||
|
sentinels := []error{role.ErrRoleHasMembers, role.ErrRoleNameTaken, role.ErrRoleLocked, role.ErrBuiltInRole, role.ErrBuiltInName, role.ErrOwnRole, role.ErrInvalidRoleName, role.ErrUnknownContentType, role.ErrNotBuiltIn, role.ErrInvalidReassign, role.ErrEscalation}
|
||||||
|
for index, sentinel := range sentinels {
|
||||||
|
for other, candidate := range sentinels {
|
||||||
|
if (index == other) != errors.Is(sentinel, candidate) {
|
||||||
|
t.Fatalf("sentinels %d and %d are not distinct", index, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a name is trimmed and a blank one is refused", func(t *testing.T) {
|
||||||
|
name, err := role.NormalizeName(" Reviewer ")
|
||||||
|
if err != nil || name != "Reviewer" {
|
||||||
|
t.Fatalf("expected Reviewer, got %q %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := role.NormalizeName(" "); !errors.Is(err, role.ErrInvalidRoleName) {
|
||||||
|
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("names collide case-insensitively", func(t *testing.T) {
|
||||||
|
if !role.SameName(" contributor ", "Contributor") || role.SameName("Intern", "Contributor") {
|
||||||
|
t.Fatal("unexpected name comparison")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("typed keys must belong to an allowed type", func(t *testing.T) {
|
||||||
|
allowed := []string{"article", "events"}
|
||||||
|
if err := role.ValidateGrantTypes(grantRows("content.update:events=all", "content.read=all"), allowed); err != nil {
|
||||||
|
t.Fatalf("expected valid types, got %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"content.update:nope=all", "content.update:author=all"} {
|
||||||
|
if err := role.ValidateGrantTypes(grantRows(key), allowed); !errors.Is(err, role.ErrUnknownContentType) {
|
||||||
|
t.Fatalf("%s: expected ErrUnknownContentType, got %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the typed keys of a content type are the five per-type permissions", func(t *testing.T) {
|
||||||
|
want := []string{"content.read:events", "content.create:events", "content.update:events", "content.delete:events", "content.publish:events"}
|
||||||
|
if got := role.TypedKeys("events"); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the repository no longer lists grants on their own", func(t *testing.T) {
|
||||||
|
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName("ListGrants"); present {
|
||||||
|
t.Fatal("role.Repository must not have ListGrants")
|
||||||
|
}
|
||||||
|
for _, method := range []string{"List", "Get", "GetByName", "Create", "Save", "Delete", "ReassignMembers", "RemoveTypedGrants"} {
|
||||||
|
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName(method); !present {
|
||||||
|
t.Fatalf("role.Repository lacks %s", method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleActivityVocabulary(t *testing.T) {
|
||||||
|
t.Run("the roles category, target and actions exist", func(t *testing.T) {
|
||||||
|
if audit.CategoryRoles != "roles" || audit.TargetRole != "role" {
|
||||||
|
t.Fatalf("unexpected category %q or target %q", audit.CategoryRoles, audit.TargetRole)
|
||||||
|
}
|
||||||
|
actions := []string{audit.ActionRoleCreate, audit.ActionRoleEdit, audit.ActionRoleDelete, audit.ActionRoleReassign}
|
||||||
|
if !reflect.DeepEqual(actions, []string{"role.create", "role.edit", "role.delete", "role.reassign"}) {
|
||||||
|
t.Fatalf("unexpected actions %v", actions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an event in the roles category is valid and the filter accepts it", func(t *testing.T) {
|
||||||
|
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
|
||||||
|
event.Source = audit.SourceWeb
|
||||||
|
event.Actor = audit.AnonymousActor("x")
|
||||||
|
if err := event.Validate(); err != nil {
|
||||||
|
t.Fatalf("expected a valid event, got %v", err)
|
||||||
|
}
|
||||||
|
if category, err := audit.ParseCategory("roles"); err != nil || category != audit.CategoryRoles {
|
||||||
|
t.Fatalf("expected the roles filter, got %q %v", category, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
|
||||||
|
t.Run("ReassignMembers refuses a target that does not exist and moves nobody", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
createUserRow(t, db, 1, "", &source.ID)
|
||||||
|
moved, err := repo.ReassignMembers(source.ID, 9999)
|
||||||
|
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 {
|
||||||
|
t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err)
|
||||||
|
}
|
||||||
|
if roleIDOf(userRow(t, db, 1)) != source.ID {
|
||||||
|
t.Fatal("the member must stay")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReassignMembers from an empty role to an existing one is a plain zero", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
writer := storedRole(t, db, "Writer")
|
||||||
|
moved, err := repo.ReassignMembers(source.ID, writer.ID)
|
||||||
|
if err != nil || moved != 0 {
|
||||||
|
t.Fatalf("expected 0 nil, got %d %v", moved, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create re-checks the name case-insensitively", func(t *testing.T) {
|
||||||
|
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||||
|
rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
for _, name := range []string{"contributor", "CONTRIBUTOR", " Contributor "} {
|
||||||
|
_, err := repo.Create(role.Role{Name: name, Version: 1})
|
||||||
|
if !errors.Is(err, role.ErrRoleNameTaken) {
|
||||||
|
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listed, _ := repo.List()
|
||||||
|
if len(listed) != 4 {
|
||||||
|
t.Fatalf("no role may be added, got %d", len(listed))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Save re-checks the name against other roles only", func(t *testing.T) {
|
||||||
|
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||||
|
first := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
second := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
||||||
|
clash := second
|
||||||
|
clash.Name = "contributor"
|
||||||
|
if _, err := repo.Save(clash); !errors.Is(err, role.ErrRoleNameTaken) {
|
||||||
|
t.Fatalf("expected ErrRoleNameTaken, got %v", err)
|
||||||
|
}
|
||||||
|
if stored, _ := repo.Get(second.ID); stored.Name != "Intern" || stored.Version != 1 {
|
||||||
|
t.Fatalf("the role must be unchanged: %+v", stored)
|
||||||
|
}
|
||||||
|
recased := first
|
||||||
|
recased.Name = "CONTRIBUTOR"
|
||||||
|
if saved, err := repo.Save(recased); err != nil || saved.Name != "CONTRIBUTOR" {
|
||||||
|
t.Fatalf("a role may change the case of its own name, got %+v %v", saved, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete with the row lock still removes a memberless role and refuses one with a member", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
||||||
|
createUserRow(t, db, 1, "", &kept.ID)
|
||||||
|
if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) {
|
||||||
|
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.Delete(gone.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIHardeningUseCases(t *testing.T) {
|
||||||
|
t.Run("stale typed rows never show in a view and a GET body stays a valid PUT body", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.read=all", "content.update:ghost=all", "content.update:events=own")})
|
||||||
|
got, err := world.container.GetRoleUseCase.Get(stale.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"content.read=all", "content.update:events=own"}
|
||||||
|
if !reflect.DeepEqual(rolesAPIGrantKeys(got.Grants), want) {
|
||||||
|
t.Fatalf("unexpected grants %v", rolesAPIGrantKeys(got.Grants))
|
||||||
|
}
|
||||||
|
listed, err := world.container.ListRolesUseCase.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, item := range listed {
|
||||||
|
if item.ID == stale.ID && !reflect.DeepEqual(rolesAPIGrantKeys(item.Grants), want) {
|
||||||
|
t.Fatalf("unexpected listed grants %v", rolesAPIGrantKeys(item.Grants))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{Caller: world.caller(raAdmin), ID: stale.ID, Name: "Stale", Grants: got.Grants, Version: got.Version})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a GET body must be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
if updated.Version != 1 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("a body identical to the view is a no-op: version %d events %d", updated.Version, len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failed content type create does not bump any role", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
delete(world.types.types, raEventsTypeID)
|
||||||
|
world.types.fail.failNext("Create")
|
||||||
|
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the create to fail")
|
||||||
|
}
|
||||||
|
if world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
|
||||||
|
t.Fatal("no role may be touched by a failed create")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failed cleanup after a create is reported", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
delete(world.types.types, raEventsTypeID)
|
||||||
|
world.roles.fail.failNext("RemoveTypedGrants")
|
||||||
|
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the failure to be reported")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a target that vanished is an invalid reassign and records nothing", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNextWith("ReassignMembers", role.ErrInvalidReassign)
|
||||||
|
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
|
||||||
|
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a reassign failure event carries from, to and a zero moved", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("ReassignMembers")
|
||||||
|
_, _ = world.reassign(raAdmin, "Contributor", world.ids["Writer"])
|
||||||
|
params := world.recorder.events[0].Params
|
||||||
|
if params[audit.ParamFrom] != world.ids["Contributor"] || params[audit.ParamTo] != world.ids["Writer"] || params[audit.ParamMoved] != 0 {
|
||||||
|
t.Fatalf("unexpected params %+v", params)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("GetByName")
|
||||||
|
err := world.remove(raAdmin, "Intern")
|
||||||
|
if !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Fatalf("expected the repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
if world.roles.calls["Delete"] != 0 {
|
||||||
|
t.Fatal("nothing may be deleted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a content type that references itself can be deleted but one referenced by another cannot", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
selfRef := validContentTypeFields()
|
||||||
|
selfRef = append(selfRef, contenttype.Field{Key: "parent", Label: "Parent", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "events", Kind: contenttype.RelationOneToMany}})
|
||||||
|
events := world.types.types[raEventsTypeID]
|
||||||
|
events.Fields = selfRef
|
||||||
|
world.types.types[raEventsTypeID] = events
|
||||||
|
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
|
||||||
|
t.Fatalf("a self reference must not block the delete, got %v", err)
|
||||||
|
}
|
||||||
|
venues := world.types.types[raVenuesTypeID]
|
||||||
|
venues.Fields = append(validContentTypeFields(), contenttype.Field{Key: "owner", Label: "Owner", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "other", Kind: contenttype.RelationOneToMany}})
|
||||||
|
world.types.types[raVenuesTypeID] = venues
|
||||||
|
world.types.types[7] = contenttype.ContentType{ID: 7, Key: "other", Name: "Other", Fields: validContentTypeFields()}
|
||||||
|
if err := world.container.DeleteContentTypeUseCase.Delete("other"); !errors.Is(err, contenttype.ErrReferenced) {
|
||||||
|
t.Fatalf("expected ErrReferenced, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscalationErrorMessage(t *testing.T) {
|
||||||
|
err := role.EscalationError{Key: "content.update:events"}
|
||||||
|
if err.Error() != role.ErrEscalation.Error()+": content.update:events" || !errors.Is(err, role.ErrEscalation) {
|
||||||
|
t.Fatalf("unexpected error %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIHardeningQuietSuccess(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
var sink bytes.Buffer
|
||||||
|
log.SetOutput(&sink)
|
||||||
|
defer log.SetOutput(os.Stderr)
|
||||||
|
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
|
||||||
|
if rec.Code != http.StatusOK || sink.Len() != 0 {
|
||||||
|
t.Fatalf("a served response must not log: %d %q", rec.Code, sink.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rolesAPIBody struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
BuiltIn bool `json:"built_in"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
Version uint32 `json:"version"`
|
||||||
|
Grants []role.Grant `json:"grants"`
|
||||||
|
Members int `json:"members"`
|
||||||
|
UpdatedAt *string `json:"updated_at"`
|
||||||
|
UpdatedBy *struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRolesAPIBody(t *testing.T, rec *httptest.ResponseRecorder, status int) rolesAPIBody {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body rolesAPIBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRolesAPIResult(t *testing.T, rec *httptest.ResponseRecorder, want string) {
|
||||||
|
t.Helper()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(want, "gate:"):
|
||||||
|
assertScopeWant(t, rec, refusedWant(strings.TrimPrefix(want, "gate:")))
|
||||||
|
case strings.HasPrefix(want, "escalation"):
|
||||||
|
assertEscalation(t, rec, strings.TrimPrefix(strings.TrimPrefix(want, "escalation"), ":"))
|
||||||
|
case strings.HasPrefix(want, "message:"):
|
||||||
|
parts := strings.SplitN(strings.TrimPrefix(want, "message:"), ":", 2)
|
||||||
|
status, _ := strconv.Atoi(parts[0])
|
||||||
|
assertAPIKeyMessage(t, rec, status, parts[1])
|
||||||
|
case strings.Contains(want, " "):
|
||||||
|
parts := strings.SplitN(want, " ", 2)
|
||||||
|
status, _ := strconv.Atoi(parts[0])
|
||||||
|
assertRefusal(t, rec, status, parts[1])
|
||||||
|
default:
|
||||||
|
status, _ := strconv.Atoi(want)
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIGate(t *testing.T) {
|
||||||
|
rows := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Owner lists", raOwner, http.MethodGet, "/v1/role", "", "200"},
|
||||||
|
{"Admin lists", raAdmin, http.MethodGet, "/v1/role", "", "200"},
|
||||||
|
{"Viewer lists", raViewer, http.MethodGet, "/v1/role", "", "200"},
|
||||||
|
{"Viewer reads the Writer", raViewer, http.MethodGet, "/v1/role/3", "", "200"},
|
||||||
|
{"Writer lists", raWriter, http.MethodGet, "/v1/role", "", "gate:roles.view"},
|
||||||
|
{"Writer reads a malformed id", raWriter, http.MethodGet, "/v1/role/abc", "", "gate:roles.view"},
|
||||||
|
{"Viewer creates", raViewer, http.MethodPost, "/v1/role", `{"name": "X"}`, "gate:roles.manage"},
|
||||||
|
{"Viewer edits before any lookup", raViewer, http.MethodPut, "/v1/role/999", "", "gate:roles.manage"},
|
||||||
|
{"Viewer deletes", raViewer, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
|
||||||
|
{"Viewer reassigns", raViewer, http.MethodPost, "/v1/role/999/reassign", "", "gate:roles.manage"},
|
||||||
|
{"Viewer resets", raViewer, http.MethodPost, "/v1/role/3/reset", `{"version": 1}`, "gate:roles.manage"},
|
||||||
|
{"Writer edits a malformed id", raWriter, http.MethodPut, "/v1/role/abc", "", "gate:roles.manage"},
|
||||||
|
{"Writer deletes an unknown id", raWriter, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
|
||||||
|
{"no cookie", 0, http.MethodGet, "/v1/role", "", "message:401:" + apperr.UnauthorizedErrorMessage},
|
||||||
|
{"unverified Admin", raUnverified, http.MethodGet, "/v1/role", "", "message:403:" + apperr.VerificationRequiredErrorMessage},
|
||||||
|
}
|
||||||
|
for _, c := range rows {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path, c.body), c.want)
|
||||||
|
if c.method != http.MethodGet && len(world.recorder.events) != 0 {
|
||||||
|
t.Fatal("a refused call records nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("API key created by user 5 reads", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
raw, _ := world.issueKey(raAdmin, "ci")
|
||||||
|
assertRolesAPIResult(t, world.withKey(t, http.MethodGet, "/v1/role", "", raw), "200")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("API key created by user 5 writes nothing", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
raw, _ := world.issueKey(raAdmin, "ci")
|
||||||
|
before := len(world.roles.roles)
|
||||||
|
for _, call := range []struct{ method, path, body string }{
|
||||||
|
{http.MethodPost, "/v1/role", `{"name": "Keyed"}`},
|
||||||
|
{http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "x", 1, rolesAPIContributorRows()...)},
|
||||||
|
{http.MethodDelete, world.path("Intern"), ""},
|
||||||
|
{http.MethodPost, world.path("Contributor") + "/reassign", `{"target_role_id": 3}`},
|
||||||
|
{http.MethodPost, world.path("Writer") + "/reset", `{"version": 1}`},
|
||||||
|
} {
|
||||||
|
assertRolesAPIResult(t, world.withKey(t, call.method, call.path, call.body, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
|
||||||
|
}
|
||||||
|
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatal("nothing may be stored or recorded")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stale token_version", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.send(t, apiKeyRequest{method: http.MethodGet, path: "/v1/role", cookieUser: raAdmin, cookieVersion: 1})
|
||||||
|
assertAPIKeyStatus(t, rec, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a session follows the role live", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "gate:roles.manage")
|
||||||
|
viewer := world.role("Viewer")
|
||||||
|
viewer.Grants = append(viewer.Grants, role.Grant{Key: role.KeyRolesManage, Scope: role.ScopeAll})
|
||||||
|
world.roles.roles[viewer.ID] = viewer
|
||||||
|
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "201")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reading one role and /auth/me never List", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.resetCounters()
|
||||||
|
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), "200")
|
||||||
|
if world.roles.calls["List"] != 0 {
|
||||||
|
t.Errorf("reading one role must not List, got %d", world.roles.calls["List"])
|
||||||
|
}
|
||||||
|
world.resetCounters()
|
||||||
|
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/auth/me", ""), "200")
|
||||||
|
if world.roles.calls["List"] != 0 {
|
||||||
|
t.Errorf("/auth/me must not List, got %d", world.roles.calls["List"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIReading(t *testing.T) {
|
||||||
|
t.Run("The list returns every role with grants and member counts", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
|
||||||
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
var listed []rolesAPIBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
members := map[string]int{}
|
||||||
|
for index, item := range listed {
|
||||||
|
members[item.Name] = item.Members
|
||||||
|
if index > 0 && listed[index-1].ID >= item.ID {
|
||||||
|
t.Fatal("the list must be ordered by id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if listed[0].Name != "Owner" || listed[1].Name != "Admin" || listed[2].Name != "Writer" || len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
|
||||||
|
t.Fatalf("unexpected built-ins %+v", listed[:3])
|
||||||
|
}
|
||||||
|
for name, count := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0} {
|
||||||
|
if members[name] != count {
|
||||||
|
t.Errorf("%s: expected %d members, got %d", name, count, members[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), `"updated_by":null`) {
|
||||||
|
t.Error("a never edited role has a null updated_by")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Reading one role", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
|
||||||
|
if body.Members != 4 || body.Version != 1 || !reflect.DeepEqual(body.Grants, grantRows(rolesAPIContributorRows()...)) {
|
||||||
|
t.Fatalf("unexpected role %+v", body)
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/role/999", ""), "404 role_not_found")
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raViewer, http.MethodGet, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A role with a hand-placed owner-only key or duplicate rows is returned normalised", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
|
||||||
|
body := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodGet, "/v1/role/"+strconv.Itoa(int(hand.ID)), ""), http.StatusOK)
|
||||||
|
if !reflect.DeepEqual(body.Grants, grantRows("content.update=own")) {
|
||||||
|
t.Fatalf("unexpected grants %v", body.Grants)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Who saved last", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, rolesAPIContributorRows()...)), "200")
|
||||||
|
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
|
||||||
|
if body.UpdatedAt == nil || *body.UpdatedAt != "2026-10-04T12:00:00Z" || body.UpdatedBy == nil || body.UpdatedBy.ID != raAdmin || body.UpdatedBy.Name != world.users.users[raAdmin].Username {
|
||||||
|
t.Fatalf("unexpected editor %+v at %v", body.UpdatedBy, body.UpdatedAt)
|
||||||
|
}
|
||||||
|
delete(world.users.users, raAdmin)
|
||||||
|
body = decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
|
||||||
|
if body.UpdatedBy != nil {
|
||||||
|
t.Fatal("a deleted editor reads as null")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPICreate(t *testing.T) {
|
||||||
|
t.Run("Creating the Contributor role (design flow, section 11)", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
request := `{"name": "Writer plus", "description": "Writes posts and edits only their own", "built_in": true, "locked": true, "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}`
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
|
||||||
|
body := decodeRolesAPIBody(t, rec, http.StatusCreated)
|
||||||
|
if body.BuiltIn || body.Locked || body.Version != 1 || body.Members != 0 || body.Name != "Writer plus" {
|
||||||
|
t.Fatalf("unexpected role %+v", body)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(body.Grants, grantRows("content.read=all", "content.create=all", "content.update=own", "schema.view=all", "media.view=all")) {
|
||||||
|
t.Fatalf("unexpected grants %v", body.Grants)
|
||||||
|
}
|
||||||
|
listed := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
|
||||||
|
if !strings.Contains(listed.Body.String(), `"name":"Writer plus"`) {
|
||||||
|
t.Fatal("the new role must be listed")
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.create" {
|
||||||
|
t.Fatalf("expected exactly one role.create event, got %v", world.recorder.actions())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A new role may start with nothing granted", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
for _, request := range []string{`{"name": "Empty"}`, `{"name": "Empty 2", "grants": []}`} {
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
|
||||||
|
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"grants":[]`) {
|
||||||
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
validation := []struct {
|
||||||
|
grants string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"content.update=all", "201"},
|
||||||
|
{"content.update:events=own", "201"},
|
||||||
|
{"content.update:article=none", "201"},
|
||||||
|
{"content.update:events=all,content.update=own", "201"},
|
||||||
|
{"content.explode=all", "400 unknown_permission"},
|
||||||
|
{"schema.view:events=all", "400 unknown_permission"},
|
||||||
|
{"content.update:Bad Key=all", "400 unknown_permission"},
|
||||||
|
{"content.update:nope=all", "400 unknown_content_type"},
|
||||||
|
{"content.update:author=all", "400 unknown_content_type"},
|
||||||
|
{"content.update=none", "400 invalid_scope"},
|
||||||
|
{"schema.view=own", "400 invalid_scope"},
|
||||||
|
{"content.update=banana", "400 invalid_scope"},
|
||||||
|
{"content.create:events=own", "400 invalid_scope"},
|
||||||
|
{"settings.delete_site=all", "400 owner_only_permission"},
|
||||||
|
{"system.apply_updates=all", "400 owner_only_permission"},
|
||||||
|
{"content.update=all,content.update=own", "400 duplicate_permission"},
|
||||||
|
{"content.update:events=all,content.update:events=own", "400 duplicate_permission"},
|
||||||
|
}
|
||||||
|
for _, c := range validation {
|
||||||
|
t.Run("Write-time validation of "+c.grants, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
before := len(world.roles.roles)
|
||||||
|
request := `{"name": "Probe", "grants": ` + rolesAPIGrantsJSON(strings.Split(c.grants, ",")...) + `}`
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", request), c.want)
|
||||||
|
if c.want != "201" && (len(world.roles.roles) != before || len(world.recorder.events) != 0) {
|
||||||
|
t.Fatal("nothing is stored and no audit event is recorded")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("owner-only keys are refused on a built-in edit too", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
body := rolesAPIUpdateBody("Admin", "", 1, append(adminRows(), "system.apply_updates=all")...)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Admin"), body), "400 owner_only_permission")
|
||||||
|
})
|
||||||
|
|
||||||
|
longName := strings.Repeat("n", 101)
|
||||||
|
names := []struct {
|
||||||
|
name string
|
||||||
|
request string
|
||||||
|
want string
|
||||||
|
storedName string
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{"an empty name", `{"name": ""}`, "400", "", ""},
|
||||||
|
{"a blank name", `{"name": " "}`, "400 invalid_role_name", "", ""},
|
||||||
|
{"101 characters", `{"name": "` + longName + `"}`, "400", "", ""},
|
||||||
|
{"100 characters", `{"name": "` + longName[:100] + `"}`, "201", longName[:100], ""},
|
||||||
|
{"surrounding spaces", `{"name": " Reviewer "}`, "201", "Reviewer", ""},
|
||||||
|
{"256 characters of description", `{"name": "Reviewer 2", "description": "` + strings.Repeat("d", 256) + `"}`, "400", "", ""},
|
||||||
|
{"Writer", `{"name": "Writer"}`, "409 role_name_taken", "", ""},
|
||||||
|
{"WRITER", `{"name": "WRITER"}`, "409 role_name_taken", "", ""},
|
||||||
|
{"padded contributor", `{"name": " contributor "}`, "409 role_name_taken", "", ""},
|
||||||
|
}
|
||||||
|
for _, c := range names {
|
||||||
|
t.Run("Name and description rules: "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", c.request)
|
||||||
|
if c.want == "400" {
|
||||||
|
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, rec, c.want)
|
||||||
|
if c.want == "201" && decodeRolesAPIBody(t, rec, http.StatusCreated).Name != c.storedName {
|
||||||
|
t.Fatalf("expected the name %q", c.storedName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a malformed body is the body error", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": `), http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), `[`), http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a grant without a key fails the validate tag", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertAPIKeyStatus(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "X", "grants": [{"scope": "all"}]}`), http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No-escalation on create", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
request := func(rows ...string) string {
|
||||||
|
return `{"name": "Escalating", "grants": ` + rolesAPIGrantsJSON(rows...) + `}`
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=all")), "escalation:content.publish")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish:events=all")), "escalation:content.publish:events")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=own")), "201")
|
||||||
|
if len(world.recorder.events) != 1 {
|
||||||
|
t.Fatalf("only the successful create is recorded, got %v", world.recorder.actions())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIEditing(t *testing.T) {
|
||||||
|
contributor := rolesAPIContributorRows
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
target string
|
||||||
|
body func(*rolesAPIWorld) string
|
||||||
|
want string
|
||||||
|
after func(*testing.T, *rolesAPIWorld, rolesAPIBody)
|
||||||
|
}{
|
||||||
|
{"grants plus delete own", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=own")...)
|
||||||
|
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
|
||||||
|
if b.Version != 2 || b.Members != 4 || !reflect.DeepEqual(b.Grants, grantRows(contributor("content.delete=own")...)) {
|
||||||
|
t.Fatalf("unexpected role %+v", b)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"grants minus update", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Contributor", "", 1, "content.read=all", "content.create=all")
|
||||||
|
}, "200", nil},
|
||||||
|
{"nothing changed", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Contributor", "", 1, contributor()...)
|
||||||
|
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
|
||||||
|
if b.Version != 1 || len(w.recorder.events) != 0 {
|
||||||
|
t.Fatalf("a no-op must not bump the version or record, got %+v", b)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"nothing changed on a stale version", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Contributor", "", 7, contributor()...)
|
||||||
|
}, "409 role_conflict", nil},
|
||||||
|
{"no version field", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return `{"name": "Contributor", "grants": []}`
|
||||||
|
}, "400", nil},
|
||||||
|
{"name taken", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("writer", "", 1, contributor()...)
|
||||||
|
}, "409 role_name_taken", nil},
|
||||||
|
{"case-only rename", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("contributor", "", 1, contributor()...)
|
||||||
|
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
|
||||||
|
if b.Name != "contributor" {
|
||||||
|
t.Fatalf("expected the name as sent, got %q", b.Name)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"unknown key", raAdmin, "Contributor", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all")
|
||||||
|
}, "400 unknown_permission", nil},
|
||||||
|
{"unknown role", raAdmin, "Missing", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("X", "", 1)
|
||||||
|
}, "404 role_not_found", nil},
|
||||||
|
{"owner edits the writer description", raOwner, "Writer", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
|
||||||
|
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
|
||||||
|
if b.Version != 2 || !b.BuiltIn {
|
||||||
|
t.Fatalf("the role keeps built_in, got %+v", b)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"owner renames the writer", raOwner, "Writer", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Author", "", 1, writerRows()...)
|
||||||
|
}, "400 built_in_name", nil},
|
||||||
|
{"admin edits the writer", raAdmin, "Writer", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
|
||||||
|
}, "gate:roles.edit_owner_only", nil},
|
||||||
|
{"admin edits admin", raAdmin, "Admin", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Admin", "d", 1, adminRows()...)
|
||||||
|
}, "gate:roles.edit_owner_only", nil},
|
||||||
|
{"owner edits the owner", raOwner, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "", 1) }, "403 role_locked", nil},
|
||||||
|
{"admin edits the owner", raAdmin, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "x", 1) }, "403 role_locked", nil},
|
||||||
|
{"role manager edits their own role", raRoleManager, "Role manager", func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Role manager", "d", 1)
|
||||||
|
}, "403 own_role", nil},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run("Editing a role: "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, c.caller, http.MethodPut, world.path(c.target), c.body(world))
|
||||||
|
if c.want == "400" {
|
||||||
|
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, rec, c.want)
|
||||||
|
if c.after != nil {
|
||||||
|
c.after(t, world, decodeRolesAPIBody(t, rec, http.StatusOK))
|
||||||
|
}
|
||||||
|
if c.want != "200" && c.target != "Missing" && world.role(c.target).Version != 1 {
|
||||||
|
t.Fatal("a refusal bumps no version")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Editing a role: a refused call with a stale version still reports the escalation first", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Content lead"), rolesAPIUpdateBody("Content lead", "", 9))
|
||||||
|
assertRolesAPIResult(t, rec, "escalation")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Order: an unknown key is a 400 before the escalation", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all", "content.delete=all"))
|
||||||
|
assertRolesAPIResult(t, rec, "400 unknown_permission")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Members see the new grants on their next request, with the same cookie and API key", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
raw, _ := world.issueKey(raContributorOne, "contributor key")
|
||||||
|
world.seedArticle(1, true)
|
||||||
|
assertAPIKeyStatus(t, world.do(t, raContributorOne, http.MethodDelete, "/v1/article/1", ""), http.StatusForbidden)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=all")...)), "200")
|
||||||
|
assertAPIKeyStatus(t, world.withKey(t, http.MethodDelete, "/v1/article/1", "", raw), http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A stale editor gets a conflict, then reads the new state", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
first := rolesAPIUpdateBody("Contributor", "from 3", 1, contributor()...)
|
||||||
|
second := rolesAPIUpdateBody("Contributor", "from 5", 1, contributor()...)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Contributor"), first), "200")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), second), "409 role_conflict")
|
||||||
|
if stored := world.role("Contributor"); stored.Description != "from 3" || stored.Version != 2 {
|
||||||
|
t.Fatalf("the stored role must be the first save, got %+v", stored)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A save that loses the race at the repository answers role_conflict", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.beforeSave = func() {
|
||||||
|
stored := world.role("Contributor")
|
||||||
|
stored.Version = 2
|
||||||
|
world.roles.roles[stored.ID] = stored
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "409 role_conflict")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A storage failure at the save is a 500", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("Save")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "500 Internal server error")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A non-numeric id is the id error", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, "/v1/role/abc", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role/abc/reassign", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, "/v1/role/abc/reset", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
noEscalation := []struct {
|
||||||
|
caller uint32
|
||||||
|
extra []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{raRoleManager, []string{"content.publish=own"}, "200"},
|
||||||
|
{raRoleManager, []string{"content.publish=all"}, "escalation:content.publish"},
|
||||||
|
{raRoleManager, []string{"content.delete=own"}, "escalation:content.delete"},
|
||||||
|
{raRoleManager, []string{"schema.edit=all"}, "escalation"},
|
||||||
|
{raRoleManager, []string{"content.publish:events=none"}, "200"},
|
||||||
|
{raRoleManager, []string{"content.publish:events=all"}, "escalation:content.publish:events"},
|
||||||
|
{raRoleManager, []string{"content.update:events=own"}, "200"},
|
||||||
|
{raRoleManager, []string{"content.update:events=all"}, "escalation"},
|
||||||
|
{raAdmin, []string{"content.delete=all"}, "200"},
|
||||||
|
{raOwner, []string{"content.delete=all"}, "200"},
|
||||||
|
}
|
||||||
|
for _, c := range noEscalation {
|
||||||
|
t.Run("No-escalation on edit: "+c.extra[0]+" by user "+strconv.Itoa(int(c.caller)), func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, c.caller, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor(c.extra...)...))
|
||||||
|
assertRolesAPIResult(t, rec, c.want)
|
||||||
|
if c.want != "200" && (world.role("Contributor").Version != 1 || len(world.recorder.events) != 0) {
|
||||||
|
t.Fatal("a refused call changes no field, bumps no version and records no event")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("No-escalation with a typed-only reader and a typed none", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raNarrowManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor()...)), "escalation")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all")), "escalation:content.update:article")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none")), "200")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No-escalation with a typed manager: a typed own above the caller's none is refused", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own"))
|
||||||
|
assertRolesAPIResult(t, rec, "escalation:content.update:article")
|
||||||
|
if world.role("Typed target").Version != 1 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatal("a refused call changes no field, bumps no version and records no event")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
above := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
method string
|
||||||
|
path func(*rolesAPIWorld) string
|
||||||
|
body func(*rolesAPIWorld) string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"PUT Content lead by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Content lead", "", 1) }, "escalation"},
|
||||||
|
{"DELETE Content lead by Role manager", raRoleManager, http.MethodDelete, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return "" }, "escalation"},
|
||||||
|
{"reassign Content lead by Role manager", raRoleManager, http.MethodPost, func(w *rolesAPIWorld) string { return w.path("Content lead") + "/reassign" }, func(w *rolesAPIWorld) string { return `{"target_role_id": ` + strconv.Itoa(int(w.ids["Intern"])) + `}` }, "escalation"},
|
||||||
|
{"PUT Contributor with a harmless change by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Contributor") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Contributor", "harmless", 1, contributor()...) }, "200"},
|
||||||
|
{"PUT Content lead with a harmless change by Admin", raAdmin, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string {
|
||||||
|
return rolesAPIUpdateBody("Content lead", "harmless", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
|
||||||
|
}, "200"},
|
||||||
|
}
|
||||||
|
for _, c := range above {
|
||||||
|
t.Run("A caller cannot touch a role above their own grants: "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path(world), c.body(world)), c.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRolesAPIDeleting(t *testing.T) {
|
||||||
|
rows := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
target string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Owner deletes Intern", raOwner, "Intern", "204"},
|
||||||
|
{"Admin deletes Intern", raAdmin, "Intern", "204"},
|
||||||
|
{"Admin deletes Contributor", raAdmin, "Contributor", "409 role_has_members"},
|
||||||
|
{"Owner deletes Writer", raOwner, "Writer", "409 role_built_in"},
|
||||||
|
{"Owner deletes Admin", raOwner, "Admin", "409 role_built_in"},
|
||||||
|
{"Owner deletes Owner", raOwner, "Owner", "403 role_locked"},
|
||||||
|
{"Admin deletes Owner", raAdmin, "Owner", "403 role_locked"},
|
||||||
|
{"Admin deletes 999", raAdmin, "Missing", "404 role_not_found"},
|
||||||
|
{"Role manager deletes Role manager", raRoleManager, "Role manager", "403 own_role"},
|
||||||
|
{"Role manager deletes Content lead", raRoleManager, "Content lead", "escalation"},
|
||||||
|
{"Role manager deletes Intern", raRoleManager, "Intern", "204"},
|
||||||
|
}
|
||||||
|
for _, c := range rows {
|
||||||
|
t.Run("Deleting a role: "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, c.caller, http.MethodDelete, world.path(c.target), "")
|
||||||
|
assertRolesAPIResult(t, rec, c.want)
|
||||||
|
_, exists := world.roles.roles[world.ids[c.target]]
|
||||||
|
if c.want == "204" {
|
||||||
|
if exists || rec.Body.Len() != 0 {
|
||||||
|
t.Fatal("the role must be gone and the body empty")
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodGet, world.path(c.target), ""), "404 role_not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.target != "Missing" && !exists {
|
||||||
|
t.Fatal("a refused delete deletes nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a delete never touches content", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.seedArticle(7, true)
|
||||||
|
article := world.articles.articles[7]
|
||||||
|
article.CreatedBy = raContributorOne
|
||||||
|
world.articles.articles[7] = article
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "204")
|
||||||
|
if world.articles.articles[7].CreatedBy != raContributorOne {
|
||||||
|
t.Fatal("content keeps its created_by")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a member appearing meanwhile answers role_has_members", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "409 role_has_members")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIReassigning(t *testing.T) {
|
||||||
|
target := func(w *rolesAPIWorld, name string) string {
|
||||||
|
return `{"target_role_id": ` + strconv.Itoa(int(w.ids[name])) + `}`
|
||||||
|
}
|
||||||
|
rows := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
source string
|
||||||
|
body func(*rolesAPIWorld) string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Admin moves Contributor to Writer", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
|
||||||
|
{"Admin moves Intern to Writer", raAdmin, "Intern", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
|
||||||
|
{"Role manager moves to Admin", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Admin") }, "escalation"},
|
||||||
|
{"Role manager moves to Intern", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "200"},
|
||||||
|
{"Admin moves to Owner", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Owner") }, "400 invalid_reassign"},
|
||||||
|
{"Admin moves to the same role", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Contributor") }, "400 invalid_reassign"},
|
||||||
|
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "400 invalid_reassign"},
|
||||||
|
{"Admin sends no target", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{}` }, "400"},
|
||||||
|
{"Admin moves from Writer", raAdmin, "Writer", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "409 role_built_in"},
|
||||||
|
{"Admin moves from Owner", raAdmin, "Owner", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 role_locked"},
|
||||||
|
{"Admin moves from 999", raAdmin, "Missing", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "404 role_not_found"},
|
||||||
|
{"Role manager moves from their own role", raRoleManager, "Role manager", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 own_role"},
|
||||||
|
}
|
||||||
|
for _, c := range rows {
|
||||||
|
t.Run("Moving members: "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, c.caller, http.MethodPost, world.path(c.source)+"/reassign", c.body(world))
|
||||||
|
if c.want == "400" {
|
||||||
|
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, rec, c.want)
|
||||||
|
if c.want != "200" && len(world.members("Contributor")) != 4 {
|
||||||
|
t.Fatal("nobody may be moved by a refusal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Moving members, then deleting", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer"))
|
||||||
|
var moved struct {
|
||||||
|
Moved int `json:"moved"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &moved); err != nil || rec.Code != http.StatusOK || moved.Moved != 4 {
|
||||||
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := world.members("Writer"); !reflect.DeepEqual(got, []uint32{raWriter, 41, 42, 43, 44}) {
|
||||||
|
t.Fatalf("unexpected Writer members %v", got)
|
||||||
|
}
|
||||||
|
account := world.users.users[41]
|
||||||
|
if account.TokenVersion != 0 || account.Password != "" || account.IsVerified != true {
|
||||||
|
t.Fatalf("only role_id may change: %+v", account)
|
||||||
|
}
|
||||||
|
assertRolesAPIResult(t, world.do(t, 41, http.MethodGet, "/v1/auth/me", ""), "200")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Contributor"), ""), "204")
|
||||||
|
actions := world.recorder.actions()
|
||||||
|
if !reflect.DeepEqual(actions, []string{"role.reassign", "role.delete"}) {
|
||||||
|
t.Fatalf("unexpected events %v", actions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Moving nobody records no event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPost, world.path("Intern")+"/reassign", target(world, "Writer"))
|
||||||
|
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"moved":0}` || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected %d %q events=%d", rec.Code, rec.Body.String(), len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A repository failure moves nobody, answers 500 and records a failed event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("ReassignMembers")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer")), "500 Internal server error")
|
||||||
|
if len(world.members("Contributor")) != 4 || len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
|
||||||
|
t.Fatalf("unexpected state, events=%+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIResetting(t *testing.T) {
|
||||||
|
edited := func(t *testing.T, world *rolesAPIWorld, name string) {
|
||||||
|
t.Helper()
|
||||||
|
body := rolesAPIUpdateBody(name, "custom", 1, "content.read=all")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path(name), body), "200")
|
||||||
|
world.recorder.events = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"Writer", "Admin"} {
|
||||||
|
t.Run("Resetting a built-in role to its defaults: Owner resets "+name+", edited", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
edited(t, world, name)
|
||||||
|
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path(name)+"/reset", `{"version": 2}`), http.StatusOK)
|
||||||
|
want := role.Role{Grants: role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants}.EffectiveGrants()
|
||||||
|
if body.Version != 3 || body.Name != name || !body.BuiltIn || body.Locked || body.Description != "" || !reflect.DeepEqual(body.Grants, want) {
|
||||||
|
t.Fatalf("unexpected role %+v", body)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.edit" || world.recorder.events[0].Target.Name != name {
|
||||||
|
t.Fatalf("expected one role.edit event, got %+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Resetting a built-in role to its defaults: a pristine role is a no-op, then a second reset too", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 1}`), http.StatusOK)
|
||||||
|
if body.Version != 1 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected %+v", body)
|
||||||
|
}
|
||||||
|
edited(t, world, "Writer")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
|
||||||
|
again := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 3}`), http.StatusOK)
|
||||||
|
if again.Version != 3 {
|
||||||
|
t.Fatalf("a second reset is a no-op, got version %d", again.Version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Resetting a built-in role to its defaults: members see the seeded grants on their next request", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
edited(t, world, "Writer")
|
||||||
|
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusForbidden)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
|
||||||
|
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
refusals := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
target string
|
||||||
|
version string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Owner, Writer, stale", raOwner, "Writer", "1", "409 role_conflict"},
|
||||||
|
{"Owner, Owner", raOwner, "Owner", "1", "403 role_locked"},
|
||||||
|
{"Owner, Contributor", raOwner, "Contributor", "1", "400 not_built_in"},
|
||||||
|
{"Owner, 999", raOwner, "Missing", "1", "404 role_not_found"},
|
||||||
|
{"Admin, Writer", raAdmin, "Writer", "2", "gate:roles.edit_owner_only"},
|
||||||
|
{"Viewer, Writer", raViewer, "Writer", "2", "gate:roles.manage"},
|
||||||
|
}
|
||||||
|
for _, c := range refusals {
|
||||||
|
t.Run("Resetting a built-in role to its defaults: "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
edited(t, world, "Writer")
|
||||||
|
assertRolesAPIResult(t, world.do(t, c.caller, http.MethodPost, world.path(c.target)+"/reset", `{"version": `+c.version+`}`), c.want)
|
||||||
|
if len(world.recorder.events) != 0 {
|
||||||
|
t.Fatal("a refused reset records nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Resetting a built-in role to its defaults: a missing version is a validation error", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertAPIKeyStatus(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{}`), http.StatusBadRequest)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{`), http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Resetting a built-in role to its defaults: the API key of the Owner is refused", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
edited(t, world, "Writer")
|
||||||
|
raw, _ := world.issueKey(raOwner, "owner key")
|
||||||
|
assertRolesAPIResult(t, world.withKey(t, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIActivity(t *testing.T) {
|
||||||
|
t.Run("Activity events for roles", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "Reviewer", "grants": `+rolesAPIGrantsJSON("content.read=all", "content.update=own", "media.view=all")+`}`), http.StatusCreated)
|
||||||
|
path := "/v1/role/" + strconv.Itoa(int(created.ID))
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, path, rolesAPIUpdateBody("Reviewers", "", 1, "content.read=all", "content.update=all", "content.delete=own")), "200")
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, path, ""), "204")
|
||||||
|
|
||||||
|
events := world.recorder.events
|
||||||
|
if !reflect.DeepEqual(world.recorder.actions(), []string{"role.create", "role.edit", "role.delete"}) {
|
||||||
|
t.Fatalf("unexpected events %v", world.recorder.actions())
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess || event.Target.Type != "role" || event.Target.ID != strconv.Itoa(int(created.ID)) {
|
||||||
|
t.Fatalf("unexpected event %+v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit := events[1]
|
||||||
|
if edit.Target.Name != "Reviewers" ||
|
||||||
|
!reflect.DeepEqual(edit.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
|
||||||
|
!reflect.DeepEqual(edit.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
|
||||||
|
!reflect.DeepEqual(edit.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
|
||||||
|
t.Fatalf("unexpected edit params %v", edit.Params)
|
||||||
|
}
|
||||||
|
if events[2].Params[audit.ParamMembers] != 0 || events[2].Target.Name != "Reviewers" {
|
||||||
|
t.Fatalf("unexpected delete event %+v", events[2])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIAuthMe(t *testing.T) {
|
||||||
|
t.Run("/auth/me exposes typed grants", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec, body := world.me(t, raEventsEditor)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected %d", rec.Code)
|
||||||
|
}
|
||||||
|
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
|
||||||
|
if !reflect.DeepEqual(body.Access.Permissions, want) {
|
||||||
|
t.Fatalf("expected %v, got %v", want, body.Access.Permissions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIContentTypes(t *testing.T) {
|
||||||
|
createEvents := `{"key": "events", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`
|
||||||
|
|
||||||
|
t.Run("Deleting a content type removes its typed grants", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), "204")
|
||||||
|
editor := world.role("Events editor")
|
||||||
|
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
|
||||||
|
t.Fatalf("unexpected role %v version %d", editor.Grants, editor.Version)
|
||||||
|
}
|
||||||
|
if world.role("Contributor").Version != 1 {
|
||||||
|
t.Fatal("a role without typed rows keeps its version")
|
||||||
|
}
|
||||||
|
_, body := world.me(t, raEventsEditor)
|
||||||
|
if !reflect.DeepEqual(body.Access.Permissions, grantRows("content.read=all", "content.update:venues=all")) {
|
||||||
|
t.Fatalf("unexpected permissions %v", body.Access.Permissions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A refused content type delete keeps the typed grants", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.entries.entries[1] = entryOfType(raEventsTypeID)
|
||||||
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), http.StatusConflict, apperr.ContentTypeHasEntriesErrorMessage)
|
||||||
|
if world.role("Events editor").Version != 1 {
|
||||||
|
t.Fatal("the role must be unchanged")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A content type created with the key of a deleted one starts clean", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
delete(world.types.types, raEventsTypeID)
|
||||||
|
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.update:events=none")})
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPost, "/v1/content-type", createEvents)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(world.roles.roles[stale.ID].Grants) != 0 {
|
||||||
|
t.Fatal("the stale row must be removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Content type keys do not change", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
rec := world.do(t, raAdmin, http.MethodPut, "/v1/content-type/events", `{"key": "other", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if world.types.types[raEventsTypeID].Key != "events" || world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
|
||||||
|
t.Fatal("an update never rewrites the key nor the typed rows")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"go/ast"
|
||||||
|
"go/token"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rolesAPIRoleUseCaseFiles() []string {
|
||||||
|
return []string{
|
||||||
|
"../internal/infrastructure/useCases/roleSupport.go",
|
||||||
|
"../internal/infrastructure/useCases/roleView.go",
|
||||||
|
"../internal/infrastructure/useCases/ListRoles.go",
|
||||||
|
"../internal/infrastructure/useCases/GetRole.go",
|
||||||
|
"../internal/infrastructure/useCases/CreateRole.go",
|
||||||
|
"../internal/infrastructure/useCases/UpdateRole.go",
|
||||||
|
"../internal/infrastructure/useCases/DeleteRole.go",
|
||||||
|
"../internal/infrastructure/useCases/ReassignRoleMembers.go",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesAPIHygiene(t *testing.T) {
|
||||||
|
t.Run("The comparison logic lives once, in the domain", func(t *testing.T) {
|
||||||
|
files := append(rolesAPIRoleUseCaseFiles(), nonTestGoFiles(t, "../api/handlers/role", "../api/middleware")...)
|
||||||
|
for _, path := range files {
|
||||||
|
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
|
||||||
|
selector, ok := node.(*ast.SelectorExpr)
|
||||||
|
if ok && (selector.Sel.Name == "ScopeAll" || selector.Sel.Name == "ScopeOwn" || selector.Sel.Name == "ScopeNone") {
|
||||||
|
t.Errorf("%s reads the scope %s", path, selector.Sel.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("api/router.go and the role handler hold no catalogue key literal", func(t *testing.T) {
|
||||||
|
keys := map[string]bool{}
|
||||||
|
for _, permission := range role.Catalogue() {
|
||||||
|
keys[permission.Key] = true
|
||||||
|
}
|
||||||
|
files := append(nonTestGoFiles(t, "../api/handlers/role"), "../api/router.go")
|
||||||
|
for _, path := range files {
|
||||||
|
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
|
||||||
|
literal, ok := node.(*ast.BasicLit)
|
||||||
|
if !ok || literal.Kind != token.STRING {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value, err := strconv.Unquote(literal.Value)
|
||||||
|
if err == nil && keys[value] {
|
||||||
|
t.Errorf("%s holds the literal %q", path, value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no handler under api/handlers/role imports persistence, mailer, models or gorm", func(t *testing.T) {
|
||||||
|
files, _ := filepath.Glob("../api/handlers/role/*.go")
|
||||||
|
if len(files) == 0 {
|
||||||
|
t.Fatal("no role handler")
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
|
||||||
|
for _, forbidden := range []string{"persistence", "mailer", "models", "gorm"} {
|
||||||
|
if strings.Contains(imported, forbidden) {
|
||||||
|
t.Errorf("%s imports %s", file, imported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the role use cases import only domain packages", func(t *testing.T) {
|
||||||
|
for _, path := range rolesAPIRoleUseCaseFiles() {
|
||||||
|
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(path), "../")) {
|
||||||
|
if strings.HasPrefix(imported, "RenewCMS/") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||||
|
t.Errorf("%s imports %s", path, imported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the router mounts every role route behind the keys", func(t *testing.T) {
|
||||||
|
source := readTestFile(t, "../api/router.go")
|
||||||
|
for _, wanted := range []string{"role.KeyRolesView", "role.KeyRolesManage", "NewRoleRouter"} {
|
||||||
|
if !strings.Contains(source, wanted) {
|
||||||
|
t.Errorf("api/router.go lacks %s", wanted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rolesAPIGrantKeys(grants []role.Grant) []string {
|
||||||
|
keys := []string{}
|
||||||
|
for _, grant := range grants {
|
||||||
|
keys = append(keys, grant.Key+"="+string(grant.Scope))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func rolesAPICreateRole(t *testing.T, repo *persistence.RoleRepository, name string, rows ...string) role.Role {
|
||||||
|
t.Helper()
|
||||||
|
created, err := repo.Create(role.Role{Name: name, Version: 1, Grants: grantRows(rows...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleRepositoryRolesAPI(t *testing.T) {
|
||||||
|
t.Run("List returns the grants of every role in one pass", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
rolesAPICreateRole(t, repo, "Contributor", "content.read=all", "content.update:events=own")
|
||||||
|
listed, err := repo.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(listed) != 4 {
|
||||||
|
t.Fatalf("expected 4 roles, got %d", len(listed))
|
||||||
|
}
|
||||||
|
for index, expected := range []int{34, 29, 9, 2} {
|
||||||
|
if len(listed[index].Grants) != expected {
|
||||||
|
t.Errorf("role %s: expected %d grants, got %d", listed[index].Name, expected, len(listed[index].Grants))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("seeded roles have no editor and no edit time", func(t *testing.T) {
|
||||||
|
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||||
|
listed, _ := repo.List()
|
||||||
|
for _, seeded := range listed {
|
||||||
|
if seeded.UpdatedBy != nil || !seeded.UpdatedAt.IsZero() {
|
||||||
|
t.Errorf("role %s: unexpected editor %v at %v", seeded.Name, seeded.UpdatedBy, seeded.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Save writes the editor and the time and bumps the version", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
editor := uint32(5)
|
||||||
|
edited := created
|
||||||
|
edited.Description = "changed"
|
||||||
|
edited.UpdatedAt = time.Date(2026, time.October, 5, 11, 2, 0, 0, time.UTC)
|
||||||
|
edited.UpdatedBy = &editor
|
||||||
|
saved, err := repo.Save(edited)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.Version != 2 || saved.UpdatedBy == nil || *saved.UpdatedBy != 5 || !saved.UpdatedAt.Equal(edited.UpdatedAt) {
|
||||||
|
t.Fatalf("unexpected saved role %+v", saved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Save is a compare-and-swap on the version", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
first := created
|
||||||
|
first.Description = "first"
|
||||||
|
second := created
|
||||||
|
second.Description = "second"
|
||||||
|
if _, err := repo.Save(first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := repo.Save(second); !errors.Is(err, role.ErrRoleConflict) {
|
||||||
|
t.Fatalf("expected ErrRoleConflict, got %v", err)
|
||||||
|
}
|
||||||
|
stored, _ := repo.Get(created.ID)
|
||||||
|
if stored.Description != "first" || stored.Version != 2 {
|
||||||
|
t.Fatalf("unexpected stored role %+v", stored)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("two concurrent saves of version 1 give exactly one winner", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
results := make([]error, 2)
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for index := range results {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
attempt := created
|
||||||
|
attempt.Description = []string{"a", "b"}[index]
|
||||||
|
_, results[index] = repo.Save(attempt)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
successes := 0
|
||||||
|
for _, err := range results {
|
||||||
|
if err == nil {
|
||||||
|
successes++
|
||||||
|
} else if !errors.Is(err, role.ErrRoleConflict) {
|
||||||
|
t.Fatalf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successes != 1 {
|
||||||
|
t.Fatalf("expected exactly one winner, got %d", successes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete removes the role and its grants", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
created := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
||||||
|
rowsBefore := countRows(t, db, &models.RolePermission{})
|
||||||
|
if err := repo.Delete(created.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := repo.Get(created.ID); !errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
t.Fatalf("expected the role to be gone, got %v", err)
|
||||||
|
}
|
||||||
|
if countRows(t, db, &models.RolePermission{}) != rowsBefore-1 {
|
||||||
|
t.Error("the grant rows were not removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete refuses a role that still has members", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
createUserRow(t, db, 1, "", &created.ID)
|
||||||
|
if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) {
|
||||||
|
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.Get(created.ID); err != nil {
|
||||||
|
t.Fatalf("the role must still exist: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete reports a missing role", func(t *testing.T) {
|
||||||
|
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
|
||||||
|
if err := repo.Delete(9999); !errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
t.Fatalf("expected ErrRoleNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReassignMembers counts the moved users and touches only role_id", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||||
|
writer := storedRole(t, db, "Writer")
|
||||||
|
createUserRow(t, db, 1, "", &source.ID)
|
||||||
|
createUserRow(t, db, 2, "", &source.ID)
|
||||||
|
createUserRow(t, db, 3, "", &writer.ID)
|
||||||
|
before := userRow(t, db, 1)
|
||||||
|
moved, err := repo.ReassignMembers(source.ID, writer.ID)
|
||||||
|
if err != nil || moved != 2 {
|
||||||
|
t.Fatalf("expected 2 moved, got %d %v", moved, err)
|
||||||
|
}
|
||||||
|
after := userRow(t, db, 1)
|
||||||
|
if roleIDOf(after) != writer.ID {
|
||||||
|
t.Errorf("user 1 was not moved")
|
||||||
|
}
|
||||||
|
after.RoleID, before.RoleID = nil, nil
|
||||||
|
if !reflect.DeepEqual(before, after) {
|
||||||
|
t.Errorf("only role_id may change: %+v vs %+v", before, after)
|
||||||
|
}
|
||||||
|
if moved, _ := repo.ReassignMembers(source.ID, writer.ID); moved != 0 {
|
||||||
|
t.Errorf("a second move must move nobody, got %d", moved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveTypedGrants removes the five typed forms of one type only and bumps affected roles once", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
affected := rolesAPICreateRole(t, repo, "Events editor", "content.read=all", "content.update:events=own", "content.delete:events=none", "content.read:events=all", "content.create:events=all", "content.publish:events=own", "content.update:venues=all")
|
||||||
|
untouched := rolesAPICreateRole(t, repo, "Venues only", "content.update:venues=all", "content.read=all")
|
||||||
|
removed, err := repo.RemoveTypedGrants("events")
|
||||||
|
if err != nil || removed != 1 {
|
||||||
|
t.Fatalf("expected 1 affected role, got %d %v", removed, err)
|
||||||
|
}
|
||||||
|
stored, _ := repo.Get(affected.ID)
|
||||||
|
if !reflect.DeepEqual(rolesAPIGrantKeys(stored.Grants), []string{"content.read=all", "content.update:venues=all"}) || stored.Version != 2 {
|
||||||
|
t.Fatalf("unexpected affected role %v version %d", rolesAPIGrantKeys(stored.Grants), stored.Version)
|
||||||
|
}
|
||||||
|
other, _ := repo.Get(untouched.ID)
|
||||||
|
if other.Version != 1 || len(other.Grants) != 2 {
|
||||||
|
t.Fatalf("the other role must be untouched: %+v", other)
|
||||||
|
}
|
||||||
|
if again, _ := repo.RemoveTypedGrants("events"); again != 0 {
|
||||||
|
t.Errorf("a second removal must affect nothing, got %d", again)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a content type key that prefixes another is not matched", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
repo := persistence.NewRoleRepository(db)
|
||||||
|
kept := rolesAPICreateRole(t, repo, "Long key", "content.update:events_extra=all")
|
||||||
|
if removed, _ := repo.RemoveTypedGrants("events"); removed != 0 {
|
||||||
|
t.Fatalf("expected nothing removed, got %d", removed)
|
||||||
|
}
|
||||||
|
stored, _ := repo.Get(kept.ID)
|
||||||
|
if len(stored.Grants) != 1 {
|
||||||
|
t.Fatal("the grant of events_extra must be kept")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the new methods fail on a broken database", func(t *testing.T) {
|
||||||
|
for name, call := range map[string]func(*persistence.RoleRepository) error{
|
||||||
|
"Delete": func(r *persistence.RoleRepository) error { return r.Delete(4) },
|
||||||
|
"ReassignMembers": func(r *persistence.RoleRepository) error { _, err := r.ReassignMembers(4, 3); return err },
|
||||||
|
"RemoveTypedGrants": func(r *persistence.RoleRepository) error { _, err := r.RemoveTypedGrants("events"); return err },
|
||||||
|
} {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
for _, table := range []string{"role_permissions", "users"} {
|
||||||
|
if err := db.Migrator().DropTable(table); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
if err := call(persistence.NewRoleRepository(db)); err == nil || errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
t.Errorf("%s: expected a storage failure, got %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the role columns exist after Migrate", func(t *testing.T) {
|
||||||
|
db := openMigratedRolesDB(t)
|
||||||
|
for _, column := range []string{"updated_at", "updated_by"} {
|
||||||
|
if !db.Migrator().HasColumn(&models.Role{}, column) {
|
||||||
|
t.Errorf("roles.%s is missing", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) update(caller uint32, name string, roleName string, description string, version uint32, rows ...string) (useCases.RoleView, error) {
|
||||||
|
return w.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{
|
||||||
|
Caller: w.caller(caller), ID: w.ids[roleName], Name: name, Description: description, Version: version, Grants: grantRows(rows...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) create(caller uint32, name string, rows ...string) (useCases.RoleView, error) {
|
||||||
|
return w.container.CreateRoleUseCase.Create(context.Background(), useCases.CreateRoleCommand{Caller: w.caller(caller), Name: name, Grants: grantRows(rows...)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) remove(caller uint32, roleName string) error {
|
||||||
|
return w.container.DeleteRoleUseCase.Delete(context.Background(), useCases.DeleteRoleCommand{Caller: w.caller(caller), ID: w.ids[roleName]})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) reassign(caller uint32, source string, targetID uint32) (int, error) {
|
||||||
|
return w.container.ReassignRoleMembersUseCase.Reassign(context.Background(), useCases.ReassignRoleMembersCommand{Caller: w.caller(caller), SourceID: w.ids[source], TargetID: targetID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireEscalationKey(t *testing.T, err error, key string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := escalationKeyOf(t, err); key != "" && got != key {
|
||||||
|
t.Fatalf("expected the escalation key %q, got %q", key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAndGetRolesUseCases(t *testing.T) {
|
||||||
|
t.Run("the list holds every role by id with members counted through the legacy fallback", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.resetCounters()
|
||||||
|
listed, err := world.container.ListRolesUseCase.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if world.roles.calls["List"] != 1 {
|
||||||
|
t.Errorf("expected one List call, got %d", world.roles.calls["List"])
|
||||||
|
}
|
||||||
|
got := map[string]int{}
|
||||||
|
for index, item := range listed {
|
||||||
|
got[item.Name] = item.Members
|
||||||
|
if index > 0 && listed[index-1].ID >= item.ID {
|
||||||
|
t.Errorf("the list must be ordered by id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, members := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0, "Content lead": 1} {
|
||||||
|
if got[name] != members {
|
||||||
|
t.Errorf("%s: expected %d members, got %d", name, members, got[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
|
||||||
|
t.Errorf("expected 34 and 29 grants, got %d and %d", len(listed[0].Grants), len(listed[1].Grants))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a hand-placed owner-only key and duplicate rows are returned normalised", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
|
||||||
|
found, err := world.container.GetRoleUseCase.Get(created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(found.Grants, grantRows("content.update=own")) {
|
||||||
|
t.Fatalf("unexpected grants %v", found.Grants)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reading one role reports members, version and a missing role", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
found, err := world.container.GetRoleUseCase.Get(world.ids["Contributor"])
|
||||||
|
if err != nil || found.Members != 4 || found.Version != 1 {
|
||||||
|
t.Fatalf("unexpected role %+v %v", found, err)
|
||||||
|
}
|
||||||
|
if _, err := world.container.GetRoleUseCase.Get(raUnknownRoleID); !errors.Is(err, role.ErrRoleNotFound) {
|
||||||
|
t.Fatalf("expected ErrRoleNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updated_by names the display name, else the username, and is null for a deleted user", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
editor := raAdmin
|
||||||
|
stored := world.role("Contributor")
|
||||||
|
stored.UpdatedAt = world.now
|
||||||
|
stored.UpdatedBy = &editor
|
||||||
|
world.roles.roles[stored.ID] = stored
|
||||||
|
|
||||||
|
found, _ := world.container.GetRoleUseCase.Get(stored.ID)
|
||||||
|
if found.UpdatedBy == nil || found.UpdatedBy.ID != raAdmin || found.UpdatedBy.Name != world.users.users[raAdmin].Username {
|
||||||
|
t.Fatalf("expected the username, got %+v", found.UpdatedBy)
|
||||||
|
}
|
||||||
|
if found.UpdatedAt == nil || !found.UpdatedAt.Equal(world.now) {
|
||||||
|
t.Fatalf("unexpected updated_at %v", found.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
named := world.users.users[raAdmin]
|
||||||
|
named.DisplayName = " Marta "
|
||||||
|
world.users.users[raAdmin] = named
|
||||||
|
found, _ = world.container.GetRoleUseCase.Get(stored.ID)
|
||||||
|
if found.UpdatedBy.Name != "Marta" {
|
||||||
|
t.Fatalf("expected the display name, got %q", found.UpdatedBy.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(world.users.users, raAdmin)
|
||||||
|
found, _ = world.container.GetRoleUseCase.Get(stored.ID)
|
||||||
|
if found.UpdatedBy != nil {
|
||||||
|
t.Fatalf("expected a null editor, got %+v", found.UpdatedBy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a role never edited has no editor and no time", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
found, _ := world.container.GetRoleUseCase.Get(world.ids["Contributor"])
|
||||||
|
if found.UpdatedBy != nil || found.UpdatedAt != nil {
|
||||||
|
t.Fatalf("expected null editor and time, got %+v %v", found.UpdatedBy, found.UpdatedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a storage failure is returned", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("List")
|
||||||
|
if _, err := world.container.ListRolesUseCase.List(); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
world.roles.fail.failNext("Get")
|
||||||
|
if _, err := world.container.GetRoleUseCase.Get(world.ids["Contributor"]); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRoleUseCase(t *testing.T) {
|
||||||
|
validation := []struct {
|
||||||
|
name string
|
||||||
|
rows []string
|
||||||
|
want error
|
||||||
|
typed bool
|
||||||
|
}{
|
||||||
|
{"content.update=all", []string{"content.update=all"}, nil, false},
|
||||||
|
{"content.update:events=own", []string{"content.update:events=own"}, nil, false},
|
||||||
|
{"content.update:article=none", []string{"content.update:article=none"}, nil, false},
|
||||||
|
{"typed plus wildcard", []string{"content.update:events=all", "content.update=own"}, nil, false},
|
||||||
|
{"content.explode=all", []string{"content.explode=all"}, role.ErrUnknownPermission, false},
|
||||||
|
{"schema.view:events=all", []string{"schema.view:events=all"}, role.ErrUnknownPermission, false},
|
||||||
|
{"content.update:Bad Key=all", []string{"content.update:Bad Key=all"}, role.ErrUnknownPermission, false},
|
||||||
|
{"content.update:nope=all", []string{"content.update:nope=all"}, role.ErrUnknownContentType, false},
|
||||||
|
{"content.update:author=all", []string{"content.update:author=all"}, role.ErrUnknownContentType, false},
|
||||||
|
{"content.update=none", []string{"content.update=none"}, role.ErrInvalidScope, false},
|
||||||
|
{"schema.view=own", []string{"schema.view=own"}, role.ErrInvalidScope, false},
|
||||||
|
{"content.update=banana", []string{"content.update=banana"}, role.ErrInvalidScope, false},
|
||||||
|
{"content.create:events=own", []string{"content.create:events=own"}, role.ErrInvalidScope, false},
|
||||||
|
{"settings.delete_site=all", []string{"settings.delete_site=all"}, role.ErrOwnerOnlyGrant, false},
|
||||||
|
{"system.apply_updates=all", []string{"system.apply_updates=all"}, role.ErrOwnerOnlyGrant, false},
|
||||||
|
{"duplicate wildcard", []string{"content.update=all", "content.update=own"}, role.ErrDuplicateGrant, false},
|
||||||
|
{"duplicate typed", []string{"content.update:events=all", "content.update:events=own"}, role.ErrDuplicateGrant, false},
|
||||||
|
}
|
||||||
|
for _, c := range validation {
|
||||||
|
t.Run("Admin creating a role with "+c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
before := len(world.roles.roles)
|
||||||
|
created, err := world.create(raAdmin, "Probe", c.rows...)
|
||||||
|
if !errors.Is(err, c.want) || (c.want == nil && err != nil) {
|
||||||
|
t.Fatalf("expected %v, got %v", c.want, err)
|
||||||
|
}
|
||||||
|
if c.want != nil {
|
||||||
|
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("a refused create must store nothing and record nothing")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if created.BuiltIn || created.Locked || created.Version != 1 || created.Members != 0 {
|
||||||
|
t.Fatalf("unexpected role %+v", created)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 {
|
||||||
|
t.Fatalf("expected one event, got %d", len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("the grants come back in catalogue order and a client cannot set the flags", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created, err := world.container.CreateRoleUseCase.Create(context.Background(), useCases.CreateRoleCommand{
|
||||||
|
Caller: world.caller(raAdmin), Name: "Writer plus", Description: "Writes", Grants: grantRows("media.view=all", "content.update=own", "content.read=all"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(created.Grants, grantRows("content.read=all", "content.update=own", "media.view=all")) {
|
||||||
|
t.Fatalf("unexpected grants %v", created.Grants)
|
||||||
|
}
|
||||||
|
stored := world.roles.roles[created.ID]
|
||||||
|
if stored.BuiltIn || stored.Locked || stored.Version != 1 || stored.UpdatedBy != nil {
|
||||||
|
t.Fatalf("unexpected stored role %+v", stored)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a new role may start with nothing granted", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created, err := world.create(raAdmin, "Empty")
|
||||||
|
if err != nil || created.Grants == nil || len(created.Grants) != 0 {
|
||||||
|
t.Fatalf("expected empty grants, got %+v %v", created.Grants, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("names are trimmed, blank ones refused and duplicates found case-insensitively", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created, err := world.create(raAdmin, " Reviewer ")
|
||||||
|
if err != nil || created.Name != "Reviewer" {
|
||||||
|
t.Fatalf("expected the trimmed name, got %q %v", created.Name, err)
|
||||||
|
}
|
||||||
|
if _, err := world.create(raAdmin, " "); !errors.Is(err, role.ErrInvalidRoleName) {
|
||||||
|
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Writer", "WRITER", " contributor ", "reviewer"} {
|
||||||
|
if _, err := world.create(raAdmin, name); !errors.Is(err, role.ErrRoleNameTaken) {
|
||||||
|
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no-escalation applies to a create", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
cases := []struct {
|
||||||
|
caller uint32
|
||||||
|
rows []string
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{raRoleManager, []string{"content.publish=all"}, "content.publish"},
|
||||||
|
{raRoleManager, []string{"content.delete=own"}, "content.delete"},
|
||||||
|
{raRoleManager, []string{"schema.edit=all"}, "schema.edit"},
|
||||||
|
{raRoleManager, []string{"content.publish:events=all"}, "content.publish:events"},
|
||||||
|
{raTypedManager, []string{"content.update=all"}, "content.update:article"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
_, err := world.create(c.caller, "Escalating", c.rows...)
|
||||||
|
requireEscalationKey(t, err, c.key)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
caller uint32
|
||||||
|
rows []string
|
||||||
|
}{
|
||||||
|
{raRoleManager, []string{"content.publish=own"}},
|
||||||
|
{raRoleManager, []string{"content.publish:events=none"}},
|
||||||
|
{raOwner, []string{"content.delete=all"}},
|
||||||
|
} {
|
||||||
|
name := "Fine " + c.rows[0] + string(rune('a'+c.caller))
|
||||||
|
if _, err := world.create(c.caller, name, c.rows...); err != nil {
|
||||||
|
t.Errorf("%v: expected success, got %v", c.rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the order of checks is grants, covers, then the name", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
if _, err := world.create(raRoleManager, "Writer", "content.explode=all"); !errors.Is(err, role.ErrUnknownPermission) {
|
||||||
|
t.Fatalf("expected the validation first, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := world.create(raRoleManager, "Writer", "content.publish=all"); !errors.Is(err, role.ErrEscalation) {
|
||||||
|
t.Fatalf("expected the escalation before the name, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the create event carries the target and every granted entry", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created, _ := world.create(raAdmin, "Reviewer", "content.read=all", "content.update=own")
|
||||||
|
event := world.recorder.events[0]
|
||||||
|
if event.Action != "role.create" || event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess {
|
||||||
|
t.Fatalf("unexpected event %+v", event)
|
||||||
|
}
|
||||||
|
if event.Target.Type != "role" || event.Target.Name != "Reviewer" || event.Target.ID != strconv.FormatUint(uint64(created.ID), 10) {
|
||||||
|
t.Fatalf("unexpected target %+v", event.Target)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(event.Params[audit.ParamAdded], created.Grants) {
|
||||||
|
t.Fatalf("unexpected added %v", event.Params[audit.ParamAdded])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a storage failure records a failed event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("Create")
|
||||||
|
if _, err := world.create(raAdmin, "Reviewer"); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].FailureReason == nil {
|
||||||
|
t.Fatalf("expected one failed event, got %+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRoleUseCase(t *testing.T) {
|
||||||
|
contributor := rolesAPIContributorRows
|
||||||
|
|
||||||
|
t.Run("editing a role", func(t *testing.T) {
|
||||||
|
rows := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
target string
|
||||||
|
newName string
|
||||||
|
description string
|
||||||
|
version uint32
|
||||||
|
grants []string
|
||||||
|
wantErr error
|
||||||
|
wantVersion uint32
|
||||||
|
}{
|
||||||
|
{"grants plus delete own", raAdmin, "Contributor", "Contributor", "", 1, contributor("content.delete=own"), nil, 2},
|
||||||
|
{"grants minus update", raAdmin, "Contributor", "Contributor", "", 1, []string{"content.read=all", "content.create=all"}, nil, 2},
|
||||||
|
{"description changed", raAdmin, "Contributor", "Contributor", "new", 1, contributor(), nil, 2},
|
||||||
|
{"nothing changed", raAdmin, "Contributor", "Contributor", "", 1, contributor(), nil, 1},
|
||||||
|
{"nothing changed on a stale version", raAdmin, "Contributor", "Contributor", "", 7, contributor(), role.ErrRoleConflict, 1},
|
||||||
|
{"body changed on a stale version", raAdmin, "Contributor", "Contributor", "x", 7, contributor(), role.ErrRoleConflict, 1},
|
||||||
|
{"name taken", raAdmin, "Contributor", "writer", "", 1, contributor(), role.ErrRoleNameTaken, 1},
|
||||||
|
{"case-only rename", raAdmin, "Contributor", "contributor", "", 1, contributor(), nil, 2},
|
||||||
|
{"name of another custom role", raAdmin, "Contributor", "Intern", "", 1, contributor(), role.ErrRoleNameTaken, 1},
|
||||||
|
{"blank name", raAdmin, "Contributor", " ", "", 1, contributor(), role.ErrInvalidRoleName, 1},
|
||||||
|
{"unknown key", raAdmin, "Contributor", "Contributor", "", 1, []string{"content.explode=all"}, role.ErrUnknownPermission, 1},
|
||||||
|
{"owner edits the writer description", raOwner, "Writer", "Writer", "d", 1, writerRows(), nil, 2},
|
||||||
|
{"owner adds media.delete=own to the writer", raOwner, "Writer", "Writer", "", 1, append(writerRows(), "media.delete=own"), nil, 2},
|
||||||
|
{"owner renames a built-in", raOwner, "Writer", "Author", "", 1, writerRows(), role.ErrBuiltInName, 1},
|
||||||
|
{"owner adds an owner-only key to admin", raOwner, "Admin", "Admin", "", 1, append(adminRows(), "system.apply_updates=all"), role.ErrOwnerOnlyGrant, 1},
|
||||||
|
{"owner removes users.remove from admin", raOwner, "Admin", "Admin", "", 1, withoutRow(adminRows(), "users.remove=all"), nil, 2},
|
||||||
|
{"owner edits the owner", raOwner, "Owner", "Owner", "", 1, nil, role.ErrRoleLocked, 1},
|
||||||
|
{"admin edits the owner", raAdmin, "Owner", "Owner", "", 1, nil, role.ErrRoleLocked, 1},
|
||||||
|
{"admin edits the writer", raAdmin, "Writer", "Writer", "d", 1, writerRows(), role.ErrMissingPermission, 1},
|
||||||
|
{"admin edits their own built-in role", raAdmin, "Admin", "Admin", "d", 1, adminRows(), role.ErrMissingPermission, 1},
|
||||||
|
{"role manager edits their own role", raRoleManager, "Role manager", "Role manager", "d", 1, nil, role.ErrOwnRole, 1},
|
||||||
|
{"unknown role", raAdmin, "Missing", "X", "", 1, nil, role.ErrRoleNotFound, 1},
|
||||||
|
}
|
||||||
|
for _, c := range rows {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
updated, err := world.update(c.caller, c.newName, c.target, c.description, c.version, c.grants...)
|
||||||
|
if c.wantErr != nil {
|
||||||
|
if !errors.Is(err, c.wantErr) {
|
||||||
|
t.Fatalf("expected %v, got %v", c.wantErr, err)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("a refusal must record nothing")
|
||||||
|
}
|
||||||
|
if c.target != "Missing" && world.role(c.target).Version != 1 {
|
||||||
|
t.Fatalf("a refusal must not bump the version")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || updated.Version != c.wantVersion {
|
||||||
|
t.Fatalf("expected version %d, got %+v %v", c.wantVersion, updated, err)
|
||||||
|
}
|
||||||
|
if updated.ID != world.ids[c.target] || updated.BuiltIn != world.role(c.target).BuiltIn || updated.Locked {
|
||||||
|
t.Fatalf("id and flags must be unchanged: %+v", updated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a missing-permission refusal names roles.edit_owner_only", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
_, err := world.update(raAdmin, "Writer", "Writer", "d", 1, writerRows()...)
|
||||||
|
requireMissingKey(t, err, role.KeyRolesEditOwnerOnly)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no-escalation on edit", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
caller uint32
|
||||||
|
extra []string
|
||||||
|
wantKey string
|
||||||
|
}{
|
||||||
|
{raRoleManager, []string{"content.publish=own"}, ""},
|
||||||
|
{raRoleManager, []string{"content.publish=all"}, "content.publish"},
|
||||||
|
{raRoleManager, []string{"content.delete=own"}, "content.delete"},
|
||||||
|
{raRoleManager, []string{"schema.edit=all"}, "schema.edit"},
|
||||||
|
{raRoleManager, []string{"content.publish:events=none"}, ""},
|
||||||
|
{raRoleManager, []string{"content.publish:events=all"}, "content.publish:events"},
|
||||||
|
{raRoleManager, []string{"content.update:events=own"}, ""},
|
||||||
|
{raRoleManager, []string{"content.update:events=all"}, "content.update:events"},
|
||||||
|
{raAdmin, []string{"content.delete=all"}, ""},
|
||||||
|
{raOwner, []string{"content.delete=all"}, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.extra[0]+" by "+string(rune('A'+c.caller)), func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
before := world.role("Contributor")
|
||||||
|
_, err := world.update(c.caller, "Contributor", "Contributor", "", 1, contributor(c.extra...)...)
|
||||||
|
if c.wantKey == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requireEscalationKey(t, err, c.wantKey)
|
||||||
|
if !reflect.DeepEqual(world.role("Contributor"), before) || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("a refused call changes nothing and records nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a typed-all wildcard raise is an escalation on the article, a typed none is not", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
_, err := world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all")
|
||||||
|
requireEscalationKey(t, err, "content.update:article")
|
||||||
|
_, err = world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own")
|
||||||
|
requireEscalationKey(t, err, "content.update:article")
|
||||||
|
if _, err = world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none"); err != nil {
|
||||||
|
t.Fatalf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the existing role must be covered by the caller", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
_, err := world.update(raNarrowManager, "Contributor", "Contributor", "", 1, contributor()...)
|
||||||
|
requireEscalationKey(t, err, "content.read")
|
||||||
|
_, err = world.update(raRoleManager, "Content lead", "Content lead", "", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
|
||||||
|
requireEscalationKey(t, err, "content.update")
|
||||||
|
if _, err = world.update(raAdmin, "Content lead", "Content lead", "d", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all"); err != nil {
|
||||||
|
t.Fatalf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err = world.update(raRoleManager, "Contributor", "Contributor", "harmless", 1, contributor()...); err != nil {
|
||||||
|
t.Fatalf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the order of checks is observable through the fake counters", func(t *testing.T) {
|
||||||
|
refused := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
target string
|
||||||
|
desc string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"locked", raAdmin, "Owner", "", role.ErrRoleLocked},
|
||||||
|
{"built-in", raAdmin, "Writer", "d", role.ErrMissingPermission},
|
||||||
|
{"own role", raRoleManager, "Role manager", "d", role.ErrOwnRole},
|
||||||
|
}
|
||||||
|
for _, c := range refused {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.resetCounters()
|
||||||
|
if _, err := world.update(c.caller, c.target, c.target, c.desc, 1); !errors.Is(err, c.want) {
|
||||||
|
t.Fatalf("%s: expected %v, got %v", c.name, c.want, err)
|
||||||
|
}
|
||||||
|
if world.roles.calls["Save"] != 0 || world.types.listCalls != 0 || world.roles.calls["List"] != 0 {
|
||||||
|
t.Errorf("%s: a refusal before the cover check must not list content types or roles, nor save", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.resetCounters()
|
||||||
|
if _, err := world.update(raRoleManager, "Content lead", "Content lead", "", 1); !errors.Is(err, role.ErrEscalation) {
|
||||||
|
t.Fatalf("expected an escalation, got %v", err)
|
||||||
|
}
|
||||||
|
if world.roles.calls["Save"] != 0 {
|
||||||
|
t.Error("an escalation must not save")
|
||||||
|
}
|
||||||
|
if _, err := world.update(raRoleManager, "Contributor", "Contributor", "", 7, contributor()...); !errors.Is(err, role.ErrRoleConflict) {
|
||||||
|
t.Fatalf("expected a conflict, got %v", err)
|
||||||
|
}
|
||||||
|
if world.roles.calls["Save"] != 0 {
|
||||||
|
t.Error("a stale version must not save")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unchanged edit writes nothing and records nothing", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.resetCounters()
|
||||||
|
unchanged, err := world.update(raAdmin, "Contributor", "Contributor", "", 1, contributor()...)
|
||||||
|
if err != nil || unchanged.Version != 1 || world.roles.calls["Save"] != 0 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected result %+v %v saves=%d events=%d", unchanged, err, world.roles.calls["Save"], len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the editor and time are stored", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
updated, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...)
|
||||||
|
if err != nil || updated.UpdatedBy == nil || updated.UpdatedBy.ID != raAdmin || updated.UpdatedAt == nil || !updated.UpdatedAt.Equal(world.now) {
|
||||||
|
t.Fatalf("unexpected editor %+v at %v: %v", updated.UpdatedBy, updated.UpdatedAt, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a save that loses the race gives a conflict", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.beforeSave = func() {
|
||||||
|
stored := world.role("Contributor")
|
||||||
|
stored.Version = 2
|
||||||
|
world.roles.roles[stored.ID] = stored
|
||||||
|
}
|
||||||
|
if _, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...); !errors.Is(err, role.ErrRoleConflict) {
|
||||||
|
t.Fatalf("expected ErrRoleConflict, got %v", err)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 0 {
|
||||||
|
t.Error("a conflict is a refusal and records nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the edit event holds the diff, the text changes and the members", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
created, err := world.create(raAdmin, "Reviewer", "content.read=all", "content.update=own", "media.view=all")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
world.recorder.events = nil
|
||||||
|
edited, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{
|
||||||
|
Caller: world.caller(raAdmin), ID: created.ID, Name: "Reviewers", Description: "Reads", Version: 1,
|
||||||
|
Grants: grantRows("content.read=all", "content.update=all", "content.delete=own"),
|
||||||
|
})
|
||||||
|
if err != nil || edited.Version != 2 {
|
||||||
|
t.Fatalf("unexpected edit %+v %v", edited, err)
|
||||||
|
}
|
||||||
|
event := world.recorder.events[0]
|
||||||
|
if event.Action != "role.edit" || event.Category != audit.CategoryRoles || event.Target.Name != "Reviewers" {
|
||||||
|
t.Fatalf("unexpected event %+v", event)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(event.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
|
||||||
|
!reflect.DeepEqual(event.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
|
||||||
|
!reflect.DeepEqual(event.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) ||
|
||||||
|
event.Params[audit.ParamMembers] != 0 {
|
||||||
|
t.Fatalf("unexpected params %v", event.Params)
|
||||||
|
}
|
||||||
|
fields := map[string][2]string{}
|
||||||
|
for _, change := range event.Changes {
|
||||||
|
fields[change.Field] = [2]string{*change.Before, *change.After}
|
||||||
|
}
|
||||||
|
if fields[audit.FieldName] != [2]string{"Reviewer", "Reviewers"} || fields[audit.FieldDescription] != [2]string{"", "Reads"} {
|
||||||
|
t.Fatalf("unexpected changes %v", fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a storage failure at the write records a failed event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("Save")
|
||||||
|
if _, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].Action != "role.edit" {
|
||||||
|
t.Fatalf("unexpected events %+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a legacy admin is not mistaken for the role they edit", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
_, err := world.update(raLegacyAdmin, "Admin", "Admin", "d", 1, adminRows()...)
|
||||||
|
requireMissingKey(t, err, role.KeyRolesEditOwnerOnly)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writerRows() []string {
|
||||||
|
rows := []string{}
|
||||||
|
for _, grant := range role.BuiltInRoles()[2].Grants {
|
||||||
|
rows = append(rows, grant.Key+"="+string(grant.Scope))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminRows() []string {
|
||||||
|
rows := []string{}
|
||||||
|
for _, grant := range role.BuiltInRoles()[1].Grants {
|
||||||
|
rows = append(rows, grant.Key+"="+string(grant.Scope))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutRow(rows []string, removed string) []string {
|
||||||
|
kept := []string{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row != removed {
|
||||||
|
kept = append(kept, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetRoleUseCase(t *testing.T) {
|
||||||
|
reset := func(world *rolesAPIWorld, caller uint32, roleID uint32, version uint32) (useCases.RoleView, error) {
|
||||||
|
return world.container.UpdateRoleUseCase.Reset(context.Background(), useCases.ResetRoleCommand{Caller: world.caller(caller), ID: roleID, Version: version})
|
||||||
|
}
|
||||||
|
edit := func(world *rolesAPIWorld, name string) {
|
||||||
|
if _, err := world.update(raOwner, name, name, "custom", 1, "content.read=all"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
world.recorder.events = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("the owner restores the seeded grants and description, bumping the version once", func(t *testing.T) {
|
||||||
|
for _, name := range []string{"Writer", "Admin"} {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
edit(world, name)
|
||||||
|
restored, err := reset(world, raOwner, world.ids[name], 2)
|
||||||
|
if err != nil || restored.Version != 3 || restored.Description != "" || restored.Name != name || !restored.BuiltIn {
|
||||||
|
t.Fatalf("%s: unexpected role %+v %v", name, restored, err)
|
||||||
|
}
|
||||||
|
seeded := role.Role{Grants: world.roles.byName(name).Grants}
|
||||||
|
want := role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants
|
||||||
|
if !reflect.DeepEqual(seeded.EffectiveGrants(), role.Role{Grants: want}.EffectiveGrants()) {
|
||||||
|
t.Fatalf("%s: grants are not the seeded ones", name)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.edit" || world.recorder.events[0].Target.Name != name {
|
||||||
|
t.Fatalf("expected one role.edit event, got %+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a pristine role is a no-op", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.resetCounters()
|
||||||
|
same, err := reset(world, raOwner, world.ids["Writer"], 1)
|
||||||
|
if err != nil || same.Version != 1 || world.roles.calls["Save"] != 0 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected result %+v %v", same, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refusals", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
edit(world, "Writer")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
id uint32
|
||||||
|
version uint32
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"stale version", raOwner, world.ids["Writer"], 1, role.ErrRoleConflict},
|
||||||
|
{"owner role", raOwner, world.ids["Owner"], 1, role.ErrRoleLocked},
|
||||||
|
{"custom role", raOwner, world.ids["Contributor"], 1, role.ErrNotBuiltIn},
|
||||||
|
{"missing role", raOwner, raUnknownRoleID, 1, role.ErrRoleNotFound},
|
||||||
|
{"admin caller", raAdmin, world.ids["Writer"], 2, role.ErrMissingPermission},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := reset(world, c.caller, c.id, c.version); !errors.Is(err, c.want) {
|
||||||
|
t.Errorf("%s: expected %v, got %v", c.name, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 0 {
|
||||||
|
t.Error("a refused reset records nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRoleUseCase(t *testing.T) {
|
||||||
|
rows := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
target string
|
||||||
|
want error
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{"owner deletes intern", raOwner, "Intern", nil, ""},
|
||||||
|
{"admin deletes intern", raAdmin, "Intern", nil, ""},
|
||||||
|
{"admin deletes contributor", raAdmin, "Contributor", role.ErrRoleHasMembers, ""},
|
||||||
|
{"owner deletes writer", raOwner, "Writer", role.ErrBuiltInRole, ""},
|
||||||
|
{"owner deletes admin", raOwner, "Admin", role.ErrBuiltInRole, ""},
|
||||||
|
{"owner deletes owner", raOwner, "Owner", role.ErrRoleLocked, ""},
|
||||||
|
{"admin deletes owner", raAdmin, "Owner", role.ErrRoleLocked, ""},
|
||||||
|
{"admin deletes a missing role", raAdmin, "Missing", role.ErrRoleNotFound, ""},
|
||||||
|
{"role manager deletes their own role", raRoleManager, "Role manager", role.ErrOwnRole, ""},
|
||||||
|
{"role manager deletes content lead", raRoleManager, "Content lead", role.ErrEscalation, "content.update"},
|
||||||
|
{"role manager deletes intern", raRoleManager, "Intern", nil, ""},
|
||||||
|
}
|
||||||
|
for _, c := range rows {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
err := world.remove(c.caller, c.target)
|
||||||
|
if c.want == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
if _, gone := world.roles.roles[world.ids[c.target]]; gone {
|
||||||
|
t.Fatal("the role must be gone")
|
||||||
|
}
|
||||||
|
event := world.recorder.events[0]
|
||||||
|
if event.Action != "role.delete" || event.Params[audit.ParamMembers] != 0 || event.Target.Name != c.target {
|
||||||
|
t.Fatalf("unexpected event %+v", event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.Is(err, c.want) {
|
||||||
|
t.Fatalf("expected %v, got %v", c.want, err)
|
||||||
|
}
|
||||||
|
if c.key != "" {
|
||||||
|
requireEscalationKey(t, err, c.key)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 0 {
|
||||||
|
t.Fatal("a refusal records nothing")
|
||||||
|
}
|
||||||
|
if c.want != role.ErrRoleNotFound {
|
||||||
|
if _, kept := world.roles.roles[world.ids[c.target]]; !kept {
|
||||||
|
t.Fatal("a refused delete deletes nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a member appearing meanwhile still gives role_has_members and no event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
|
||||||
|
if err := world.remove(raAdmin, "Intern"); !errors.Is(err, role.ErrRoleHasMembers) || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected result %v events=%d", err, len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a storage failure records a failed event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("Delete")
|
||||||
|
if err := world.remove(raAdmin, "Intern"); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
|
||||||
|
t.Fatalf("unexpected events %+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReassignRoleMembersUseCase(t *testing.T) {
|
||||||
|
t.Run("moving the contributors to Writer", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
before := world.users.users[raContributorOne]
|
||||||
|
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
|
||||||
|
if err != nil || moved != 4 {
|
||||||
|
t.Fatalf("expected 4 moved, got %d %v", moved, err)
|
||||||
|
}
|
||||||
|
after := world.users.users[raContributorOne]
|
||||||
|
if after.RoleID != world.ids["Writer"] {
|
||||||
|
t.Fatal("the member was not moved")
|
||||||
|
}
|
||||||
|
before.RoleID = after.RoleID
|
||||||
|
if !reflect.DeepEqual(before, after) {
|
||||||
|
t.Fatal("only role_id may change")
|
||||||
|
}
|
||||||
|
event := world.recorder.events[0]
|
||||||
|
if event.Action != "role.reassign" || event.Params[audit.ParamFrom] != world.ids["Contributor"] || event.Params[audit.ParamTo] != world.ids["Writer"] || event.Params[audit.ParamMoved] != 4 {
|
||||||
|
t.Fatalf("unexpected event %+v", event)
|
||||||
|
}
|
||||||
|
if err := world.remove(raAdmin, "Contributor"); err != nil {
|
||||||
|
t.Fatalf("the emptied role must be deletable, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("moving nobody records nothing", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
moved, err := world.reassign(raAdmin, "Intern", world.ids["Writer"])
|
||||||
|
if err != nil || moved != 0 || len(world.recorder.events) != 0 {
|
||||||
|
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
refusals := []struct {
|
||||||
|
name string
|
||||||
|
caller uint32
|
||||||
|
source string
|
||||||
|
target func(*rolesAPIWorld) uint32
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"above the caller's grants", raRoleManager, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Admin"] }, role.ErrEscalation},
|
||||||
|
{"owner as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Owner"] }, role.ErrInvalidReassign},
|
||||||
|
{"same role as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Contributor"] }, role.ErrInvalidReassign},
|
||||||
|
{"unknown target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return raUnknownRoleID }, role.ErrInvalidReassign},
|
||||||
|
{"zero target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return 0 }, role.ErrInvalidReassign},
|
||||||
|
{"built-in source", raAdmin, "Writer", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrBuiltInRole},
|
||||||
|
{"owner source", raAdmin, "Owner", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrRoleLocked},
|
||||||
|
{"unknown source", raAdmin, "Missing", func(w *rolesAPIWorld) uint32 { return w.ids["Writer"] }, role.ErrRoleNotFound},
|
||||||
|
{"own role as source", raRoleManager, "Role manager", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrOwnRole},
|
||||||
|
{"source above the caller", raRoleManager, "Content lead", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrEscalation},
|
||||||
|
}
|
||||||
|
for _, c := range refusals {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
moved, err := world.reassign(c.caller, c.source, c.target(world))
|
||||||
|
if !errors.Is(err, c.want) || moved != 0 {
|
||||||
|
t.Fatalf("expected %v, got %d %v", c.want, moved, err)
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 0 || len(world.members("Contributor")) != 4 {
|
||||||
|
t.Fatal("a refusal moves nobody and records nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a role manager may move members to a role they cover", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
moved, err := world.reassign(raRoleManager, "Contributor", world.ids["Intern"])
|
||||||
|
if err != nil || moved != 4 {
|
||||||
|
t.Fatalf("expected 4 moved, got %d %v", moved, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a repository failure moves nobody and records a failed event", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("ReassignMembers")
|
||||||
|
if _, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"]); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
if len(world.members("Contributor")) != 4 {
|
||||||
|
t.Fatal("nobody may be moved")
|
||||||
|
}
|
||||||
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].Action != "role.reassign" {
|
||||||
|
t.Fatalf("unexpected events %+v", world.recorder.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentTypeTypedGrantCleanup(t *testing.T) {
|
||||||
|
t.Run("deleting a content type removes its typed rows and bumps only the affected roles", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
editor := world.role("Events editor")
|
||||||
|
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
|
||||||
|
t.Fatalf("unexpected editor role %v version %d", editor.Grants, editor.Version)
|
||||||
|
}
|
||||||
|
if world.role("Contributor").Version != 1 {
|
||||||
|
t.Fatal("a role without typed rows keeps its version")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a refused delete keeps the typed rows", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.entries.entries[1] = entryOfType(raEventsTypeID)
|
||||||
|
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err == nil {
|
||||||
|
t.Fatal("expected a refusal")
|
||||||
|
}
|
||||||
|
if world.role("Events editor").Version != 1 || world.roles.calls["RemoveTypedGrants"] != 0 {
|
||||||
|
t.Fatal("nothing may be removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failure while removing the rows is reported", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
world.roles.fail.failNext("RemoveTypedGrants")
|
||||||
|
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("creating a content type clears the stale typed rows of its key", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
delete(world.types.types, raEventsTypeID)
|
||||||
|
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.update:events=none")})
|
||||||
|
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := world.roles.roles[stale.ID]; len(got.Grants) != 0 || got.Version != 2 {
|
||||||
|
t.Fatalf("unexpected role %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a refused create keeps the rows", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the key to be taken")
|
||||||
|
}
|
||||||
|
if world.roles.calls["RemoveTypedGrants"] != 0 {
|
||||||
|
t.Fatal("nothing may be removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMyAccessTypedGrants(t *testing.T) {
|
||||||
|
t.Run("the events editor lists typed entries including none, in order", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
access, err := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[raEventsEditor])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
|
||||||
|
if !reflect.DeepEqual(access.Permissions, want) {
|
||||||
|
t.Fatalf("expected %v, got %v", want, access.Permissions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) {
|
||||||
|
world := newRolesAPIWorld(t)
|
||||||
|
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")})
|
||||||
|
world.addUser(60, user.RoleAdmin, hand.ID, true)
|
||||||
|
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
|
||||||
|
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
|
||||||
|
t.Fatalf("unexpected permissions %v", access.Permissions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/contenttype"
|
||||||
|
"RenewCMS/internal/domain/entry"
|
||||||
|
"RenewCMS/internal/domain/role"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
raOwner uint32 = 3
|
||||||
|
raAdmin uint32 = 5
|
||||||
|
raWriter uint32 = 9
|
||||||
|
raLegacyAdmin uint32 = 10
|
||||||
|
raUnverified uint32 = 12
|
||||||
|
raRoleManager uint32 = 31
|
||||||
|
raViewer uint32 = 32
|
||||||
|
raTypedManager uint32 = 33
|
||||||
|
raNarrowManager uint32 = 34
|
||||||
|
raEventsEditor uint32 = 35
|
||||||
|
raContributorOne uint32 = 41
|
||||||
|
raContentLead uint32 = 45
|
||||||
|
raEventsTypeID uint32 = 1
|
||||||
|
raVenuesTypeID uint32 = 2
|
||||||
|
raUnknownRoleID uint32 = 999
|
||||||
|
)
|
||||||
|
|
||||||
|
type rolesAPIRoleSpec struct {
|
||||||
|
name string
|
||||||
|
grants []string
|
||||||
|
members []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
var rolesAPICustomRoles = []rolesAPIRoleSpec{
|
||||||
|
{"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, []uint32{41, 42, 43, 44}},
|
||||||
|
{"Intern", []string{"content.read=own"}, nil},
|
||||||
|
{"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=all"}, []uint32{raContentLead}},
|
||||||
|
{"Role manager", []string{"roles.view=all", "roles.manage=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all"}, []uint32{raRoleManager}},
|
||||||
|
{"Viewer", []string{"roles.view=all"}, []uint32{raViewer}},
|
||||||
|
{"Typed manager", []string{"roles.view=all", "roles.manage=all", "content.read=all", "content.update=all", "content.update:article=none"}, []uint32{raTypedManager}},
|
||||||
|
{"Narrow manager", []string{"roles.view=all", "roles.manage=all", "content.read:events=all"}, []uint32{raNarrowManager}},
|
||||||
|
{"Events editor", []string{"content.read=all", "content.update:events=own", "content.delete:events=none", "content.update:venues=all"}, []uint32{raEventsEditor}},
|
||||||
|
{"Typed target", []string{"content.read=all", "content.update=own", "content.update:article=none"}, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
type rolesAPIWorld struct {
|
||||||
|
*apiKeyEnv
|
||||||
|
recorder *contentScopeRecorder
|
||||||
|
types *mockContentTypeRepo
|
||||||
|
entries *mockEntryRepo
|
||||||
|
ids map[string]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRolesAPIWorld(t *testing.T) *rolesAPIWorld {
|
||||||
|
t.Helper()
|
||||||
|
recorder := &contentScopeRecorder{}
|
||||||
|
env := newAPIKeyEnvRecording(t, recorder)
|
||||||
|
world := &rolesAPIWorld{apiKeyEnv: env, recorder: recorder, types: newMockContentTypeRepo(), entries: newMockEntryRepo(), ids: map[string]uint32{}}
|
||||||
|
env.users.users = map[uint32]user.User{}
|
||||||
|
env.roles.users = env.users
|
||||||
|
world.seedBuiltIns()
|
||||||
|
world.seedRoles()
|
||||||
|
world.seedContentTypes()
|
||||||
|
world.wire()
|
||||||
|
return world
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) seedBuiltIns() {
|
||||||
|
w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID
|
||||||
|
w.addUser(raOwner, user.RoleOwner, ownerRoleID, true)
|
||||||
|
w.addUser(raAdmin, user.RoleAdmin, adminRoleID, true)
|
||||||
|
w.addUser(raLegacyAdmin, user.RoleAdmin, 0, true)
|
||||||
|
w.addUser(raWriter, "", writerRoleID, true)
|
||||||
|
w.addUser(raUnverified, user.RoleAdmin, adminRoleID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) seedRoles() {
|
||||||
|
for _, spec := range rolesAPICustomRoles {
|
||||||
|
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
|
||||||
|
w.ids[spec.name] = created.ID
|
||||||
|
for _, member := range spec.members {
|
||||||
|
w.addUser(member, "", created.ID, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) seedContentTypes() {
|
||||||
|
title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}
|
||||||
|
w.types.types[raEventsTypeID] = contenttype.ContentType{ID: raEventsTypeID, Key: "events", Name: "Events", Fields: []contenttype.Field{title}}
|
||||||
|
w.types.types[raVenuesTypeID] = contenttype.ContentType{ID: raVenuesTypeID, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}}
|
||||||
|
w.types.nextID = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
func validContentTypeFields() []contenttype.Field {
|
||||||
|
return []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryOfType(typeID uint32) entry.Entry {
|
||||||
|
return entry.Entry{ID: 1, ContentTypeID: typeID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) clock() time.Time {
|
||||||
|
return w.now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) wire() {
|
||||||
|
c := w.container
|
||||||
|
c.ListRolesUseCase = useCases.NewListRolesUseCase(w.roles, w.users, w.types)
|
||||||
|
c.GetRoleUseCase = useCases.NewGetRoleUseCase(w.roles, w.users, w.types)
|
||||||
|
c.CreateRoleUseCase = useCases.NewCreateRoleUseCase(w.roles, w.users, w.types, w.recorder)
|
||||||
|
c.UpdateRoleUseCase = useCases.NewUpdateRoleUseCase(w.roles, w.users, w.types, w.recorder, w.clock)
|
||||||
|
c.DeleteRoleUseCase = useCases.NewDeleteRoleUseCase(w.roles, w.users, w.types, w.recorder)
|
||||||
|
c.ReassignRoleMembersUseCase = useCases.NewReassignRoleMembersUseCase(w.roles, w.users, w.types, w.recorder)
|
||||||
|
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.types, w.roles, w.clock)
|
||||||
|
c.ListContentTypesUseCase = useCases.NewListContentTypesUseCase(w.types)
|
||||||
|
c.GetContentTypeUseCase = useCases.NewGetContentTypeUseCase(w.types)
|
||||||
|
c.UpdateContentTypeUseCase = useCases.NewUpdateContentTypeUseCase(w.types, w.entries, w.clock)
|
||||||
|
c.DeleteContentTypeUseCase = useCases.NewDeleteContentTypeUseCase(w.types, w.entries, w.roles)
|
||||||
|
w.rebuildRouter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) role(name string) role.Role {
|
||||||
|
found, err := w.roles.Get(w.ids[name])
|
||||||
|
if err != nil {
|
||||||
|
panic("unknown role " + name)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) path(name string) string {
|
||||||
|
return "/v1/role/" + strconv.FormatUint(uint64(w.ids[name]), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller {
|
||||||
|
account := w.users.users[userID]
|
||||||
|
roleID := account.RoleID
|
||||||
|
if roleID == 0 {
|
||||||
|
roleID = w.ids[map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"}[account.Role]]
|
||||||
|
}
|
||||||
|
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[roleID].GrantSet()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) members(name string) []uint32 {
|
||||||
|
found := []uint32{}
|
||||||
|
for _, id := range w.users.sortedIds() {
|
||||||
|
if w.users.users[id].RoleID == w.ids[name] {
|
||||||
|
found = append(found, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rolesAPIWorld) resetCounters() {
|
||||||
|
w.roles.calls = map[string]int{}
|
||||||
|
w.types.listCalls = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func rolesAPIUpdateBody(name string, description string, version uint32, grants ...string) string {
|
||||||
|
return `{"name": ` + strconv.Quote(name) + `, "description": ` + strconv.Quote(description) + `, "version": ` + strconv.FormatUint(uint64(version), 10) + `, "grants": ` + rolesAPIGrantsJSON(grants...) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rolesAPIGrantsJSON(rows ...string) string {
|
||||||
|
body := "["
|
||||||
|
for index, grant := range grantRows(rows...) {
|
||||||
|
if index > 0 {
|
||||||
|
body += ", "
|
||||||
|
}
|
||||||
|
body += `{"key": ` + strconv.Quote(grant.Key) + `, "scope": ` + strconv.Quote(string(grant.Scope)) + `}`
|
||||||
|
}
|
||||||
|
return body + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rolesAPIContributorRows(extra ...string) []string {
|
||||||
|
return append([]string{"content.read=all", "content.create=all", "content.update=own"}, extra...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRefusal(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != status || rec.Body.String() != code+"\n" {
|
||||||
|
t.Fatalf("expected %d %q, got %d %q", status, code, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Header().Get("X-Missing-Permission") != "" || rec.Header().Get("X-Escalation-Key") != "" {
|
||||||
|
t.Fatalf("unexpected permission header on %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEscalation(t *testing.T, rec *httptest.ResponseRecorder, key string) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != http.StatusForbidden || rec.Body.String() != "escalation\n" {
|
||||||
|
t.Fatalf("expected 403 escalation, got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if header := rec.Header().Get("X-Escalation-Key"); key != "" && header != key {
|
||||||
|
t.Fatalf("expected X-Escalation-Key %q, got %q", key, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -583,8 +583,8 @@ func TestContentScopeRequestLoads(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
|
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
|
||||||
t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
|
t.Errorf("expected at most 1 user Get, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) {
|
t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) {
|
||||||
@@ -595,30 +595,30 @@ func TestContentScopeRequestLoads(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
|
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
|
||||||
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
|
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("A legacy owner is resolved by one GetByName and no ListGrants", func(t *testing.T) {
|
t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) {
|
||||||
world := newContentScopeWorld(t)
|
world := newContentScopeWorld(t)
|
||||||
legacy := world.users.users[csOwner]
|
legacy := world.users.users[csOwner]
|
||||||
legacy.RoleID = 0
|
legacy.RoleID = 0
|
||||||
world.users.users[csOwner] = legacy
|
world.users.users[csOwner] = legacy
|
||||||
world.resetCounters()
|
world.resetCounters()
|
||||||
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
|
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
|
||||||
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 {
|
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 {
|
||||||
t.Errorf("unexpected role loads %+v", world.roleRepo)
|
t.Errorf("unexpected role loads %+v", world.roleRepo)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GET /auth/me does not call ListGrants", func(t *testing.T) {
|
t.Run("GET /auth/me does not call List", func(t *testing.T) {
|
||||||
world := newContentScopeWorld(t)
|
world := newContentScopeWorld(t)
|
||||||
world.resetCounters()
|
world.resetCounters()
|
||||||
rec, _ := world.me(t, csWriter)
|
rec, _ := world.me(t, csWriter)
|
||||||
if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 {
|
if rec.Code != http.StatusOK || world.roleRepo.lists != 0 {
|
||||||
t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN)
|
t.Errorf("unexpected %d and %d List calls", rec.Code, world.roleRepo.lists)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -654,8 +654,8 @@ func TestContentScopeOwnerOnlyAndMe(t *testing.T) {
|
|||||||
for _, grant := range me.Access.Permissions {
|
for _, grant := range me.Access.Permissions {
|
||||||
got[grant.Key] = string(grant.Scope)
|
got[grant.Key] = string(grant.Scope)
|
||||||
}
|
}
|
||||||
if got["content.update"] != "own" || len(got) != 1 {
|
if got["content.update"] != "own" || got["content.update:blog"] != "all" || len(got) != 2 {
|
||||||
t.Errorf("expected only content.update at own, got %v", got)
|
t.Errorf("expected content.update at own and the typed entry (superseded by roles_permissions_roles_api), got %v", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -86,14 +87,14 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) {
|
|||||||
if _, err := check.Require(writer, "content.read"); err != nil {
|
if _, err := check.Require(writer, "content.read"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if counting.gets != 1 || counting.getByNames != 0 || counting.listGrantsN != 0 {
|
if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 {
|
||||||
t.Errorf("unexpected loads %+v", counting)
|
t.Errorf("unexpected loads %+v", counting)
|
||||||
}
|
}
|
||||||
counting.gets = 0
|
counting.gets = 0
|
||||||
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil {
|
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if counting.gets != 0 || counting.getByNames != 1 || counting.listGrantsN != 0 {
|
if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 {
|
||||||
t.Errorf("unexpected legacy loads %+v", counting)
|
t.Errorf("unexpected legacy loads %+v", counting)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -120,10 +121,10 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(access.Permissions) != 1 || access.Permissions[0].Key != "content.update" || access.Permissions[0].Scope != role.ScopeOwn {
|
if !reflect.DeepEqual(access.Permissions, grantRows("content.update=own", "content.update:blog=all")) {
|
||||||
t.Errorf("unexpected permissions %+v", access.Permissions)
|
t.Errorf("unexpected permissions %+v (typed entries superseded by roles_permissions_roles_api)", access.Permissions)
|
||||||
}
|
}
|
||||||
if counting.gets != 1 || counting.listGrantsN != 0 {
|
if counting.gets != 1 || counting.lists != 0 {
|
||||||
t.Errorf("unexpected loads %+v", counting)
|
t.Errorf("unexpected loads %+v", counting)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -478,7 +479,7 @@ func TestContentScopeCreateContentTypeReservesBuiltInKeys(t *testing.T) {
|
|||||||
for _, key := range []string{"article", "author", "site", "media", "user"} {
|
for _, key := range []string{"article", "author", "site", "media", "user"} {
|
||||||
t.Run(key, func(t *testing.T) {
|
t.Run(key, func(t *testing.T) {
|
||||||
types := newMockContentTypeRepo()
|
types := newMockContentTypeRepo()
|
||||||
create := useCases.NewCreateContentTypeUseCase(types, time.Now)
|
create := useCases.NewCreateContentTypeUseCase(types, newMockRoleRepo(), time.Now)
|
||||||
_, err := create.Create(useCases.CreateContentTypeCommand{Key: key, Name: "Reserved", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}})
|
_, err := create.Create(useCases.CreateContentTypeCommand{Key: key, Name: "Reserved", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}})
|
||||||
if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 {
|
if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 {
|
||||||
t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls)
|
t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type contentScopeRoleRepo struct {
|
|||||||
role.Repository
|
role.Repository
|
||||||
gets int
|
gets int
|
||||||
getByNames int
|
getByNames int
|
||||||
listGrantsN int
|
lists int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) {
|
func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) {
|
||||||
@@ -76,13 +76,13 @@ func (c *contentScopeRoleRepo) GetByName(name string) (role.Role, error) {
|
|||||||
return c.Repository.GetByName(name)
|
return c.Repository.GetByName(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *contentScopeRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
|
func (c *contentScopeRoleRepo) List() ([]role.Role, error) {
|
||||||
c.listGrantsN++
|
c.lists++
|
||||||
return c.Repository.ListGrants(roleID)
|
return c.Repository.List()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *contentScopeRoleRepo) touched() int {
|
func (c *contentScopeRoleRepo) touched() int {
|
||||||
return c.gets + c.getByNames + c.listGrantsN
|
return c.gets + c.getByNames + c.lists
|
||||||
}
|
}
|
||||||
|
|
||||||
type contentScopeUserRepo struct {
|
type contentScopeUserRepo struct {
|
||||||
@@ -219,7 +219,7 @@ func (w *contentScopeWorld) wire() {
|
|||||||
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
|
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
|
||||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
|
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
|
||||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
|
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
|
||||||
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, clock)
|
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
|
||||||
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
||||||
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
|
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
|
||||||
c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images)
|
c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images)
|
||||||
@@ -234,7 +234,7 @@ func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *contentScopeWorld) resetCounters() {
|
func (w *contentScopeWorld) resetCounters() {
|
||||||
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.listGrantsN = 0, 0, 0
|
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.lists = 0, 0, 0
|
||||||
w.userRepo.gets = 0
|
w.userRepo.gets = 0
|
||||||
w.typeRepo.lookups = 0
|
w.typeRepo.lookups = 0
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user