From 094df63a7fb1b2f71846b913cc4b350db6c65444 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 27 Oct 2026 00:45:24 +0100 Subject: [PATCH] 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. --- api/dependecyInjection.go | 16 +- api/handlers/role/roleHandler.go | 249 +++++ api/pkg/apperr/messages.go | 18 + api/router.go | 35 + docs/api.md | 53 +- docs/architecture.md | 4 +- features/activity-log-producers.qa.md | 1 + .../roles_permissions_content_scope.feature | 8 +- .../roles_permissions_content_scope.qa.md | 12 +- features/roles_permissions_foundation.feature | 4 +- features/roles_permissions_roles_api.feature | 381 ++++++++ features/roles_permissions_roles_api.qa.md | 110 +++ internal/domain/audit/auditDomain.go | 13 +- internal/domain/role/roleDomain.go | 29 +- internal/domain/role/roleGrantDiff.go | 45 + internal/domain/role/roleGrantSet.go | 79 +- .../domain/role/roleRepositoryInterface.go | 4 +- internal/domain/role/roleWriteRules.go | 44 + .../persistence/mappers/roleMapper.go | 15 + .../persistence/models/roleModel.go | 16 +- .../repositories/roleRepository.go | 100 +- .../useCases/CreateContentType.go | 15 +- .../infrastructure/useCases/CreateRole.go | 56 ++ .../useCases/DeleteContentType.go | 12 +- .../infrastructure/useCases/DeleteRole.go | 69 ++ .../infrastructure/useCases/GetMyAccess.go | 12 +- internal/infrastructure/useCases/GetRole.go | 28 + internal/infrastructure/useCases/ListRoles.go | 24 + .../useCases/ReassignRoleMembers.go | 86 ++ .../infrastructure/useCases/UpdateRole.go | 170 ++++ .../infrastructure/useCases/roleSupport.go | 117 +++ internal/infrastructure/useCases/roleView.go | 143 +++ test/activity_domain_test.go | 2 +- test/api_catalogue_drift_test.go | 7 + .../custom_content_types_support_http_test.go | 4 +- test/custom_content_types_support_test.go | 4 +- test/custom_content_types_usecases_test.go | 4 +- test/relation_field_type_test.go | 4 +- test/role_repository_gaps_test.go | 6 +- test/role_support_test.go | 106 ++- test/roles_api_domain_test.go | 233 +++++ test/roles_api_hardening_test.go | 214 +++++ test/roles_api_http_test.go | 560 +++++++++++ test/roles_api_http_write_test.go | 332 +++++++ test/roles_api_hygiene_test.go | 95 ++ test/roles_api_persistence_test.go | 252 +++++ test/roles_api_usecases_test.go | 887 ++++++++++++++++++ test/roles_api_world_test.go | 206 ++++ test/roles_content_scope_http_test.go | 22 +- test/roles_content_scope_usecases_test.go | 13 +- test/roles_content_scope_world_test.go | 18 +- 51 files changed, 4822 insertions(+), 115 deletions(-) create mode 100644 api/handlers/role/roleHandler.go create mode 100644 features/roles_permissions_roles_api.feature create mode 100644 features/roles_permissions_roles_api.qa.md create mode 100644 internal/domain/role/roleGrantDiff.go create mode 100644 internal/domain/role/roleWriteRules.go create mode 100644 internal/infrastructure/useCases/CreateRole.go create mode 100644 internal/infrastructure/useCases/DeleteRole.go create mode 100644 internal/infrastructure/useCases/GetRole.go create mode 100644 internal/infrastructure/useCases/ListRoles.go create mode 100644 internal/infrastructure/useCases/ReassignRoleMembers.go create mode 100644 internal/infrastructure/useCases/UpdateRole.go create mode 100644 internal/infrastructure/useCases/roleSupport.go create mode 100644 internal/infrastructure/useCases/roleView.go create mode 100644 test/roles_api_domain_test.go create mode 100644 test/roles_api_hardening_test.go create mode 100644 test/roles_api_http_test.go create mode 100644 test/roles_api_http_write_test.go create mode 100644 test/roles_api_hygiene_test.go create mode 100644 test/roles_api_persistence_test.go create mode 100644 test/roles_api_usecases_test.go create mode 100644 test/roles_api_world_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index c28ddba..2124e9e 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -76,6 +76,12 @@ type UseCases struct { GetContentTypeUseCase *useCases.GetContentTypeUseCase UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase + ListRolesUseCase *useCases.ListRolesUseCase + GetRoleUseCase *useCases.GetRoleUseCase + CreateRoleUseCase *useCases.CreateRoleUseCase + UpdateRoleUseCase *useCases.UpdateRoleUseCase + DeleteRoleUseCase *useCases.DeleteRoleUseCase + ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase LookupUserNamesUseCase *useCases.LookupUserNamesUseCase @@ -380,11 +386,17 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(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), GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo), 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(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo), diff --git a/api/handlers/role/roleHandler.go b/api/handlers/role/roleHandler.go new file mode 100644 index 0000000..524ff53 --- /dev/null +++ b/api/handlers/role/roleHandler.go @@ -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}) +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 03dd68c..d9d161f 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -100,3 +100,21 @@ const InvalidFieldErrorMessage = "invalid_field" const SchemaIncompatibleErrorMessage = "schema_incompatible" const EntryNotFoundErrorMessage = "entry_not_found" 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" diff --git a/api/router.go b/api/router.go index cdd356e..bf60797 100644 --- a/api/router.go +++ b/api/router.go @@ -14,6 +14,7 @@ import ( "RenewCMS/api/handlers/llms" "RenewCMS/api/handlers/media" "RenewCMS/api/handlers/permission" + rolehandler "RenewCMS/api/handlers/role" "RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/site" "RenewCMS/api/handlers/staticimages" @@ -244,6 +245,39 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { 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 { 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("/settings", NewSettingsRouter(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("/content-type", NewContentTypeRouter(container, settings.TrustedProxies)) r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies)) diff --git a/docs/api.md b/docs/api.md index 5e37d2c..dc93598 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. | 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 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`). @@ -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 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 Articles and the entries of custom content types are checked against the `content.*` permissions of the diff --git a/docs/architecture.md b/docs/architecture.md index 3a4f686..3cac978 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,9 +113,9 @@ At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMe ### 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. diff --git a/features/activity-log-producers.qa.md b/features/activity-log-producers.qa.md index af8c557..a99bfb8 100644 --- a/features/activity-log-producers.qa.md +++ b/features/activity-log-producers.qa.md @@ -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. 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. + 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) 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. diff --git a/features/roles_permissions_content_scope.feature b/features/roles_permissions_content_scope.feature index 74ed0bb..cf64363 100644 --- a/features/roles_permissions_content_scope.feature +++ b/features/roles_permissions_content_scope.feature @@ -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 When a gated request is sent with a session cookie Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once - And ListGrants is called zero times by the permission check and by GetMyAccessUseCase + And 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 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 @@ -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 When the client sends GET /v1/auth/me Then access.permissions holds content.update at own and no settings.delete_site - And typed grants are not listed in this slice + 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 Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route - And typed grants are written by slice 3 and exposed to the SPA by a later slice - And rows of deleted content types in typed grants are cleaned up by slice 3 + 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 (done by roles_permissions_roles_api) diff --git a/features/roles_permissions_content_scope.qa.md b/features/roles_permissions_content_scope.qa.md index 0de2f3a..09c2e04 100644 --- a/features/roles_permissions_content_scope.qa.md +++ b/features/roles_permissions_content_scope.qa.md @@ -6,9 +6,9 @@ Do not copy any `Role.Allows`, `Role ==` or hand-rolled own/all comparison into ## 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. -- 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. ## 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 -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. 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. @@ -68,7 +68,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n ## B. Use cases (fakes only) 13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls. -14. Counters: for one `Require` call, `ListGrants` is 0, `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`. +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). 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`. @@ -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. 27. API key: article created through the key has `created_by` of the creator. 28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes. -29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `ListGrants`; with an API key at most 2 user `Get`. Run on one article route and one entry route. +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. 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. @@ -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. - 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). diff --git a/features/roles_permissions_foundation.feature b/features/roles_permissions_foundation.feature index f459c64..af9268f 100644 --- a/features/roles_permissions_foundation.feature +++ b/features/roles_permissions_foundation.feature @@ -12,7 +12,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills - Scope with values none, own and all - Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants} - the sentinels ErrRoleNotFound and ErrUnknownPermission - - the Repository interface (list, get by id, get by name, list grants, create, save) + - the 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) And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = 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.transfer_ownership | B | yes | And content permissions are stored under wildcard keys meaning "all content types"; per-type - overrides of the form content.: are deferred to slice 3 (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3) + overrides of the form content.: 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. 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, diff --git a/features/roles_permissions_roles_api.feature b/features/roles_permissions_roles_api.feature new file mode 100644 index 0000000..7be5f1e --- /dev/null +++ b/features/roles_permissions_roles_api.feature @@ -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.:, 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 sends + Then the result is + + 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/ | 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/ + 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/ + 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 + Then the result is + 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 and description + Then the result is + + 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 + When sends PUT /v1/role/ with the current version and the Contributor grants plus + Then the result is + + 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 sends + Then the result is + + Examples: + | caller | call | result | + | user 31 Role manager | PUT /v1/role/ with no change | 403 escalation (Content lead publishes and updates at all) | + | user 31 Role manager | DELETE /v1/role/ | 403 escalation | + | user 31 Role manager | POST /v1/role//reassign {"target_role_id": } | 403 escalation | + | user 31 Role manager | PUT /v1/role/ with a harmless change | 200 (Contributor is covered) | + | user 5 Admin | PUT /v1/role/ with a harmless change | 200 | + + Scenario Outline: Editing a role + Given the roles of the Background, each at version 1 + When sends PUT /v1/role/ with + Then the result is + + 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/ + 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 sends DELETE /v1/role/ + Then the result is + + 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 sends POST /v1/role//reassign with + Then the result is + + Examples: + | caller | source | body | result | + | user 5 Admin | Contributor | {"target_role_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": } | 200 {"moved": 0}, no event | + | user 31 Role manager | Contributor | {"target_role_id": } | 403 escalation (Admin is above Role manager), nobody moved | + | user 31 Role manager | Contributor | {"target_role_id": } | 200 {"moved": 4} | + | user 5 Admin | Contributor | {"target_role_id": } | 400 invalid_reassign (there is one owner) | + | user 5 Admin | Contributor | {"target_role_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": } | 409 role_built_in | + | user 5 Admin | Owner | {"target_role_id": } | 403 role_locked | + | user 5 Admin | 999 | {"target_role_id": } | 404 role_not_found | + | user 31 Role manager | Role manager | {"target_role_id": } | 403 own_role | + And after the first row, DELETE /v1/role/ 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 sends POST /v1/role//reset with {"version": } + Then the answer is + 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) diff --git a/features/roles_permissions_roles_api.qa.md b/features/roles_permissions_roles_api.qa.md new file mode 100644 index 0000000..b760a60 --- /dev/null +++ b/features/roles_permissions_roles_api.qa.md @@ -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.*:` 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). diff --git a/internal/domain/audit/auditDomain.go b/internal/domain/audit/auditDomain.go index 7f5c9fe..938ca32 100644 --- a/internal/domain/audit/auditDomain.go +++ b/internal/domain/audit/auditDomain.go @@ -34,6 +34,7 @@ const ( CategorySystem Category = "system" CategorySites Category = "sites" CategoryAuthors Category = "authors" + CategoryRoles Category = "roles" ) type Outcome string @@ -80,6 +81,10 @@ const ( ActionAuthorCreate = "author.create" ActionAuthorEdit = "author.edit" ActionAuthorDelete = "author.delete" + ActionRoleCreate = "role.create" + ActionRoleEdit = "role.edit" + ActionRoleDelete = "role.delete" + ActionRoleReassign = "role.reassign" ActionMediaUpload = "media.upload" ActionMediaRename = "media.rename" ActionMediaDelete = "media.delete" @@ -118,6 +123,7 @@ const ( TargetRelease = "release" TargetSite = "site" TargetAuthor = "author" + TargetRole = "role" ) const ( @@ -147,6 +153,10 @@ const ( ParamFormat = "format" ParamQuality = "quality" ParamTag = "tag" + ParamAdded = "added" + ParamChanged = "changed" + ParamMembers = "members" + ParamMoved = "moved" FieldTitle = "title" FieldOnline = "online" FieldBody = "body" @@ -158,6 +168,7 @@ const ( FieldFormat = "format" FieldQuality = "quality" FieldName = "name" + FieldDescription = "description" FieldBaseURL = "base_url" FieldDefaultRobots = "default_robots" FieldLocale = "locale" @@ -193,7 +204,7 @@ func ActorTypes() []ActorType { } 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 { diff --git a/internal/domain/role/roleDomain.go b/internal/domain/role/roleDomain.go index f78d513..decee4a 100644 --- a/internal/domain/role/roleDomain.go +++ b/internal/domain/role/roleDomain.go @@ -3,6 +3,7 @@ package role import ( "errors" "strings" + "time" ) var ( @@ -14,6 +15,18 @@ var ( ErrDuplicateGrant = errors.New("permission granted more than once") 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 @@ -30,13 +43,15 @@ type Grant struct { } type Role 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 []Grant `json:"grants"` + 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 []Grant `json:"grants"` + UpdatedAt time.Time `json:"-"` + UpdatedBy *uint32 `json:"-"` } func resolveGrantKey(key string) (Permission, error) { diff --git a/internal/domain/role/roleGrantDiff.go b/internal/domain/role/roleGrantDiff.go new file mode 100644 index 0000000..93bc851 --- /dev/null +++ b/internal/domain/role/roleGrantDiff.go @@ -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 +} diff --git a/internal/domain/role/roleGrantSet.go b/internal/domain/role/roleGrantSet.go index 938d222..cb38be8 100644 --- a/internal/domain/role/roleGrantSet.go +++ b/internal/domain/role/roleGrantSet.go @@ -1,5 +1,10 @@ package role +import ( + "slices" + "strings" +) + type MissingPermissionError struct { Key string } @@ -33,14 +38,13 @@ func (r Role) GrantSet() GrantSet { } func effectiveScope(kind Kind, scope Scope) Scope { - switch { - case scope == ScopeAll: + if scope == 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 { @@ -112,3 +116,66 @@ func (g GrantSet) Holds(key string) bool { func (g GrantSet) HoldsAll(key string) bool { 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() +} diff --git a/internal/domain/role/roleRepositoryInterface.go b/internal/domain/role/roleRepositoryInterface.go index 8241b3e..3da0f54 100644 --- a/internal/domain/role/roleRepositoryInterface.go +++ b/internal/domain/role/roleRepositoryInterface.go @@ -4,7 +4,9 @@ type Repository interface { List() ([]Role, error) Get(id uint32) (Role, error) GetByName(name string) (Role, error) - ListGrants(roleID uint32) ([]Grant, error) Create(created Role) (Role, error) Save(saved Role) (Role, error) + Delete(id uint32) error + ReassignMembers(from uint32, to uint32) (int, error) + RemoveTypedGrants(typeKey string) (int, error) } diff --git a/internal/domain/role/roleWriteRules.go b/internal/domain/role/roleWriteRules.go new file mode 100644 index 0000000..6b8be81 --- /dev/null +++ b/internal/domain/role/roleWriteRules.go @@ -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 +} diff --git a/internal/infrastructure/persistence/mappers/roleMapper.go b/internal/infrastructure/persistence/mappers/roleMapper.go index e766744..cf928ef 100644 --- a/internal/infrastructure/persistence/mappers/roleMapper.go +++ b/internal/infrastructure/persistence/mappers/roleMapper.go @@ -1,12 +1,20 @@ package mappers import ( + "time" + "RenewCMS/internal/domain/role" "RenewCMS/internal/infrastructure/persistence/models" ) 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{ + UpdatedAt: editedAt, + UpdatedBy: m.EditedBy, ID: m.ID, Name: m.Name, Description: m.Description, @@ -18,7 +26,14 @@ func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role { } func RoleToModel(r role.Role) models.Role { + var editedAt *time.Time + if !r.UpdatedAt.IsZero() { + stamp := r.UpdatedAt + editedAt = &stamp + } return models.Role{ + EditedAt: editedAt, + EditedBy: r.UpdatedBy, ID: r.ID, Name: r.Name, Description: r.Description, diff --git a/internal/infrastructure/persistence/models/roleModel.go b/internal/infrastructure/persistence/models/roleModel.go index df1677b..dc4b6b6 100644 --- a/internal/infrastructure/persistence/models/roleModel.go +++ b/internal/infrastructure/persistence/models/roleModel.go @@ -1,12 +1,16 @@ package models +import "time" + type Role struct { - ID uint32 `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"not null;size:100;index"` - Description string `gorm:"not null;default:'';size:255"` - BuiltIn bool `gorm:"not null;default:false"` - Locked bool `gorm:"not null;default:false"` - Version uint32 `gorm:"not null;default:1"` + ID uint32 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"not null;size:100;index"` + Description string `gorm:"not null;default:'';size:255"` + BuiltIn bool `gorm:"not null;default:false"` + Locked bool `gorm:"not null;default:false"` + Version uint32 `gorm:"not null;default:1"` + EditedAt *time.Time `gorm:"column:updated_at"` + EditedBy *uint32 `gorm:"column:updated_by"` } type RolePermission struct { diff --git a/internal/infrastructure/persistence/repositories/roleRepository.go b/internal/infrastructure/persistence/repositories/roleRepository.go index fd2df4e..6d91637 100644 --- a/internal/infrastructure/persistence/repositories/roleRepository.go +++ b/internal/infrastructure/persistence/repositories/roleRepository.go @@ -1,11 +1,14 @@ package persistence import ( + "strings" + "RenewCMS/internal/domain/role" "RenewCMS/internal/infrastructure/persistence/mappers" entity "RenewCMS/internal/infrastructure/persistence/models" "gorm.io/gorm" + "gorm.io/gorm/clause" ) 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 { 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)) for _, row := range found { - roles = append(roles, mappers.RoleToDomain(row, nil)) + roles = append(roles, mappers.RoleToDomain(row, rowsByRole[row.ID])) } return roles, nil } @@ -60,18 +71,28 @@ func (r *RoleRepository) GetByName(name string) (role.Role, error) { return r.withGrants(found) } -func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) { - rows, err := r.grantRows(roleID) - if err != nil { - return nil, err +func ensureNameUnused(tx *gorm.DB, name string, excludedID uint32) error { + var taken int64 + query := tx.Model(&entity.Role{}).Where("LOWER(name) = LOWER(?)", strings.TrimSpace(name)) + 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) { model := mappers.RoleToModel(created) model.ID = 0 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 { 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 { return mapNotFoundErr(err, role.ErrRoleNotFound) } + if err := ensureNameUnused(tx, saved.Name, saved.ID); err != nil { + return err + } update := tx.Model(&entity.Role{}). 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 { return update.Error } @@ -116,4 +140,66 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) { 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{} diff --git a/internal/infrastructure/useCases/CreateContentType.go b/internal/infrastructure/useCases/CreateContentType.go index 583718b..062ce2c 100644 --- a/internal/infrastructure/useCases/CreateContentType.go +++ b/internal/infrastructure/useCases/CreateContentType.go @@ -3,12 +3,14 @@ package useCases import ( "RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/contenttype" + "RenewCMS/internal/domain/role" "errors" "time" ) type CreateContentTypeUseCase struct { contentTypes contenttype.Repository + roles role.Repository now func() time.Time } @@ -19,8 +21,8 @@ type CreateContentTypeCommand struct { Fields []contenttype.Field } -func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase { - return &CreateContentTypeUseCase{contentTypes, now} +func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, roles role.Repository, now func() time.Time) *CreateContentTypeUseCase { + return &CreateContentTypeUseCase{contentTypes, roles, now} } func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) { @@ -45,7 +47,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con return contenttype.ContentType{}, err } created := u.now() - return u.contentTypes.Create(contenttype.ContentType{ + made, err := u.contentTypes.Create(contenttype.ContentType{ Key: command.Key, Name: command.Name, Icon: icon, @@ -53,6 +55,13 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con CreatedAt: 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 { diff --git a/internal/infrastructure/useCases/CreateRole.go b/internal/infrastructure/useCases/CreateRole.go new file mode 100644 index 0000000..89738df --- /dev/null +++ b/internal/infrastructure/useCases/CreateRole.go @@ -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) +} diff --git a/internal/infrastructure/useCases/DeleteContentType.go b/internal/infrastructure/useCases/DeleteContentType.go index 3a0800a..f0400f5 100644 --- a/internal/infrastructure/useCases/DeleteContentType.go +++ b/internal/infrastructure/useCases/DeleteContentType.go @@ -3,15 +3,17 @@ package useCases import ( "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/entry" + "RenewCMS/internal/domain/role" ) type DeleteContentTypeUseCase struct { contentTypes contenttype.Repository entries entry.Repository + roles role.Repository } -func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase { - return &DeleteContentTypeUseCase{contentTypes, entries} +func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, roles role.Repository) *DeleteContentTypeUseCase { + return &DeleteContentTypeUseCase{contentTypes, entries, roles} } func (u *DeleteContentTypeUseCase) Delete(key string) error { @@ -35,5 +37,9 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error { if count > 0 { 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 } diff --git a/internal/infrastructure/useCases/DeleteRole.go b/internal/infrastructure/useCases/DeleteRole.go new file mode 100644 index 0000000..1ceea3b --- /dev/null +++ b/internal/infrastructure/useCases/DeleteRole.go @@ -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 +} diff --git a/internal/infrastructure/useCases/GetMyAccess.go b/internal/infrastructure/useCases/GetMyAccess.go index bf8c724..8d5e1ef 100644 --- a/internal/infrastructure/useCases/GetMyAccess.go +++ b/internal/infrastructure/useCases/GetMyAccess.go @@ -46,15 +46,5 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) { if err != nil { return Access{}, err } - return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, 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 + return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil } diff --git a/internal/infrastructure/useCases/GetRole.go b/internal/infrastructure/useCases/GetRole.go new file mode 100644 index 0000000..3bb55ed --- /dev/null +++ b/internal/infrastructure/useCases/GetRole.go @@ -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) +} diff --git a/internal/infrastructure/useCases/ListRoles.go b/internal/infrastructure/useCases/ListRoles.go new file mode 100644 index 0000000..57feef1 --- /dev/null +++ b/internal/infrastructure/useCases/ListRoles.go @@ -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) +} diff --git a/internal/infrastructure/useCases/ReassignRoleMembers.go b/internal/infrastructure/useCases/ReassignRoleMembers.go new file mode 100644 index 0000000..91fc8b9 --- /dev/null +++ b/internal/infrastructure/useCases/ReassignRoleMembers.go @@ -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) +} diff --git a/internal/infrastructure/useCases/UpdateRole.go b/internal/infrastructure/useCases/UpdateRole.go new file mode 100644 index 0000000..033ef35 --- /dev/null +++ b/internal/infrastructure/useCases/UpdateRole.go @@ -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 +} diff --git a/internal/infrastructure/useCases/roleSupport.go b/internal/infrastructure/useCases/roleSupport.go new file mode 100644 index 0000000..26a3f4f --- /dev/null +++ b/internal/infrastructure/useCases/roleSupport.go @@ -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, + } +} diff --git a/internal/infrastructure/useCases/roleView.go b/internal/infrastructure/useCases/roleView.go new file mode 100644 index 0000000..3045d6a --- /dev/null +++ b/internal/infrastructure/useCases/roleView.go @@ -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 +} diff --git a/test/activity_domain_test.go b/test/activity_domain_test.go index 3f99c2f..b6ab914 100644 --- a/test/activity_domain_test.go +++ b/test/activity_domain_test.go @@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) { wanted []string }{ {"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"}}, {"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}}, {"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}}, diff --git a/test/api_catalogue_drift_test.go b/test/api_catalogue_drift_test.go index 6a65ce9..5e5b9df 100644 --- a/test/api_catalogue_drift_test.go +++ b/test/api_catalogue_drift_test.go @@ -54,6 +54,7 @@ var routesNotYetInTheCatalogue = []string{ "DELETE /v1/content-type/{key}/entry/{id}", "DELETE /v1/image-variants/{name}", "DELETE /v1/media/{id}/variants/{name}", + "DELETE /v1/role/{id}", "GET /v1/builtin-type", "GET /v1/builtin-type/{key}", "GET /v1/content-type", @@ -65,6 +66,8 @@ var routesNotYetInTheCatalogue = []string{ "GET /v1/media/{id}/variants", "GET /v1/user/lookup", "GET /v1/permission", + "GET /v1/role", + "GET /v1/role/{id}", "PATCH /v1/image-variants/{name}", "POST /v1/content-type", "POST /v1/content-type/{key}/entry", @@ -73,9 +76,13 @@ var routesNotYetInTheCatalogue = []string{ "POST /v1/image-variants/{name}/purge", "POST /v1/media/{id}/variants", "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}/entry/{id}", "PUT /v1/image-variants/{name}", + "PUT /v1/role/{id}", } func TestRouterRoutesAreInTheCatalogue(t *testing.T) { diff --git a/test/custom_content_types_support_http_test.go b/test/custom_content_types_support_http_test.go index b6b840d..ead3f4e 100644 --- a/test/custom_content_types_support_http_test.go +++ b/test/custom_content_types_support_http_test.go @@ -217,7 +217,7 @@ func TestContentTypeIconUseCase(t *testing.T) { repo := newMockContentTypeRepo() repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields} 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 { 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), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), - CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now), + CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, newMockRoleRepo(), now), CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, newMockImageRepo(), now), } router := chi.NewRouter() diff --git a/test/custom_content_types_support_test.go b/test/custom_content_types_support_test.go index 8950e5a..ccada0e 100644 --- a/test/custom_content_types_support_test.go +++ b/test/custom_content_types_support_test.go @@ -268,11 +268,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv { GetUserUseCase: useCases.NewGetUserUseCase(env.users), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now), - CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now), + CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, newMockRoleRepo(), now), ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types), GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types), 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()), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), diff --git a/test/custom_content_types_usecases_test.go b/test/custom_content_types_usecases_test.go index 1e89e18..b8d1ef6 100644 --- a/test/custom_content_types_usecases_test.go +++ b/test/custom_content_types_usecases_test.go @@ -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) { fixture, stored := newContentTypeFixture(t) _, _ = 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") @@ -593,7 +593,7 @@ func TestContentTypeNameRule(t *testing.T) { t.Run("The use cases enforce it for non-HTTP callers", func(t *testing.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) if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) { t.Errorf("create blank: %v", err) diff --git a/test/relation_field_type_test.go b/test/relation_field_type_test.go index 58844d1..d064ec8 100644 --- a/test/relation_field_type_test.go +++ b/test/relation_field_type_test.go @@ -124,7 +124,7 @@ func TestRelationFieldUseCases(t *testing.T) { t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) { 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) { t.Errorf("venue: %v", err) @@ -231,7 +231,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) { 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}}}}) - 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) } }) diff --git a/test/role_repository_gaps_test.go b/test/role_repository_gaps_test.go index 9d39474..339a3bd 100644 --- a/test/role_repository_gaps_test.go +++ b/test/role_repository_gaps_test.go @@ -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") - if _, err := repo.ListGrants(1); err == nil { - t.Error("ListGrants: expected error") + if _, err := repo.List(); err == nil { + t.Error("List: expected error") } if _, err := repo.Get(1); err == nil { t.Error("Get: expected error") diff --git a/test/role_support_test.go b/test/role_support_test.go index 37ec919..4cdf91b 100644 --- a/test/role_support_test.go +++ b/test/role_support_test.go @@ -12,13 +12,16 @@ const ( ) type mockRoleRepo struct { - roles map[uint32]role.Role - nextID uint32 - fail failSwitch + roles map[uint32]role.Role + nextID uint32 + fail failSwitch + users *mockUserRepo + calls map[string]int + beforeSave func() } func newMockRoleRepo() *mockRoleRepo { - return &mockRoleRepo{roles: map[uint32]role.Role{}} + return &mockRoleRepo{roles: map[uint32]role.Role{}, calls: map[string]int{}} } func newSeededMockRoleRepo() *mockRoleRepo { @@ -44,6 +47,7 @@ func (m *mockRoleRepo) sortedIDs() []uint32 { } func (m *mockRoleRepo) List() ([]role.Role, error) { + m.calls["List"]++ if err := m.fail.trigger("List"); err != nil { return nil, err } @@ -55,6 +59,7 @@ func (m *mockRoleRepo) List() ([]role.Role, error) { } func (m *mockRoleRepo) Get(id uint32) (role.Role, error) { + m.calls["Get"]++ if err := m.fail.trigger("Get"); err != nil { 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) { + m.calls["GetByName"]++ if err := m.fail.trigger("GetByName"); err != nil { return role.Role{}, err } @@ -82,18 +88,8 @@ func (m *mockRoleRepo) GetByName(name string) (role.Role, error) { 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) { + m.calls["Create"]++ if err := m.fail.trigger("Create"); err != nil { 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) { + m.calls["Save"]++ if err := m.fail.trigger("Save"); err != nil { return role.Role{}, err } + if m.beforeSave != nil { + hook := m.beforeSave + m.beforeSave = nil + hook() + } stored, ok := m.roles[saved.ID] if !ok { return role.Role{}, role.ErrRoleNotFound @@ -118,8 +120,82 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) { stored.Description = saved.Description stored.Version++ stored.Grants = slices.Clone(saved.Grants) + stored.UpdatedAt = saved.UpdatedAt + stored.UpdatedBy = saved.UpdatedBy m.roles[saved.ID] = stored 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{} diff --git a/test/roles_api_domain_test.go b/test/roles_api_domain_test.go new file mode 100644 index 0000000..03cf07d --- /dev/null +++ b/test/roles_api_domain_test.go @@ -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) + } + }) +} diff --git a/test/roles_api_hardening_test.go b/test/roles_api_hardening_test.go new file mode 100644 index 0000000..d8d63ce --- /dev/null +++ b/test/roles_api_hardening_test.go @@ -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()) + } +} diff --git a/test/roles_api_http_test.go b/test/roles_api_http_test.go new file mode 100644 index 0000000..293e8f0 --- /dev/null +++ b/test/roles_api_http_test.go @@ -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) + }) + } +} diff --git a/test/roles_api_http_write_test.go b/test/roles_api_http_write_test.go new file mode 100644 index 0000000..ac6acb9 --- /dev/null +++ b/test/roles_api_http_write_test.go @@ -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") + } + }) +} diff --git a/test/roles_api_hygiene_test.go b/test/roles_api_hygiene_test.go new file mode 100644 index 0000000..f8cd91c --- /dev/null +++ b/test/roles_api_hygiene_test.go @@ -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) + } + } + }) +} diff --git a/test/roles_api_persistence_test.go b/test/roles_api_persistence_test.go new file mode 100644 index 0000000..a5d0a29 --- /dev/null +++ b/test/roles_api_persistence_test.go @@ -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) + } + } + }) +} diff --git a/test/roles_api_usecases_test.go b/test/roles_api_usecases_test.go new file mode 100644 index 0000000..fe77653 --- /dev/null +++ b/test/roles_api_usecases_test.go @@ -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) + } + }) +} diff --git a/test/roles_api_world_test.go b/test/roles_api_world_test.go new file mode 100644 index 0000000..7dfc2a6 --- /dev/null +++ b/test/roles_api_world_test.go @@ -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) + } +} diff --git a/test/roles_content_scope_http_test.go b/test/roles_content_scope_http_test.go index 4bb5421..4abaae9 100644 --- a/test/roles_content_scope_http_test.go +++ b/test/roles_content_scope_http_test.go @@ -583,8 +583,8 @@ func TestContentScopeRequestLoads(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) } - if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 { - t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN) + 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 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) { @@ -595,30 +595,30 @@ func TestContentScopeRequestLoads(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) } - if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 { - t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN) + 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 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) legacy := world.users.users[csOwner] legacy.RoleID = 0 world.users.users[csOwner] = legacy world.resetCounters() assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200)) - if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 { + if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 { 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.resetCounters() rec, _ := world.me(t, csWriter) - if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 { - t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN) + if rec.Code != http.StatusOK || world.roleRepo.lists != 0 { + 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 { got[grant.Key] = string(grant.Scope) } - if got["content.update"] != "own" || len(got) != 1 { - t.Errorf("expected only content.update at own, got %v", got) + if got["content.update"] != "own" || got["content.update:blog"] != "all" || len(got) != 2 { + t.Errorf("expected content.update at own and the typed entry (superseded by roles_permissions_roles_api), got %v", got) } }) } diff --git a/test/roles_content_scope_usecases_test.go b/test/roles_content_scope_usecases_test.go index a98b7ba..4f252f9 100644 --- a/test/roles_content_scope_usecases_test.go +++ b/test/roles_content_scope_usecases_test.go @@ -9,6 +9,7 @@ import ( "RenewCMS/internal/infrastructure/useCases" "context" "errors" + "reflect" "testing" "time" ) @@ -86,14 +87,14 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) { if _, err := check.Require(writer, "content.read"); err != nil { 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) } counting.gets = 0 if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil { t.Fatal(err) } - if counting.gets != 0 || counting.getByNames != 1 || counting.listGrantsN != 0 { + if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 { t.Errorf("unexpected legacy loads %+v", counting) } }) @@ -120,10 +121,10 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(access.Permissions) != 1 || access.Permissions[0].Key != "content.update" || access.Permissions[0].Scope != role.ScopeOwn { - t.Errorf("unexpected permissions %+v", access.Permissions) + if !reflect.DeepEqual(access.Permissions, grantRows("content.update=own", "content.update:blog=all")) { + 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) } }) @@ -478,7 +479,7 @@ func TestContentScopeCreateContentTypeReservesBuiltInKeys(t *testing.T) { for _, key := range []string{"article", "author", "site", "media", "user"} { t.Run(key, func(t *testing.T) { types := newMockContentTypeRepo() - create := useCases.NewCreateContentTypeUseCase(types, time.Now) + 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}}}) if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 { t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls) diff --git a/test/roles_content_scope_world_test.go b/test/roles_content_scope_world_test.go index 18a015c..0d39f5a 100644 --- a/test/roles_content_scope_world_test.go +++ b/test/roles_content_scope_world_test.go @@ -61,9 +61,9 @@ func (r *contentScopeRecorder) actions() []string { type contentScopeRoleRepo struct { role.Repository - gets int - getByNames int - listGrantsN int + gets int + getByNames int + lists int } 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) } -func (c *contentScopeRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) { - c.listGrantsN++ - return c.Repository.ListGrants(roleID) +func (c *contentScopeRoleRepo) List() ([]role.Role, error) { + c.lists++ + return c.Repository.List() } func (c *contentScopeRoleRepo) touched() int { - return c.gets + c.getByNames + c.listGrantsN + return c.gets + c.getByNames + c.lists } type contentScopeUserRepo struct { @@ -219,7 +219,7 @@ func (w *contentScopeWorld) wire() { c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock) c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images) c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images) - c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, clock) + c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock) c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock) c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images) c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images) @@ -234,7 +234,7 @@ func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path } 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.typeRepo.lookups = 0 }