Files
RenewCMS/features/roles_permissions_enforcement.qa.md
T

14 KiB

QA procedure: roles_permissions_enforcement

Package: test (black-box). Name new fakes uniquely, for example enforcementRoleRepo, enforcementUserRepo. Do not copy the old Role == checks into new code.

Deferred to later slices

  • 2b: own/all enforcement after loading an article or entry, publish separate from update, Draft behaviour. Articles and entries stay ungated here. (Superseded: implemented by roles_permissions_content_scope, which also makes owner-only keys effective only on a locked role.)
  • 3: /v1/role CRUD, no-escalation, ValidateGrants at write time, per-type override keys.
  • 4: role change, invite with a role, transfer ownership, removal of the legacy role column.
  • SPA (sidebar, read-only pages, 403 toasts) is out of scope. It reacts to the status code only.

Design notes for the user

  • docs/design/screens/roles.md "Owner-only items" disables View roles and Manage roles for an admin. This slice follows the user's decision (Admin has 29 keys). The design needs a fix by the user.
  • docs/design/change-list.md item 21 ("Sites: owner only, hidden for other roles") and its SEO "Permissions" paragraph ("only the owner edits site SEO defaults", 403 owner_only), plus the owner_only clause in routes.md "SEO", disagree with this slice. Admin has settings.manage_sites, so an admin can now edit sites and their SEO defaults.
  • roles.md is inconsistent about Writer and Schema. The "Writer: Articles list" state hides Schema from the sidebar, while the catalogue, the built-in table and change-list ("Schema read-only for view-only roles") give Writer schema.view.
  • change-list line 100 mentions a "restricted user role" that does not exist.
  • The docs name GET /v1/me. The implemented route is GET /v1/auth/me (resolved in slice 1).
  • change-list item 15 and routes.md word activity export as "owner and admin"; it is now activity.export plus activity.view.
  • change-list item 22 says GET /v1/author works for any role; under 2a a role without authors.view gets 403.
  • The Home activity card may call activity summary, so a Writer would get 403 there; check before the SPA slice.

Inventory (route, current gate, new key, Owner / Admin / Writer)

Routes gated today by RequireOwner or RequireOwnerOrAdmin:

Route New key (scope) Owner / Admin / Writer Change
POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew users.invite ok / ok / 403 Admin gains
PATCH /v1/settings system.edit_retention ok / 403 / 403 none
POST /v1/site, PATCH /v1/site/{id}, PATCH /v1/site/{id}/default settings.manage_sites ok / ok / 403 Admin gains
DELETE /v1/site/{id} settings.delete_site ok / 403 / 403 none
POST /v1/author authors.create ok / ok / 403 none
DELETE /v1/author/{id} authors.delete ok / ok / 403 none
POST, PUT /v1/content-type schema.edit ok / ok / 403 none
DELETE /v1/content-type/{key} schema.delete ok / ok / 403 none
GET /v1/builtin-type, /{key} schema.view ok / ok / ok Writer gains
GET /v1/activity/summary activity.view at all ok / ok / 403 none
GET /v1/activity/export activity.export plus activity.view (same scoping as the list) ok / ok / 403 none
POST /v1/system/update/check, /download, /restart system.apply_updates ok / 403 / 403 none
GET /v1/media/purge-settings settings.view ok / ok / 403 none
PATCH /v1/media/purge-settings settings.edit_general ok / ok / 403 none
GET /v1/media/purge-preview, POST /v1/media/purge media.delete at all ok / ok / 403 none
GET /v1/media/{id}/variants (after RejectAPIKey) media.view ok / ok / ok Writer gains
POST, DELETE /v1/media/{id}/variants... (after RejectAPIKey) settings.edit_general ok / ok / 403 none
GET /v1/image-variants media.view ok / ok / ok Writer gains
GET /v1/image-variants/cache settings.view ok / ok / 403 none
other image-variants mutations and cache purge settings.edit_general ok / ok / 403 none

Gated by a legacy Role == inside a use case or handler:

