Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates As the owner of a RenewCMS site I want every owner and admin gate replaced by a check of the caller's role permissions So that built-in roles are enforced by what they hold, API keys follow their creator's live role, and later slices can add custom roles without touching the routes again Background: Given the spec of record is docs/design/screens/roles.md (catalogue, "Built-in roles", "HTTP status to copy", section 7) And features/roles_permissions_foundation.feature provides the catalogue, the Owner (34), Admin (29) and Writer (9) roles, users.role_id, the legacy fallback and GetMyAccessUseCase And "ok" in a table means the request passes the permission gate. A later 400, 404 or 409 from the handler is not a gate failure And "403" in a table means status 403, body "missing_permission\n" (http.Error, plain text, like the other snake_case codes in api/pkg/apperr) and the use case or handler body is not invoked And the code is apperr.MissingPermissionErrorMessage = "missing_permission" And the response carries the header X-Missing-Permission with the catalogue key that failed, so the SPA can word "Your role doesn't include {permission name}" And api/middleware gains RequirePermission(key) for on/off keys and for the minimum "not none" check of a scoped key. It uses a use case that depends on domain interfaces only. It resolves the caller's role live on every request, with no cache And a request that fails the gate answers only after the existing steps: authtoken.Authenticator, RequireCurrentToken (401), RequireVerifiedUser (403 verification), RequireSession (403 session_required for an API key) and RejectAPIKey (401) And RequireOwner, RequireOwnerOrAdmin, apperr.OwnerRequiredErrorMessage and apperr.OwnerOrAdminRequiredErrorMessage are removed And no use case, handler or middleware reads user.Role for an authorization decision. user.Role stays only for the role_id fallback, for CreateUser and AcceptInvite assigning it, for ListUsersAndInvites output and for migrations And a scoped decision is taken from the caller's resolved grants. Use cases receive them, not user.Role And the role is resolved as in GetMyAccessUseCase: role_id first, else the built-in named by the legacy string And a role that cannot be resolved (role_id unknown, or no role_id and a legacy string other than "owner" or "admin") is refused with 403 missing_permission on every gated route. A repository failure other than role.ErrRoleNotFound answers 500 "Internal server error" And enforcement honours any grant it finds, including an owner-only key placed on a custom role by hand. Refusing to grant owner-only keys belongs to slice 3 (ValidateGrants at write time) And the users, tokens and cookies are the ones of the foundation feature: user 3 Owner, user 5 Admin, user 9 Writer, all verified with a valid jwt cookie and token_version 0 And routes with no obvious catalogue key reuse existing keys: media rename = media.upload; purge settings GET = settings.view, PATCH = settings.edit_general; purge preview and run = media.delete at scope all (own fails closed, images record no creator); per-image variant mutations and image-variants configuration = settings.edit_general; media variants GET and image-variants list = media.view; image-variants cache stats = settings.view; builtin types GET = schema.view And GET /v1/site stays open to any verified user And settings.manage_sites covers PATCH /v1/site/{id} including site SEO defaults, so Admin may edit them Scenario: A refused request carries the missing_permission contract Given user 9 (Writer) When the client sends POST /v1/user/invite with body {"email": "new@example.com"} and user 9's cookie Then the response status is 403 and the body is "missing_permission\n" And the header X-Missing-Permission is "users.invite" And no invite is created and no mail is sent Scenario: Verification and token checks still come first Given user 12 is not verified and holds the Admin role When the client sends POST /v1/user/invite with user 12's cookie Then the status is 403 and the body is apperr.VerificationRequiredErrorMessage And a stale token_version gives 401 apperr.UnauthorizedErrorMessage and no cookie gives 401, as before Scenario Outline: Users and invites Given the caller is When the client sends with that caller's cookie Then the gate result is Examples: | role | call | key | result | | Owner | GET /v1/user | users.view | ok | | Admin | GET /v1/user | users.view | ok | | Writer | GET /v1/user | users.view | 403 | | Owner | POST /v1/user/invite | users.invite | ok | | Admin | POST /v1/user/invite | users.invite | ok | | Writer | POST /v1/user/invite | users.invite | 403 | | Admin | DELETE /v1/user/invite/1 | users.invite | ok | | Writer | DELETE /v1/user/invite/1 | users.invite | 403 | | Admin | POST /v1/user/invite/1/renew | users.invite | ok | | Writer | POST /v1/user/invite/1/renew | users.invite | 403 | Scenario Outline: Settings, sites and system Given the caller is When the client sends with that caller's cookie (and a real session, not an API key, where the route requires one) Then the gate result is Examples: | role | call | key | result | | Owner | GET /v1/settings | settings.view | ok | | Admin | GET /v1/settings | settings.view | ok | | Writer | GET /v1/settings | settings.view | 403 | | Owner | PATCH /v1/settings | system.edit_retention | ok | | Admin | PATCH /v1/settings | system.edit_retention | 403 | | Writer | PATCH /v1/settings | system.edit_retention | 403 | | Owner | POST /v1/site | settings.manage_sites | ok | | Admin | POST /v1/site | settings.manage_sites | ok | | Writer | POST /v1/site | settings.manage_sites | 403 | | Admin | PATCH /v1/site/2 | settings.manage_sites | ok | | Admin | PATCH /v1/site/2/default | settings.manage_sites | ok | | Writer | PATCH /v1/site/2 | settings.manage_sites | 403 | | Owner | DELETE /v1/site/2 | settings.delete_site | ok | | Admin | DELETE /v1/site/2 | settings.delete_site | 403 | | Writer | DELETE /v1/site/2 | settings.delete_site | 403 | | Writer | GET /v1/site | (none) | ok | | Owner | GET /v1/system/update | settings.view | ok | | Admin | GET /v1/system/update | settings.view | ok | | Writer | GET /v1/system/update | settings.view | 403 | | Owner | POST /v1/system/update/check | system.apply_updates | ok | | Admin | POST /v1/system/update/check | system.apply_updates | 403 | | Owner | POST /v1/system/update/download | system.apply_updates | ok | | Admin | POST /v1/system/update/download | system.apply_updates | 403 | | Owner | POST /v1/system/update/restart | system.apply_updates | ok | | Admin | POST /v1/system/update/restart | system.apply_updates | 403 | | Writer | POST /v1/system/update/restart | system.apply_updates | 403 | Scenario: The permission gate runs before the update rate limiters Given the update-check limiter allows 5 attempts per minute per user When the Admin sends POST /v1/system/update/check 6 times Then every response is 403 missing_permission and none is 429 Scenario: The permission gate runs before the invite rate limiter Given the invite limiter is exhausted for user 9 When the Writer sends POST /v1/user/invite Then the response is 403 missing_permission and none is 429 Scenario: An admin reads the update status but cannot apply it Given the Admin and the Owner each send GET /v1/system/update Then both get 200 And the Admin's body has can_apply false And the Owner's body has can_apply true when the update helper is installed, false otherwise, as before Scenario: An admin reads the activity retention but cannot change it Given the Admin sends GET /v1/settings Then the status is 200 and the body holds activity_retention_days and activity_retention_max_gb And PATCH /v1/settings with {"activity_retention_days": 30} gives 403 missing_permission and nothing is stored Scenario Outline: Authors, schema, media and API key creation Given the caller is When the client sends with that caller's cookie Then the gate result is Examples: | role | call | key | result | | Owner | GET /v1/author | authors.view | ok | | Writer | GET /v1/author | authors.view | ok | | Admin | POST /v1/author | authors.create | ok | | Writer | POST /v1/author | authors.create | 403 | | Admin | DELETE /v1/author/4 | authors.delete | ok | | Writer | DELETE /v1/author/4 | authors.delete | 403 | | Writer | POST /v1/author/me | (none) | ok | | Admin | GET /v1/content-type | schema.view | ok | | Writer | GET /v1/content-type | schema.view | ok | | Writer | GET /v1/content-type/events | schema.view | ok | | Admin | POST /v1/content-type | schema.edit | ok | | Writer | POST /v1/content-type | schema.edit | 403 | | Writer | PUT /v1/content-type/events | schema.edit | 403 | | Admin | DELETE /v1/content-type/events | schema.delete | ok | | Writer | DELETE /v1/content-type/events | schema.delete | 403 | | Writer | GET /v1/builtin-type | schema.view | ok | | Writer | GET /v1/media | media.view | ok | | Writer | GET /v1/media/1 | media.view | ok | | Writer | GET /v1/media/1/usage | media.view | ok | | Writer | POST /v1/media (multipart image) | media.upload | ok | | Writer | PATCH /v1/media/1 | media.upload | ok | | Writer | DELETE /v1/media/1 | media.delete | 403 | | Writer | POST /v1/media/merge | media.delete | 403 | | Admin | DELETE /v1/media/1 | media.delete | ok | | Writer | GET /v1/media/purge-settings | settings.view | 403 | | Writer | PATCH /v1/media/purge-settings | settings.edit_general| 403 | | Writer | GET /v1/media/purge-preview | media.delete | 403 | | Writer | POST /v1/media/purge | media.delete | 403 | | Admin | POST /v1/media/purge | media.delete | ok | | Writer | GET /v1/media/1/variants | media.view | ok | | Writer | POST /v1/media/1/variants | settings.edit_general| 403 | | Writer | DELETE /v1/media/1/variants/hero | settings.edit_general| 403 | | Writer | GET /v1/image-variants | media.view | ok | | Writer | GET /v1/image-variants/cache | settings.view | 403 | | Writer | POST /v1/image-variants | settings.edit_general| 403 | | Writer | POST /v1/image-variants/cache/purge | settings.edit_general| 403 | | Admin | PUT /v1/image-variants/hero | settings.edit_general| ok | | Admin | POST /v1/api-key | apikeys.create | ok | | Writer | POST /v1/api-key | apikeys.create | 403 | Scenario: Routes that keep their old behaviour for API keys Given a valid API key whose creator is the Admin Then POST /v1/api-key, GET /v1/activity and POST /v1/system/update/check answer 403 apperr.SessionRequiredErrorMessage And GET /v1/media/1/variants answers 401 apperr.UnauthorizedErrorMessage (RejectAPIKey runs before the permission check) Scenario Outline: Roles are judged by the keys they hold, with no implied keys Given user 20 has a custom role that grants only When the client sends with user 20's cookie Then the gate result is Examples: | grants | call | result | | schema.edit=all | POST /v1/content-type | ok | | schema.edit=all | GET /v1/content-type | 403 | | schema.view=all | PUT /v1/content-type/events | 403 | | users.invite=all | GET /v1/user | 403 | | users.view=all | POST /v1/user/invite | 403 | | settings.manage_sites=all | DELETE /v1/site/2 | 403 | | settings.delete_site=all | PATCH /v1/site/2 | 403 | | media.upload=all | GET /v1/media | 403 | | media.view=all | POST /v1/media | 403 | | apikeys.create=all | GET /v1/api-key | 403 | | settings.edit_general=all | PATCH /v1/media/purge-settings | ok | | system.apply_updates=all | POST /v1/system/update/check | ok | | (no grants) | GET /v1/author | 403 | Scenario: A user whose role cannot be resolved is refused everywhere that is gated Given user 13 has role_id 99 (no such role) and legacy role "" When user 13 sends GET /v1/user, GET /v1/content-type and POST /v1/media Then every response is 403 missing_permission Scenario: A repository failure while resolving the role is a server error Given the role repository fails with a non-sentinel error When user 3 sends GET /v1/user Then the status is 500 and the body is "Internal server error" Scenario Outline: The legacy role string is no longer read for authorization Given user has legacy role "" and role_id When the client sends with that user's cookie Then the gate result is Examples: | id | legacy | role_id | call | result | | 14 | owner | Writer's id | POST /v1/user/invite | 403 | | 15 | admin | Writer's id | POST /v1/content-type| 403 | | 16 | "" | Admin's id | POST /v1/content-type| ok | | 17 | owner | 0 | POST /v1/user/invite | ok | | 18 | admin | 0 | POST /v1/content-type| ok | | 19 | admin | 0 | PATCH /v1/settings | 403 | Scenario: A session follows its role live Given user 9 is on the Writer role and GET /v1/media/purge-settings gives 403 When Writer's grants are edited in the repository to add settings.view=all Then the next GET /v1/settings with the same cookie answers ok, with no re-login and no cache Scenario: Admin relaxations Given the Admin Then the Admin can now call POST /v1/user/invite, DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew And can now call POST /v1/site, PATCH /v1/site/{id} and PATCH /v1/site/{id}/default And can now list every API key with created_by_name and revoke any key And can read every setting: GET /v1/settings, GET /v1/system/update and the retention values Scenario: Owner-only items stay owner-only Given the Admin Then DELETE /v1/site/{id}, PATCH /v1/settings, POST /v1/system/update/check, /download and /restart answer 403 missing_permission And the Owner passes all of them Scenario: Writer is effective Given user 9 on the Writer role Then GET /v1/content-type and GET /v1/content-type/events answer ok And GET /v1/media, GET /v1/media/1 and POST /v1/media answer ok And GET /v1/author answers ok and the author items carry no user_email And GET /v1/user, GET /v1/settings, GET /v1/system/update, POST /v1/user/invite, POST /v1/site and POST /v1/api-key answer 403 And GET /v1/activity answers ok and returns only the events whose actor is user 9 And GET /v1/permission, GET /v1/auth/me, PATCH /v1/user/me and POST /v1/author/me answer ok Scenario Outline: activity.view scope decides what the list returns Given events by user 3, user 5 and user 9 exist And the caller's activity.view grant is When the client sends GET /v1/activity Then the result is Examples: | scope | query | result | | all | | 200, events of users 3, 5 and 9 | | all | ?actor=user:5 | 200, events of user 5 only | | own | | 200, only the caller's own events | | own | ?actor=user:9 | 200, only the caller's own events (self is allowed) | | own | ?actor=user:3 | 403 missing_permission | | none | | 403 missing_permission | Scenario Outline: Activity summary and export follow the scope Given the caller holds When the client sends Then the result is Examples: | grants | call | result | | activity.view=all | GET /v1/activity/summary| 200 | | activity.view=own | GET /v1/activity/summary| 403 missing_permission | | activity.view=all, activity.export=all | GET /v1/activity/export | 200 CSV of every event | | activity.view=own, activity.export=all | GET /v1/activity/export | 200 CSV of the caller's own events only | | activity.view=none, activity.export=all | GET /v1/activity/export | 403 missing_permission | | activity.view=all | GET /v1/activity/export | 403 missing_permission | Scenario Outline: apikeys.view scope decides which keys are listed Given keys 1 (created by user 3), 2 (user 5) and 3 (user 9) exist And the caller's apikeys.view grant is When the client sends GET /v1/api-key Then the result is Examples: | scope | result | | all | 200, keys 1, 2 and 3, each with created_by_name | | own | 200, only the caller's own keys, without created_by_name | | none | 403 missing_permission | Scenario Outline: apikeys.revoke scope decides which keys can be revoked Given key 2 was created by user 5 And the caller is user 9 and holds apikeys.revoke = When the client sends DELETE /v1/api-key/2 Then the result is Examples: | scope | result | | all | 204 and key 2 is revoked | | own | 404 apperr.APIKeyNotFoundErrorMessage and key 2 is not revoked | | none | 403 missing_permission, key 2 is not revoked and no lookup occurs | Scenario: The Admin lists and revokes every key Given the Admin and a key created by the Owner When the Admin sends GET /v1/api-key and DELETE /v1/api-key/ Then the list contains the Owner's key with created_by_name, and the delete returns 204 Scenario Outline: authors.edit scope decides who can edit an author Given author 4 is linked to user 9 and author 5 is linked to user 3 And the caller is user 9 with authors.edit = When the client sends PATCH with body Then the result is Examples: | scope | path | body | result | | all | /v1/author/5 | {"bio": "x"} | 200 | | all | /v1/author/5 | {"user_id": 9} | 200 or the existing link errors | | own | /v1/author/4 | {"bio": "x"} | 200 | | own | /v1/author/5 | {"bio": "x"} | 403 author_rights (existing contract) | | own | /v1/author/4 | {"user_id": null} | 403 author_rights (existing contract) | | none | /v1/author/4 | {"bio": "x"} | 403 missing_permission | | none | /v1/author/999 | {"bio": "x"} | 403 missing_permission (before any lookup) | Scenario Outline: user_email on authors needs users.view Given author 4 is linked to a user with an email And the caller holds authors.view and users.view = When the client sends GET /v1/author Then user_email is Examples: | users_view | shown | | all | present | | none | absent | Scenario: An API key resolves its creator's current role on every request Given a valid API key created by user 5 (Admin) And GET /v1/content-type with that key answers 200 and POST /v1/user/invite answers ok When user 5's role_id is changed to the Writer role in the repository Then the next POST /v1/user/invite with the same key answers 403 missing_permission And GET /v1/content-type with the same key still answers 200 And POST /v1/content-type with the same key answers 403 missing_permission And no new key and no new login is needed for the change to apply Scenario: A key grows when its creator's role grows Given a valid API key created by user 9 (Writer), and POST /v1/content-type with it gives 403 When user 9's role_id is changed to the Admin role Then the next POST /v1/content-type with the same key passes the gate Scenario: Editing the creator's role grants shrinks the key as well Given a valid API key created by user 20 whose custom role grants media.view and media.upload When the role's media.upload grant is removed in the repository Then POST /v1/media with the key gives 403 missing_permission and GET /v1/media still passes Scenario: A key whose creator's role cannot be resolved is refused by the gate Given a valid API key created by user 13 (role_id 99) When the client sends GET /v1/media with the key Then the status is 403 missing_permission Scenario: Key authentication failures are unchanged Then an unknown, revoked or expired key, or one whose creator is deleted or unverified, still gives 401 apperr.UnauthorizedErrorMessage Scenario: Public and self-service routes keep no permission gate Given no cookie Then GET /v1/article, GET /v1/article/{id}, GET /v1/article/{id}/seo and GET /v1/site/{key}/sitemap answer as before And user 9 on a role with no grants still gets 200 on GET /v1/auth/me, GET /v1/permission, GET /v1/site and GET /v1/user/lookup?ids=1, and ok on PATCH /v1/user/me Scenario: Articles and entries are not gated in this slice Given user 20 has a custom role with no content grants Then the article and entry routes answer exactly as before this slice (own/all, publish and draft are slice 2b) Scenario Outline: Existing owner and admin behaviour does not regress Given the caller is When the client sends Then the status is exactly as before this slice Examples: | role | call | status | | Owner | GET /v1/user | 200 | | Admin | GET /v1/user | 200 | | Owner | POST /v1/content-type | as before | | Admin | POST /v1/content-type | as before | | Admin | GET /v1/builtin-type | 200 | | Admin | GET /v1/activity/summary | 200 | | Admin | GET /v1/activity/export | 200 CSV | | Admin | GET /v1/media/purge-settings | 200 | | Admin | PATCH /v1/media/purge-settings | as before | | Admin | GET /v1/image-variants | 200 | | Admin | GET /v1/media/1/variants | as before | | Owner | PATCH /v1/settings | as before | | Admin | DELETE /v1/author/4 | as before | Scenario: The owner and admin gates are gone from the code Then api/router.go and api/middleware hold no RequireOwner or RequireOwnerOrAdmin And no file under api/ or internal/infrastructure/useCases compares a user.Role value for authorization, apart from the role_id fallback in GetMyAccessUseCase, CreateUser and AcceptInvite assigning a role, and the ListUsersAndInvites output And the apperr constants OwnerRequiredErrorMessage and OwnerOrAdminRequiredErrorMessage no longer exist