Files
RenewCMS/features/roles_permissions_enforcement.feature
T

400 lines
27 KiB
Gherkin

Feature: Roles and permissions enforcement: permission checks replace the owner and admin gates
Superseded in part by roles_permissions_users_api: a role is resolved by role_id only. The lines about the legacy resolution ("else the built-in named by the legacy string", "no role_id and a legacy string other than owner or admin") are replaced by: role_id 0 or an unknown role_id is an unresolvable role, refused as missing_permission.
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)
(superseded by roles_permissions_content_scope: owner-only keys are effective only on a locked role, so a hand-placed one is ignored)
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 <role>
When the client sends <call> with that caller's cookie
Then the gate result is <result>
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 <role>
When the client sends <call> with that caller's cookie (and a real session, not an API key, where the route requires one)
Then the gate result is <result>
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 <role>
When the client sends <call> with that caller's cookie
Then the gate result is <result>
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 <grants>
When the client sends <call> with user 20's cookie
Then the gate result is <result>
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 | 403 system.apply_updates (superseded by roles_permissions_content_scope: owner-only keys count only on a locked role) |
| (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 <id> has legacy role "<legacy>" and role_id <role_id>
When the client sends <call> with that user's cookie
Then the gate result is <result>
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 <scope>
When the client sends GET /v1/activity<query>
Then the result is <result>
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 <grants>
When the client sends <call>
Then the result is <result>
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 <scope>
When the client sends GET /v1/api-key
Then the result is <result>
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 = <scope>
When the client sends DELETE /v1/api-key/2
Then the result is <result>
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/<that id>
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 = <scope>
When the client sends PATCH <path> with body <body>
Then the result is <result>
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 = <users_view>
When the client sends GET /v1/author
Then user_email is <shown>
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)
And this scenario is superseded by roles_permissions_content_scope: articles and entries are gated by the content.* keys
Scenario Outline: Existing owner and admin behaviour does not regress
Given the caller is <role>
When the client sends <call>
Then the status is <status> 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