From 8c6c6456dac975f97d732288f08ea1b2623f806a Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 26 Oct 2026 05:02:49 +0100 Subject: [PATCH] feat: roles system backend --- api/dependecyInjection.go | 7 + api/handlers/auth/meHandler.go | 33 +- api/handlers/permission/permissionHandler.go | 53 +++ api/router.go | 18 +- docs/architecture.md | 8 + features/roles_permissions_foundation.feature | 293 ++++++++++++ features/roles_permissions_foundation.qa.md | 57 +++ internal/domain/article/articleDomain.go | 1 + internal/domain/role/roleBuiltIn.go | 48 ++ internal/domain/role/roleCatalogue.go | 118 +++++ internal/domain/role/roleDomain.go | 120 +++++ .../domain/role/roleRepositoryInterface.go | 10 + internal/domain/user/userDomain.go | 1 + .../persistence/database/createdByBackfill.go | 25 + .../persistence/database/migrate.go | 11 +- .../persistence/database/roleSeed.go | 70 +++ .../database/userRoleIDBackfill.go | 35 ++ .../persistence/mappers/articleMapper.go | 2 + .../persistence/mappers/roleMapper.go | 45 ++ .../persistence/mappers/userMapper.go | 16 + .../persistence/models/articleModel.go | 1 + .../persistence/models/roleModel.go | 17 + .../persistence/models/userModel.go | 1 + .../repositories/roleRepository.go | 119 +++++ .../infrastructure/useCases/CreateArticle.go | 1 + .../infrastructure/useCases/GetMyAccess.go | 69 +++ .../useCases/ListPermissions.go | 27 ++ test/accept_invitation_test.go | 2 +- test/activity_log_backend_http_test.go | 1 + test/api_catalogue_drift_test.go | 1 + test/api_keys_http_test.go | 4 + test/author_support_test.go | 8 +- test/container_update_guidance_http_test.go | 1 + test/get_current_user_http_test.go | 12 +- test/image_variants_env_test.go | 1 + test/media_env_test.go | 1 + test/role_domain_hygiene_test.go | 25 + test/role_hardening_test.go | 296 ++++++++++++ test/role_repository_gaps_test.go | 134 ++++++ test/role_support_test.go | 125 +++++ .../roles_permissions_foundation_http_test.go | 329 ++++++++++++++ test/roles_permissions_foundation_test.go | 429 ++++++++++++++++++ test/runtime_settings_injection_http_test.go | 1 + test/session_revocation_http_test.go | 1 + test/sqlite_schema_unchanged_test.go | 9 +- test/update_download_http_test.go | 1 + test/update_http_test.go | 1 + test/update_restart_http_test.go | 1 + test/user_display_name_test.go | 9 +- test/user_roles_test.go | 2 +- 50 files changed, 2578 insertions(+), 22 deletions(-) create mode 100644 api/handlers/permission/permissionHandler.go create mode 100644 features/roles_permissions_foundation.feature create mode 100644 features/roles_permissions_foundation.qa.md create mode 100644 internal/domain/role/roleBuiltIn.go create mode 100644 internal/domain/role/roleCatalogue.go create mode 100644 internal/domain/role/roleDomain.go create mode 100644 internal/domain/role/roleRepositoryInterface.go create mode 100644 internal/infrastructure/persistence/database/createdByBackfill.go create mode 100644 internal/infrastructure/persistence/database/roleSeed.go create mode 100644 internal/infrastructure/persistence/database/userRoleIDBackfill.go create mode 100644 internal/infrastructure/persistence/mappers/roleMapper.go create mode 100644 internal/infrastructure/persistence/models/roleModel.go create mode 100644 internal/infrastructure/persistence/repositories/roleRepository.go create mode 100644 internal/infrastructure/useCases/GetMyAccess.go create mode 100644 internal/infrastructure/useCases/ListPermissions.go create mode 100644 test/role_domain_hygiene_test.go create mode 100644 test/role_hardening_test.go create mode 100644 test/role_repository_gaps_test.go create mode 100644 test/role_support_test.go create mode 100644 test/roles_permissions_foundation_http_test.go create mode 100644 test/roles_permissions_foundation_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 18ca927..fde7499 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -12,6 +12,7 @@ import ( domainInvite "RenewCMS/internal/domain/invite" domainMail "RenewCMS/internal/domain/mail" domainPurge "RenewCMS/internal/domain/purge" + domainRole "RenewCMS/internal/domain/role" domainSession "RenewCMS/internal/domain/session" domainSite "RenewCMS/internal/domain/site" domainTransaction "RenewCMS/internal/domain/transaction" @@ -62,6 +63,8 @@ type UseCases struct { UpdateAuthorUseCase *useCases.UpdateAuthorUseCase DeleteAuthorUseCase *useCases.DeleteAuthorUseCase GetMyAuthorUseCase *useCases.GetMyAuthorUseCase + GetMyAccessUseCase *useCases.GetMyAccessUseCase + ListPermissionsUseCase *useCases.ListPermissionsUseCase ListSitesUseCase *useCases.ListSitesUseCase CreateSiteUseCase *useCases.CreateSiteUseCase UpdateSiteUseCase *useCases.UpdateSiteUseCase @@ -161,6 +164,7 @@ type Repositories struct { EntryRepo domainEntry.Repository AuthorRepo domainAuthor.Repository UserRepo domainUser.Repository + RoleRepo domainRole.Repository ImageRepo domainImage.Repository Transactor domainTransaction.Transactor VariantRepo domainImage.VariantRepository @@ -305,6 +309,7 @@ func getRepositories(connection *database.Connection, settings config.Settings) EntryRepo: persistence.NewEntryRepository(db), AuthorRepo: persistence.NewAuthorRepository(db), UserRepo: persistence.NewUserRepository(db), + RoleRepo: persistence.NewRoleRepository(db), ImageRepo: persistence.NewImageRepository(db), Transactor: persistence.NewTransactor(db), VariantRepo: persistence.NewVariantRepository(db), @@ -366,6 +371,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder), DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo), + ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo), CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), diff --git a/api/handlers/auth/meHandler.go b/api/handlers/auth/meHandler.go index c2b77e9..6d58a3e 100644 --- a/api/handlers/auth/meHandler.go +++ b/api/handlers/auth/meHandler.go @@ -5,6 +5,7 @@ import ( "RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/publicuser" "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" @@ -15,12 +16,14 @@ import ( type MeHandler struct { GetUseCase *useCases.GetUserUseCase GetAuthorUseCase *useCases.GetMyAuthorUseCase + GetAccessUseCase *useCases.GetMyAccessUseCase } -func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase) *MeHandler { +func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase, getAccess *useCases.GetMyAccessUseCase) *MeHandler { return &MeHandler{ GetUseCase: getUser, GetAuthorUseCase: getAuthor, + GetAccessUseCase: getAccess, } } @@ -29,9 +32,29 @@ type meAuthor struct { ArticleCount int `json:"article_count"` } +type meRole struct { + ID uint32 `json:"id"` + Name string `json:"name"` + BuiltIn bool `json:"built_in"` + Locked bool `json:"locked"` +} + +type meAccess struct { + Role meRole `json:"role"` + Permissions []role.Grant `json:"permissions"` +} + type meResponse struct { publicuser.PublicUser Author *meAuthor `json:"author"` + Access meAccess `json:"access"` +} + +func toMeAccess(access useCases.Access) meAccess { + return meAccess{ + Role: meRole{ID: access.Role.ID, Name: access.Role.Name, BuiltIn: access.Role.BuiltIn, Locked: access.Role.Locked}, + Permissions: access.Permissions, + } } func toMeAuthor(found *author.Author) *meAuthor { @@ -64,7 +87,13 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { return } - body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked)}) + access, err := h.GetAccessUseCase.Get(loadedUser) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)}) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return diff --git a/api/handlers/permission/permissionHandler.go b/api/handlers/permission/permissionHandler.go new file mode 100644 index 0000000..1ee0d5e --- /dev/null +++ b/api/handlers/permission/permissionHandler.go @@ -0,0 +1,53 @@ +package permission + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "net/http" +) + +type Handler struct { + ListUseCase *useCases.ListPermissionsUseCase +} + +func NewHandler(list *useCases.ListPermissionsUseCase) *Handler { + return &Handler{ListUseCase: list} +} + +type permissionItem struct { + Key string `json:"key"` + ScopeKind role.Kind `json:"scope_kind"` + OwnerOnly bool `json:"owner_only"` + PerType bool `json:"per_type"` +} + +type permissionGroup struct { + Key string `json:"key"` + Items []permissionItem `json:"items"` +} + +type permissionResponse struct { + Groups []permissionGroup `json:"groups"` +} + +func toResponse(groups []useCases.PermissionGroup) permissionResponse { + response := permissionResponse{Groups: make([]permissionGroup, 0, len(groups))} + for _, group := range groups { + items := make([]permissionItem, 0, len(group.Items)) + for _, permission := range group.Items { + items = append(items, permissionItem{Key: permission.Key, ScopeKind: permission.Kind, OwnerOnly: permission.OwnerOnly, PerType: permission.PerType()}) + } + response.Groups = append(response.Groups, permissionGroup{Key: group.Key, Items: items}) + } + return response +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + body, err := json.Marshal(toResponse(h.ListUseCase.List())) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + _, _ = w.Write(body) +} diff --git a/api/router.go b/api/router.go index 11f1a80..da9b1b8 100644 --- a/api/router.go +++ b/api/router.go @@ -13,6 +13,7 @@ import ( "RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/llms" "RenewCMS/api/handlers/media" + "RenewCMS/api/handlers/permission" "RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/site" "RenewCMS/api/handlers/staticimages" @@ -92,7 +93,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) - meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase) + meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase) passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction) @@ -375,6 +376,20 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler return r } +func NewPermissionRouter(container *UseCases) http.Handler { + r := chi.NewRouter() + + permissionHandler := permission.NewHandler(container.ListPermissionsUseCase) + + 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.Get("/", permissionHandler.List) + return r +} + func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler { r := chi.NewRouter() @@ -457,6 +472,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies)) r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies)) r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies)) + r.Mount("/permission", NewPermissionRouter(container)) r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) diff --git a/docs/architecture.md b/docs/architecture.md index f80b001..a3749a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,6 +111,14 @@ The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepos At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written. +### Roles and permissions + +`internal/domain/role` holds the permission catalogue, the built-in roles and the pure rules over grants: `ValidateGrants`, `Role.Allows`, `Role.AllowsFor` and `ParseTypedKey`. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`, and an owner-only permission only counts on the locked Owner role. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the specific grant overrides the wildcard, and without an override the wildcard applies. The existence of the type key is not checked in the domain. `role.Repository.Save` is version checked: it updates the row only when the caller's version matches, bumps the version, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. + +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. + +Created-by backfill: `database.BackfillCreatedBy` assigns articles (soft-deleted ones included) and entries whose `created_by` is 0 to the first owner by id. A database without an owner is left alone. Entries have no soft delete, so no unscoped query is needed for them. + ## Frontend ↔ backend ``` diff --git a/features/roles_permissions_foundation.feature b/features/roles_permissions_foundation.feature new file mode 100644 index 0000000..a22370c --- /dev/null +++ b/features/roles_permissions_foundation.feature @@ -0,0 +1,293 @@ +Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints + + As the owner of a RenewCMS site + I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope) + So that later slices can replace the fixed owner/admin checks without changing what existing + owners and admins can do today + + Background: + Given the domain package internal/domain/role holds, with the stdlib only: + - the catalogue: a fixed ordered list of 34 permission keys in 9 groups + - 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 pure function Role.Allows(key, owned bool) bool + And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only): + | group | key | kind | owner-only | + | content | content.read | S | no | + | content | content.create | B | no | + | content | content.update | S | no | + | content | content.delete | S | no | + | content | content.publish | S | no | + | schema | schema.view | B | no | + | schema | schema.edit | B | no | + | schema | schema.delete | B | no | + | media | media.view | B | no | + | media | media.upload | B | no | + | media | media.delete | S | no | + | authors | authors.view | B | no | + | authors | authors.create | B | no | + | authors | authors.edit | S | no | + | authors | authors.delete | B | no | + | activity | activity.view | S | no | + | activity | activity.export | B | no | + | users | users.view | B | no | + | users | users.invite | B | no | + | users | users.change_role | B | no | + | users | users.remove | B | no | + | settings | settings.view | B | no | + | settings | settings.edit_general | B | no | + | settings | settings.manage_sites | B | no | + | settings | settings.delete_site | B | yes | + | settings | apikeys.view | S | no | + | settings | apikeys.create | B | no | + | settings | apikeys.revoke | S | no | + | roles | roles.view | B | no | + | roles | roles.manage | B | no | + | roles | roles.edit_owner_only | B | yes | + | system | system.apply_updates | B | yes | + | 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 + 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, + with built_in true and version 1: + | name | locked | granted keys | + | Owner | true | all 34 keys at all | + | Admin | false | the 29 keys that are not owner-only, at all | + | Writer | false | content.read=all, content.create=all, content.update=all, schema.view=all, media.view=all, media.upload=all, authors.view=all, authors.edit=own, activity.view=own | + And Admin keeps roles.view and roles.manage (confirmed by the user) + And users gain a nullable-by-default role_id referencing role. The legacy users.role string, the + domain user.Role constants and every RequireOwner / RequireOwnerOrAdmin check are unchanged + in this slice + And articles gain created_by (user id). Entries already carry created_by + And Migrate registers role and role_permission in AutoMigrate, then runs in this order: + seed built-in roles, the existing BackfillUserRoles, BackfillUserRoleIDs, BackfillCreatedBy + And the use cases are GetMyAccessUseCase and ListPermissionsUseCase. They depend on domain + interfaces only. Handlers receive use cases through constructors + And GET /v1/auth/me keeps its current contract (features/get_current_user.feature) and adds one + top-level field, access. No /v1/me route is added + And GET /v1/permission is mounted in api/router.go (NewPermissionRouter) behind + authtoken.Verifier, container.apiKeyAuth(), authtoken.Authenticator, RequireCurrentToken and + RequireVerifiedUser. It has no role check yet + + Scenario Outline: Role.Allows evaluates scope and ownership + Given a role whose grant for "" is "" + When Allows("", owned=) is evaluated + Then the result is + + Examples: + | key | scope | owned | allowed | + | content.update | all | false | true | + | content.update | all | true | true | + | content.update | own | true | true | + | content.update | own | false | false | + | content.update | none | true | false | + | content.create | all | false | true | + | schema.view | none | false | false | + + Scenario: A key absent from the role is not allowed + Given a role with no grants + When Allows("content.read", owned=true) is evaluated + Then the result is false + + Scenario: An unknown key is rejected, never silently allowed + Given a role with grants for every catalogue key at all + When Allows("content.explode", owned=true) is evaluated + Then the result is false + And looking the key up in the catalogue returns ErrUnknownPermission (errors.Is) + + Scenario: The catalogue is stable and complete + Then it has 34 keys in the order and groups of the Background table + And exactly these 5 keys are owner-only: settings.delete_site, roles.edit_owner_only, + system.apply_updates, system.edit_retention, system.transfer_ownership + And content.create is the only content key of kind B + And the scoped keys are content.read, content.update, content.delete, content.publish, media.delete, + authors.edit, activity.view, apikeys.view, apikeys.revoke + + Scenario: Seeding an empty database creates the three built-in roles + Given an empty database after AutoMigrate + When the built-in roles are seeded + Then roles "Owner", "Admin" and "Writer" exist with built_in true and version 1 + And only Owner has locked true + And Owner has 34 grants, all at scope all + And Admin has 29 grants, all at scope all, and none of the 5 owner-only keys + And Writer has exactly the 9 grants of the Background table + + Scenario: Seeding twice changes nothing + Given a database where the built-in roles are already seeded + When the built-in roles are seeded again + Then there are still exactly 3 roles with the same ids and 72 role_permission rows + + Scenario: Seeding never overwrites an edited built-in role + Given a seeded database where the Writer role carries version 4 and an extra grant media.delete=own + When the built-in roles are seeded again + Then the Writer role keeps version 4 and the extra grant + + Scenario: Seeding never touches a custom role that reuses a built-in name + Given a custom role named "Writer" with built_in false exists before seeding + When the built-in roles are seeded + Then startup is not aborted and the existing row is left unchanged + And the built-in Writer is still created or found by built_in true + + Scenario: Existing owner and admin users get the matching built-in role + Given the built-in roles are seeded + And users 3 (legacy role "owner"), 5 (legacy role "admin") and 9 (legacy role "admin") with no role_id + When BackfillUserRoleIDs runs + Then user 3 has role_id of the Owner role + And users 5 and 9 have role_id of the Admin role + And the legacy role string of every user is unchanged + + Scenario: The backfill runs after BackfillUserRoles on a database that predates both + Given users 2 and 4 with legacy role "" and no role_id + When Migrate runs + Then user 2 has legacy role "owner" and the Owner role_id + And user 4 has legacy role "admin" and the Admin role_id + + Scenario: The role_id backfill is idempotent and respects an existing role_id + Given user 5 has legacy role "admin" and role_id of a custom role "Contributor" + When BackfillUserRoleIDs runs twice + Then user 5 keeps the role_id of "Contributor" + + Scenario: A database with no users is left alone + Given a seeded database with no users + When BackfillUserRoleIDs runs + Then no error is returned + + Scenario: Existing articles are attributed to the first owner + Given the owner is user 3 and articles 10 and 11 with created_by 0 + When BackfillCreatedBy runs + Then articles 10 and 11 have created_by 3 + + Scenario: Entries without an author are attributed to the first owner + Given the owner is user 3, entry 20 with created_by 0 and entry 21 with created_by 7 + When BackfillCreatedBy runs + Then entry 20 has created_by 3 and entry 21 keeps created_by 7 + + Scenario: The created_by backfill never rewrites an existing value, and is idempotent + Given article 10 with created_by 7 + When BackfillCreatedBy runs twice + Then article 10 keeps created_by 7 + + Scenario: With no owner yet the backfill does nothing + Given articles with created_by 0 and no users + When BackfillCreatedBy runs + Then no error is returned and every created_by is still 0 + + Scenario: New articles record their creator + Given user 7 creates an article through CreateArticleUseCase + Then the stored article has created_by 7 + + Scenario: The owner's /me carries the Owner role and all 34 permissions + Given user 7, verified, legacy role "owner", role_id of the Owner role, valid jwt cookie, token_version 0 + When the client sends GET /v1/auth/me with that cookie + Then the response status is 200 and Cache-Control is "no-store" + And the body keeps id, username, email, is_verified, display_name, role "owner" and author + And access.role is {"id": , "name": "Owner", "built_in": true, "locked": true} + And access.permissions lists the 34 catalogue keys in catalogue order, each as {"key", "scope": "all"} + + Scenario: An admin's /me omits the owner-only keys + Given user 8, verified, legacy role "admin", role_id of the Admin role, valid jwt cookie + When the client sends GET /v1/auth/me + Then access.role.name is "Admin", built_in true, locked false + And access.permissions has 29 entries, none of them owner-only + And roles.view and roles.manage are present at scope "all" + + Scenario: A writer's /me lists only granted keys with their scope + Given user 9, verified, role_id of the Writer role, valid jwt cookie + When the client sends GET /v1/auth/me + Then access.permissions is, in catalogue order: + | key | scope | + | content.read | all | + | content.create | all | + | content.update | all | + | schema.view | all | + | media.view | all | + | media.upload | all | + | authors.view | all | + | authors.edit | own | + | activity.view | own | + + Scenario: A user on a custom role sees that role + Given user 10, verified, role_id of a custom role "Contributor" (not built-in, not locked) granting content.read=all and content.update=own + When the client sends GET /v1/auth/me + Then access.role.name is "Contributor", built_in false + And access.permissions is [{"key":"content.read","scope":"all"},{"key":"content.update","scope":"own"}] + + Scenario Outline: A user with no role_id falls back to the built-in role named by the legacy role + Given user 11, verified, legacy role "", role_id 0, valid jwt cookie + When the client sends GET /v1/auth/me + Then access.role.name is "" + + Examples: + | legacy | name | + | owner | Owner | + | admin | Admin | + + Scenario: The permissions follow the role live + Given user 9 is on the Writer role, and the Writer role's media.delete grant is then set to own in the repository + When the client sends GET /v1/auth/me again with the same cookie + Then access.permissions now contains {"key":"media.delete","scope":"own"} + + Scenario: An unverified user still gets /me, with access + Given user 12, is_verified false, role_id of the Writer role, valid jwt cookie + When the client sends GET /v1/auth/me + Then the response status is 200, is_verified is false and access.role.name is "Writer" + + Scenario: A user whose role cannot be resolved gets a server error, not an empty access block + Given user 13, role_id 99 (no such role), legacy role "", valid jwt cookie + When the client sends GET /v1/auth/me + Then the response status is 500 and the body is "Internal server error" + + Scenario: The existing /me failures are unchanged + Then no cookie gives 401, a stale token_version gives 401 apperr.UnauthorizedErrorMessage, and a deleted account gives 401 apperr.UnauthorizedErrorMessage, exactly as in features/get_current_user.feature + + Scenario: A signed-in verified user reads the catalogue + Given user 9 on the Writer role, verified, valid jwt cookie + When the client sends GET /v1/permission with that cookie + Then the response status is 200 + And the body is {"groups":[{"key":"content","items":[...]}, ...]} with the 9 groups in the order content, schema, media, authors, activity, users, settings, roles, system + And each item is {"key": "content.read", "scope_kind": "scoped"|"toggle", "owner_only": bool, "per_type": bool} + And per_type is true only for the 5 content.* items + And scope_kind is "toggle" for on/off keys and "scoped" for none/own/all keys + And the 5 owner-only keys have owner_only true + And the body holds no human-readable names (copy lives in the SPA) + + Scenario: The catalogue is the same for every role + Given the Writer and the Owner each request GET /v1/permission + Then both bodies are byte-identical + + Scenario: An API key may read the catalogue + Given a valid API key whose creator is verified + When the client sends GET /v1/permission with that key + Then the response status is 200 + + Scenario: No cookie is refused + When the client sends GET /v1/permission with no jwt cookie + Then the response status is 401 + + Scenario: A stale token is refused + Given a stored token_version 1 and a cookie encoding 0 + When the client sends GET /v1/permission + Then the response status is 401 and the body is apperr.UnauthorizedErrorMessage + + Scenario: An unverified user is refused + Given user 12, is_verified false + When the client sends GET /v1/permission + Then the response status is 403 and the body is apperr.VerificationRequiredErrorMessage + + Scenario Outline: Existing role checks are untouched in this slice + Given the migration has run and user has legacy role "" + When the client calls with a valid session for that user + Then the status is exactly as before the migration + + Examples: + | id | legacy | call | status | + | 3 | owner | POST /v1/user/invite | allowed (not 403) | + | 5 | admin | POST /v1/user/invite | 403 apperr.OwnerRequiredErrorMessage | + | 5 | admin | POST /v1/content-type | allowed (not 403) | + | 5 | admin | PATCH /v1/settings | 403 apperr.OwnerRequiredErrorMessage | + | 5 | admin | POST /v1/system/update/check | 403 apperr.OwnerRequiredErrorMessage | diff --git a/features/roles_permissions_foundation.qa.md b/features/roles_permissions_foundation.qa.md new file mode 100644 index 0000000..8964a12 --- /dev/null +++ b/features/roles_permissions_foundation.qa.md @@ -0,0 +1,57 @@ +# QA procedure: roles_permissions_foundation + +Package: `test` (black-box). Name new fakes uniquely, for example `mockRoleRepo`, `rolesFoundationUserRepo`. +Do not copy the old `Role ==` checks into new code. + +## Deferred to later slices + +- 2a: permission-based route and middleware checks, API keys on the creator's live role, admin relaxations, 403 missing_permission, Writer effective. +- 2b: own/all enforcement after loading the item, publish separate from update, Draft and "Ask an editor to publish." +- 3: /v1/role CRUD, reassign, no-escalation, role_locked, role_has_members, role_name_taken, role_conflict, per-type content override keys `content.:`, role events. +- 4: PUT /v1/user/{id}/role, invite role (default Writer), transfer ownership, protected owner, removal of the legacy role string. +- 5-7: all SPA states in docs/design/screens/roles.md. + +## Design note for the user + +The "Owner-only items" state in docs/design/screens/roles.md disables View roles and Manage roles for an admin. This slice follows the user's decision that Admin keeps roles.view and roles.manage (29 of 34 keys), matching the catalogue table. roles.md needs a design fix by the user; no stage edits docs/design. + +## Test infrastructure needed first + +1. `mockRoleRepo` implementing the role domain `Repository` in memory. Seed it with the three built-ins through the same Go function the migration uses, or assert it matches the Background tables. +2. Extend the user fake with `RoleID`, and the article fake with `CreatedBy`. +3. The persistence tests use an in-memory SQLite DSN (as `TestBackfillUserRoles` in `test/user_roles_test.go`), never a file. Register `models.Role`, `models.RolePermission`, `models.User`, `models.Article` and `models.Entry` via `database.Migrate` where the whole sequence is asserted, and via targeted AutoMigrate for single-backfill tests. + +## A. Domain (pure Go, no HTTP, no GORM) + +4. Table-test `Role.Allows` with the Examples of "Role.Allows evaluates scope and ownership", plus the absent-key and unknown-key cases. Assert `errors.Is(err, role.ErrUnknownPermission)` for the lookup. +5. Catalogue test: length 34, group order, key order, the 5 owner-only keys, the 9 scoped keys, and `per_type` only for the 5 content keys. A unique-keys check guards against duplicates. +6. A hygiene test that `internal/domain/role` imports only the stdlib (grep the import list, as in the existing `*_hygiene_test.go` files). + +## B. Persistence (in-memory SQLite) + +7. Seed on an empty DB: assert 3 roles, flags, versions, and the 34 / 29 / 9 grant counts and scopes. Run again: still 3 roles with the same ids and 72 role_permission rows. +8. Edited built-in: bump Writer's `version` to 4 and add `media.delete=own`, seed again, assert untouched. +9. Name collision: pre-insert a custom "Writer" (built_in false), seed, assert no error and the row is unchanged. +10. `BackfillUserRoleIDs`: cases for owner/admin mapping, the legacy string unchanged, an existing `role_id` kept, double run, empty DB. +11. Full `database.Migrate` on a DB created with the old schema (`users.role = ""`, no `role_id`): assert ordering (legacy role first, then role_id). +12. `BackfillCreatedBy`: articles and entries with 0, entries with an existing value, double run, no owner. +13. Portability: assert there is no raw SQL specific to SQLite in the new repositories (code review item); MySQL and PostgreSQL are not exercised here. + +## C. Use cases (fakes only) + +14. `GetMyAccessUseCase`: owner, admin, writer, custom role, legacy fallback (role_id 0 with "owner" and "admin"), live change after a grant edit, unresolvable role (expect a non-sentinel error). +15. `ListPermissionsUseCase`: returns the domain catalogue unchanged. +16. `CreateArticleUseCase` sets `CreatedBy` to the acting user id. + +## D. HTTP (`httptest`, fresh `chi.NewRouter()` mirroring `api/router.go`) + +17. Call `validator.InitValidator()`. Use the token helper the current `/me` tests use. Mint cookies as `get_current_user.qa.md` step 7 does. +18. Mount `/v1/auth/me` as in `NewAuthRouter` (Verifier, apiKeyAuth, Authenticator, RequireCurrentToken) with `auth.NewMeHandler(getUser, getMyAuthor, getMyAccess)`. Re-run all `get_current_user` cases unchanged (additive field) and add the access cases: owner, admin, writer, custom, fallback, live change, unverified, unresolvable role (500). +19. Mount `/v1/permission` as in the new `NewPermissionRouter` (adds RequireVerifiedUser). Cases: Writer 200 with the full shape, Owner and Writer bodies equal, API key 200, no cookie 401, stale token 401, unverified 403. +20. "Unchanged behaviour" Outline: build the existing `NewUserRouter`, `NewSettingsRouter`, `NewContentTypeRouter` and `NewSystemRouter` groups over users carrying role_id, and assert the statuses are as before. The simplest source is the existing router tests; add a seeded-role_id variant. + +## Manual verification + +- Start a dev build (`task dev:api`) on a copy of a database created before this change. Check that the startup migration succeeds, that no data is lost, and that `GET /v1/auth/me` and `GET /v1/permission` respond for the existing owner and admin. +- MySQL and PostgreSQL migration of the new tables is a manual item; the repo has no CI and no automated run for it. +- The SPA is untouched in this slice. SPA states in docs/design/screens/roles.md are informational. diff --git a/internal/domain/article/articleDomain.go b/internal/domain/article/articleDomain.go index 580951d..7583638 100644 --- a/internal/domain/article/articleDomain.go +++ b/internal/domain/article/articleDomain.go @@ -118,6 +118,7 @@ type Article struct { Body string `json:"body"` IsOnline bool `json:"is_online"` SiteID uint32 `json:"site_id"` + CreatedBy uint32 `json:"-"` Authors []author.Byline `json:"authors"` Slug string `json:"slug"` Excerpt string `json:"excerpt"` diff --git a/internal/domain/role/roleBuiltIn.go b/internal/domain/role/roleBuiltIn.go new file mode 100644 index 0000000..2a3f2b2 --- /dev/null +++ b/internal/domain/role/roleBuiltIn.go @@ -0,0 +1,48 @@ +package role + +const ( + OwnerName = "Owner" + AdminName = "Admin" + WriterName = "Writer" +) + +func allGrants(include func(Permission) bool) []Grant { + grants := []Grant{} + for _, permission := range catalogue { + if include(permission) { + grants = append(grants, Grant{Key: permission.Key, Scope: ScopeAll}) + } + } + return grants +} + +func writerGrants() []Grant { + return []Grant{ + {Key: "content.read", Scope: ScopeAll}, + {Key: "content.create", Scope: ScopeAll}, + {Key: "content.update", Scope: ScopeAll}, + {Key: "schema.view", Scope: ScopeAll}, + {Key: "media.view", Scope: ScopeAll}, + {Key: "media.upload", Scope: ScopeAll}, + {Key: "authors.view", Scope: ScopeAll}, + {Key: "authors.edit", Scope: ScopeOwn}, + {Key: "activity.view", Scope: ScopeOwn}, + } +} + +func BuiltInRoles() []Role { + return []Role{ + { + Name: OwnerName, BuiltIn: true, Locked: true, Version: 1, + Grants: allGrants(func(Permission) bool { return true }), + }, + { + Name: AdminName, BuiltIn: true, Version: 1, + Grants: allGrants(func(p Permission) bool { return !p.OwnerOnly }), + }, + { + Name: WriterName, BuiltIn: true, Version: 1, + Grants: writerGrants(), + }, + } +} diff --git a/internal/domain/role/roleCatalogue.go b/internal/domain/role/roleCatalogue.go new file mode 100644 index 0000000..7f15002 --- /dev/null +++ b/internal/domain/role/roleCatalogue.go @@ -0,0 +1,118 @@ +package role + +import "strings" + +type Kind string + +const ( + KindScoped Kind = "scoped" + KindToggle Kind = "toggle" +) + +const contentGroup = "content" + +type Permission struct { + Key string + Group string + Kind Kind + OwnerOnly bool +} + +func (p Permission) PerType() bool { + return strings.HasPrefix(p.Key, contentGroup+".") +} + +func scoped(group, key string) Permission { + return Permission{Key: key, Group: group, Kind: KindScoped} +} + +func toggle(group, key string) Permission { + return Permission{Key: key, Group: group, Kind: KindToggle} +} + +func ownerOnly(group, key string) Permission { + return Permission{Key: key, Group: group, Kind: KindToggle, OwnerOnly: true} +} + +var catalogue = []Permission{ + scoped("content", "content.read"), + toggle("content", "content.create"), + scoped("content", "content.update"), + scoped("content", "content.delete"), + scoped("content", "content.publish"), + toggle("schema", "schema.view"), + toggle("schema", "schema.edit"), + toggle("schema", "schema.delete"), + toggle("media", "media.view"), + toggle("media", "media.upload"), + scoped("media", "media.delete"), + toggle("authors", "authors.view"), + toggle("authors", "authors.create"), + scoped("authors", "authors.edit"), + toggle("authors", "authors.delete"), + scoped("activity", "activity.view"), + toggle("activity", "activity.export"), + toggle("users", "users.view"), + toggle("users", "users.invite"), + toggle("users", "users.change_role"), + toggle("users", "users.remove"), + toggle("settings", "settings.view"), + toggle("settings", "settings.edit_general"), + toggle("settings", "settings.manage_sites"), + ownerOnly("settings", "settings.delete_site"), + scoped("settings", "apikeys.view"), + toggle("settings", "apikeys.create"), + scoped("settings", "apikeys.revoke"), + toggle("roles", "roles.view"), + toggle("roles", "roles.manage"), + ownerOnly("roles", "roles.edit_owner_only"), + ownerOnly("system", "system.apply_updates"), + ownerOnly("system", "system.edit_retention"), + ownerOnly("system", "system.transfer_ownership"), +} + +func Catalogue() []Permission { + return append([]Permission(nil), catalogue...) +} + +func Lookup(key string) (Permission, error) { + for _, permission := range catalogue { + if permission.Key == key { + return permission, nil + } + } + return Permission{}, ErrUnknownPermission +} + +func Groups() []string { + groups := []string{} + for _, permission := range catalogue { + if len(groups) == 0 || groups[len(groups)-1] != permission.Group { + groups = append(groups, permission.Group) + } + } + return groups +} + +const ( + typeSeparator = ":" + maxKeyLength = 100 + typeKeyAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-" +) + +func ParseTypedKey(composite string) (string, string, error) { + base, typeKey, found := strings.Cut(composite, typeSeparator) + if !found || len(composite) > maxKeyLength || typeKey == "" { + return "", "", ErrUnknownPermission + } + permission, err := Lookup(base) + if err != nil || !permission.PerType() { + return "", "", ErrUnknownPermission + } + for _, char := range typeKey { + if !strings.ContainsRune(typeKeyAlphabet, char) { + return "", "", ErrUnknownPermission + } + } + return base, typeKey, nil +} diff --git a/internal/domain/role/roleDomain.go b/internal/domain/role/roleDomain.go new file mode 100644 index 0000000..c119582 --- /dev/null +++ b/internal/domain/role/roleDomain.go @@ -0,0 +1,120 @@ +package role + +import ( + "errors" + "strings" +) + +var ( + ErrRoleNotFound = errors.New("role not found") + ErrUnknownPermission = errors.New("unknown permission") + ErrRoleConflict = errors.New("role was modified concurrently") + ErrInvalidScope = errors.New("invalid scope for permission") + ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role") +) + +type Scope string + +const ( + ScopeNone Scope = "none" + ScopeOwn Scope = "own" + ScopeAll Scope = "all" +) + +type Grant struct { + Key string `json:"key"` + Scope Scope `json:"scope"` +} + +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"` +} + +func (r Role) scopeOf(key string) Scope { + for _, grant := range r.Grants { + if grant.Key == key { + return grant.Scope + } + } + return ScopeNone +} + +func (r Role) Allows(key string, owned bool) bool { + permission, err := Lookup(key) + if err != nil { + return false + } + if permission.OwnerOnly && !r.Locked { + return false + } + return scopeAllows(permission.Kind, r.scopeOf(key), owned) +} + +func (r Role) AllowsFor(key string, typeKey string, owned bool) bool { + if typeKey == "" { + return r.Allows(key, owned) + } + composite := key + typeSeparator + typeKey + base, _, err := ParseTypedKey(composite) + if err != nil { + return false + } + permission, _ := Lookup(base) + for _, grant := range r.Grants { + if grant.Key == composite { + return scopeAllows(permission.Kind, grant.Scope, owned) + } + } + return r.Allows(key, owned) +} + +func scopeAllows(kind Kind, scope Scope, owned bool) bool { + switch scope { + case ScopeAll: + return true + case ScopeOwn: + return kind == KindScoped && owned + default: + return false + } +} + +func resolveGrantKey(key string) (Permission, error) { + if strings.Contains(key, typeSeparator) { + base, _, err := ParseTypedKey(key) + if err != nil { + return Permission{}, err + } + return Lookup(base) + } + return Lookup(key) +} + +func ValidateGrants(locked bool, grants []Grant) error { + for _, grant := range grants { + permission, err := resolveGrantKey(grant.Key) + if err != nil { + return err + } + if permission.OwnerOnly && !locked { + return ErrOwnerOnlyGrant + } + if !scopeValidFor(permission.Kind, grant.Scope) { + return ErrInvalidScope + } + } + return nil +} + +func scopeValidFor(kind Kind, scope Scope) bool { + if kind == KindToggle { + return scope == ScopeAll + } + return scope == ScopeOwn || scope == ScopeAll +} diff --git a/internal/domain/role/roleRepositoryInterface.go b/internal/domain/role/roleRepositoryInterface.go new file mode 100644 index 0000000..8241b3e --- /dev/null +++ b/internal/domain/role/roleRepositoryInterface.go @@ -0,0 +1,10 @@ +package role + +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) +} diff --git a/internal/domain/user/userDomain.go b/internal/domain/user/userDomain.go index 51d3b04..bda013e 100644 --- a/internal/domain/user/userDomain.go +++ b/internal/domain/user/userDomain.go @@ -47,6 +47,7 @@ type User struct { DisplayName string `json:"display_name"` IsVerified bool `json:"is_verified"` Role Role `json:"role"` + RoleID uint32 `json:"role_id"` VerificationCode string `json:"verification_code"` VerificationExpiration time.Time `json:"verification_expiration"` PasswordResetExpiration time.Time `json:"password_reset_expiration"` diff --git a/internal/infrastructure/persistence/database/createdByBackfill.go b/internal/infrastructure/persistence/database/createdByBackfill.go new file mode 100644 index 0000000..eaa57fa --- /dev/null +++ b/internal/infrastructure/persistence/database/createdByBackfill.go @@ -0,0 +1,25 @@ +package database + +import ( + domainUser "RenewCMS/internal/domain/user" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +func BackfillCreatedBy(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + var owner entity.User + result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + if err := tx.Unscoped().Model(&entity.Article{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error; err != nil { + return err + } + return tx.Model(&entity.Entry{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error + }) +} diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index d3e2a7b..cd49650 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -7,16 +7,25 @@ import ( ) func Migrate(db *gorm.DB, publicURL string) error { - err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}) + err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}) if err != nil { return err } if err := ensureImagePathIndex(db); err != nil { return err } + if err := SeedBuiltInRoles(db); err != nil { + return err + } if err := BackfillUserRoles(db); err != nil { return err } + if err := BackfillUserRoleIDs(db); err != nil { + return err + } + if err := BackfillCreatedBy(db); err != nil { + return err + } if err := SeedDefaultSite(db, publicURL); err != nil { return err } diff --git a/internal/infrastructure/persistence/database/roleSeed.go b/internal/infrastructure/persistence/database/roleSeed.go new file mode 100644 index 0000000..9d12ce1 --- /dev/null +++ b/internal/infrastructure/persistence/database/roleSeed.go @@ -0,0 +1,70 @@ +package database + +import ( + "slices" + + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/persistence/mappers" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +func SeedBuiltInRoles(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + for _, builtIn := range role.BuiltInRoles() { + if err := seedBuiltInRole(tx, builtIn); err != nil { + return err + } + } + return nil + }) +} + +func seedBuiltInRole(tx *gorm.DB, builtIn role.Role) error { + id, found, err := builtInRoleID(tx, builtIn.Name) + if err != nil { + return err + } + if found { + return reconcileBuiltInRole(tx, id, builtIn) + } + model := mappers.RoleToModel(builtIn) + if err := tx.Create(&model).Error; err != nil { + return err + } + return tx.Create(mappers.GrantsToModel(model.ID, builtIn.Grants)).Error +} + +func builtInRoleID(tx *gorm.DB, name string) (uint32, bool, error) { + var found entity.Role + result := tx.Where("name = ? AND built_in = ?", name, true).Order("id asc").Limit(1).Find(&found) + if result.Error != nil { + return 0, false, result.Error + } + return found.ID, result.RowsAffected > 0, nil +} + +func reconcileBuiltInRole(tx *gorm.DB, id uint32, builtIn role.Role) error { + var stored entity.Role + if err := tx.First(&stored, id).Error; err != nil { + return err + } + if !builtIn.Locked && stored.Version != 1 { + return nil + } + var present []string + if err := tx.Model(&entity.RolePermission{}).Where("role_id = ?", id).Pluck("permission_key", &present).Error; err != nil { + return err + } + missing := []role.Grant{} + for _, grant := range builtIn.Grants { + if !slices.Contains(present, grant.Key) { + missing = append(missing, grant) + } + } + if len(missing) == 0 { + return nil + } + return tx.Create(mappers.GrantsToModel(id, missing)).Error +} diff --git a/internal/infrastructure/persistence/database/userRoleIDBackfill.go b/internal/infrastructure/persistence/database/userRoleIDBackfill.go new file mode 100644 index 0000000..b555495 --- /dev/null +++ b/internal/infrastructure/persistence/database/userRoleIDBackfill.go @@ -0,0 +1,35 @@ +package database + +import ( + "RenewCMS/internal/domain/role" + domainUser "RenewCMS/internal/domain/user" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +var legacyRoleNames = map[domainUser.Role]string{ + domainUser.RoleOwner: role.OwnerName, + domainUser.RoleAdmin: role.AdminName, +} + +func BackfillUserRoleIDs(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + for legacy, name := range legacyRoleNames { + roleID, found, err := builtInRoleID(tx, name) + if err != nil { + return err + } + if !found { + continue + } + err = tx.Model(&entity.User{}). + Where("role = ? AND (role_id IS NULL OR role_id = 0)", string(legacy)). + UpdateColumn("role_id", roleID).Error + if err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/infrastructure/persistence/mappers/articleMapper.go b/internal/infrastructure/persistence/mappers/articleMapper.go index 3510989..13cfbb9 100644 --- a/internal/infrastructure/persistence/mappers/articleMapper.go +++ b/internal/infrastructure/persistence/mappers/articleMapper.go @@ -37,6 +37,7 @@ func ArticleToDomain(m models.Article) article.Article { Body: m.Body, IsOnline: m.IsOnline, SiteID: m.SiteID, + CreatedBy: m.CreatedBy, Slug: m.Slug, Excerpt: m.Excerpt, SEOTitle: m.SEOTitle, @@ -60,6 +61,7 @@ func ArticleToModel(a article.Article) models.Article { Body: a.Body, IsOnline: a.IsOnline, SiteID: a.SiteID, + CreatedBy: a.CreatedBy, Slug: a.Slug, Excerpt: a.Excerpt, SEOTitle: a.SEOTitle, diff --git a/internal/infrastructure/persistence/mappers/roleMapper.go b/internal/infrastructure/persistence/mappers/roleMapper.go new file mode 100644 index 0000000..e766744 --- /dev/null +++ b/internal/infrastructure/persistence/mappers/roleMapper.go @@ -0,0 +1,45 @@ +package mappers + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/persistence/models" +) + +func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role { + return role.Role{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + BuiltIn: m.BuiltIn, + Locked: m.Locked, + Version: m.Version, + Grants: GrantsToDomain(grants), + } +} + +func RoleToModel(r role.Role) models.Role { + return models.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + BuiltIn: r.BuiltIn, + Locked: r.Locked, + Version: r.Version, + } +} + +func GrantsToDomain(rows []models.RolePermission) []role.Grant { + grants := make([]role.Grant, 0, len(rows)) + for _, row := range rows { + grants = append(grants, role.Grant{Key: row.Key, Scope: role.Scope(row.Scope)}) + } + return grants +} + +func GrantsToModel(roleID uint32, grants []role.Grant) []models.RolePermission { + rows := make([]models.RolePermission, 0, len(grants)) + for _, grant := range grants { + rows = append(rows, models.RolePermission{RoleID: roleID, Key: grant.Key, Scope: string(grant.Scope)}) + } + return rows +} diff --git a/internal/infrastructure/persistence/mappers/userMapper.go b/internal/infrastructure/persistence/mappers/userMapper.go index d27e288..e2641c2 100644 --- a/internal/infrastructure/persistence/mappers/userMapper.go +++ b/internal/infrastructure/persistence/mappers/userMapper.go @@ -19,6 +19,20 @@ func DomainToNullableString(value string) *string { return &value } +func nullableIDToDomain(value *uint32) uint32 { + if value == nil { + return 0 + } + return *value +} + +func domainToNullableID(value uint32) *uint32 { + if value == 0 { + return nil + } + return &value +} + func UserToDomain(m models.User) user.User { return user.User{ ID: m.ID, @@ -29,6 +43,7 @@ func UserToDomain(m models.User) user.User { DisplayName: m.DisplayName, IsVerified: m.IsVerified, Role: user.Role(m.Role), + RoleID: nullableIDToDomain(m.RoleID), VerificationCode: nullableStringToDomain(m.VerificationCode), VerificationExpiration: m.VerificationExpiration, PasswordResetExpiration: m.PasswordResetExpiration, @@ -47,6 +62,7 @@ func UserToModel(a user.User) models.User { DisplayName: a.DisplayName, IsVerified: a.IsVerified, Role: string(a.Role), + RoleID: domainToNullableID(a.RoleID), VerificationCode: DomainToNullableString(a.VerificationCode), VerificationExpiration: a.VerificationExpiration, PasswordResetExpiration: a.PasswordResetExpiration, diff --git a/internal/infrastructure/persistence/models/articleModel.go b/internal/infrastructure/persistence/models/articleModel.go index d7064e2..c29502f 100644 --- a/internal/infrastructure/persistence/models/articleModel.go +++ b/internal/infrastructure/persistence/models/articleModel.go @@ -13,6 +13,7 @@ type Article struct { Body string IsOnline bool `gorm:"not_null;default:false"` SiteID uint32 `gorm:"index"` + CreatedBy uint32 `gorm:"not null;default:0"` Slug string `gorm:"size:100;not null;default:''"` Excerpt string `gorm:"size:300;not null;default:''"` SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"` diff --git a/internal/infrastructure/persistence/models/roleModel.go b/internal/infrastructure/persistence/models/roleModel.go new file mode 100644 index 0000000..df1677b --- /dev/null +++ b/internal/infrastructure/persistence/models/roleModel.go @@ -0,0 +1,17 @@ +package models + +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"` +} + +type RolePermission struct { + ID uint32 `gorm:"primaryKey;autoIncrement"` + RoleID uint32 `gorm:"not null;uniqueIndex:idx_role_permission_key"` + Key string `gorm:"column:permission_key;not null;size:100;uniqueIndex:idx_role_permission_key"` + Scope string `gorm:"not null;size:8"` +} diff --git a/internal/infrastructure/persistence/models/userModel.go b/internal/infrastructure/persistence/models/userModel.go index 73b82ab..3698f0a 100644 --- a/internal/infrastructure/persistence/models/userModel.go +++ b/internal/infrastructure/persistence/models/userModel.go @@ -13,6 +13,7 @@ type User struct { DisplayName string `gorm:"not null;default:'';size:255"` IsVerified bool `gorm:"default=false;not null"` Role string `gorm:"not null;default:'';size:32"` + RoleID *uint32 `gorm:"index"` VerificationCode *string `gorm:"unique;size:255"` VerificationExpiration time.Time PasswordResetExpiration time.Time diff --git a/internal/infrastructure/persistence/repositories/roleRepository.go b/internal/infrastructure/persistence/repositories/roleRepository.go new file mode 100644 index 0000000..fd2df4e --- /dev/null +++ b/internal/infrastructure/persistence/repositories/roleRepository.go @@ -0,0 +1,119 @@ +package persistence + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/persistence/mappers" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +type RoleRepository struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) *RoleRepository { + return &RoleRepository{db} +} + +func (r *RoleRepository) grantRows(roleID uint32) ([]entity.RolePermission, error) { + var rows []entity.RolePermission + if err := r.db.Where("role_id = ?", roleID).Order("id asc").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (r *RoleRepository) withGrants(found entity.Role) (role.Role, error) { + rows, err := r.grantRows(found.ID) + if err != nil { + return role.Role{}, err + } + return mappers.RoleToDomain(found, rows), nil +} + +func (r *RoleRepository) List() ([]role.Role, error) { + var found []entity.Role + if err := r.db.Order("id asc").Find(&found).Error; err != nil { + return nil, err + } + roles := make([]role.Role, 0, len(found)) + for _, row := range found { + roles = append(roles, mappers.RoleToDomain(row, nil)) + } + return roles, nil +} + +func (r *RoleRepository) Get(id uint32) (role.Role, error) { + var found entity.Role + if err := r.db.First(&found, id).Error; err != nil { + return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound) + } + return r.withGrants(found) +} + +func (r *RoleRepository) GetByName(name string) (role.Role, error) { + var found entity.Role + if err := r.db.Where("name = ?", name).Order("built_in DESC, id ASC").First(&found).Error; err != nil { + return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound) + } + return r.withGrants(found) +} + +func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) { + rows, err := r.grantRows(roleID) + if err != nil { + return nil, err + } + return mappers.GrantsToDomain(rows), 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 := tx.Create(&model).Error; err != nil { + return err + } + rows := mappers.GrantsToModel(model.ID, created.Grants) + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) + if err != nil { + return role.Role{}, err + } + return r.withGrants(model) +} + +func (r *RoleRepository) Save(saved role.Role) (role.Role, error) { + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil { + return mapNotFoundErr(err, role.ErrRoleNotFound) + } + 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}) + if update.Error != nil { + return update.Error + } + if update.RowsAffected == 0 { + return role.ErrRoleConflict + } + if err := tx.Where("role_id = ?", saved.ID).Delete(&entity.RolePermission{}).Error; err != nil { + return err + } + rows := mappers.GrantsToModel(saved.ID, saved.Grants) + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) + if err != nil { + return role.Role{}, err + } + return r.Get(saved.ID) +} + +var _ role.Repository = &RoleRepository{} diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index 26ca394..fab1ba7 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -71,6 +71,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle Body: createArticle.Body, IsOnline: false, SiteID: siteID, + CreatedBy: createArticle.CreatedBy, Authors: creditedAuthors, SEODismissed: []string{}, }) diff --git a/internal/infrastructure/useCases/GetMyAccess.go b/internal/infrastructure/useCases/GetMyAccess.go new file mode 100644 index 0000000..112cac1 --- /dev/null +++ b/internal/infrastructure/useCases/GetMyAccess.go @@ -0,0 +1,69 @@ +package useCases + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" +) + +type Access struct { + Role role.Role + Permissions []role.Grant +} + +type GetMyAccessUseCase struct { + roles role.Repository +} + +func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase { + return &GetMyAccessUseCase{roles: roles} +} + +var legacyBuiltInNames = map[user.Role]string{ + user.RoleOwner: role.OwnerName, + user.RoleAdmin: role.AdminName, +} + +func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) { + if account.RoleID != 0 { + return u.roles.Get(account.RoleID) + } + name, known := legacyBuiltInNames[account.Role] + if !known { + return role.Role{}, role.ErrRoleNotFound + } + found, err := u.roles.GetByName(name) + if err != nil { + return role.Role{}, err + } + if !found.BuiltIn { + return role.Role{}, role.ErrRoleNotFound + } + return found, nil +} + +func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) { + resolved, err := u.resolveRole(account) + if err != nil { + return Access{}, err + } + granted, err := u.roles.ListGrants(resolved.ID) + if err != nil { + return Access{}, err + } + return Access{Role: resolved, Permissions: orderedGrants(granted)}, nil +} + +func orderedGrants(granted []role.Grant) []role.Grant { + scopes := make(map[string]role.Scope, len(granted)) + for _, grant := range granted { + scopes[grant.Key] = grant.Scope + } + ordered := []role.Grant{} + for _, permission := range role.Catalogue() { + scope, present := scopes[permission.Key] + if present && scope != role.ScopeNone { + ordered = append(ordered, role.Grant{Key: permission.Key, Scope: scope}) + } + } + return ordered +} diff --git a/internal/infrastructure/useCases/ListPermissions.go b/internal/infrastructure/useCases/ListPermissions.go new file mode 100644 index 0000000..e204164 --- /dev/null +++ b/internal/infrastructure/useCases/ListPermissions.go @@ -0,0 +1,27 @@ +package useCases + +import "RenewCMS/internal/domain/role" + +type PermissionGroup struct { + Key string + Items []role.Permission +} + +type ListPermissionsUseCase struct{} + +func NewListPermissionsUseCase() *ListPermissionsUseCase { + return &ListPermissionsUseCase{} +} + +func (u *ListPermissionsUseCase) List() []PermissionGroup { + groups := []PermissionGroup{} + for _, permission := range role.Catalogue() { + last := len(groups) - 1 + if last < 0 || groups[last].Key != permission.Group { + groups = append(groups, PermissionGroup{Key: permission.Group}) + last++ + } + groups[last].Items = append(groups[last].Items, permission) + } + return groups +} diff --git a/test/accept_invitation_test.go b/test/accept_invitation_test.go index 66910be..4799829 100644 --- a/test/accept_invitation_test.go +++ b/test/accept_invitation_test.go @@ -59,7 +59,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture f.token, true, ) - meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors())) + meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) listHandler := userhandler.NewInviteHandler( useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder), nil, diff --git a/test/activity_log_backend_http_test.go b/test/activity_log_backend_http_test.go index 1aa99a4..9daed48 100644 --- a/test/activity_log_backend_http_test.go +++ b/test/activity_log_backend_http_test.go @@ -74,6 +74,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv { Token: e.token, GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder), diff --git a/test/api_catalogue_drift_test.go b/test/api_catalogue_drift_test.go index eeec12b..6a65ce9 100644 --- a/test/api_catalogue_drift_test.go +++ b/test/api_catalogue_drift_test.go @@ -64,6 +64,7 @@ var routesNotYetInTheCatalogue = []string{ "GET /v1/image-variants/cache", "GET /v1/media/{id}/variants", "GET /v1/user/lookup", + "GET /v1/permission", "PATCH /v1/image-variants/{name}", "POST /v1/content-type", "POST /v1/content-type/{key}/entry", diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 331c522..01564a9 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -45,6 +45,7 @@ var apiKeyRawPattern = regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`) type apiKeyEnv struct { users *mockUserRepo + roles *mockRoleRepo keys *mockAPIKeyRepo articles *mockArticleRepo images *mockImageRepo @@ -72,6 +73,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { env := &apiKeyEnv{ users: newMockUserRepo(), + roles: newSeededMockRoleRepo(), keys: newMockAPIKeyRepo(), articles: newMockArticleRepo(), images: newMockImageRepo(), @@ -113,6 +115,8 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder), GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles), + ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder), diff --git a/test/author_support_test.go b/test/author_support_test.go index 14d1f23..bdb23d2 100644 --- a/test/author_support_test.go +++ b/test/author_support_test.go @@ -351,8 +351,8 @@ type authorEnv struct { func newAuthorEnv(t *testing.T) *authorEnv { t.Helper() p := newProducersEnv(t) - p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole} - p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole} + p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} + p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} p.users.nextID = authorOtherEditorID authors := newMockAuthorRepo(p.articles) sites := plainSites() @@ -437,9 +437,9 @@ func joinNames(names []string) string { } func userWithEmail(id uint32, username string, email string) user.User { - return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole} + return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} } func userNamed(id uint32, username string, displayName string) user.User { - return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole} + return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID} } diff --git a/test/container_update_guidance_http_test.go b/test/container_update_guidance_http_test.go index eb31b84..b9d98c4 100644 --- a/test/container_update_guidance_http_test.go +++ b/test/container_update_guidance_http_test.go @@ -58,6 +58,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co Token: token, GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder), StartUpdateDownloadUseCase: start, diff --git a/test/get_current_user_http_test.go b/test/get_current_user_http_test.go index 710eeaa..ff72ad0 100644 --- a/test/get_current_user_http_test.go +++ b/test/get_current_user_http_test.go @@ -19,7 +19,7 @@ import ( func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) - meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors())) + meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) verificationHandler := auth.NewVerificationHandler( useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder), useCases.NewCancelRegistrationUseCase(userRepo), @@ -67,6 +67,7 @@ func TestGetCurrentUserHttp(t *testing.T) { userRepo.users[7] = user.User{ ID: 7, Username: "alice", + Role: user.RoleOwner, Email: "alice@example.com", IsVerified: true, TokenVersion: 0, @@ -97,6 +98,7 @@ func TestGetCurrentUserHttp(t *testing.T) { userRepo.users[8] = user.User{ ID: 8, Username: "bob", + Role: user.RoleOwner, Email: "bob@example.com", IsVerified: false, TokenVersion: 0, @@ -124,7 +126,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("No jwt cookie at all is refused", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} + userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil) @@ -137,7 +139,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1} + userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 1} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) cookie := mintGetCurrentUserCookie(t, token, 7, 0) @@ -154,7 +156,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0} + userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: false, TokenVersion: 0} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) cookie := mintGetCurrentUserCookie(t, token, 7, 0) @@ -175,7 +177,7 @@ func TestGetCurrentUserHttp(t *testing.T) { t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) { token := authtoken.New([]byte("test-secret")) userRepo := newMockUserRepo() - userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} + userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0} router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) cookie := mintGetCurrentUserCookie(t, token, 7, 0) diff --git a/test/image_variants_env_test.go b/test/image_variants_env_test.go index b76c862..81d592b 100644 --- a/test/image_variants_env_test.go +++ b/test/image_variants_env_test.go @@ -114,6 +114,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases Token: e.token, GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles), MergeMediaUseCase: newTestMergeMedia(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), diff --git a/test/media_env_test.go b/test/media_env_test.go index 62b59ba..716c7be 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -74,6 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv { DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder), GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), diff --git a/test/role_domain_hygiene_test.go b/test/role_domain_hygiene_test.go new file mode 100644 index 0000000..6880d75 --- /dev/null +++ b/test/role_domain_hygiene_test.go @@ -0,0 +1,25 @@ +package test + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestRoleDomainImportsOnlyTheStandardLibrary(t *testing.T) { + t.Run("The role domain imports only the standard library", func(t *testing.T) { + files, err := filepath.Glob("../internal/domain/role/*.go") + if err != nil || len(files) == 0 { + t.Fatalf("no role domain files: %v", err) + } + for _, file := range files { + relative := strings.TrimPrefix(filepath.ToSlash(file), "../") + for _, imported := range importsOf(t, relative) { + first := strings.Split(imported, "/")[0] + if strings.Contains(first, ".") || first == "RenewCMS" { + t.Errorf("%s imports %s", relative, imported) + } + } + } + }) +} diff --git a/test/role_hardening_test.go b/test/role_hardening_test.go new file mode 100644 index 0000000..f057ebf --- /dev/null +++ b/test/role_hardening_test.go @@ -0,0 +1,296 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/persistence/database" + "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "errors" + "strings" + "testing" + + "gorm.io/gorm" +) + +func deleteGrantRow(t *testing.T, db *gorm.DB, roleID uint32, key string) { + t.Helper() + if err := db.Where("role_id = ? AND permission_key = ?", roleID, key).Delete(&models.RolePermission{}).Error; err != nil { + t.Fatal(err) + } +} + +func TestBuiltInReconciliation(t *testing.T) { + reseed := func(t *testing.T, db *gorm.DB) { + t.Helper() + if err := database.SeedBuiltInRoles(db); err != nil { + t.Fatal(err) + } + } + + t.Run("Owner regains a missing key at scope all without a version bump", func(t *testing.T) { + db := openMigratedRolesDB(t) + owner := storedRole(t, db, "Owner") + deleteGrantRow(t, db, owner.ID, "system.apply_updates") + deleteGrantRow(t, db, owner.ID, "content.read") + reseed(t, db) + after := storedRole(t, db, "Owner") + scopes := grantMap(after.Grants) + if scopes["system.apply_updates"] != role.ScopeAll || scopes["content.read"] != role.ScopeAll { + t.Errorf("owner did not regain keys") + } + if after.Version != owner.Version || len(after.Grants) != len(owner.Grants) { + t.Errorf("version or grant count changed: %+v", after.Version) + } + }) + + t.Run("Owner keeps an existing grant untouched", func(t *testing.T) { + db := openMigratedRolesDB(t) + owner := storedRole(t, db, "Owner") + if err := db.Model(&models.RolePermission{}).Where("role_id = ? AND permission_key = ?", owner.ID, "content.read").Update("scope", "own").Error; err != nil { + t.Fatal(err) + } + reseed(t, db) + if grantMap(storedRole(t, db, "Owner").Grants)["content.read"] != role.ScopeOwn { + t.Errorf("existing grant downgraded or overwritten") + } + }) + + t.Run("An unedited Admin regains a deleted grant but never an owner-only one", func(t *testing.T) { + db := openMigratedRolesDB(t) + admin := storedRole(t, db, "Admin") + deleteGrantRow(t, db, admin.ID, "users.invite") + reseed(t, db) + scopes := grantMap(storedRole(t, db, "Admin").Grants) + if scopes["users.invite"] != role.ScopeAll { + t.Errorf("admin did not regain users.invite") + } + if _, owned := scopes["system.apply_updates"]; owned { + t.Errorf("admin gained an owner-only key") + } + }) + + t.Run("An edited Writer does not regain a deleted grant", func(t *testing.T) { + db := openMigratedRolesDB(t) + writer := storedRole(t, db, "Writer") + if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 2).Error; err != nil { + t.Fatal(err) + } + deleteGrantRow(t, db, writer.ID, "media.upload") + reseed(t, db) + after := storedRole(t, db, "Writer") + if _, back := grantMap(after.Grants)["media.upload"]; back || after.Version != 2 { + t.Errorf("edited writer reconciled: %+v", after) + } + }) + + t.Run("An unedited Writer regains a deleted grant", func(t *testing.T) { + db := openMigratedRolesDB(t) + writer := storedRole(t, db, "Writer") + deleteGrantRow(t, db, writer.ID, "media.upload") + reseed(t, db) + if grantMap(storedRole(t, db, "Writer").Grants)["media.upload"] != role.ScopeAll { + t.Errorf("writer did not regain media.upload") + } + }) +} + +func TestRoleRepositorySaveVersionCheck(t *testing.T) { + t.Run("A stale version is a conflict and leaves grants untouched", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + writer := storedRole(t, db, "Writer") + before := len(writer.Grants) + writer.Version = 7 + writer.Name = "Renamed" + writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}} + if _, err := repo.Save(writer); !errors.Is(err, role.ErrRoleConflict) { + t.Fatalf("want ErrRoleConflict, got %v", err) + } + after := storedRole(t, db, "Writer") + if len(after.Grants) != before || after.Version != 1 { + t.Errorf("stale save changed the role: %+v", after) + } + }) + + t.Run("A successful save bumps the version and replaces grants", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + writer := storedRole(t, db, "Writer") + writer.Description = "edited" + writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}} + saved, err := repo.Save(writer) + if err != nil { + t.Fatal(err) + } + if saved.Version != writer.Version+1 || saved.Description != "edited" || len(saved.Grants) != 1 { + t.Errorf("saved: %+v", saved) + } + again := writer + if _, err := repo.Save(again); !errors.Is(err, role.ErrRoleConflict) { + t.Errorf("reusing the old version must conflict, got %v", err) + } + }) + + t.Run("Save cannot flip BuiltIn or Locked", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + writer := storedRole(t, db, "Writer") + writer.BuiltIn = false + writer.Locked = true + saved, err := repo.Save(writer) + if err != nil { + t.Fatal(err) + } + if !saved.BuiltIn || saved.Locked { + t.Errorf("flags changed: %+v", saved) + } + }) +} + +func TestMockRoleRepoSaveMirrorsVersionCheck(t *testing.T) { + repo := newSeededMockRoleRepo() + writer := repo.byName("Writer") + stale := writer + stale.Version = 9 + if _, err := repo.Save(stale); !errors.Is(err, role.ErrRoleConflict) { + t.Errorf("want conflict, got %v", err) + } + writer.BuiltIn = false + saved, err := repo.Save(writer) + if err != nil || saved.Version != 2 || !saved.BuiltIn { + t.Errorf("saved %+v err %v", saved, err) + } +} + +func TestValidateGrants(t *testing.T) { + cases := []struct { + name string + locked bool + grants []role.Grant + want error + }{ + {"valid scoped and toggle", false, []role.Grant{{Key: "content.read", Scope: role.ScopeOwn}, {Key: "schema.view", Scope: role.ScopeAll}}, nil}, + {"unknown key", false, []role.Grant{{Key: "content.explode", Scope: role.ScopeAll}}, role.ErrUnknownPermission}, + {"scope none stored", false, []role.Grant{{Key: "content.read", Scope: role.ScopeNone}}, role.ErrInvalidScope}, + {"unknown scope", false, []role.Grant{{Key: "content.read", Scope: "some"}}, role.ErrInvalidScope}, + {"toggle with own", false, []role.Grant{{Key: "schema.view", Scope: role.ScopeOwn}}, role.ErrInvalidScope}, + {"owner-only on regular role", false, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, role.ErrOwnerOnlyGrant}, + {"owner-only on locked owner", true, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, nil}, + {"per-type key", false, []role.Grant{{Key: "content.read:blog", Scope: role.ScopeOwn}}, nil}, + {"malformed per-type key", false, []role.Grant{{Key: "content.read:Bad Type", Scope: role.ScopeAll}}, role.ErrUnknownPermission}, + {"per-type toggle with own", false, []role.Grant{{Key: "content.create:blog", Scope: role.ScopeOwn}}, role.ErrInvalidScope}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if err := role.ValidateGrants(c.locked, c.grants); !errors.Is(err, c.want) { + t.Errorf("want %v, got %v", c.want, err) + } + }) + } + + t.Run("Built-in definitions are valid", func(t *testing.T) { + for _, builtIn := range role.BuiltInRoles() { + if err := role.ValidateGrants(builtIn.Locked, builtIn.Grants); err != nil { + t.Errorf("%s: %v", builtIn.Name, err) + } + } + }) +} + +func TestRoleAllowsKindAndOwnerOnly(t *testing.T) { + t.Run("A toggle grant at scope own does not count", func(t *testing.T) { + if roleWithGrant("schema.view", role.ScopeOwn).Allows("schema.view", true) { + t.Errorf("expected false") + } + }) + t.Run("Owner-only grants are ignored on a role that is not locked", func(t *testing.T) { + if roleWithGrant("system.apply_updates", role.ScopeAll).Allows("system.apply_updates", true) { + t.Errorf("expected false") + } + }) + t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) { + if !role.BuiltInRoles()[0].Allows("system.apply_updates", false) { + t.Errorf("expected true") + } + }) +} + +func TestParseTypedKey(t *testing.T) { + t.Run("A valid composite key is split", func(t *testing.T) { + base, typeKey, err := role.ParseTypedKey("content.update:blog_post-2") + if err != nil || base != "content.update" || typeKey != "blog_post-2" { + t.Errorf("got %q %q %v", base, typeKey, err) + } + }) + rejected := map[string]string{ + "no separator": "content.read", + "empty type key": "content.read:", + "non per-type key": "schema.view:blog", + "unknown base": "content.explode:blog", + "uppercase": "content.read:Blog", + "space": "content.read:a b", + "second colon": "content.read:a:b", + "too long": "content.read:" + strings.Repeat("a", 100), + } + for name, composite := range rejected { + t.Run("Rejects "+name, func(t *testing.T) { + if _, _, err := role.ParseTypedKey(composite); !errors.Is(err, role.ErrUnknownPermission) { + t.Errorf("want ErrUnknownPermission, got %v", err) + } + }) + } + t.Run("Accepts a composite key of exactly 100 characters", func(t *testing.T) { + composite := "content.read:" + strings.Repeat("a", 87) + if _, _, err := role.ParseTypedKey(composite); err != nil { + t.Errorf("unexpected %v", err) + } + }) +} + +func TestRoleAllowsFor(t *testing.T) { + t.Run("A per-type override denies despite the wildcard", func(t *testing.T) { + r := role.Role{Grants: []role.Grant{{Key: "content.update", Scope: role.ScopeAll}, {Key: "content.update:blog", Scope: role.ScopeOwn}}} + if r.AllowsFor("content.update", "blog", false) { + t.Errorf("override should deny a non-owned entry") + } + if !r.AllowsFor("content.update", "blog", true) { + t.Errorf("override should allow an owned entry") + } + if !r.AllowsFor("content.update", "news", false) { + t.Errorf("other types fall back to the wildcard") + } + }) + t.Run("A per-type override grants without a wildcard", func(t *testing.T) { + r := role.Role{Grants: []role.Grant{{Key: "content.read:blog", Scope: role.ScopeAll}}} + if !r.AllowsFor("content.read", "blog", false) || r.AllowsFor("content.read", "news", false) { + t.Errorf("override should apply to blog only") + } + }) + t.Run("An empty type key behaves like Allows", func(t *testing.T) { + r := roleWithGrant("content.read", role.ScopeAll) + if !r.AllowsFor("content.read", "", false) { + t.Errorf("expected true") + } + }) + t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) { + r := role.BuiltInRoles()[0] + if r.AllowsFor("content.read", "Bad Key", true) || r.AllowsFor("schema.view", "blog", true) { + t.Errorf("expected false") + } + }) +} + +func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) { + db := openMigratedRolesDB(t) + createUserRow(t, db, 3, "owner", nil) + createArticleRow(t, db, 10, 0) + if err := db.Delete(&models.Article{}, 10).Error; err != nil { + t.Fatal(err) + } + if err := database.BackfillCreatedBy(db); err != nil { + t.Fatal(err) + } + if createdByOf(t, db, "articles", 10) != 3 { + t.Errorf("soft-deleted article not attributed") + } +} diff --git a/test/role_repository_gaps_test.go b/test/role_repository_gaps_test.go new file mode 100644 index 0000000..9d39474 --- /dev/null +++ b/test/role_repository_gaps_test.go @@ -0,0 +1,134 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/infrastructure/persistence/database" + "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "errors" + "testing" +) + +func TestRoleRepositoryFailures(t *testing.T) { + broken := func(t *testing.T, table string) *persistence.RoleRepository { + t.Helper() + db := openMigratedRolesDB(t) + if err := db.Migrator().DropTable(table); err != nil { + t.Fatal(err) + } + return persistence.NewRoleRepository(db) + } + + t.Run("List fails when the roles table is missing", func(t *testing.T) { + if _, err := broken(t, "roles").List(); err == nil { + t.Error("expected error") + } + }) + + t.Run("Get distinguishes a missing role from a storage failure", func(t *testing.T) { + repo := persistence.NewRoleRepository(openMigratedRolesDB(t)) + if _, err := repo.Get(9999); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("want ErrRoleNotFound, got %v", err) + } + if _, err := broken(t, "roles").Get(1); err == nil || errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("want storage error, got %v", err) + } + }) + + t.Run("GetByName reports a missing role", func(t *testing.T) { + repo := persistence.NewRoleRepository(openMigratedRolesDB(t)) + if _, err := repo.GetByName("Nobody"); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("want ErrRoleNotFound, got %v", err) + } + }) + + t.Run("ListGrants 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.Get(1); err == nil { + t.Error("Get: expected error") + } + }) + + t.Run("Create persists grants and returns them", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + created, err := repo.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{{Key: "content.read", Scope: role.ScopeAll}}}) + if err != nil { + t.Fatal(err) + } + if len(created.Grants) != 1 || created.Grants[0].Key != "content.read" { + t.Errorf("returned grants: %+v", created.Grants) + } + if countRows(t, db, &models.RolePermission{}) != 73 { + t.Errorf("grant rows not stored") + } + }) + + t.Run("Create rolls back when the grants cannot be stored", func(t *testing.T) { + db := openMigratedRolesDB(t) + if err := db.Migrator().DropTable("role_permissions"); err != nil { + t.Fatal(err) + } + repo := persistence.NewRoleRepository(db) + before := countRows(t, db, &models.Role{}) + _, err := repo.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{{Key: "content.read", Scope: role.ScopeAll}}}) + if err == nil { + t.Fatal("expected error") + } + if countRows(t, db, &models.Role{}) != before { + t.Errorf("role row not rolled back") + } + }) + + t.Run("Create fails when the roles table is missing", func(t *testing.T) { + if _, err := broken(t, "roles").Create(role.Role{Name: "X", Version: 1}); err == nil { + t.Error("expected error") + } + }) + + t.Run("Save returns the stored role with its new grants", func(t *testing.T) { + db := openMigratedRolesDB(t) + repo := persistence.NewRoleRepository(db) + writer := storedRole(t, db, "Writer") + writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}} + saved, err := repo.Save(writer) + if err != nil { + t.Fatal(err) + } + if saved.ID != writer.ID || len(saved.Grants) != 1 || saved.Grants[0].Key != "media.delete" { + t.Errorf("saved: %+v", saved) + } + }) + + t.Run("Save reports a missing role and storage failures", func(t *testing.T) { + repo := persistence.NewRoleRepository(openMigratedRolesDB(t)) + if _, err := repo.Save(role.Role{ID: 9999, Name: "Ghost", Version: 1}); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("want ErrRoleNotFound, got %v", err) + } + db := openMigratedRolesDB(t) + writer := storedRole(t, db, "Writer") + if err := db.Migrator().DropTable("role_permissions"); err != nil { + t.Fatal(err) + } + if _, err := persistence.NewRoleRepository(db).Save(writer); err == nil { + t.Error("expected storage error") + } + }) +} + +func TestBackfillUserRoleIDsWithoutBuiltInRoles(t *testing.T) { + db := openUnmigratedSQLite(t) + if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}); err != nil { + t.Fatal(err) + } + createUserRow(t, db, 1, "owner", nil) + if err := database.BackfillUserRoleIDs(db); err != nil { + t.Fatal(err) + } + if userRow(t, db, 1).RoleID != nil { + t.Errorf("role_id must stay unset when no built-in role exists") + } +} diff --git a/test/role_support_test.go b/test/role_support_test.go new file mode 100644 index 0000000..37ec919 --- /dev/null +++ b/test/role_support_test.go @@ -0,0 +1,125 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "slices" +) + +const ( + ownerRoleID uint32 = 1 + adminRoleID uint32 = 2 + writerRoleID uint32 = 3 +) + +type mockRoleRepo struct { + roles map[uint32]role.Role + nextID uint32 + fail failSwitch +} + +func newMockRoleRepo() *mockRoleRepo { + return &mockRoleRepo{roles: map[uint32]role.Role{}} +} + +func newSeededMockRoleRepo() *mockRoleRepo { + repo := newMockRoleRepo() + for _, builtIn := range role.BuiltInRoles() { + _, _ = repo.Create(builtIn) + } + return repo +} + +func (m *mockRoleRepo) byName(name string) role.Role { + found, _ := m.GetByName(name) + return found +} + +func (m *mockRoleRepo) sortedIDs() []uint32 { + ids := make([]uint32, 0, len(m.roles)) + for id := range m.roles { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +func (m *mockRoleRepo) List() ([]role.Role, error) { + if err := m.fail.trigger("List"); err != nil { + return nil, err + } + listed := []role.Role{} + for _, id := range m.sortedIDs() { + listed = append(listed, m.roles[id]) + } + return listed, nil +} + +func (m *mockRoleRepo) Get(id uint32) (role.Role, error) { + if err := m.fail.trigger("Get"); err != nil { + return role.Role{}, err + } + found, ok := m.roles[id] + if !ok { + return role.Role{}, role.ErrRoleNotFound + } + return found, nil +} + +func (m *mockRoleRepo) GetByName(name string) (role.Role, error) { + if err := m.fail.trigger("GetByName"); err != nil { + return role.Role{}, err + } + var match *role.Role + for _, id := range m.sortedIDs() { + candidate := m.roles[id] + if candidate.Name == name && (match == nil || candidate.BuiltIn && !match.BuiltIn) { + match = &candidate + } + } + if match == nil { + return role.Role{}, role.ErrRoleNotFound + } + 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) { + if err := m.fail.trigger("Create"); err != nil { + return role.Role{}, err + } + m.nextID++ + created.ID = m.nextID + m.roles[created.ID] = created + return created, nil +} + +func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) { + if err := m.fail.trigger("Save"); err != nil { + return role.Role{}, err + } + stored, ok := m.roles[saved.ID] + if !ok { + return role.Role{}, role.ErrRoleNotFound + } + if stored.Version != saved.Version { + return role.Role{}, role.ErrRoleConflict + } + stored.Name = saved.Name + stored.Description = saved.Description + stored.Version++ + stored.Grants = slices.Clone(saved.Grants) + m.roles[saved.ID] = stored + return stored, nil +} + +var _ role.Repository = &mockRoleRepo{} diff --git a/test/roles_permissions_foundation_http_test.go b/test/roles_permissions_foundation_http_test.go new file mode 100644 index 0000000..9c5511b --- /dev/null +++ b/test/roles_permissions_foundation_http_test.go @@ -0,0 +1,329 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" +) + +type accessBody struct { + Role struct { + ID uint32 `json:"id"` + Name string `json:"name"` + BuiltIn bool `json:"built_in"` + Locked bool `json:"locked"` + } `json:"role"` + Permissions []role.Grant `json:"permissions"` +} + +type meBody struct { + ID uint32 `json:"id"` + Username string `json:"username"` + IsVerified bool `json:"is_verified"` + Role string `json:"role"` + Author any `json:"author"` + Access accessBody `json:"access"` +} + +func (e *apiKeyEnv) addUser(id uint32, legacy user.Role, roleID uint32, verified bool) { + e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, Role: legacy, RoleID: roleID} +} + +func (e *apiKeyEnv) me(t *testing.T, id uint32) (*httptest.ResponseRecorder, meBody) { + t.Helper() + rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", id) + var body meBody + if rec.Code == http.StatusOK { + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + } + return rec, body +} + +func permissionKeys(grants []role.Grant) []string { + keys := []string{} + for _, grant := range grants { + keys = append(keys, grant.Key) + } + return keys +} + +func TestRolesPermissionsFoundationHTTP(t *testing.T) { + t.Run("The owner's /me carries the Owner role and all 34 permissions", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(7, user.RoleOwner, ownerRoleID, true) + rec, body := e.me(t, 7) + if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status %d cache %q", rec.Code, rec.Header().Get("Cache-Control")) + } + if body.ID != 7 || body.Role != "owner" || !body.IsVerified { + t.Errorf("existing fields changed: %+v", body) + } + if body.Access.Role.ID != ownerRoleID || body.Access.Role.Name != "Owner" || !body.Access.Role.BuiltIn || !body.Access.Role.Locked { + t.Errorf("role %+v", body.Access.Role) + } + want := []string{} + for _, permission := range role.Catalogue() { + want = append(want, permission.Key) + } + if !slices.Equal(permissionKeys(body.Access.Permissions), want) { + t.Errorf("permissions %v", permissionKeys(body.Access.Permissions)) + } + for _, grant := range body.Access.Permissions { + if grant.Scope != role.ScopeAll { + t.Errorf("%s scope %s", grant.Key, grant.Scope) + } + } + }) + + t.Run("An admin's /me omits the owner-only keys", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(8, user.RoleAdmin, adminRoleID, true) + _, body := e.me(t, 8) + if body.Access.Role.Name != "Admin" || !body.Access.Role.BuiltIn || body.Access.Role.Locked { + t.Errorf("role %+v", body.Access.Role) + } + if len(body.Access.Permissions) != 29 { + t.Fatalf("expected 29 permissions, got %d", len(body.Access.Permissions)) + } + scopes := grantMap(body.Access.Permissions) + for _, permission := range role.Catalogue() { + if _, present := scopes[permission.Key]; present == permission.OwnerOnly { + t.Errorf("%s presence %v", permission.Key, present) + } + } + if scopes["roles.view"] != role.ScopeAll || scopes["roles.manage"] != role.ScopeAll { + t.Errorf("roles keys missing") + } + }) + + t.Run("A writer's /me lists only granted keys with their scope", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(9, user.RoleAdmin, writerRoleID, true) + _, body := e.me(t, 9) + want := []role.Grant{ + {Key: "content.read", Scope: role.ScopeAll}, {Key: "content.create", Scope: role.ScopeAll}, + {Key: "content.update", Scope: role.ScopeAll}, {Key: "schema.view", Scope: role.ScopeAll}, + {Key: "media.view", Scope: role.ScopeAll}, {Key: "media.upload", Scope: role.ScopeAll}, + {Key: "authors.view", Scope: role.ScopeAll}, {Key: "authors.edit", Scope: role.ScopeOwn}, + {Key: "activity.view", Scope: role.ScopeOwn}, + } + if !slices.Equal(body.Access.Permissions, want) { + t.Errorf("permissions %v", body.Access.Permissions) + } + }) + + t.Run("A user on a custom role sees that role", func(t *testing.T) { + e := newAPIKeyEnv(t) + custom, _ := e.roles.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{ + {Key: "content.update", Scope: role.ScopeOwn}, {Key: "content.read", Scope: role.ScopeAll}, + }}) + e.addUser(10, user.RoleAdmin, custom.ID, true) + _, body := e.me(t, 10) + want := []role.Grant{{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.update", Scope: role.ScopeOwn}} + if body.Access.Role.Name != "Contributor" || body.Access.Role.BuiltIn || !slices.Equal(body.Access.Permissions, want) { + t.Errorf("body %+v", body.Access) + } + }) + + t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) { + for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} { + e := newAPIKeyEnv(t) + e.addUser(11, legacy, 0, true) + _, body := e.me(t, 11) + if body.Access.Role.Name != name { + t.Errorf("%s: got %q", legacy, body.Access.Role.Name) + } + } + }) + + t.Run("The permissions follow the role live", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(9, user.RoleAdmin, writerRoleID, true) + writer := e.roles.byName("Writer") + writer.Grants = append(writer.Grants, role.Grant{Key: "media.delete", Scope: role.ScopeOwn}) + _, _ = e.roles.Save(writer) + _, body := e.me(t, 9) + if grantMap(body.Access.Permissions)["media.delete"] != role.ScopeOwn { + t.Errorf("permissions %v", body.Access.Permissions) + } + }) + + t.Run("An unverified user still gets /me, with access", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(12, user.RoleAdmin, writerRoleID, false) + rec, body := e.me(t, 12) + if rec.Code != http.StatusOK || body.IsVerified || body.Access.Role.Name != "Writer" { + t.Errorf("status %d body %+v", rec.Code, body) + } + }) + + t.Run("A user whose role cannot be resolved gets a server error, not an empty access block", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(13, "", 99, true) + rec, _ := e.me(t, 13) + if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" { + t.Errorf("status %d body %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("The existing /me failures are unchanged", func(t *testing.T) { + e := newAPIKeyEnv(t) + if rec := e.anonymous(t, http.MethodGet, "/v1/auth/me", ""); rec.Code != http.StatusUnauthorized { + t.Errorf("no cookie: %d", rec.Code) + } + e.addUser(7, user.RoleOwner, ownerRoleID, true) + stale := e.users.users[7] + stale.TokenVersion = 1 + e.users.users[7] = stale + rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" { + t.Errorf("stale token: %d %q", rec.Code, rec.Body.String()) + } + delete(e.users.users, 7) + rec = e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" { + t.Errorf("deleted account: %d %q", rec.Code, rec.Body.String()) + } + }) +} + +func TestPermissionCatalogueHTTP(t *testing.T) { + type item struct { + Key string `json:"key"` + ScopeKind string `json:"scope_kind"` + OwnerOnly bool `json:"owner_only"` + PerType bool `json:"per_type"` + } + type catalogueBody struct { + Groups []struct { + Key string `json:"key"` + Items []item `json:"items"` + } `json:"groups"` + } + + t.Run("A signed-in verified user reads the catalogue", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(9, user.RoleAdmin, writerRoleID, true) + rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var body catalogueBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + groups := []string{} + total := 0 + for _, group := range body.Groups { + groups = append(groups, group.Key) + for _, found := range group.Items { + total++ + permission, err := role.Lookup(found.Key) + if err != nil { + t.Fatalf("unknown key %s", found.Key) + } + wantKind := "scoped" + if permission.Kind == role.KindToggle { + wantKind = "toggle" + } + if found.ScopeKind != wantKind || found.OwnerOnly != permission.OwnerOnly { + t.Errorf("item %+v", found) + } + if found.PerType != (group.Key == "content") { + t.Errorf("per_type of %s: %v", found.Key, found.PerType) + } + } + } + if !slices.Equal(groups, []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}) || total != 34 { + t.Errorf("groups %v total %d", groups, total) + } + if body.Groups[0].Items[0] != (item{Key: "content.read", ScopeKind: "scoped", PerType: true}) { + t.Errorf("first item %+v", body.Groups[0].Items[0]) + } + }) + + t.Run("The catalogue is the same for every role", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(9, user.RoleAdmin, writerRoleID, true) + e.addUser(7, user.RoleOwner, ownerRoleID, true) + writer := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9) + owner := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7) + if writer.Body.String() != owner.Body.String() || writer.Body.Len() == 0 { + t.Errorf("bodies differ") + } + }) + + t.Run("An API key may read the catalogue", func(t *testing.T) { + e := newAPIKeyEnv(t) + raw, _ := e.issueKey(apiKeyDevId, "reader") + if rec := e.withKey(t, http.MethodGet, "/v1/permission", "", raw); rec.Code != http.StatusOK { + t.Errorf("status %d: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("No cookie is refused", func(t *testing.T) { + e := newAPIKeyEnv(t) + if rec := e.anonymous(t, http.MethodGet, "/v1/permission", ""); rec.Code != http.StatusUnauthorized { + t.Errorf("status %d", rec.Code) + } + }) + + t.Run("A stale token is refused", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(7, user.RoleOwner, ownerRoleID, true) + stored := e.users.users[7] + stored.TokenVersion = 1 + e.users.users[7] = stored + rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" { + t.Errorf("status %d body %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("An unverified user is refused", func(t *testing.T) { + e := newAPIKeyEnv(t) + e.addUser(12, user.RoleAdmin, writerRoleID, false) + rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 12) + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" { + t.Errorf("status %d body %q", rec.Code, rec.Body.String()) + } + }) +} + +func TestExistingRoleChecksAreUntouched(t *testing.T) { + t.Run("Existing role checks are untouched in this slice", func(t *testing.T) { + cases := []struct { + name string + user uint32 + method string + path string + body string + denied bool + }{ + {"owner invites", apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false}, + {"admin invites", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, true}, + {"admin creates a content type", apiKeyDevId, http.MethodPost, "/v1/content-type", `{}`, false}, + {"admin patches settings", apiKeyDevId, http.MethodPatch, "/v1/settings", `{}`, true}, + {"admin checks for updates", apiKeyDevId, http.MethodPost, "/v1/system/update/check", "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newAPIKeyEnv(t) + rec := e.withCookie(t, c.method, c.path, c.body, c.user) + if c.denied && (rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n") { + t.Errorf("expected owner-required 403, got %d %q", rec.Code, rec.Body.String()) + } + if !c.denied && rec.Code == http.StatusForbidden { + t.Errorf("unexpected 403 %q", rec.Body.String()) + } + }) + } + }) +} diff --git a/test/roles_permissions_foundation_test.go b/test/roles_permissions_foundation_test.go new file mode 100644 index 0000000..6cb4e71 --- /dev/null +++ b/test/roles_permissions_foundation_test.go @@ -0,0 +1,429 @@ +package test + +import ( + "RenewCMS/internal/domain/role" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/persistence/database" + "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/useCases" + "errors" + "slices" + "testing" + + "gorm.io/gorm" +) + +func roleWithGrant(key string, scope role.Scope) role.Role { + return role.Role{Name: "Probe", Grants: []role.Grant{{Key: key, Scope: scope}}} +} + +func TestRoleAllows(t *testing.T) { + cases := []struct { + key string + scope role.Scope + owned bool + allowed bool + }{ + {"content.update", role.ScopeAll, false, true}, + {"content.update", role.ScopeAll, true, true}, + {"content.update", role.ScopeOwn, true, true}, + {"content.update", role.ScopeOwn, false, false}, + {"content.update", role.ScopeNone, true, false}, + {"content.create", role.ScopeAll, false, true}, + {"schema.view", role.ScopeNone, false, false}, + } + for _, c := range cases { + t.Run(string(c.scope)+" "+c.key, func(t *testing.T) { + if got := roleWithGrant(c.key, c.scope).Allows(c.key, c.owned); got != c.allowed { + t.Errorf("owned=%v: expected %v, got %v", c.owned, c.allowed, got) + } + }) + } + + t.Run("A key absent from the role is not allowed", func(t *testing.T) { + if (role.Role{}).Allows("content.read", true) { + t.Errorf("expected false") + } + }) + + t.Run("An unknown key is rejected, never silently allowed", func(t *testing.T) { + everything := role.BuiltInRoles()[0] + everything.Grants = append(everything.Grants, role.Grant{Key: "content.explode", Scope: role.ScopeAll}) + if everything.Allows("content.explode", true) { + t.Errorf("expected false") + } + if _, err := role.Lookup("content.explode"); !errors.Is(err, role.ErrUnknownPermission) { + t.Errorf("expected ErrUnknownPermission, got %v", err) + } + }) +} + +func TestRoleCatalogue(t *testing.T) { + keys := []string{ + "content.read", "content.create", "content.update", "content.delete", "content.publish", + "schema.view", "schema.edit", "schema.delete", + "media.view", "media.upload", "media.delete", + "authors.view", "authors.create", "authors.edit", "authors.delete", + "activity.view", "activity.export", + "users.view", "users.invite", "users.change_role", "users.remove", + "settings.view", "settings.edit_general", "settings.manage_sites", "settings.delete_site", + "apikeys.view", "apikeys.create", "apikeys.revoke", + "roles.view", "roles.manage", "roles.edit_owner_only", + "system.apply_updates", "system.edit_retention", "system.transfer_ownership", + } + + t.Run("The catalogue is stable and complete", func(t *testing.T) { + catalogue := role.Catalogue() + got := []string{} + ownerOnly := []string{} + scoped := []string{} + for _, permission := range catalogue { + got = append(got, permission.Key) + if permission.OwnerOnly { + ownerOnly = append(ownerOnly, permission.Key) + } + if permission.Kind == role.KindScoped { + scoped = append(scoped, permission.Key) + } + if permission.Group == "content" && permission.Kind == role.KindToggle && permission.Key != "content.create" { + t.Errorf("unexpected toggle %s", permission.Key) + } + } + if !slices.Equal(got, keys) { + t.Errorf("catalogue order differs: %v", got) + } + wantOwnerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"} + if !slices.Equal(ownerOnly, wantOwnerOnly) { + t.Errorf("owner-only keys: %v", ownerOnly) + } + wantScoped := []string{"content.read", "content.update", "content.delete", "content.publish", "media.delete", "authors.edit", "activity.view", "apikeys.view", "apikeys.revoke"} + if !slices.Equal(scoped, wantScoped) { + t.Errorf("scoped keys: %v", scoped) + } + wantGroups := []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"} + if !slices.Equal(role.Groups(), wantGroups) { + t.Errorf("groups: %v", role.Groups()) + } + }) +} + +func grantMap(grants []role.Grant) map[string]role.Scope { + scopes := map[string]role.Scope{} + for _, grant := range grants { + scopes[grant.Key] = grant.Scope + } + return scopes +} + +func openMigratedRolesDB(t *testing.T) *gorm.DB { + t.Helper() + db := openUnmigratedSQLite(t) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func storedRole(t *testing.T, db *gorm.DB, name string) role.Role { + t.Helper() + found, err := persistence.NewRoleRepository(db).GetByName(name) + if err != nil { + t.Fatalf("role %s: %v", name, err) + } + return found +} + +func countRows(t *testing.T, db *gorm.DB, model any) int64 { + t.Helper() + var total int64 + if err := db.Model(model).Count(&total).Error; err != nil { + t.Fatalf("count: %v", err) + } + return total +} + +func TestSeedBuiltInRoles(t *testing.T) { + t.Run("Seeding an empty database creates the three built-in roles", func(t *testing.T) { + db := openMigratedRolesDB(t) + owner, admin, writer := storedRole(t, db, "Owner"), storedRole(t, db, "Admin"), storedRole(t, db, "Writer") + for _, found := range []role.Role{owner, admin, writer} { + if !found.BuiltIn || found.Version != 1 { + t.Errorf("%s: built_in %v version %d", found.Name, found.BuiltIn, found.Version) + } + } + if !owner.Locked || admin.Locked || writer.Locked { + t.Errorf("only Owner is locked") + } + if len(owner.Grants) != 34 || len(admin.Grants) != 29 || len(writer.Grants) != 9 { + t.Fatalf("grant counts %d %d %d", len(owner.Grants), len(admin.Grants), len(writer.Grants)) + } + for _, grant := range append(slices.Clone(owner.Grants), admin.Grants...) { + if grant.Scope != role.ScopeAll { + t.Errorf("%s not at all", grant.Key) + } + } + for _, grant := range admin.Grants { + if permission, _ := role.Lookup(grant.Key); permission.OwnerOnly { + t.Errorf("admin holds owner-only %s", grant.Key) + } + } + scopes := grantMap(writer.Grants) + if scopes["authors.edit"] != role.ScopeOwn || scopes["activity.view"] != role.ScopeOwn || scopes["content.create"] != role.ScopeAll { + t.Errorf("writer grants %v", scopes) + } + }) + + t.Run("Seeding twice changes nothing", func(t *testing.T) { + db := openMigratedRolesDB(t) + ownerID := storedRole(t, db, "Owner").ID + if err := database.SeedBuiltInRoles(db); err != nil { + t.Fatal(err) + } + if countRows(t, db, &models.Role{}) != 3 || countRows(t, db, &models.RolePermission{}) != 72 || storedRole(t, db, "Owner").ID != ownerID { + t.Errorf("seeding was not idempotent") + } + }) + + t.Run("Seeding never overwrites an edited built-in role", func(t *testing.T) { + db := openMigratedRolesDB(t) + writer := storedRole(t, db, "Writer") + if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 4).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.RolePermission{RoleID: writer.ID, Key: "media.delete", Scope: "own"}).Error; err != nil { + t.Fatal(err) + } + if err := database.SeedBuiltInRoles(db); err != nil { + t.Fatal(err) + } + after := storedRole(t, db, "Writer") + if after.Version != 4 || grantMap(after.Grants)["media.delete"] != role.ScopeOwn { + t.Errorf("edited writer overwritten: %+v", after) + } + }) + + t.Run("Seeding never touches a custom role that reuses a built-in name", func(t *testing.T) { + db := openUnmigratedSQLite(t) + if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}); err != nil { + t.Fatal(err) + } + custom := models.Role{Name: "Writer", Version: 7} + if err := db.Create(&custom).Error; err != nil { + t.Fatal(err) + } + if err := database.SeedBuiltInRoles(db); err != nil { + t.Fatalf("startup aborted: %v", err) + } + var kept models.Role + if err := db.First(&kept, custom.ID).Error; err != nil || kept.BuiltIn || kept.Version != 7 { + t.Errorf("custom row changed: %+v %v", kept, err) + } + if found := storedRole(t, db, "Writer"); !found.BuiltIn || found.ID == custom.ID { + t.Errorf("built-in writer missing: %+v", found) + } + }) +} + +func createUserRow(t *testing.T, db *gorm.DB, id uint32, legacy string, roleID *uint32) { + t.Helper() + row := models.User{ID: id, Username: "u" + legacy + string(rune('a'+id)), Email: legacy + string(rune('a'+id)) + "@x.example", Password: "x", Role: legacy, RoleID: roleID} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("user %d: %v", id, err) + } +} + +func userRow(t *testing.T, db *gorm.DB, id uint32) models.User { + t.Helper() + var row models.User + if err := db.First(&row, id).Error; err != nil { + t.Fatal(err) + } + return row +} + +func roleIDOf(row models.User) uint32 { + if row.RoleID == nil { + return 0 + } + return *row.RoleID +} + +func TestBackfillUserRoleIDs(t *testing.T) { + t.Run("Existing owner and admin users get the matching built-in role", func(t *testing.T) { + db := openMigratedRolesDB(t) + createUserRow(t, db, 3, "owner", nil) + createUserRow(t, db, 5, "admin", nil) + createUserRow(t, db, 9, "admin", nil) + if err := database.BackfillUserRoleIDs(db); err != nil { + t.Fatal(err) + } + ownerID, adminID := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID + if roleIDOf(userRow(t, db, 3)) != ownerID || roleIDOf(userRow(t, db, 5)) != adminID || roleIDOf(userRow(t, db, 9)) != adminID { + t.Errorf("role ids not backfilled") + } + if userRow(t, db, 3).Role != "owner" || userRow(t, db, 5).Role != "admin" { + t.Errorf("legacy role changed") + } + }) + + t.Run("The backfill runs after BackfillUserRoles on a database that predates both", func(t *testing.T) { + db := openMigratedRolesDB(t) + createUserRow(t, db, 2, "", nil) + createUserRow(t, db, 4, "", nil) + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatal(err) + } + two, four := userRow(t, db, 2), userRow(t, db, 4) + if two.Role != "owner" || roleIDOf(two) != storedRole(t, db, "Owner").ID { + t.Errorf("user 2: %+v", two) + } + if four.Role != "admin" || roleIDOf(four) != storedRole(t, db, "Admin").ID { + t.Errorf("user 4: %+v", four) + } + }) + + t.Run("The role_id backfill is idempotent and respects an existing role_id", func(t *testing.T) { + db := openMigratedRolesDB(t) + custom, err := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1}) + if err != nil { + t.Fatal(err) + } + createUserRow(t, db, 5, "admin", &custom.ID) + for range 2 { + if err := database.BackfillUserRoleIDs(db); err != nil { + t.Fatal(err) + } + } + if roleIDOf(userRow(t, db, 5)) != custom.ID { + t.Errorf("custom role replaced") + } + }) + + t.Run("A database with no users is left alone", func(t *testing.T) { + if err := database.BackfillUserRoleIDs(openMigratedRolesDB(t)); err != nil { + t.Errorf("unexpected error %v", err) + } + }) +} + +func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 { + t.Helper() + var value uint32 + if err := db.Table(table).Unscoped().Select("created_by").Where("id = ?", id).Scan(&value).Error; err != nil { + t.Fatal(err) + } + return value +} + +func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) { + t.Helper() + if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil { + t.Fatal(err) + } +} + +func createEntryRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) { + t.Helper() + if err := db.Create(&models.Entry{ID: id, ContentTypeID: 1, Data: "{}", CreatedBy: createdBy}).Error; err != nil { + t.Fatal(err) + } +} + +func TestBackfillCreatedBy(t *testing.T) { + t.Run("Existing articles are attributed to the first owner", func(t *testing.T) { + db := openMigratedRolesDB(t) + createUserRow(t, db, 3, "owner", nil) + createUserRow(t, db, 4, "owner", nil) + createArticleRow(t, db, 10, 0) + createArticleRow(t, db, 11, 0) + if err := database.BackfillCreatedBy(db); err != nil { + t.Fatal(err) + } + if createdByOf(t, db, "articles", 10) != 3 || createdByOf(t, db, "articles", 11) != 3 { + t.Errorf("articles not attributed") + } + }) + + t.Run("Entries without an author are attributed to the first owner", func(t *testing.T) { + db := openMigratedRolesDB(t) + createUserRow(t, db, 3, "owner", nil) + createEntryRow(t, db, 20, 0) + createEntryRow(t, db, 21, 7) + if err := database.BackfillCreatedBy(db); err != nil { + t.Fatal(err) + } + if createdByOf(t, db, "entries", 20) != 3 || createdByOf(t, db, "entries", 21) != 7 { + t.Errorf("entries wrong") + } + }) + + t.Run("The created_by backfill never rewrites an existing value, and is idempotent", func(t *testing.T) { + db := openMigratedRolesDB(t) + createUserRow(t, db, 3, "owner", nil) + createArticleRow(t, db, 10, 7) + for range 2 { + if err := database.BackfillCreatedBy(db); err != nil { + t.Fatal(err) + } + } + if createdByOf(t, db, "articles", 10) != 7 { + t.Errorf("existing value rewritten") + } + }) + + t.Run("With no owner yet the backfill does nothing", func(t *testing.T) { + db := openMigratedRolesDB(t) + createArticleRow(t, db, 10, 0) + if err := database.BackfillCreatedBy(db); err != nil { + t.Fatalf("unexpected error %v", err) + } + if createdByOf(t, db, "articles", 10) != 0 { + t.Errorf("created_by changed") + } + }) +} + +func TestNewArticlesRecordTheirCreator(t *testing.T) { + t.Run("New articles record their creator", func(t *testing.T) { + repo := newMockArticleRepo() + uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) + created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", CreatedBy: 7}) + if err != nil { + t.Fatal(err) + } + if repo.articles[created.ID].CreatedBy != 7 { + t.Errorf("stored created_by %d", repo.articles[created.ID].CreatedBy) + } + }) +} + +func TestGetMyAccessUseCase(t *testing.T) { + t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) { + uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()) + for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} { + access, err := uc.Get(user.User{ID: 11, Role: legacy}) + if err != nil || access.Role.Name != name { + t.Errorf("%s: %v %v", legacy, access.Role.Name, err) + } + } + }) + + t.Run("A user whose role cannot be resolved gets an error", func(t *testing.T) { + uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()) + if _, err := uc.Get(user.User{ID: 13, RoleID: 99}); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("expected ErrRoleNotFound, got %v", err) + } + if _, err := uc.Get(user.User{ID: 13}); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("expected ErrRoleNotFound, got %v", err) + } + }) + + t.Run("The fallback never picks a custom role that reuses a built-in name", func(t *testing.T) { + repo := newMockRoleRepo() + _, _ = repo.Create(role.Role{Name: "Owner"}) + if _, err := useCases.NewGetMyAccessUseCase(repo).Get(user.User{Role: user.RoleOwner}); !errors.Is(err, role.ErrRoleNotFound) { + t.Errorf("expected ErrRoleNotFound, got %v", err) + } + }) +} diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 0355cfb..6f046be 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -137,6 +137,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo), diff --git a/test/session_revocation_http_test.go b/test/session_revocation_http_test.go index a343ca0..b5c316f 100644 --- a/test/session_revocation_http_test.go +++ b/test/session_revocation_http_test.go @@ -176,6 +176,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv { Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))), GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), } diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index 8b1a44b..cd7cf37 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) { if _, indexed := after.schema["idx_articles_site_slug"]; !indexed { t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema) } - for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id"} { + for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id"} { delete(before.schema, name) delete(after.schema, name) } @@ -190,13 +190,16 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) { for _, column := range addedArticleColumns { delete(after.rows["articles"][0], column) } + for _, row := range after.rows["users"] { + delete(row, "role_id") + } if !reflect.DeepEqual(before.rows, after.rows) { t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows) } }) } -var addedArticleColumns = []string{"site_id", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"} +var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"} func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { t.Helper() @@ -216,7 +219,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { } func isNewTableObject(name string) bool { - for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_"} { + for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "role", "idx_role"} { if strings.HasPrefix(name, prefix) { return true } diff --git a/test/update_download_http_test.go b/test/update_download_http_test.go index 1f692a5..d8af4aa 100644 --- a/test/update_download_http_test.go +++ b/test/update_download_http_test.go @@ -39,6 +39,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness { Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: rig.status, CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder), StartUpdateDownloadUseCase: rig.start, diff --git a/test/update_http_test.go b/test/update_http_test.go index 1f79b0f..eb53ec5 100644 --- a/test/update_http_test.go +++ b/test/update_http_test.go @@ -64,6 +64,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness { Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder), } diff --git a/test/update_restart_http_test.go b/test/update_restart_http_test.go index 5c3dff4..423e442 100644 --- a/test/update_restart_http_test.go +++ b/test/update_restart_http_test.go @@ -59,6 +59,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP { Token: h.token, GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), + GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase( h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now), RequestRestartUseCase: useCases.NewRequestRestartUseCase( diff --git a/test/user_display_name_test.go b/test/user_display_name_test.go index ddf876f..85b1fcc 100644 --- a/test/user_display_name_test.go +++ b/test/user_display_name_test.go @@ -27,7 +27,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu getUser := useCases.NewGetUserUseCase(userRepo) update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder) handler := userhandler.NewHandler(update) - meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors())) + meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) loginHandler := auth.NewLoginHandler( token, useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), @@ -91,6 +91,7 @@ func seededDisplayNameRepo(displayName string) *mockUserRepo { Email: "marta@renew.example", Password: "hashed:correct-horse", IsVerified: true, + Role: user.RoleOwner, DisplayName: displayName, } return repo @@ -123,7 +124,7 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage { func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage { t.Helper() decoded := decodeKeys(t, body) - expected := append(append([]string{}, displayNameResponseKeys...), "author") + expected := append(append([]string{}, displayNameResponseKeys...), "author", "access") if len(decoded) != len(expected) { t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body)) } @@ -392,7 +393,7 @@ func TestUserDisplayNameHttp(t *testing.T) { t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) { repo := newMockUserRepo() - repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false} + repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin} router := newDisplayNameRouter(repo, token) rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0)) if rec.Code != http.StatusOK { @@ -420,7 +421,7 @@ func TestUserDisplayNameHttp(t *testing.T) { t.Run("An unverified user is refused", func(t *testing.T) { repo := newMockUserRepo() - repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false} + repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin} router := newDisplayNameRouter(repo, token) rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0)) if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" { diff --git a/test/user_roles_test.go b/test/user_roles_test.go index b326b33..7f308f6 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -60,7 +60,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture { f.token, true, ) - meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors())) + meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())) inviteHandler := userhandler.NewInviteHandler( useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder), nil,