Place New
ListActivity.scopeToViewer, activityHandler.writeActivityError activity.view: all unfiltered, own forced to self (another actor gives 403 missing_permission), none gives 403
ListAPIKeys apikeys.view: all lists everything with created_by_name, own only the caller's keys without it, none 403
RevokeAPIKey apikeys.revoke: all any key, own only the caller's (404 API key not found on another's), none 403
ListAuthors (CallerRole, isAdminRole) gate authors.view; user_email only if the caller holds users.view
UpdateAuthor / AuthorCaller.isAdmin authors.edit: all any author and the link, own the linked author only (else author_rights), none 403 missing_permission
updateHandler.isOwner can_apply true only with system.apply_updates (and helper installed, as today)
middleware.RequireOwner, RequireOwnerOrAdmin deleted; replaced by RequirePermission(key) and a scope-aware variant resolving the live role

Previously ungated or verification-only:

Route New
GET /v1/user users.view
GET /v1/settings settings.view
GET /v1/system/update settings.view
GET /v1/author authors.view
GET /v1/content-type, /{key} schema.view
GET /v1/media, /{id}, /{id}/usage media.view
POST /v1/media media.upload
PATCH /v1/media/{id} media.upload
DELETE /v1/media/{id}, POST /v1/media/merge media.delete at all (own fails closed)
POST /v1/api-key apikeys.create (RequireSession stays)
GET /v1/api-key, DELETE /v1/api-key/{id} apikeys.view, apikeys.revoke (scoped)
GET /v1/activity activity.view (scoped)
GET /v1/site, GET /v1/user/lookup, PATCH /v1/user/me, POST /v1/author/me, GET /v1/permission, GET /v1/auth/me ungated
/v1/article/, /v1/content-type/{key}/entry/, public reads ungated in 2a

Test infrastructure first

  1. enforcementRoleRepo implements the role Repository in memory, seeded with the three built-ins through the same Go function the migration uses. It supports editing grants and a switchable failure.
  2. The user fake carries RoleID. Add users 3 (Owner), 5 (Admin), 9 (Writer), 12 (unverified Admin), 13 (role_id 99), 14-19 (legacy and role_id combinations from the Outline), 20 (custom role).
  3. A helper mintCookie(userID, tokenVersion) and a helper to mint API keys backed by the existing apikey fakes (see test/api_keys_http_test.go).
  4. HTTP harness. Call validator.InitValidator(). Build each router with the same middleware chain as api/router.go for that group, now with the permission middleware, over use cases wired to fakes.
    • Follow whichever approach the closest existing HTTP test uses. Existing tests for NewUserRouter, NewSettingsRouter, NewSiteRouter, NewAuthorRouter, NewContentTypeRouter, NewBuiltinTypeRouter, NewActivityRouter, NewSystemRouter, NewAPIKeyRouter, NewMediaRouter and NewImageVariantRouter need the role repository added to their fixtures.
    • Mount authtoken.Verifier and container.apiKeyAuth() equivalents where the real router has them, so key scenarios traverse the same order.
    • No live server, no real SQLite file, no real SMTP, no real update helper.
  5. A gate-matrix helper assertGate(t, rec, want). For "ok" assert the status is not the missing_permission 403 and the handler or use case spy was reached. For "403" assert status 403, body missing_permission\n, header X-Missing-Permission equals the key, and the spy was not reached.

A. Domain and use cases (fakes only)

  1. Permission check use case (name left to coder, for example CheckPermission): table-test owner, admin, writer, a custom role, unresolvable role (expect role.ErrMissingPermission via errors.Is; the failing key travels in a typed error via errors.As), a failing repository (expect a non-sentinel error), and a live grant change between two calls.
  2. ListActivity: all, own (self filter forced, other actor returns audit.ErrActorNotAllowed), none. No user.Role.
  3. ListAPIKeys and RevokeAPIKey: all, own, none. created_by_name follows scope all. Own on another's key returns apikey.ErrAPIKeyNotFound.
  4. ListAuthors and UpdateAuthor: user_email follows users.view. authors.edit own, all, none, including author.ErrAuthorRights for own violations.
  5. The can_apply computation in the update status follows system.apply_updates.
  6. Hygiene tests, in the style of the existing *_hygiene_test.go:
    • api/router.go and api/middleware contain no RequireOwner.
    • Non-test Go under api/ and internal/infrastructure/useCases has no user.RoleOwner or user.RoleAdmin comparison apart from the allowed spots listed in the feature.
    • internal/domain/role still imports only the stdlib.

B. HTTP

  1. Gate matrices: run every Scenario Outline row. One test per outline, subtests per row, named after the row.
  2. Contract: status 403, body missing_permission\n, X-Missing-Permission, no side effect (no invite stored, no mail sent, no site created, retention unchanged).
  3. Order: stale token 401, no cookie 401, unverified 403 verification, API key on session-only routes 403 session_required, API key on /v1/media/1/variants 401, all before the permission check. Limiter order: 6 admin update checks give 6 times 403; exhausted invite limiter gives 403 for a Writer.
  4. Scoped: activity (list, summary, export), api keys (list, revoke), authors (edit, user_email) per the Outlines.
  5. API key live role: change role_id between requests, edit grants between requests, and unresolvable role. Use the same key value throughout.
  6. Session live role: edit grants between two requests with the same cookie.
  7. Legacy string: the six rows of the "legacy role string" Outline.
  8. Ungated: public routes, /v1/auth/me, /v1/permission, /v1/site, /v1/user/lookup, PATCH /v1/user/me, POST /v1/author/me answer for a user whose role has no grants. Article and entry routes behave as in their existing tests.
  9. Regression: run the existing suites for the owner and admin cases in the "does not regress" Outline unchanged, after the assertion edits listed below.

Existing tests that must change (found by grep, so recheck with go vet)

Assertions of the removed constants become missing_permission, or the expectation flips where Admin gains access.

File (lines) Change
test/roles_permissions_foundation_http_test.go (around 320, the "Existing role checks are untouched" Outline) Superseded. The admin rows for invite and /system/update/check move to this slice (invite becomes ok, update check becomes missing_permission). Keep the owner rows.
test/update_http_test.go:248; test/update_download_http_test.go:187; test/update_restart_http_test.go:263-264 Admin 403 body becomes missing_permission
test/activity_log_backend_http_test.go:280, 735; test/activity_filter_cards_and_export_http_test.go:75; test/activity_next_cleanup_test.go:199 Message becomes missing_permission. The "editor" fixtures need a Writer or custom role_id. Admin retention read stays 200.
test/user_roles_test.go:280; test/invite_management_test.go:410; test/api_keys_http_test.go:434, 573 Admin invite is now allowed. Other Owner-required assertions become missing_permission. API key list and revoke fixtures need roles.
test/seo_output_sitemap_http_test.go:167; test/seo_data_site_http_test.go; test/site_acceptance_test.go; test/authors_backend_http_test.go Admin PATCH /v1/site/{id} is now allowed. Message assertions for non-admins become missing_permission.
test/custom_content_types_test.go:131 "member without a role" gets 403 missing_permission. The fixture needs an unresolvable or empty role.
test/media_purge_http_test.go:160-167; test/media_purge_qa_test.go:31; test/image_variants_per_image_http_test.go:152-155; test/image_variants_cache_http_test.go:32-33; test/image_variants_admin_http_test.go:42-47 The "editor", "plain" and "empty role" users need role_ids. Expected body becomes missing_permission.
every test that builds users with a legacy Role and no RoleID Add RoleID (or rely on the fallback for "owner" and "admin" only). Users with legacy roles like "editor" or "" are now unresolvable.

Existing features to amend or mark superseded

Edit the owner/admin 403 clauses in place or add a "superseded by roles_permissions_enforcement" note.

  • roles_permissions_foundation (last Outline)
  • user_roles, invite_management, invitations
  • sites_backend, sites_authors_integration, seo_data_backend, seo_output_backend, seo_sites_articles_integration
  • authors_backend
  • activity-log-backend, activity_filter_cards_and_export, activity_home_card_and_retention
  • api_keys, integration_api_key_routes, integration
  • media_purge, media_cleanup_spa, image-variants, image_variants_cache, image_variants_per_image_api
  • updates_download_stage, updates_restart_helper, updates_release_feed, container_update_guidance
  • custom_content_types, custom_content_types_support

Also check the matching .qa.md files and these non-test texts: web/src/data/apiCatalogue.ts (OWNER_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE), web/src/views/IntegrationView.vue ("Owner and admin routes answer 403…") and anything under internal/infrastructure/llmdocs that documents the old messages. The SPA stores handle 403 by status only, so they should be unaffected; this is a manual check.

Manual verification

  • Start task dev:api on a copy of a migrated database. As the admin, check that settings are readable and software and retention updates return 403. As the owner, check everything still works.
  • Create an API key as an admin, change that user to Writer by editing users.role_id in the database copy, and confirm the same key now gets 403 on a schema write.
  • The SPA is untouched, so the admin will still see owner-only UI hidden. That is expected until the SPA slices.